اولین نسخه بکاند آرامش پلازا
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# ==========================================
|
||||||
|
# آدرس اتصال به دیتابیس PostgreSQL
|
||||||
|
# فرمت: postgresql://USERNAME:PASSWORD@HOST:PORT/DBNAME
|
||||||
|
# ==========================================
|
||||||
|
DATABASE_URL=postgresql://aramesh_user:aramesh_pass@localhost:5432/aramesh_db
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# کلید مخفی برای امضای توکنهای JWT
|
||||||
|
# حتماً یک رشتهی رندوم و طولانی بذار (برای پروداکشن)
|
||||||
|
# با این دستور میتونی یکی بسازی: openssl rand -hex 32
|
||||||
|
# ==========================================
|
||||||
|
SECRET_KEY=change-this-to-a-long-random-secret-string
|
||||||
|
|
||||||
|
# مدت اعتبار توکن ورود ادمین (به دقیقه)
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||||
|
|
||||||
|
# آدرس(های) فرانتاند که اجازه دارن به این بکاند وصل بشن (با کاما جدا کن)
|
||||||
|
CORS_ORIGINS=http://localhost:5173,https://aramesh-six.vercel.app
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# بکاند آرامش پلازا (FastAPI + PostgreSQL)
|
||||||
|
|
||||||
|
این بکاند برای سایت **آرامش پلازا** ساخته شده و شامل:
|
||||||
|
- API کامل برای مدیریت محتوا: پروژهها، مقالات، خدمات، تیم، نظرات مشتریان، تایملاین، آمار، سوالات متداول، اطلاعات برند
|
||||||
|
- فرم تماس (ذخیره در دیتابیس)
|
||||||
|
- فرم خبرنامه (ذخیره در دیتابیس)
|
||||||
|
- ورود ادمین با JWT برای مدیریت محتوا (ایجاد/ویرایش/حذف)
|
||||||
|
- مستندات خودکار API (Swagger) روی آدرس `/docs`
|
||||||
|
|
||||||
|
قبلاً همهی این محتوا داخل فایل `src/data/site.ts` توی فرانتاند بهصورت ثابت نوشته شده بود. با این بکاند، همون محتوا از دیتابیس میاد و میتونی بدون نیاز به دیپلوی دوبارهی فرانت، محتوا رو تغییر بدی.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
|
||||||
|
روی سیستم خودت باید این دو تا نصب باشن:
|
||||||
|
1. **Python 3.11 یا بالاتر** — از [python.org](https://www.python.org/downloads/) دانلود کن
|
||||||
|
2. **PostgreSQL** — از [postgresql.org](https://www.postgresql.org/download/) دانلود کن (موقع نصب، یک رمز عبور برای کاربر `postgres` ازت میپرسه؛ یادت بمونه)
|
||||||
|
|
||||||
|
برای چک کردن نصب بودنشون:
|
||||||
|
```bash
|
||||||
|
python --version
|
||||||
|
psql --version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## قدم ۱: ساخت دیتابیس
|
||||||
|
|
||||||
|
بعد از نصب PostgreSQL، یک ترمینال باز کن و وارد psql شو:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -U postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
(رمزی که موقع نصب گذاشتی رو وارد کن). حالا داخل psql این دستورها رو بزن:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE USER aramesh_user WITH PASSWORD 'یک-رمز-قوی-انتخاب-کن';
|
||||||
|
CREATE DATABASE aramesh_db OWNER aramesh_user;
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## قدم ۲: نصب پروژه
|
||||||
|
|
||||||
|
توی پوشهی این پروژه (همونجایی که این فایل `README.md` هست) یک ترمینال باز کن:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ساخت محیط مجازی پایتون (اختیاری ولی توصیهشده)
|
||||||
|
python -m venv venv
|
||||||
|
|
||||||
|
# فعالسازی محیط مجازی
|
||||||
|
# ویندوز:
|
||||||
|
venv\Scripts\activate
|
||||||
|
# مک/لینوکس:
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# نصب کتابخانهها
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## قدم ۳: تنظیم فایل `.env`
|
||||||
|
|
||||||
|
فایل `.env.example` رو کپی کن و اسمش رو بذار `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ویندوز (Command Prompt):
|
||||||
|
copy .env.example .env
|
||||||
|
# مک/لینوکس:
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
حالا فایل `.env` رو با یک ادیتور متنی (مثل Notepad یا VS Code) باز کن و این خط رو با اطلاعات واقعی خودت پر کن:
|
||||||
|
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgresql://aramesh_user:همون-رمزی-که-انتخاب-کردی@localhost:5432/aramesh_db
|
||||||
|
```
|
||||||
|
|
||||||
|
خط `SECRET_KEY` رو هم به یک رشتهی رندوم و طولانی تغییر بده (هر متن دلخواه طولانی کافیه، برای پروداکشن بهتره واقعاً رندوم باشه).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## قدم ۴: ساخت جدولهای دیتابیس (Migration)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
این دستور همهی جدولهای لازم (پروژهها، مقالات، تماسها و...) رو توی دیتابیست میسازه.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## قدم ۵: پر کردن دیتابیس با محتوای فعلی سایت
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/seed_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
این اسکریپت همون محتوایی که الان توی `site.ts` هست (پروژهها، مقالات، تیم و...) رو توی دیتابیس میریزه تا سایت خالی نمونه.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## قدم ۶: ساخت اکانت ادمین
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/create_admin.py
|
||||||
|
```
|
||||||
|
|
||||||
|
یک نام کاربری و رمز عبور وارد کن. با همین اکانت وارد پنل مدیریت میشی (بعداً وقتی پنل ادمین رو هم بسازیم).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## قدم ۷: اجرای سرور
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
اگه همه چیز درست باشه، پیام `Application startup complete` رو میبینی. حالا:
|
||||||
|
- API روی آدرس **http://localhost:8000** در دسترسه
|
||||||
|
- مستندات تعاملی (میتونی همونجا API رو تست کنی) روی **http://localhost:8000/docs**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## لیست اندپوینتهای اصلی
|
||||||
|
|
||||||
|
| بخش | آدرس | توضیح |
|
||||||
|
|---|---|---|
|
||||||
|
| پروژهها | `GET /api/projects` | لیست همه پروژهها |
|
||||||
|
| پروژهها | `GET /api/projects/{slug}` | جزئیات یک پروژه |
|
||||||
|
| مقالات | `GET /api/articles` | لیست همه مقالات |
|
||||||
|
| مقالات | `GET /api/articles/{slug}` | جزئیات یک مقاله |
|
||||||
|
| خدمات | `GET /api/services` | لیست خدمات |
|
||||||
|
| تیم | `GET /api/team` | اعضای تیم |
|
||||||
|
| نظرات مشتریان | `GET /api/testimonials` | نظرات |
|
||||||
|
| تایملاین | `GET /api/timeline` | تاریخچه شرکت |
|
||||||
|
| چرا ما | `GET /api/why-us` | نکات "چرا آرامش پلازا" |
|
||||||
|
| آمار | `GET /api/stats` | آمار صفحه اصلی |
|
||||||
|
| سوالات متداول | `GET /api/faqs` | لیست FAQ |
|
||||||
|
| برند | `GET /api/brand` | اطلاعات تماس/لوگو/آدرس |
|
||||||
|
| تماس | `POST /api/contact` | ثبت فرم تماس (عمومی) |
|
||||||
|
| خبرنامه | `POST /api/newsletter` | عضویت در خبرنامه (عمومی) |
|
||||||
|
| ورود ادمین | `POST /api/auth/login` | گرفتن توکن JWT |
|
||||||
|
|
||||||
|
تمام درخواستهای `GET` روی این منابع **عمومی** هستن (فرانتاند بدون نیاز به ورود میتونه بخونتشون).
|
||||||
|
تمام درخواستهای `POST` / `PUT` / `DELETE` (بهجز فرم تماس و خبرنامه) نیاز به توکن ادمین دارن — یعنی باید هدر زیر رو بفرستی:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <توکنی-که-از-لاگین-گرفتی>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## اتصال فرانتاند به این بکاند
|
||||||
|
|
||||||
|
فرانتاند فعلی (`src/data/site.ts`) دادهها رو بهصورت ثابت داره. برای اتصال به این بکاند باید:
|
||||||
|
1. بهجای import کردن از `site.ts`، هر کامپوننت دادههاش رو با `fetch` یا `axios` از این آدرسها بگیره (مثلاً `fetch('http://localhost:8000/api/projects')`)
|
||||||
|
2. فرم `ContactSection.tsx` و `Newsletter.tsx` بهجای فقط تغییر state داخلی، باید یک درخواست `POST` به `/api/contact` و `/api/newsletter` بفرستن
|
||||||
|
|
||||||
|
اگه بخوای، توی یک مرحلهی بعدی میتونم دقیقاً همین تغییرات رو توی کدهای فرانتاندت اعمال کنم تا کامل به این بکاند وصل بشه.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## دیپلوی (وقتی آمادهی انتشار شدی)
|
||||||
|
|
||||||
|
گزینههای ساده برای هاست این بکاند:
|
||||||
|
- **Railway** یا **Render**: خیلی راحت از روی گیتهاب دیپلوی میکنن و PostgreSQL هم بهت میدن
|
||||||
|
- سرور شخصی با Docker یا مستقیم با `uvicorn`/`gunicorn`
|
||||||
|
|
||||||
|
نکته مهم امنیتی: قبل از دیپلوی روی سرور واقعی، حتماً:
|
||||||
|
1. یک `SECRET_KEY` رندوم و قوی بذار (نه همون مقدار پیشفرض)
|
||||||
|
2. آدرس دقیق دامنهی فرانتاندت (مثلاً `https://aramesh-six.vercel.app`) رو توی `CORS_ORIGINS` بذار
|
||||||
|
3. رمز عبور دیتابیس رو قوی انتخاب کن
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import sys
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import Base
|
||||||
|
from app import models # noqa: F401 -- ensures all models are registered on Base.metadata
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""initial tables
|
||||||
|
|
||||||
|
Revision ID: 55a6f15ea7ef
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-08-26 08:23:45.465259
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '55a6f15ea7ef'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('admin_users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_admin_users_username'), 'admin_users', ['username'], unique=True)
|
||||||
|
op.create_table('articles',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('date', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('category', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('image', sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column('read', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('excerpt', sa.Text(), nullable=False),
|
||||||
|
sa.Column('body', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_articles_slug'), 'articles', ['slug'], unique=True)
|
||||||
|
op.create_table('brand',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('logo', sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column('hero', sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column('contact_bg', sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column('phone', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('phone_href', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('address', sa.String(length=1024), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('contact_leads',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('phone', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('note', sa.Text(), nullable=False),
|
||||||
|
sa.Column('is_read', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('faqs',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('q', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('a', sa.Text(), nullable=False),
|
||||||
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('newsletter_subscribers',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_newsletter_subscribers_email'), 'newsletter_subscribers', ['email'], unique=True)
|
||||||
|
op.create_table('projects',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('location', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('year', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('done', 'progress', name='projectstatus'), nullable=False),
|
||||||
|
sa.Column('image', sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column('gallery', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('area', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('floors', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('units', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('client', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('summary', sa.Text(), nullable=False),
|
||||||
|
sa.Column('body', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_projects_slug'), 'projects', ['slug'], unique=True)
|
||||||
|
op.create_table('services',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('desc', sa.Text(), nullable=False),
|
||||||
|
sa.Column('icon', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('stats',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('value', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('suffix', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('label', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('team_members',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('role', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('bio', sa.Text(), nullable=False),
|
||||||
|
sa.Column('photo', sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('testimonials',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('role', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('quote', sa.Text(), nullable=False),
|
||||||
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('timeline_items',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('year', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('desc', sa.Text(), nullable=False),
|
||||||
|
sa.Column('image', sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('why_us_items',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('desc', sa.Text(), nullable=False),
|
||||||
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('why_us_items')
|
||||||
|
op.drop_table('timeline_items')
|
||||||
|
op.drop_table('testimonials')
|
||||||
|
op.drop_table('team_members')
|
||||||
|
op.drop_table('stats')
|
||||||
|
op.drop_table('services')
|
||||||
|
op.drop_index(op.f('ix_projects_slug'), table_name='projects')
|
||||||
|
op.drop_table('projects')
|
||||||
|
op.drop_index(op.f('ix_newsletter_subscribers_email'), table_name='newsletter_subscribers')
|
||||||
|
op.drop_table('newsletter_subscribers')
|
||||||
|
op.drop_table('faqs')
|
||||||
|
op.drop_table('contact_leads')
|
||||||
|
op.drop_table('brand')
|
||||||
|
op.drop_index(op.f('ix_articles_slug'), table_name='articles')
|
||||||
|
op.drop_table('articles')
|
||||||
|
op.drop_index(op.f('ix_admin_users_username'), table_name='admin_users')
|
||||||
|
op.drop_table('admin_users')
|
||||||
|
# ### end Alembic commands ###
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import AdminUser
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_admin(db: Session, username: str, password: str) -> AdminUser | None:
|
||||||
|
user = db.query(AdminUser).filter(AdminUser.username == username).first()
|
||||||
|
if not user or not verify_password(password, user.hashed_password):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_admin(
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> AdminUser:
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="توکن نامعتبر یا منقضی شده است",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||||
|
username: str | None = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
user = db.query(AdminUser).filter(AdminUser.username == username).first()
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
DATABASE_URL: str
|
||||||
|
SECRET_KEY: str
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
|
||||||
|
CORS_ORIGINS: str = "http://localhost:5173"
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins_list(self) -> list[str]:
|
||||||
|
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
یک فکتوری عمومی برای ساخت روترهای CRUD.
|
||||||
|
چون خیلی از منابع (خدمات، آمار، تایملاین، تیم، نظرات مشتریان، چرا ما، FAQ)
|
||||||
|
ساختار مشابهی دارن (لیست عمومی + مدیریت فقط برای ادمین)، این تابع کد تکراری
|
||||||
|
رو حذف میکنه. برای منابعی که پیچیدهتر هستن (پروژهها، مقالات، تماس، خبرنامه)
|
||||||
|
روتر جداگانه با منطق اختصاصی نوشته شده.
|
||||||
|
"""
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import get_current_admin
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
|
||||||
|
def build_crud_router(
|
||||||
|
*,
|
||||||
|
prefix: str,
|
||||||
|
tag: str,
|
||||||
|
model,
|
||||||
|
create_schema: Type,
|
||||||
|
update_schema: Type,
|
||||||
|
out_schema: Type,
|
||||||
|
order_field: str = "order",
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(prefix=prefix, tags=[tag])
|
||||||
|
|
||||||
|
@router.get("", response_model=list[out_schema])
|
||||||
|
def list_items(db: Session = Depends(get_db)):
|
||||||
|
return db.query(model).order_by(getattr(model, order_field)).all()
|
||||||
|
|
||||||
|
@router.get("/{item_id}", response_model=out_schema)
|
||||||
|
def get_item(item_id: int, db: Session = Depends(get_db)):
|
||||||
|
item = db.get(model, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "مورد پیدا نشد")
|
||||||
|
return item
|
||||||
|
|
||||||
|
@router.post("", response_model=out_schema, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_item(
|
||||||
|
payload: create_schema,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
item = model(**payload.model_dump())
|
||||||
|
db.add(item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
@router.put("/{item_id}", response_model=out_schema)
|
||||||
|
def update_item(
|
||||||
|
item_id: int,
|
||||||
|
payload: update_schema,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
item = db.get(model, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "مورد پیدا نشد")
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(item, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_item(
|
||||||
|
item_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
item = db.get(model, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "مورد پیدا نشد")
|
||||||
|
db.delete(item)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.routers import auth, projects, articles, brand, contact, newsletter
|
||||||
|
from app.routers.simple_resources import (
|
||||||
|
services_router,
|
||||||
|
stats_router,
|
||||||
|
timeline_router,
|
||||||
|
why_us_router,
|
||||||
|
testimonials_router,
|
||||||
|
team_router,
|
||||||
|
faqs_router,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Aramesh Plaza API",
|
||||||
|
description="بکاند سایت آرامش پلازا — مدیریت محتوا، فرم تماس و خبرنامه",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(brand.router)
|
||||||
|
app.include_router(services_router)
|
||||||
|
app.include_router(projects.router)
|
||||||
|
app.include_router(stats_router)
|
||||||
|
app.include_router(timeline_router)
|
||||||
|
app.include_router(why_us_router)
|
||||||
|
app.include_router(testimonials_router)
|
||||||
|
app.include_router(team_router)
|
||||||
|
app.include_router(articles.router)
|
||||||
|
app.include_router(faqs_router)
|
||||||
|
app.include_router(contact.router)
|
||||||
|
app.include_router(newsletter.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", tags=["Health"])
|
||||||
|
def health_check():
|
||||||
|
return {"status": "ok", "message": "Aramesh Plaza API در حال اجراست"}
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, Integer, Text, DateTime, Boolean, Enum, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectStatus(str, enum.Enum):
|
||||||
|
done = "done"
|
||||||
|
progress = "progress"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUser(Base):
|
||||||
|
__tablename__ = "admin_users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Brand(Base):
|
||||||
|
"""اطلاعات کلی برند - همیشه فقط یک رکورد (id=1) دارد."""
|
||||||
|
__tablename__ = "brand"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
logo: Mapped[str] = mapped_column(String(1024), default="")
|
||||||
|
hero: Mapped[str] = mapped_column(String(1024), default="")
|
||||||
|
contact_bg: Mapped[str] = mapped_column(String(1024), default="")
|
||||||
|
phone: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
phone_href: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
email: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
address: Mapped[str] = mapped_column(String(1024), default="")
|
||||||
|
|
||||||
|
|
||||||
|
class Service(Base):
|
||||||
|
__tablename__ = "services"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
desc: Mapped[str] = mapped_column(Text)
|
||||||
|
icon: Mapped[str] = mapped_column(String(64))
|
||||||
|
order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
location: Mapped[str] = mapped_column(String(255))
|
||||||
|
year: Mapped[str] = mapped_column(String(64))
|
||||||
|
status: Mapped[ProjectStatus] = mapped_column(Enum(ProjectStatus), default=ProjectStatus.progress)
|
||||||
|
image: Mapped[str] = mapped_column(String(1024))
|
||||||
|
gallery: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
area: Mapped[str] = mapped_column(String(128))
|
||||||
|
floors: Mapped[str] = mapped_column(String(128))
|
||||||
|
units: Mapped[str] = mapped_column(String(128))
|
||||||
|
client: Mapped[str] = mapped_column(String(255))
|
||||||
|
summary: Mapped[str] = mapped_column(Text)
|
||||||
|
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Stat(Base):
|
||||||
|
__tablename__ = "stats"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
value: Mapped[int] = mapped_column(Integer)
|
||||||
|
suffix: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
label: Mapped[str] = mapped_column(String(255))
|
||||||
|
order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineItem(Base):
|
||||||
|
__tablename__ = "timeline_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
year: Mapped[str] = mapped_column(String(64))
|
||||||
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
desc: Mapped[str] = mapped_column(Text)
|
||||||
|
image: Mapped[str] = mapped_column(String(1024))
|
||||||
|
order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class WhyUsItem(Base):
|
||||||
|
__tablename__ = "why_us_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
desc: Mapped[str] = mapped_column(Text)
|
||||||
|
order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Testimonial(Base):
|
||||||
|
__tablename__ = "testimonials"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
role: Mapped[str] = mapped_column(String(255))
|
||||||
|
quote: Mapped[str] = mapped_column(Text)
|
||||||
|
order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMember(Base):
|
||||||
|
__tablename__ = "team_members"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
role: Mapped[str] = mapped_column(String(255))
|
||||||
|
bio: Mapped[str] = mapped_column(Text)
|
||||||
|
photo: Mapped[str] = mapped_column(String(1024), default="")
|
||||||
|
order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Article(Base):
|
||||||
|
__tablename__ = "articles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
date: Mapped[str] = mapped_column(String(64))
|
||||||
|
category: Mapped[str] = mapped_column(String(128))
|
||||||
|
image: Mapped[str] = mapped_column(String(1024))
|
||||||
|
read: Mapped[str] = mapped_column(String(32))
|
||||||
|
excerpt: Mapped[str] = mapped_column(Text)
|
||||||
|
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Faq(Base):
|
||||||
|
__tablename__ = "faqs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
q: Mapped[str] = mapped_column(String(512))
|
||||||
|
a: Mapped[str] = mapped_column(Text)
|
||||||
|
order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactLead(Base):
|
||||||
|
__tablename__ = "contact_leads"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
phone: Mapped[str] = mapped_column(String(64))
|
||||||
|
note: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterSubscriber(Base):
|
||||||
|
__tablename__ = "newsletter_subscribers"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import get_current_admin
|
||||||
|
from app.database import get_db
|
||||||
|
from app import models, schemas
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/articles", tags=["Articles"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[schemas.ArticleOut])
|
||||||
|
def list_articles(category: str | None = None, db: Session = Depends(get_db)):
|
||||||
|
query = db.query(models.Article)
|
||||||
|
if category:
|
||||||
|
query = query.filter(models.Article.category == category)
|
||||||
|
return query.order_by(models.Article.order).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}", response_model=schemas.ArticleOut)
|
||||||
|
def get_article(slug: str, db: Session = Depends(get_db)):
|
||||||
|
article = db.query(models.Article).filter(models.Article.slug == slug).first()
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "مقاله پیدا نشد")
|
||||||
|
return article
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=schemas.ArticleOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_article(
|
||||||
|
payload: schemas.ArticleCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
if db.query(models.Article).filter(models.Article.slug == payload.slug).first():
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "این slug قبلاً استفاده شده است")
|
||||||
|
article = models.Article(**payload.model_dump())
|
||||||
|
db.add(article)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(article)
|
||||||
|
return article
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{article_id}", response_model=schemas.ArticleOut)
|
||||||
|
def update_article(
|
||||||
|
article_id: int,
|
||||||
|
payload: schemas.ArticleUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
article = db.get(models.Article, article_id)
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "مقاله پیدا نشد")
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(article, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(article)
|
||||||
|
return article
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{article_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_article(
|
||||||
|
article_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
article = db.get(models.Article, article_id)
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "مقاله پیدا نشد")
|
||||||
|
db.delete(article)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import authenticate_admin, create_access_token
|
||||||
|
from app.database import get_db
|
||||||
|
from app.schemas import Token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["Auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
ورود ادمین. با فرم استاندارد OAuth2 (username, password) کار میکند —
|
||||||
|
یعنی از Swagger UI (/docs) هم مستقیماً قابل تست است.
|
||||||
|
"""
|
||||||
|
user = authenticate_admin(db, form_data.username, form_data.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="نام کاربری یا رمز عبور اشتباه است",
|
||||||
|
)
|
||||||
|
token = create_access_token({"sub": user.username})
|
||||||
|
return Token(access_token=token)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import get_current_admin
|
||||||
|
from app.database import get_db
|
||||||
|
from app import models, schemas
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/brand", tags=["Brand"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_brand(db: Session) -> models.Brand:
|
||||||
|
brand = db.get(models.Brand, 1)
|
||||||
|
if not brand:
|
||||||
|
brand = models.Brand(id=1)
|
||||||
|
db.add(brand)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(brand)
|
||||||
|
return brand
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=schemas.BrandOut)
|
||||||
|
def get_brand(db: Session = Depends(get_db)):
|
||||||
|
"""اطلاعات کلی برند (نام، لوگو، تلفن، ایمیل، آدرس و...) — یک رکورد ثابت."""
|
||||||
|
return _get_or_create_brand(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=schemas.BrandOut)
|
||||||
|
def update_brand(
|
||||||
|
payload: schemas.BrandUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
brand = _get_or_create_brand(db)
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(brand, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(brand)
|
||||||
|
return brand
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import get_current_admin
|
||||||
|
from app.database import get_db
|
||||||
|
from app import models, schemas
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/contact", tags=["Contact"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=schemas.ContactOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def submit_contact(payload: schemas.ContactCreate, db: Session = Depends(get_db)):
|
||||||
|
"""این اندپوینت عمومی است — فرم تماس سایت آن را صدا میزند."""
|
||||||
|
lead = models.ContactLead(**payload.model_dump())
|
||||||
|
db.add(lead)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(lead)
|
||||||
|
return lead
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[schemas.ContactOut])
|
||||||
|
def list_contacts(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""فقط ادمین: لیست همه درخواستهای تماس، جدیدترین اول."""
|
||||||
|
return db.query(models.ContactLead).order_by(models.ContactLead.created_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{lead_id}", response_model=schemas.ContactOut)
|
||||||
|
def update_contact(
|
||||||
|
lead_id: int,
|
||||||
|
payload: schemas.ContactUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""فقط ادمین: مثلاً علامت زدن بهعنوان خواندهشده."""
|
||||||
|
lead = db.get(models.ContactLead, lead_id)
|
||||||
|
if not lead:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "درخواست پیدا نشد")
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(lead, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(lead)
|
||||||
|
return lead
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{lead_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_contact(
|
||||||
|
lead_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
lead = db.get(models.ContactLead, lead_id)
|
||||||
|
if not lead:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "درخواست پیدا نشد")
|
||||||
|
db.delete(lead)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import get_current_admin
|
||||||
|
from app.database import get_db
|
||||||
|
from app import models, schemas
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/newsletter", tags=["Newsletter"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=schemas.NewsletterOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def subscribe(payload: schemas.NewsletterCreate, db: Session = Depends(get_db)):
|
||||||
|
"""اندپوینت عمومی — فرم خبرنامه سایت این را صدا میزند."""
|
||||||
|
existing = db.query(models.NewsletterSubscriber).filter(
|
||||||
|
models.NewsletterSubscriber.email == payload.email
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "این ایمیل قبلاً ثبت شده است")
|
||||||
|
sub = models.NewsletterSubscriber(email=payload.email)
|
||||||
|
db.add(sub)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sub)
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[schemas.NewsletterOut])
|
||||||
|
def list_subscribers(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""فقط ادمین: لیست همه مشترکین خبرنامه."""
|
||||||
|
return db.query(models.NewsletterSubscriber).order_by(
|
||||||
|
models.NewsletterSubscriber.created_at.desc()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sub_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_subscriber(
|
||||||
|
sub_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
sub = db.get(models.NewsletterSubscriber, sub_id)
|
||||||
|
if not sub:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "مشترک پیدا نشد")
|
||||||
|
db.delete(sub)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import get_current_admin
|
||||||
|
from app.database import get_db
|
||||||
|
from app import models, schemas
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/projects", tags=["Projects"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[schemas.ProjectOut])
|
||||||
|
def list_projects(status_filter: str | None = None, db: Session = Depends(get_db)):
|
||||||
|
query = db.query(models.Project)
|
||||||
|
if status_filter:
|
||||||
|
query = query.filter(models.Project.status == status_filter)
|
||||||
|
return query.order_by(models.Project.order).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}", response_model=schemas.ProjectOut)
|
||||||
|
def get_project(slug: str, db: Session = Depends(get_db)):
|
||||||
|
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "پروژه پیدا نشد")
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=schemas.ProjectOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_project(
|
||||||
|
payload: schemas.ProjectCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
if db.query(models.Project).filter(models.Project.slug == payload.slug).first():
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "این slug قبلاً استفاده شده است")
|
||||||
|
project = models.Project(**payload.model_dump())
|
||||||
|
db.add(project)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(project)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}", response_model=schemas.ProjectOut)
|
||||||
|
def update_project(
|
||||||
|
project_id: int,
|
||||||
|
payload: schemas.ProjectUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
project = db.get(models.Project, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "پروژه پیدا نشد")
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(project, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(project)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_project(
|
||||||
|
project_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
project = db.get(models.Project, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "پروژه پیدا نشد")
|
||||||
|
db.delete(project)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from app.crud_router import build_crud_router
|
||||||
|
from app import models, schemas
|
||||||
|
|
||||||
|
services_router = build_crud_router(
|
||||||
|
prefix="/api/services",
|
||||||
|
tag="Services",
|
||||||
|
model=models.Service,
|
||||||
|
create_schema=schemas.ServiceCreate,
|
||||||
|
update_schema=schemas.ServiceUpdate,
|
||||||
|
out_schema=schemas.ServiceOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats_router = build_crud_router(
|
||||||
|
prefix="/api/stats",
|
||||||
|
tag="Stats",
|
||||||
|
model=models.Stat,
|
||||||
|
create_schema=schemas.StatCreate,
|
||||||
|
update_schema=schemas.StatUpdate,
|
||||||
|
out_schema=schemas.StatOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
timeline_router = build_crud_router(
|
||||||
|
prefix="/api/timeline",
|
||||||
|
tag="Timeline",
|
||||||
|
model=models.TimelineItem,
|
||||||
|
create_schema=schemas.TimelineCreate,
|
||||||
|
update_schema=schemas.TimelineUpdate,
|
||||||
|
out_schema=schemas.TimelineOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
why_us_router = build_crud_router(
|
||||||
|
prefix="/api/why-us",
|
||||||
|
tag="WhyUs",
|
||||||
|
model=models.WhyUsItem,
|
||||||
|
create_schema=schemas.WhyUsCreate,
|
||||||
|
update_schema=schemas.WhyUsUpdate,
|
||||||
|
out_schema=schemas.WhyUsOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
testimonials_router = build_crud_router(
|
||||||
|
prefix="/api/testimonials",
|
||||||
|
tag="Testimonials",
|
||||||
|
model=models.Testimonial,
|
||||||
|
create_schema=schemas.TestimonialCreate,
|
||||||
|
update_schema=schemas.TestimonialUpdate,
|
||||||
|
out_schema=schemas.TestimonialOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
team_router = build_crud_router(
|
||||||
|
prefix="/api/team",
|
||||||
|
tag="Team",
|
||||||
|
model=models.TeamMember,
|
||||||
|
create_schema=schemas.TeamMemberCreate,
|
||||||
|
update_schema=schemas.TeamMemberUpdate,
|
||||||
|
out_schema=schemas.TeamMemberOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
faqs_router = build_crud_router(
|
||||||
|
prefix="/api/faqs",
|
||||||
|
tag="FAQs",
|
||||||
|
model=models.Faq,
|
||||||
|
create_schema=schemas.FaqCreate,
|
||||||
|
update_schema=schemas.FaqUpdate,
|
||||||
|
out_schema=schemas.FaqOut,
|
||||||
|
)
|
||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
|
||||||
|
|
||||||
|
from app.models import ProjectStatus
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Auth ----------
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Brand ----------
|
||||||
|
class BrandBase(BaseModel):
|
||||||
|
name: str = ""
|
||||||
|
logo: str = ""
|
||||||
|
hero: str = ""
|
||||||
|
contact_bg: str = ""
|
||||||
|
phone: str = ""
|
||||||
|
phone_href: str = ""
|
||||||
|
email: str = ""
|
||||||
|
address: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class BrandOut(BrandBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
class BrandUpdate(BrandBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Service ----------
|
||||||
|
class ServiceBase(BaseModel):
|
||||||
|
title: str
|
||||||
|
desc: str
|
||||||
|
icon: str
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCreate(ServiceBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
desc: Optional[str] = None
|
||||||
|
icon: Optional[str] = None
|
||||||
|
order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceOut(ServiceBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Project ----------
|
||||||
|
class ProjectBase(BaseModel):
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
location: str
|
||||||
|
year: str
|
||||||
|
status: ProjectStatus
|
||||||
|
image: str
|
||||||
|
gallery: list[str] = []
|
||||||
|
area: str
|
||||||
|
floors: str
|
||||||
|
units: str
|
||||||
|
client: str
|
||||||
|
summary: str
|
||||||
|
body: list[str] = []
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreate(ProjectBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUpdate(BaseModel):
|
||||||
|
slug: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
year: Optional[str] = None
|
||||||
|
status: Optional[ProjectStatus] = None
|
||||||
|
image: Optional[str] = None
|
||||||
|
gallery: Optional[list[str]] = None
|
||||||
|
area: Optional[str] = None
|
||||||
|
floors: Optional[str] = None
|
||||||
|
units: Optional[str] = None
|
||||||
|
client: Optional[str] = None
|
||||||
|
summary: Optional[str] = None
|
||||||
|
body: Optional[list[str]] = None
|
||||||
|
order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectOut(ProjectBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Stat ----------
|
||||||
|
class StatBase(BaseModel):
|
||||||
|
value: int
|
||||||
|
suffix: str = ""
|
||||||
|
label: str
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class StatCreate(StatBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StatUpdate(BaseModel):
|
||||||
|
value: Optional[int] = None
|
||||||
|
suffix: Optional[str] = None
|
||||||
|
label: Optional[str] = None
|
||||||
|
order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StatOut(StatBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Timeline ----------
|
||||||
|
class TimelineBase(BaseModel):
|
||||||
|
year: str
|
||||||
|
title: str
|
||||||
|
desc: str
|
||||||
|
image: str
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineCreate(TimelineBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineUpdate(BaseModel):
|
||||||
|
year: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
desc: Optional[str] = None
|
||||||
|
image: Optional[str] = None
|
||||||
|
order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineOut(TimelineBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- WhyUs ----------
|
||||||
|
class WhyUsBase(BaseModel):
|
||||||
|
title: str
|
||||||
|
desc: str
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class WhyUsCreate(WhyUsBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WhyUsUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
desc: Optional[str] = None
|
||||||
|
order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WhyUsOut(WhyUsBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Testimonial ----------
|
||||||
|
class TestimonialBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
role: str
|
||||||
|
quote: str
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestimonialCreate(TestimonialBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestimonialUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
role: Optional[str] = None
|
||||||
|
quote: Optional[str] = None
|
||||||
|
order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TestimonialOut(TestimonialBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Team ----------
|
||||||
|
class TeamMemberBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
role: str
|
||||||
|
bio: str
|
||||||
|
photo: str = ""
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMemberCreate(TeamMemberBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMemberUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
role: Optional[str] = None
|
||||||
|
bio: Optional[str] = None
|
||||||
|
photo: Optional[str] = None
|
||||||
|
order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMemberOut(TeamMemberBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Article ----------
|
||||||
|
class ArticleBase(BaseModel):
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
date: str
|
||||||
|
category: str
|
||||||
|
image: str
|
||||||
|
read: str
|
||||||
|
excerpt: str
|
||||||
|
body: list[str] = []
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleCreate(ArticleBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleUpdate(BaseModel):
|
||||||
|
slug: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
date: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
image: Optional[str] = None
|
||||||
|
read: Optional[str] = None
|
||||||
|
excerpt: Optional[str] = None
|
||||||
|
body: Optional[list[str]] = None
|
||||||
|
order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleOut(ArticleBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- FAQ ----------
|
||||||
|
class FaqBase(BaseModel):
|
||||||
|
q: str
|
||||||
|
a: str
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class FaqCreate(FaqBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FaqUpdate(BaseModel):
|
||||||
|
q: Optional[str] = None
|
||||||
|
a: Optional[str] = None
|
||||||
|
order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FaqOut(FaqBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Contact ----------
|
||||||
|
class ContactCreate(BaseModel):
|
||||||
|
name: str = ""
|
||||||
|
phone: str
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
@field_validator("phone")
|
||||||
|
@classmethod
|
||||||
|
def phone_min_length(cls, v: str) -> str:
|
||||||
|
if len(v.strip()) < 8:
|
||||||
|
raise ValueError("شماره تلفن معتبر نیست")
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class ContactOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
phone: str
|
||||||
|
note: str
|
||||||
|
is_read: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUpdate(BaseModel):
|
||||||
|
is_read: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Newsletter ----------
|
||||||
|
class NewsletterCreate(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
sqlalchemy==2.0.35
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
|
alembic==1.13.2
|
||||||
|
pydantic==2.9.2
|
||||||
|
email-validator==2.2.0
|
||||||
|
pydantic-settings==2.5.2
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.0.1
|
||||||
|
python-multipart==0.0.9
|
||||||
|
python-dotenv==1.0.1
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
اسکریپت ساخت اولین کاربر ادمین.
|
||||||
|
اجرا: python scripts/create_admin.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from getpass import getpass
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import AdminUser
|
||||||
|
from app.auth import hash_password
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
username = input("نام کاربری ادمین: ").strip()
|
||||||
|
password = getpass("رمز عبور: ").strip()
|
||||||
|
password2 = getpass("تکرار رمز عبور: ").strip()
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
print("نام کاربری و رمز عبور نمیتوانند خالی باشند.")
|
||||||
|
return
|
||||||
|
if password != password2:
|
||||||
|
print("رمزهای عبور یکسان نیستند.")
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
existing = db.query(AdminUser).filter(AdminUser.username == username).first()
|
||||||
|
if existing:
|
||||||
|
print(f"کاربری با نام '{username}' از قبل وجود دارد.")
|
||||||
|
return
|
||||||
|
admin = AdminUser(username=username, hashed_password=hash_password(password))
|
||||||
|
db.add(admin)
|
||||||
|
db.commit()
|
||||||
|
print(f"کاربر ادمین '{username}' با موفقیت ساخته شد.")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
این اسکریپت دیتابیس رو با همون محتوایی که الان توی فرانتاند (src/data/site.ts)
|
||||||
|
بهصورت ثابت نوشته شده، پر میکنه. اینطوری وقتی فرانت رو به بکاند وصل کردی،
|
||||||
|
سایت خالی نمیمونه و دقیقاً همون محتوای فعلی رو از دیتابیس میگیره.
|
||||||
|
|
||||||
|
اجرا: python scripts/seed_data.py
|
||||||
|
(چند بار اجرا کردنش مشکلی نداره -- اگه رکوردی از قبل باشه، دوباره اضافه نمیکنه)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app import models
|
||||||
|
|
||||||
|
IMAGES = [
|
||||||
|
'https://i.postimg.cc/wTQ0QFkG/Gemini-Generated-Image-1tjszd1tjszd1tjs.png',
|
||||||
|
'https://i.postimg.cc/1zfBPS1G/Gemini-Generated-Image-at69yhat69yhat69.png',
|
||||||
|
'https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png',
|
||||||
|
'https://i.postimg.cc/4xFvW0HX/Gemini-Generated-Image-n7mhonn7mhonn7mh.png',
|
||||||
|
'https://i.postimg.cc/N036cr4s/Gemini-Generated-Image-ytbhvvytbhvvytbh.png',
|
||||||
|
]
|
||||||
|
|
||||||
|
BRAND = dict(
|
||||||
|
name='آرامش پلازا',
|
||||||
|
logo='https://i.postimg.cc/XYNrsHsM/Chat-GPT-Image-Aug-6-2026-04-34-46-PM.png',
|
||||||
|
hero='https://i.postimg.cc/J0bKgQVg/Gemini-Generated-Image-mdh969mdh969mdh9.png',
|
||||||
|
contact_bg='https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png',
|
||||||
|
phone='۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰',
|
||||||
|
phone_href='tel:+982188742210',
|
||||||
|
email='info@arameshplaza.com',
|
||||||
|
address='تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم',
|
||||||
|
)
|
||||||
|
|
||||||
|
SERVICES = [
|
||||||
|
dict(title='طراحی معماری', desc='خلق فرمهای معاصر با نگاه ایرانی؛ از کانسپت اولیه تا نقشههای اجرایی و جزئیات نما.', icon='draft'),
|
||||||
|
dict(title='ساخت برجهای مسکونی', desc='اجرای برجهای بلندمرتبه با استانداردهای سازهای بینالمللی و کیفیت تحویل بینقص.', icon='tower'),
|
||||||
|
dict(title='مجتمعهای تجاری', desc='طراحی و ساخت مراکز تجاری با مطالعه دقیق جریان مخاطب و بازدهی بلندمدت سرمایه.', icon='mall'),
|
||||||
|
dict(title='ویلاسازی', desc='ویلاهای اختصاصی همنشین با طبیعت؛ محرمانگی، نور طبیعی و مصالح ماندگار.', icon='villa'),
|
||||||
|
dict(title='بازسازی', desc='بازآفرینی ساختمانهای موجود با ارتقای سازه، نما و تجربه زندگی در فضا.', icon='renew'),
|
||||||
|
dict(title='مدیریت پروژه', desc='کنترل همزمان زمان، هزینه و کیفیت با گزارشدهی شفاف در تمام مراحل ساخت.', icon='manage'),
|
||||||
|
dict(title='سرمایهگذاری ساختمانی', desc='ساختارهای مشارکت شفاف، ارزیابی ریسک و طراحی سبد ملکی متناسب با سرمایه شما.', icon='invest'),
|
||||||
|
dict(title='محوطهسازی و لنداسکیپ', desc='طراحی حیاط، بام سبز و مسیرهای دسترسی؛ ادامه معماری در فضای باز پروژه.', icon='landscape'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATS = [
|
||||||
|
dict(value=21, suffix='+', label='سال تجربه ساخت'),
|
||||||
|
dict(value=47, suffix='', label='پروژه تحویلشده'),
|
||||||
|
dict(value=860, suffix='هزار', label='مترمربع زیربنا'),
|
||||||
|
dict(value=1200, suffix='+', label='خانواده ساکن'),
|
||||||
|
]
|
||||||
|
|
||||||
|
PROJECTS = [
|
||||||
|
dict(slug='aramesh-elahieh', title='برج آرامش الهیه', location='تهران، الهیه', year='۱۴۰۲', status='done',
|
||||||
|
image=IMAGES[0], gallery=[IMAGES[0], IMAGES[3], IMAGES[1]], area='۲۴٬۰۰۰ متر مربع', floors='۱۸ طبقه',
|
||||||
|
units='۵۶ واحد', client='سرمایهگذاری خصوصی',
|
||||||
|
summary='یک برج مسکونی لوکس با نمای سنگ تراورتن و پوسته دوپوستهای که مصرف انرژی را تا ۳۵ درصد کاهش داده است.',
|
||||||
|
body=[
|
||||||
|
'برج آرامش الهیه روی زمینی با شیب طبیعی شمالی طراحی شد؛ همین شیب فرصت داد تا لابی، استخر و فضاهای مشترک در سه تراز مختلف بنشینند و همه واحدها دید بیواسطه به شهر داشته باشند.',
|
||||||
|
'پوسته بیرونی ترکیبی از سنگ تراورتن روشن و پنجرههای قدی با شیشه سهجداره است. سایهبانهای عمودی مسیرنگ، نور مستقیم تابستان را میشکنند و در زمستان اجازه ورود کامل نور را میدهند.',
|
||||||
|
'اجرای پروژه در سیودو ماه به پایان رسید و تمام واحدها پیش از تحویل کلید به فروش رسیدند.',
|
||||||
|
]),
|
||||||
|
dict(slug='parseh-mall', title='مجتمع تجاری پارسه', location='اصفهان، مرکز شهر', year='۱۴۰۱', status='done',
|
||||||
|
image=IMAGES[1], gallery=[IMAGES[1], IMAGES[2], IMAGES[4]], area='۳۸٬۵۰۰ متر مربع', floors='۷ طبقه',
|
||||||
|
units='۱۴۰ واحد تجاری', client='گروه سرمایهگذاری پارسه',
|
||||||
|
summary='مرکز تجاری با آتریوم مرکزی و نورگیر سقفی که جریان مخاطب را بهصورت طبیعی در همه ترازها توزیع میکند.',
|
||||||
|
body=[
|
||||||
|
'مطالعه رفتار مخاطب پیش از طراحی نشان داد کوتاهترین مسیر عمودی، کلید موفقیت تجاری این مجموعه است؛ بنابراین آتریوم مرکزی با پلهبرقیهای قطری در قلب پلان قرار گرفت.',
|
||||||
|
'نورگیر سقفی با شیشه لو-ای، روشنایی روز را تا تراز منفی دو میرساند و مصرف روشنایی مصنوعی را بهطور محسوس کاهش میدهد.',
|
||||||
|
'مجموعه دو ماه پیش از موعد قراردادی به بهرهبرداری رسید.',
|
||||||
|
]),
|
||||||
|
dict(slug='daryakenar-villas', title='ویلاهای دریاکنار', location='مازندران، نوشهر', year='۱۴۰۰', status='done',
|
||||||
|
image=IMAGES[2], gallery=[IMAGES[2], IMAGES[4], IMAGES[0]], area='۹٬۲۰۰ متر مربع', floors='۲ طبقه',
|
||||||
|
units='۱۲ ویلا', client='مجموعه خصوصی دریاکنار',
|
||||||
|
summary='دوازده ویلای اختصاصی با چیدمان شانهای که برای هر واحد، دید مستقیم به دریا و محرمانگی کامل فراهم میکند.',
|
||||||
|
body=[
|
||||||
|
'چیدمان شانهای ویلاها نتیجه دهها بررسی زاویه دید بود؛ هیچ ویلایی در دید ویلای دیگر نیست و همه به دریا باز میشوند.',
|
||||||
|
'مصالح، پاسخی به اقلیم مرطوب شمال است: چوب ترموود، بتن اکسپوز و پوششهای ضد نمک برای فلزات نما.',
|
||||||
|
'محوطه با گیاهان بومی طراحی شد تا نیاز آبیاری در طول سال حداقل باشد.',
|
||||||
|
]),
|
||||||
|
dict(slug='niavaran-twins', title='برج دوقلوی نیاوران', location='تهران، نیاوران', year='۱۳۹۹', status='done',
|
||||||
|
image=IMAGES[3], gallery=[IMAGES[3], IMAGES[0], IMAGES[2]], area='۳۱٬۷۰۰ متر مربع', floors='۲۰ طبقه',
|
||||||
|
units='۸۰ واحد', client='مشارکت با مالکان زمین',
|
||||||
|
summary='دو برج همسان با پادست مشترک که فضاهای رفاهی و پارکینگها را در یک تراز واحد ادغام میکند.',
|
||||||
|
body=[
|
||||||
|
'دو برج روی یک پادست مشترک نشستهاند؛ این تصمیم اجازه داد فضاهای رفاهی، سالن اجتماعات و پارکینگها بین دو ساختمان مشترک شوند و سطح اشغال باغ کاهش یابد.',
|
||||||
|
'سازه با سیستم دیوار برشی و دال پسکشیده اجرا شد تا دهانههای داخلی آزاد و پلانها قابل شخصیسازی باشند.',
|
||||||
|
]),
|
||||||
|
dict(slug='kish-garden-tower', title='باغبرج کیش', location='کیش، مروارید', year='۱۴۰۵ (پیشبینی)', status='progress',
|
||||||
|
image=IMAGES[4], gallery=[IMAGES[4], IMAGES[1], IMAGES[3]], area='۱۸٬۴۰۰ متر مربع', floors='۱۴ طبقه',
|
||||||
|
units='۶۲ واحد اقامتی', client='صندوق پروژهمحور آرامش',
|
||||||
|
summary='باغبرجی اقامتی با تراسهای سبز پیوسته که در حال ساخت است؛ پیشرفت فیزیکی پروژه ۴۸ درصد.',
|
||||||
|
body=[
|
||||||
|
'تراسهای سبز پیوسته، پوستهای زنده برای ساختمان میسازند که در اقلیم گرم جزیره، دمای سطح نما را بهطور محسوس پایین میآورد.',
|
||||||
|
'در حال حاضر سازه تا تراز طبقه نهم اجرا شده و نصب نما از ضلع جنوبی آغاز شده است.',
|
||||||
|
]),
|
||||||
|
dict(slug='aramesh-business-center', title='مرکز تجاری آرامش تهران', location='تهران، سعادتآباد', year='۱۴۰۶ (پیشبینی)', status='progress',
|
||||||
|
image=IMAGES[1], gallery=[IMAGES[1], IMAGES[3], IMAGES[4]], area='۲۷٬۳۰۰ متر مربع', floors='۱۲ طبقه',
|
||||||
|
units='۹۰ دفتر اداری', client='مشارکت با هلدینگ آرامش',
|
||||||
|
summary='مجتمع اداری-تجاری با هسته مرکزی شفاف و لابی دوبلکس؛ در مرحله اجرای سفتکاری.',
|
||||||
|
body=[
|
||||||
|
'برنامهریزی فضایی این مجموعه بر اساس دفاتر انعطافپذیر انجام شد؛ هر تراز میتواند به دو، سه یا شش دفتر مستقل تقسیم شود.',
|
||||||
|
'اجرای تأسیسات مکانیکی بهصورت همزمان با سفتکاری پیش میرود تا زمان کل پروژه کوتاهتر شود.',
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
TIMELINE = [
|
||||||
|
dict(year='۱۳۸۳', title='آغاز مسیر', desc='تأسیس دفتر معماری آرامش با تیمی چهارنفره و نخستین پروژه بازسازی در تهران.', image=IMAGES[2]),
|
||||||
|
dict(year='۱۳۸۵', title='نخستین ساخت مستقل', desc='تحویل اولین ساختمان مسکونی هفتطبقه؛ آغاز همکاری با پیمانکاران سازهای بزرگ.', image=IMAGES[0]),
|
||||||
|
dict(year='۱۳۸۷', title='ورود به بازار تجاری', desc='طراحی و اجرای اولین مجتمع تجاری با رویکرد بازدهی سرمایه و جریان مخاطب.', image=IMAGES[1]),
|
||||||
|
dict(year='۱۳۸۹', title='استاندارد بینالمللی', desc='استقرار نظام کنترل کیفیت و دریافت گواهینامههای مدیریت پروژه.', image=IMAGES[4]),
|
||||||
|
dict(year='۱۳۹۵', title='بلندمرتبهسازی', desc='آغاز ساخت نخستین برج بیستطبقه شرکت در شمال تهران.', image=IMAGES[3]),
|
||||||
|
dict(year='۱۴۰۳', title='آرامش پلازا امروز', desc='پرتفوی ۴۷ پروژه، دفاتر در سه شهر و صندوق سرمایهگذاری ساختمانی اختصاصی.', image=IMAGES[0]),
|
||||||
|
]
|
||||||
|
|
||||||
|
WHY = [
|
||||||
|
dict(title='کیفیت بدون مصالحه', desc='انتخاب مصالح و مجریان بر اساس ماندگاری چنددههای، نه سریعترین تحویل.'),
|
||||||
|
dict(title='شفافیت مالی کامل', desc='گزارش ماهانه هزینهها و پیشرفت فیزیکی برای همه شرکای پروژه.'),
|
||||||
|
dict(title='تحویل بهموقع', desc='۹۴٪ پروژههای ما در بازه زمانی قراردادی یا زودتر تحویل داده شدهاند.'),
|
||||||
|
dict(title='طراحی اختصاصی', desc='هیچ نقشهای دو بار اجرا نمیشود؛ هر پروژه هویت معماری خودش را دارد.'),
|
||||||
|
dict(title='خدمات پس از تحویل', desc='پنج سال پشتیبانی فنی و نگهداری تأسیسات پس از تحویل کلید.'),
|
||||||
|
dict(title='پایداری و انرژی', desc='طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
TESTIMONIALS = [
|
||||||
|
dict(name='مهندس سارا کیانی', role='سرمایهگذار پروژه پارسه', quote='گزارشهای ماهانه آرامش پلازا آنقدر دقیق بود که هیچوقت نیاز نشد نگران سرمایهام باشم. پروژه دو ماه زودتر از موعد به بهرهبرداری رسید.'),
|
||||||
|
dict(name='دکتر امیرحسین راد', role='ساکن برج الهیه', quote='کیفیت اجرا در جزئیاتی دیده میشود که معمولاً کسی به آنها فکر نمیکند؛ از عایق صوتی پنجرهها تا نور غیرمستقیم راهروها.'),
|
||||||
|
dict(name='مریم دلاور', role='مدیر مجموعه ویلاهای دریاکنار', quote='همکاری با تیم طراحی، تجربهای واقعاً حرفهای بود. ایدههای ما را شنیدند و به زبان معماری ترجمه کردند.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
TEAM = [
|
||||||
|
dict(name='کامران آرامش', role='بنیانگذار و مدیرعامل', bio='بیش از سه دهه تجربه در ساخت بلندمرتبه.'),
|
||||||
|
dict(name='نگار شریفی', role='مدیر ارشد طراحی', bio='معمار ارشد، فارغالتحصیل دانشگاه تهران و میلان.'),
|
||||||
|
dict(name='بهزاد مرادی', role='مدیر اجرایی پروژهها', bio='مسئول کنترل کیفیت و زمانبندی همه کارگاهها.'),
|
||||||
|
dict(name='الهام نیکآیین', role='مدیر سرمایهگذاری', bio='طراح ساختارهای مشارکت و تحلیل بازدهی ملکی.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
ARTICLES = [
|
||||||
|
dict(slug='natural-light', title='معماری آرام: چرا نور طبیعی گرانترین مصالح ساختمان است؟', date='۲۴ مرداد ۱۴۰۳',
|
||||||
|
category='معماری', image=IMAGES[1], read='۶ دقیقه',
|
||||||
|
excerpt='نور طبیعی نهتنها کیفیت زندگی در فضا را تعیین میکند، بلکه مستقیماً بر مصرف انرژی و ارزش بلندمدت ملک اثر میگذارد.',
|
||||||
|
body=[
|
||||||
|
'در بازار ساختوساز، ارزش یک واحد اغلب با متراژ سنجیده میشود؛ اما تجربه ما در بیست سال گذشته نشان داده کیفیت نور، تعیینکنندهتر از چند متر اضافه است.',
|
||||||
|
'جهتگیری ساختمان، عمق دهانه و ارتفاع کف تا سقف، سه متغیر اصلی نور طبیعی هستند. تصمیم درباره هر سه، پیش از ترسیم اولین پلان گرفته میشود و بعد از آن تقریباً برگشتپذیر نیست.',
|
||||||
|
'در برج آرامش الهیه، چرخش ۱۲ درجهای توده ساختمان نسبت به شمال، دو ساعت نور مفید بیشتر در روزهای زمستان به واحدها اضافه کرد؛ تصمیمی بیهزینه با اثری چنددههای.',
|
||||||
|
'برای انتخاب یک واحد، پیشنهاد ما ساده است: بازدید را در ساعتهای میانی صبح و اواخر بعدازظهر انجام دهید و کیفیت نور را در همان ساعتهایی بسنجید که واقعاً در خانه هستید.',
|
||||||
|
]),
|
||||||
|
dict(slug='real-estate-portfolio', title='راهنمای انتخاب سبد سرمایهگذاری ملکی در بازار امروز', date='۱۰ مرداد ۱۴۰۳',
|
||||||
|
category='سرمایهگذاری', image=IMAGES[3], read='۹ دقیقه',
|
||||||
|
excerpt='سه معیار کلیدی برای تشکیل سبدی متعادل از دارایی ملکی، بر اساس افق زمانی و سطح ریسک شما.',
|
||||||
|
body=[
|
||||||
|
'سبد ملکی متعادل، ترکیبی از داراییهای با نقدشوندگی متفاوت است؛ نه همه سرمایه در یک پروژه بلندمدت، نه همه در واحدهای آماده.',
|
||||||
|
'افق زمانی، نخستین معیار است. اگر برنامه شما کمتر از دو سال است، پیشخرید در پروژهای که تازه شروع شده انتخاب پرریسکی خواهد بود.',
|
||||||
|
'معیار دوم شفافیت گزارشدهی است. بدون گزارش ماهانه پیشرفت فیزیکی و مالی، ارزیابی وضعیت سرمایه شما عملاً غیرممکن است.',
|
||||||
|
'معیار سوم، تنوع کاربری است. ترکیب مسکونی و تجاری، نوسان بازدهی سبد را در دورههای رکود محسوس کاهش میدهد.',
|
||||||
|
]),
|
||||||
|
dict(slug='facade-quality', title='پنج نکته فنی که کیفیت نما را پس از ده سال مشخص میکند', date='۲۸ تیر ۱۴۰۳',
|
||||||
|
category='اجرا', image=IMAGES[4], read='۵ دقیقه',
|
||||||
|
excerpt='نمای ساختمان در سال اول زیبا به نظر میرسد؛ تفاوت کیفیت اجرا در سال دهم آشکار میشود.',
|
||||||
|
body=[
|
||||||
|
'اتصالات مکانیکی سنگ نما، مهمتر از خود سنگ است. اتصال چسبی در اقلیم سردوگرم ایران عمر کوتاهی دارد.',
|
||||||
|
'درزهای انقباضی باید در نقشه دیده شوند، نه در کارگاه تصمیمگیری شوند؛ نبودشان اولین دلیل ترکهای افقی نما است.',
|
||||||
|
'آببندی دور پنجره، پرتکرارترین نقطه شکست است. اجرای نوار آببند پیش از نصب قرنیز، این ریسک را حذف میکند.',
|
||||||
|
'زیرسازی گالوانیزه گرم، در برابر زیرسازی رنگشده، تفاوت قیمت اندکی دارد و تفاوت عمر بسیار زیادی.',
|
||||||
|
'در پایان، برنامه بازدید دورهای سالانه نما را جدی بگیرید؛ ترمیم یک درز، هزینهای در حد هیچ در برابر بازسازی کل نما است.',
|
||||||
|
]),
|
||||||
|
dict(slug='commercial-yield', title='چگونه بازدهی یک پروژه تجاری را پیش از ساخت تخمین بزنیم؟', date='۱۵ تیر ۱۴۰۳',
|
||||||
|
category='سرمایهگذاری', image=IMAGES[2], read='۷ دقیقه',
|
||||||
|
excerpt='مدل سادهای برای برآورد بازدهی، بر پایه جریان مخاطب، ترکیب واحدها و نرخ اجاره منطقه.',
|
||||||
|
body=[
|
||||||
|
'برآورد بازدهی تجاری با سه عدد آغاز میشود: جریان روزانه مخاطب، نرخ اجاره هر مترمربع در منطقه و نسبت سطح مفید به کل.',
|
||||||
|
'خطای رایج، نادیدهگرفتن ترازهای بالاتر است. بدون طراحی درست مسیر عمودی، ترازهای سه به بالا معمولاً نیمی از نرخ تراز همکف را هم بهدست نمیآورند.',
|
||||||
|
'در نهایت، سناریوی محافظهکارانه را هم بنویسید؛ اگر پروژه در سناریوی بدبینانه هم توجیه داشت، سرمایهگذاری منطقی است.',
|
||||||
|
]),
|
||||||
|
dict(slug='facade-materials', title='مصالح نما: مقایسه سنگ، کامپوزیت و ترموود در اقلیم ایران', date='۲ تیر ۱۴۰۳',
|
||||||
|
category='اجرا', image=IMAGES[0], read='۸ دقیقه',
|
||||||
|
excerpt='هر مصالح نما در اقلیم متفاوتی درست کار میکند؛ انتخاب اشتباه، هزینه نگهداری را چند برابر میکند.',
|
||||||
|
body=[
|
||||||
|
'سنگ در اقلیم خشک، بهترین ماندگاری را دارد؛ اما وزن آن نیازمند زیرسازی دقیق و محاسبه سازهای است.',
|
||||||
|
'کامپوزیت انتخاب سریع و سبکی است، مشروط بر انتخاب گرید ضد آتش و درنظرگرفتن انقباض حرارتی در طول ورق.',
|
||||||
|
'ترموود در اقلیم مرطوب شمال، بهترین پاسخ زیباییشناسانه است، ولی برنامه نگهداری دوسالانه دارد.',
|
||||||
|
]),
|
||||||
|
dict(slug='renovate-or-rebuild', title='بازسازی یا نوسازی؟ معیارهای تصمیمگیری برای مالکان', date='۲۰ خرداد ۱۴۰۳',
|
||||||
|
category='معماری', image=IMAGES[1], read='۶ دقیقه',
|
||||||
|
excerpt='اگر عمر سازه و ضوابط شهری اجازه دهند، بازسازی میتواند بازدهی بالاتری از تخریب کامل داشته باشد.',
|
||||||
|
body=[
|
||||||
|
'نخست وضعیت سازه ارزیابی میشود؛ اگر مقاومسازی هزینهای بیش از ۴۰ درصد ساخت نو دارد، نوسازی منطقیتر است.',
|
||||||
|
'دومین معیار، ضوابط شهری روز است. گاهی تخریب، سطح زیربنای مجاز کمتری به شما میدهد و این تصمیم را کاملاً تغییر میدهد.',
|
||||||
|
'در بازسازی، بیشترین ارزش افزوده از سه جا میآید: نما، ورودی و بازآرایی پلان داخلی.',
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
FAQS = [
|
||||||
|
dict(q='حداقل سرمایه برای مشارکت در پروژههای آرامش پلازا چقدر است؟', a='ساختار مشارکت هر پروژه متفاوت است، اما معمولاً واحدهای سرمایهگذاری از سطح مشخصی آغاز میشوند و جزئیات دقیق در جلسه ارزیابی اولیه ارائه میشود.'),
|
||||||
|
dict(q='مدت زمان معمول ساخت یک برج مسکونی چقدر است؟', a='بسته به تعداد طبقات و شرایط زمین، میانگین اجرای یک برج مسکونی بین ۲۸ تا ۴۲ ماه از زمان صدور پروانه تا تحویل کلید است.'),
|
||||||
|
dict(q='آیا امکان تغییر نقشه داخلی واحد وجود دارد؟', a='بله. تا پیش از پایان مرحله سفتکاری، امکان شخصیسازی پلان داخلی با هماهنگی تیم طراحی و تأیید سازه فراهم است.'),
|
||||||
|
dict(q='خدمات پس از تحویل شامل چه مواردی میشود؟', a='پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دورهای سالانه و تضمین اجرای نما و عایقکاری.'),
|
||||||
|
dict(q='پروژهها در چه شهرهایی اجرا میشوند؟', a='در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژهای نیز در جزیره کیش در دست ساخت است.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# برند (تک رکورد)
|
||||||
|
if not db.get(models.Brand, 1):
|
||||||
|
db.add(models.Brand(id=1, **BRAND))
|
||||||
|
print("✓ برند اضافه شد")
|
||||||
|
|
||||||
|
def add_all(model, rows, unique_field=None):
|
||||||
|
added = 0
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
if unique_field:
|
||||||
|
exists = db.query(model).filter(
|
||||||
|
getattr(model, unique_field) == row[unique_field]
|
||||||
|
).first()
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
db.add(model(order=i, **row))
|
||||||
|
added += 1
|
||||||
|
print(f"✓ {added} رکورد به {model.__tablename__} اضافه شد")
|
||||||
|
|
||||||
|
add_all(models.Service, SERVICES)
|
||||||
|
add_all(models.Project, PROJECTS, unique_field='slug')
|
||||||
|
add_all(models.Stat, STATS)
|
||||||
|
add_all(models.TimelineItem, TIMELINE)
|
||||||
|
add_all(models.WhyUsItem, WHY)
|
||||||
|
add_all(models.Testimonial, TESTIMONIALS)
|
||||||
|
add_all(models.TeamMember, TEAM)
|
||||||
|
add_all(models.Article, ARTICLES, unique_field='slug')
|
||||||
|
add_all(models.Faq, FAQS)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print("\n✅ seed کامل شد.")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed()
|
||||||
Reference in New Issue
Block a user