diff --git a/.env.example b/.env.example index 6f1b154..f88754f 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,17 @@ -# ========================================== -# آدرس اتصال به دیتابیس PostgreSQL -# فرمت: postgresql://USERNAME:PASSWORD@HOST:PORT/DBNAME -# ========================================== -DATABASE_URL=postgresql://aramesh_user:aramesh_pass@localhost:5432/aramesh_db +# کپی کنید و نام آن را بگذارید ‎.env‎ — مقادیر را با تنظیمات خودتان جایگزین کنید -# ========================================== -# کلید مخفی برای امضای توکن‌های JWT -# حتماً یک رشته‌ی رندوم و طولانی بذار (برای پروداکشن) -# با این دستور می‌تونی یکی بسازی: openssl rand -hex 32 -# ========================================== -SECRET_KEY=change-this-to-a-long-random-secret-string +# رشته اتصال به PostgreSQL (پسورد، نام کاربری و نام دیتابیس را تنظیم کنید) +DATABASE_URL=postgresql+psycopg://postgres:YOUR_PASSWORD@localhost:5432/arameshplaza_api -# مدت اعتبار توکن ورود ادمین (به دقیقه) +# کلید امضای توکن JWT — حتماً یک مقدار تصادفی طولانی بگذارید +SECRET_KEY=change-me-to-a-long-random-string + +# مدت اعتبار توکن مدیریت بر حسب دقیقه (پیش‌فرض: ۲۴ ساعت) ACCESS_TOKEN_EXPIRE_MINUTES=1440 -# آدرس(های) فرانت‌اند که اجازه دارن به این بک‌اند وصل بشن (با کاما جدا کن) -CORS_ORIGINS=http://localhost:5173,https://aramesh-six.vercel.app +# آدرس‌های مجاز برای CORS (لیست JSON — آدرس فرانت‌اند خود را اضافه کنید) +CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"] + +# مشخصات ادمین پیش‌فرض که هنگام seed ساخته می‌شود +ADMIN_USERNAME=admin +ADMIN_PASSWORD=aramesh123 diff --git a/.gitignore b/.gitignore index 2678da7..aafabe2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,20 @@ -.env +# Python __pycache__/ -*.pyc +*.py[cod] +*.egg-info/ .venv/ venv/ -*.egg-info/ +env/ + +# Environment +.env + +# IDE / OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# Database +*.sqlite3 +*.db diff --git a/README.md b/README.md index af5bac6..3cf3f45 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,154 @@ -# بک‌اند آرامش پلازا (FastAPI + PostgreSQL) +# بک‌اند آرامش پلازا (ArameshPlaza API) -این بک‌اند برای سایت **آرامش پلازا** ساخته شده و شامل: -- API کامل برای مدیریت محتوا: پروژه‌ها، مقالات، خدمات، تیم، نظرات مشتریان، تایم‌لاین، آمار، سوالات متداول، اطلاعات برند -- فرم تماس (ذخیره در دیتابیس) -- فرم خبرنامه (ذخیره در دیتابیس) -- ورود ادمین با JWT برای مدیریت محتوا (ایجاد/ویرایش/حذف) -- مستندات خودکار API (Swagger) روی آدرس `/docs` +بک‌اند سایت آرامش پلازا — ساخته‌شده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT**. -قبلاً همه‌ی این محتوا داخل فایل `src/data/site.ts` توی فرانت‌اند به‌صورت ثابت نوشته شده بود. با این بک‌اند، همون محتوا از دیتابیس میاد و می‌تونی بدون نیاز به دیپلوی دوباره‌ی فرانت، محتوا رو تغییر بدی. +## امکانات ---- +- **API عمومی محتوا**: برند، خدمات، پروژه‌ها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایم‌لاین، آمار، چرا آرامش، و بخش قبل/بعد +- **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`) +- **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`) +- **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیام‌های تماس و مشترکین +- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار می‌گیرد +- مستندات تعاملی Swagger در مسیر `/docs` -## پیش‌نیازها +## راه‌اندازی -روی سیستم خودت باید این دو تا نصب باشن: -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 -``` +- Python 3.11 یا جدیدتر +- PostgreSQL در حال اجرا (پورت پیش‌فرض 5432) ---- - -## قدم ۱: ساخت دیتابیس - -بعد از نصب PostgreSQL، یک ترمینال باز کن و وارد psql شو: +### ۲. نصب وابستگی‌ها ```bash -psql -U postgres -``` +cd arameshplaza-backend +python -m venv .venv -(رمزی که موقع نصب گذاشتی رو وارد کن). حالا داخل psql این دستورها رو بزن: +# ویندوز +.venv\Scripts\activate +# لینوکس / مک +source .venv/bin/activate -```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`: +فایل `.env.example` را به نام `.env` کپی کنید و مقادیر را تنظیم کنید: ```bash -# ویندوز (Command Prompt): -copy .env.example .env -# مک/لینوکس: -cp .env.example .env +cp .env.example .env # لینوکس / مک +copy .env.example .env # ویندوز ``` -حالا فایل `.env` رو با یک ادیتور متنی (مثل Notepad یا VS Code) باز کن و این خط رو با اطلاعات واقعی خودت پر کن: +مهم‌ترین تنظیم `DATABASE_URL` است: ``` -DATABASE_URL=postgresql://aramesh_user:همون-رمزی-که-انتخاب-کردی@localhost:5432/aramesh_db +DATABASE_URL=postgresql+psycopg://postgres:YOUR_PASSWORD@localhost:5432/arameshplaza_api ``` -خط `SECRET_KEY` رو هم به یک رشته‌ی رندوم و طولانی تغییر بده (هر متن دلخواه طولانی کافیه، برای پروداکشن بهتره واقعاً رندوم باشه). - ---- - -## قدم ۴: ساخت جدول‌های دیتابیس (Migration) +### ۴. ساخت دیتابیس و Seed ```bash -alembic upgrade head +# ساخت دیتابیس (یک‌بار): +psql -U postgres -c "CREATE DATABASE arameshplaza_api;" + +# ساخت جداول + وارد کردن محتوای سایت + ساخت ادمین پیش‌فرض: +python -m app.seed + +# بازنشانی کامل داده‌ها در صورت نیاز: +python -m app.seed --force ``` -این دستور همه‌ی جدول‌های لازم (پروژه‌ها، مقالات، تماس‌ها و...) رو توی دیتابیست می‌سازه. - ---- - -## قدم ۵: پر کردن دیتابیس با محتوای فعلی سایت - -```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** +سپس: +- مستندات API: http://localhost:8000/docs +- بررسی سلامت: http://localhost:8000/api/health ---- +## ورود به پنل مدیریت -## لیست اندپوینت‌های اصلی +``` +POST /api/auth/login +{ "username": "admin", "password": "aramesh123" } +``` -| بخش | آدرس | توضیح | +پاسخ شامل `access_token` است. توکن را در هدر همه درخواست‌های مدیریتی قرار دهید: + +``` +Authorization: Bearer +``` + +> نام کاربری و رمز عبور پیش‌فرض از تنظیمات `.env` خوانده می‌شود — حتماً در محیط تولید تغییرشان دهید. + +## فهرست انتهاپوینت‌ها + +### عمومی (بدون نیاز به توکن) + +| متد | مسیر | توضیح | |---|---|---| -| پروژه‌ها | `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 | `/api/health` | وضعیت سرور | +| GET | `/api/brand` | اطلاعات برند و تماس | +| GET | `/api/services` | خدمات | +| GET | `/api/projects` | همه پروژه‌ها | +| GET | `/api/projects/{slug}` | جزئیات یک پروژه | +| GET | `/api/articles` | همه مقالات | +| GET | `/api/articles/{slug}` | جزئیات یک مقاله | +| GET | `/api/testimonials` | نظرات مشتریان | +| GET | `/api/faqs` | سوالات متداول | +| GET | `/api/team` | اعضای تیم | +| GET | `/api/timeline` | تایم‌لاین شرکت | +| GET | `/api/stats` | آمار | +| GET | `/api/why-us` | چرا آرامش پلازا | +| GET | `/api/before-after` | بخش قبل/بعد | +| GET | `/api/site` | **همه محتوا در یک درخواست** | +| POST | `/api/contact` | ثبت پیام تماس | +| POST | `/api/newsletter` | عضویت در خبرنامه | +| POST | `/api/auth/login` | ورود ادمین و دریافت توکن | -تمام درخواست‌های `GET` روی این منابع **عمومی** هستن (فرانت‌اند بدون نیاز به ورود می‌تونه بخونتشون). -تمام درخواست‌های `POST` / `PUT` / `DELETE` (به‌جز فرم تماس و خبرنامه) نیاز به توکن ادمین دارن — یعنی باید هدر زیر رو بفرستی: +### مدیریت (نیاز به توکن Bearer) -``` -Authorization: Bearer <توکنی-که-از-لاگین-گرفتی> +برای هر یک از این مسیرها، CRUD کامل در دسترس است: +`brand`، `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `before-after` + +| متد | مسیر | توضیح | +|---|---|---| +| GET/POST | `/api/admin/{resource}` | لیست / ساخت | +| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش / حذف | +| GET | `/api/admin/messages` | پیام‌های تماس (با `?unread_only=true` فقط خوانده‌نشده‌ها) | +| PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خوانده‌شدن | +| DELETE | `/api/admin/messages/{id}` | حذف پیام | +| GET | `/api/admin/subscribers` | مشترکین خبرنامه | +| DELETE | `/api/admin/subscribers/{id}` | حذف مشترک | + +## اتصال به فرانت‌اند + +در پروژه React (پوشه `ArameshPlaza`)، فایل `src/config.ts` آدرس بک‌اند را نگه می‌دارد: + +```ts +export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000' ``` ---- +در محیط تولید مقدار `VITE_API_URL` را روی آدرس عمومی API تنظیم کنید و مطمئن شوید آدرس سایت در `CORS_ORIGINS` فایل `.env` بک‌اند قرار دارد. -## اتصال فرانت‌اند به این بک‌اند +## ساختار پروژه -فرانت‌اند فعلی (`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. رمز عبور دیتابیس رو قوی انتخاب کن +``` +app/ +├── main.py # نقطه ورود FastAPI، CORS، ثبت روترها +├── config.py # تنظیمات از .env +├── database.py # اتصال PostgreSQL و نشست‌ها +├── models.py # مدل‌های SQLAlchemy +├── schemas.py # اسکیماهای Pydantic (ورودی/خروجی) +├── auth.py # هش رمز عبور و JWT +├── seed.py # اسکریپت seed +├── seed_data.py # داده‌های اولیه سایت +└── api/ + ├── public.py # انتهای عمومی محتوا + ├── forms.py # فرم تماس و خبرنامه + └── admin.py # ورود و پنل مدیریت +``` diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index 27e668c..0000000 --- a/alembic.ini +++ /dev/null @@ -1,38 +0,0 @@ -[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 diff --git a/alembic/env.py b/alembic/env.py deleted file mode 100644 index e1bd8e7..0000000 --- a/alembic/env.py +++ /dev/null @@ -1,50 +0,0 @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako deleted file mode 100644 index 55df286..0000000 --- a/alembic/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${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"} diff --git a/alembic/versions/55a6f15ea7ef_initial_tables.py b/alembic/versions/55a6f15ea7ef_initial_tables.py deleted file mode 100644 index 8800d0b..0000000 --- a/alembic/versions/55a6f15ea7ef_initial_tables.py +++ /dev/null @@ -1,168 +0,0 @@ -"""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 ### diff --git a/app/routers/__init__.py b/app/api/__init__.py similarity index 100% rename from app/routers/__init__.py rename to app/api/__init__.py diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000..82a8887 --- /dev/null +++ b/app/api/admin.py @@ -0,0 +1,163 @@ +"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from .. import models, schemas +from ..auth import create_access_token, get_current_admin, verify_password +from ..database import get_db +from ..schemas import ( + ArticleIn, + ArticleOut, + BeforeAfterIn, + BeforeAfterOut, + BrandIn, + BrandOut, + ContactMessageOut, + FaqIn, + FaqOut, + LoginIn, + MessageReadIn, + ProjectIn, + ProjectOut, + ServiceIn, + ServiceOut, + StatIn, + StatOut, + SubscriberOut, + TeamMemberIn, + TeamMemberOut, + TestimonialIn, + TestimonialOut, + TimelineItemIn, + TimelineItemOut, + TokenOut, + WhyUsItemIn, + WhyUsItemOut, +) + +# روتر عمومی برای ورود — بدون نیاز به توکن +auth_router = APIRouter() + +# روتر محافظت‌شده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند +router = APIRouter(dependencies=[Depends(get_current_admin)]) + + +@auth_router.post("/auth/login", response_model=TokenOut) +def login(payload: LoginIn, db: Session = Depends(get_db)): + user = db.query(models.AdminUser).filter(models.AdminUser.username == payload.username).first() + if user is None or not verify_password(payload.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="نام کاربری یا رمز عبور اشتباه است", + ) + return TokenOut(access_token=create_access_token(user.username)) + + +def register_crud(path: str, model, out_schema, create_schema) -> None: + """ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت.""" + + @router.get(f"/{path}", response_model=list[out_schema]) + def list_items(db: Session = Depends(get_db)): + return db.query(model).order_by(model.id).all() + + @router.post(f"/{path}", response_model=out_schema, status_code=201) + def create_item(payload: create_schema, db: Session = Depends(get_db)): + item = model(**payload.model_dump()) + db.add(item) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)") + db.refresh(item) + return item + + @router.get(f"/{path}/{{item_id}}", response_model=out_schema) + def get_item(item_id: int, db: Session = Depends(get_db)): + item = db.get(model, item_id) + if item is None: + raise HTTPException(status_code=404, detail="رکورد یافت نشد") + return item + + @router.put(f"/{path}/{{item_id}}", response_model=out_schema) + def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db)): + item = db.get(model, item_id) + if item is None: + raise HTTPException(status_code=404, detail="رکورد یافت نشد") + for key, value in payload.model_dump().items(): + setattr(item, key, value) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)") + db.refresh(item) + return item + + @router.delete(f"/{path}/{{item_id}}", status_code=204) + def delete_item(item_id: int, db: Session = Depends(get_db)): + item = db.get(model, item_id) + if item is None: + raise HTTPException(status_code=404, detail="رکورد یافت نشد") + db.delete(item) + db.commit() + + +# CRUD محتوا +register_crud("brand", models.Brand, BrandOut, BrandIn) +register_crud("services", models.Service, ServiceOut, ServiceIn) +register_crud("projects", models.Project, ProjectOut, ProjectIn) +register_crud("articles", models.Article, ArticleOut, ArticleIn) +register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn) +register_crud("faqs", models.Faq, FaqOut, FaqIn) +register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn) +register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn) +register_crud("stats", models.Stat, StatOut, StatIn) +register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn) +register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn) + + +# پیام‌های تماس +@router.get("/messages", response_model=list[ContactMessageOut]) +def list_messages(unread_only: bool = False, db: Session = Depends(get_db)): + query = db.query(models.ContactMessage) + if unread_only: + query = query.filter(models.ContactMessage.is_read.is_(False)) + return query.order_by(models.ContactMessage.created_at.desc()).all() + + +@router.patch("/messages/{message_id}", response_model=ContactMessageOut) +def update_message(message_id: int, payload: MessageReadIn, db: Session = Depends(get_db)): + message = db.get(models.ContactMessage, message_id) + if message is None: + raise HTTPException(status_code=404, detail="پیام یافت نشد") + message.is_read = payload.is_read + db.commit() + db.refresh(message) + return message + + +@router.delete("/messages/{message_id}", status_code=204) +def delete_message(message_id: int, db: Session = Depends(get_db)): + message = db.get(models.ContactMessage, message_id) + if message is None: + raise HTTPException(status_code=404, detail="پیام یافت نشد") + db.delete(message) + db.commit() + + +# مشترکین خبرنامه +@router.get("/subscribers", response_model=list[SubscriberOut]) +def list_subscribers(db: Session = Depends(get_db)): + return db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all() + + +@router.delete("/subscribers/{subscriber_id}", status_code=204) +def delete_subscriber(subscriber_id: int, db: Session = Depends(get_db)): + subscriber = db.get(models.Subscriber, subscriber_id) + if subscriber is None: + raise HTTPException(status_code=404, detail="مشترک یافت نشد") + db.delete(subscriber) + db.commit() diff --git a/app/api/forms.py b/app/api/forms.py new file mode 100644 index 0000000..9692b40 --- /dev/null +++ b/app/api/forms.py @@ -0,0 +1,42 @@ +"""انتهای فرم‌ها — ثبت پیام تماس و عضویت در خبرنامه.""" + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from ..database import get_db +from .. import models +from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut + +router = APIRouter() + + +@router.post("/contact", response_model=ContactMessageOut, status_code=201) +def submit_contact(payload: ContactIn, db: Session = Depends(get_db)): + message = models.ContactMessage( + name=payload.name.strip(), + phone=payload.phone.strip(), + note=payload.note.strip(), + ) + db.add(message) + db.commit() + db.refresh(message) + return message + + +@router.post("/newsletter", response_model=SubscriberOut, status_code=201) +def subscribe(payload: NewsletterIn, db: Session = Depends(get_db)): + email = str(payload.email).lower() + subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first() + + if subscriber is not None: + if not subscriber.is_active: + subscriber.is_active = True + db.commit() + db.refresh(subscriber) + return subscriber + + subscriber = models.Subscriber(email=email) + db.add(subscriber) + db.commit() + db.refresh(subscriber) + return subscriber diff --git a/app/api/public.py b/app/api/public.py new file mode 100644 index 0000000..3b79ee9 --- /dev/null +++ b/app/api/public.py @@ -0,0 +1,122 @@ +"""انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from ..database import get_db +from .. import models +from ..schemas import ( + ArticleOut, + BeforeAfterOut, + BrandOut, + FaqOut, + ProjectOut, + ServiceOut, + StatOut, + TeamMemberOut, + TestimonialOut, + TimelineItemOut, + WhyUsItemOut, +) + +router = APIRouter() + + +@router.get("/health") +def health(): + return {"status": "ok", "app": "ArameshPlaza API"} + + +@router.get("/brand", response_model=BrandOut) +def get_brand(db: Session = Depends(get_db)): + brand = db.query(models.Brand).first() + if brand is None: + raise HTTPException(status_code=404, detail="اطلاعات برند یافت نشد") + return brand + + +@router.get("/services", response_model=list[ServiceOut]) +def get_services(db: Session = Depends(get_db)): + return db.query(models.Service).all() + + +@router.get("/projects", response_model=list[ProjectOut]) +def get_projects(db: Session = Depends(get_db)): + return db.query(models.Project).order_by(models.Project.id).all() + + +@router.get("/projects/{slug}", response_model=ProjectOut) +def get_project(slug: str, db: Session = Depends(get_db)): + project = db.query(models.Project).filter(models.Project.slug == slug).first() + if project is None: + raise HTTPException(status_code=404, detail="پروژه یافت نشد") + return project + + +@router.get("/articles", response_model=list[ArticleOut]) +def get_articles(db: Session = Depends(get_db)): + return db.query(models.Article).order_by(models.Article.id).all() + + +@router.get("/articles/{slug}", response_model=ArticleOut) +def get_article(slug: str, db: Session = Depends(get_db)): + article = db.query(models.Article).filter(models.Article.slug == slug).first() + if article is None: + raise HTTPException(status_code=404, detail="مقاله یافت نشد") + return article + + +@router.get("/testimonials", response_model=list[TestimonialOut]) +def get_testimonials(db: Session = Depends(get_db)): + return db.query(models.Testimonial).all() + + +@router.get("/faqs", response_model=list[FaqOut]) +def get_faqs(db: Session = Depends(get_db)): + return db.query(models.Faq).all() + + +@router.get("/team", response_model=list[TeamMemberOut]) +def get_team(db: Session = Depends(get_db)): + return db.query(models.TeamMember).all() + + +@router.get("/timeline", response_model=list[TimelineItemOut]) +def get_timeline(db: Session = Depends(get_db)): + return db.query(models.TimelineItem).order_by(models.TimelineItem.id).all() + + +@router.get("/stats", response_model=list[StatOut]) +def get_stats(db: Session = Depends(get_db)): + return db.query(models.Stat).all() + + +@router.get("/why-us", response_model=list[WhyUsItemOut]) +def get_why_us(db: Session = Depends(get_db)): + return db.query(models.WhyUsItem).all() + + +@router.get("/before-after", response_model=BeforeAfterOut) +def get_before_after(db: Session = Depends(get_db)): + item = db.query(models.BeforeAfter).first() + if item is None: + raise HTTPException(status_code=404, detail="بخش قبل/بعد یافت نشد") + return item + + +@router.get("/site") +def get_site(db: Session = Depends(get_db)): + """همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند.""" + return { + "brand": db.query(models.Brand).first(), + "services": db.query(models.Service).all(), + "projects": db.query(models.Project).order_by(models.Project.id).all(), + "articles": db.query(models.Article).order_by(models.Article.id).all(), + "testimonials": db.query(models.Testimonial).all(), + "faqs": db.query(models.Faq).all(), + "team": db.query(models.TeamMember).all(), + "timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(), + "stats": db.query(models.Stat).all(), + "why_us": db.query(models.WhyUsItem).all(), + "before_after": db.query(models.BeforeAfter).first(), + } diff --git a/app/auth.py b/app/auth.py index 27614d4..0532e31 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,59 +1,52 @@ from datetime import datetime, timedelta, timezone +import bcrypt +import jwt 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 +from .config import settings +from .database import get_db +from .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) + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") -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 verify_password(password: str, hashed: str) -> bool: + return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8")) -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 create_access_token(username: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) + payload = {"sub": username, "exp": expire} + return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) def get_current_admin( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db), ) -> AdminUser: - credentials_exception = HTTPException( + """وابستگی محافظت‌شده — فقط با توکن JWT معتبر دسترسی می‌دهد.""" + credentials_error = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="توکن نامعتبر یا منقضی شده است", + 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 + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + except jwt.PyJWTError: + raise credentials_error + + username = payload.get("sub") + if not username: + raise credentials_error user = db.query(AdminUser).filter(AdminUser.username == username).first() if user is None: - raise credentials_exception + raise credentials_error return user diff --git a/app/config.py b/app/config.py index cc95d2d..641e5aa 100644 --- a/app/config.py +++ b/app/config.py @@ -2,16 +2,21 @@ 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" + """تنظیمات برنامه — از متغیرهای محیطی یا فایل .env خوانده می‌شود.""" - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) - @property - def cors_origins_list(self) -> list[str]: - return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()] + database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api" + secret_key: str = "dev-secret-change-me-in-production" + algorithm: str = "HS256" + access_token_expire_minutes: int = 1440 + cors_origins: list[str] = ["*"] + admin_username: str = "admin" + admin_password: str = "aramesh123" settings = Settings() diff --git a/app/crud_router.py b/app/crud_router.py deleted file mode 100644 index 2397fde..0000000 --- a/app/crud_router.py +++ /dev/null @@ -1,81 +0,0 @@ -""" -یک فکتوری عمومی برای ساخت روترهای 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 diff --git a/app/database.py b/app/database.py index 04e83fa..e1eee4c 100644 --- a/app/database.py +++ b/app/database.py @@ -1,10 +1,10 @@ from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker, DeclarativeBase +from sqlalchemy.orm import DeclarativeBase, sessionmaker -from app.config import settings +from .config import settings -engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) class Base(DeclarativeBase): @@ -12,6 +12,7 @@ class Base(DeclarativeBase): def get_db(): + """وابستگی FastAPI برای تزریق نشست دیتابیس.""" db = SessionLocal() try: yield db diff --git a/app/main.py b/app/main.py index 55e8c33..1699f5a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,47 +1,42 @@ 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, -) +from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت جداول +from .api import admin, forms, public +from .config import settings +from .database import Base, engine + +Base.metadata.create_all(bind=engine) app = FastAPI( - title="Aramesh Plaza API", - description="بک‌اند سایت آرامش پلازا — مدیریت محتوا، فرم تماس و خبرنامه", + title="ArameshPlaza API", + description=( + "بک‌اند سایت آرامش پلازا — API محتوای سایت، فرم تماس و خبرنامه، " + "و پنل مدیریت با احراز هویت JWT." + ), version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", ) app.add_middleware( CORSMiddleware, - allow_origins=settings.cors_origins_list, - allow_credentials=True, + allow_origins=settings.cors_origins, + allow_credentials=False, 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.include_router(public.router, prefix="/api", tags=["عمومی"]) +app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"]) +app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"]) +app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"]) -@app.get("/", tags=["Health"]) -def health_check(): - return {"status": "ok", "message": "Aramesh Plaza API در حال اجراست"} +@app.get("/") +def root(): + return { + "app": "ArameshPlaza API", + "docs": "/docs", + "health": "/api/health", + } diff --git a/app/models.py b/app/models.py index 3313c21..d8b3d02 100644 --- a/app/models.py +++ b/app/models.py @@ -1,160 +1,159 @@ -import enum -from datetime import datetime +from datetime import datetime, timezone -from sqlalchemy import String, Integer, Text, DateTime, Boolean, Enum, JSON +from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column -from app.database import Base +from .database import Base -class ProjectStatus(str, enum.Enum): - done = "done" - progress = "progress" +def utcnow() -> datetime: + return datetime.now(timezone.utc) class AdminUser(Base): __tablename__ = "admin_users" - id: Mapped[int] = mapped_column(Integer, primary_key=True) + id: Mapped[int] = mapped_column(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) + password_hash: Mapped[str] = mapped_column(String(255)) 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="") + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128), default="") + logo: Mapped[str] = mapped_column(Text, default="") + hero: Mapped[str] = mapped_column(Text, default="") + contact_bg: Mapped[str] = mapped_column(Text, 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="") + phone_href: Mapped[str] = mapped_column(String(128), default="") + email: Mapped[str] = mapped_column(String(128), default="") + address: Mapped[str] = mapped_column(Text, 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) + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(256)) + desc: Mapped[str] = mapped_column(Text, default="") + icon: Mapped[str] = mapped_column(String(32), default="draft") 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)) + id: Mapped[int] = mapped_column(primary_key=True) + slug: Mapped[str] = mapped_column(String(128), unique=True, index=True) + title: Mapped[str] = mapped_column(String(256)) + location: Mapped[str] = mapped_column(String(128), default="") + year: Mapped[str] = mapped_column(String(64), default="") + status: Mapped[str] = mapped_column(String(16), default="done") # done | progress + image: Mapped[str] = mapped_column(Text, default="") 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) + area: Mapped[str] = mapped_column(String(64), default="") + floors: Mapped[str] = mapped_column(String(64), default="") + units: Mapped[str] = mapped_column(String(64), default="") + client: Mapped[str] = mapped_column(String(128), default="") + summary: Mapped[str] = mapped_column(Text, default="") 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) + id: Mapped[int] = mapped_column(primary_key=True) + slug: Mapped[str] = mapped_column(String(128), unique=True, index=True) + title: Mapped[str] = mapped_column(String(256)) + date: Mapped[str] = mapped_column(String(64), default="") + category: Mapped[str] = mapped_column(String(64), default="") + image: Mapped[str] = mapped_column(Text, default="") + read: Mapped[str] = mapped_column(String(32), default="") + excerpt: Mapped[str] = mapped_column(Text, default="") body: Mapped[list] = mapped_column(JSON, default=list) - order: Mapped[int] = mapped_column(Integer, default=0) + + +class Testimonial(Base): + __tablename__ = "testimonials" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128)) + role: Mapped[str] = mapped_column(String(128), default="") + quote: Mapped[str] = mapped_column(Text, default="") 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) + id: Mapped[int] = mapped_column(primary_key=True) + question: Mapped[str] = mapped_column(Text) + answer: Mapped[str] = mapped_column(Text, default="") -class ContactLead(Base): - __tablename__ = "contact_leads" +class TeamMember(Base): + __tablename__ = "team" - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(255), default="") - phone: Mapped[str] = mapped_column(String(64)) + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128)) + role: Mapped[str] = mapped_column(String(128), default="") + bio: Mapped[str] = mapped_column(Text, default="") + + +class TimelineItem(Base): + __tablename__ = "timeline" + + id: Mapped[int] = mapped_column(primary_key=True) + year: Mapped[str] = mapped_column(String(32)) + title: Mapped[str] = mapped_column(String(256)) + desc: Mapped[str] = mapped_column(Text, default="") + image: Mapped[str] = mapped_column(Text, default="") + + +class Stat(Base): + __tablename__ = "stats" + + id: Mapped[int] = mapped_column(primary_key=True) + value: Mapped[int] = mapped_column(Integer) + suffix: Mapped[str] = mapped_column(String(16), default="") + label: Mapped[str] = mapped_column(String(128)) + + +class WhyUsItem(Base): + __tablename__ = "why_us" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(256)) + desc: Mapped[str] = mapped_column(Text, default="") + + +class BeforeAfter(Base): + __tablename__ = "before_after" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(256), default="") + lead: Mapped[str] = mapped_column(Text, default="") + before: Mapped[str] = mapped_column(Text, default="") + after: Mapped[str] = mapped_column(Text, default="") + before_label: Mapped[str] = mapped_column(String(128), default="") + after_label: Mapped[str] = mapped_column(String(128), default="") + + +class ContactMessage(Base): + __tablename__ = "contact_messages" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128)) + phone: Mapped[str] = mapped_column(String(32)) 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) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) -class NewsletterSubscriber(Base): - __tablename__ = "newsletter_subscribers" +class Subscriber(Base): + __tablename__ = "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) + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(256), unique=True, index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/app/routers/articles.py b/app/routers/articles.py deleted file mode 100644 index 5432d6f..0000000 --- a/app/routers/articles.py +++ /dev/null @@ -1,70 +0,0 @@ -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 diff --git a/app/routers/auth.py b/app/routers/auth.py deleted file mode 100644 index fa082b5..0000000 --- a/app/routers/auth.py +++ /dev/null @@ -1,25 +0,0 @@ -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) diff --git a/app/routers/brand.py b/app/routers/brand.py deleted file mode 100644 index bb57d0c..0000000 --- a/app/routers/brand.py +++ /dev/null @@ -1,38 +0,0 @@ -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 diff --git a/app/routers/contact.py b/app/routers/contact.py deleted file mode 100644 index 315a7c6..0000000 --- a/app/routers/contact.py +++ /dev/null @@ -1,59 +0,0 @@ -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 diff --git a/app/routers/newsletter.py b/app/routers/newsletter.py deleted file mode 100644 index c361d65..0000000 --- a/app/routers/newsletter.py +++ /dev/null @@ -1,48 +0,0 @@ -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 diff --git a/app/routers/projects.py b/app/routers/projects.py deleted file mode 100644 index 27028b7..0000000 --- a/app/routers/projects.py +++ /dev/null @@ -1,70 +0,0 @@ -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 diff --git a/app/routers/simple_resources.py b/app/routers/simple_resources.py deleted file mode 100644 index e76608b..0000000 --- a/app/routers/simple_resources.py +++ /dev/null @@ -1,65 +0,0 @@ -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, -) diff --git a/app/schemas.py b/app/schemas.py index 25af0b6..d9815db 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,23 +1,160 @@ from datetime import datetime -from typing import Optional -from pydantic import BaseModel, ConfigDict, EmailStr, field_validator +from typing import Literal -from app.models import ProjectStatus +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +# ---------- پایه ---------- -# ---------- Auth ---------- -class Token(BaseModel): - access_token: str - token_type: str = "bearer" +class ORMModel(BaseModel): + model_config = ConfigDict(from_attributes=True) -class LoginRequest(BaseModel): +# ---------- خروجی‌ها ---------- + + +class BrandOut(ORMModel): + id: int + name: str + logo: str + hero: str + contact_bg: str + phone: str + phone_href: str + email: str + address: str + + +class ServiceOut(ORMModel): + id: int + title: str + desc: str + icon: str + + +class ProjectOut(ORMModel): + id: int + slug: str + title: str + location: str + year: str + status: str + image: str + gallery: list[str] + area: str + floors: str + units: str + client: str + summary: str + body: list[str] + + +class ArticleOut(ORMModel): + id: int + slug: str + title: str + date: str + category: str + image: str + read: str + excerpt: str + body: list[str] + + +class TestimonialOut(ORMModel): + id: int + name: str + role: str + quote: str + + +class FaqOut(ORMModel): + id: int + question: str + answer: str + + +class TeamMemberOut(ORMModel): + id: int + name: str + role: str + bio: str + + +class TimelineItemOut(ORMModel): + id: int + year: str + title: str + desc: str + image: str + + +class StatOut(ORMModel): + id: int + value: int + suffix: str + label: str + + +class WhyUsItemOut(ORMModel): + id: int + title: str + desc: str + + +class BeforeAfterOut(ORMModel): + id: int + title: str + lead: str + before: str + after: str + before_label: str + after_label: str + + +class ContactMessageOut(ORMModel): + id: int + name: str + phone: str + note: str + is_read: bool + created_at: datetime + + +class SubscriberOut(ORMModel): + id: int + email: str + is_active: bool + created_at: datetime + + +# ---------- ورودی فرم‌ها ---------- + + +class ContactIn(BaseModel): + name: str = Field(min_length=3, max_length=128) + phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$") + note: str = Field(default="", max_length=2000) + + +class NewsletterIn(BaseModel): + email: EmailStr + + +# ---------- ورودی‌های پنل مدیریت ---------- + + +class LoginIn(BaseModel): username: str password: str -# ---------- Brand ---------- -class BrandBase(BaseModel): +class TokenOut(BaseModel): + access_token: str + token_type: str = "bearer" + + +class BrandIn(BaseModel): name: str = "" logo: str = "" hero: str = "" @@ -28,296 +165,82 @@ class BrandBase(BaseModel): address: str = "" -class BrandOut(BrandBase): - model_config = ConfigDict(from_attributes=True) - id: int +class ServiceIn(BaseModel): + title: str = Field(min_length=1, max_length=256) + desc: str = "" + icon: str = "draft" -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 +class ProjectIn(BaseModel): + slug: str = Field(min_length=1, max_length=128) + title: str = Field(min_length=1, max_length=256) + location: str = "" + year: str = "" + status: Literal["done", "progress"] = "done" + image: str = "" gallery: list[str] = [] - area: str - floors: str - units: str - client: str - summary: str + area: str = "" + floors: str = "" + units: str = "" + client: str = "" + summary: str = "" body: list[str] = [] - order: int = 0 -class ProjectCreate(ProjectBase): - pass +class ArticleIn(BaseModel): + slug: str = Field(min_length=1, max_length=128) + title: str = Field(min_length=1, max_length=256) + date: str = "" + category: str = "" + image: str = "" + read: str = "" + excerpt: str = "" + body: list[str] = [] -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 TestimonialIn(BaseModel): + name: str = Field(min_length=1, max_length=128) + role: str = "" + quote: str = "" -class ProjectOut(ProjectBase): - model_config = ConfigDict(from_attributes=True) - id: int +class FaqIn(BaseModel): + question: str = Field(min_length=1) + answer: str = "" -# ---------- Stat ---------- -class StatBase(BaseModel): +class TeamMemberIn(BaseModel): + name: str = Field(min_length=1, max_length=128) + role: str = "" + bio: str = "" + + +class TimelineItemIn(BaseModel): + year: str = Field(min_length=1, max_length=32) + title: str = Field(min_length=1, max_length=256) + desc: str = "" + image: str = "" + + +class StatIn(BaseModel): value: int suffix: str = "" - label: str - order: int = 0 + label: str = Field(min_length=1, max_length=128) -class StatCreate(StatBase): - pass +class WhyUsItemIn(BaseModel): + title: str = Field(min_length=1, max_length=256) + desc: str = "" -class StatUpdate(BaseModel): - value: Optional[int] = None - suffix: Optional[str] = None - label: Optional[str] = None - order: Optional[int] = None +class BeforeAfterIn(BaseModel): + title: str = "" + lead: str = "" + before: str = "" + after: str = "" + before_label: str = "" + after_label: str = "" -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 +class MessageReadIn(BaseModel): + is_read: bool = True diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..99f7410 --- /dev/null +++ b/app/seed.py @@ -0,0 +1,84 @@ +"""اسکریپت seed دیتابیس. + +اجرا: + python -m app.seed # فقط اگر دیتابیس خالی باشد + python -m app.seed --force # حذف کامل داده‌ها و ساخت مجدد +""" + +import argparse + +from . import models, seed_data +from .auth import hash_password +from .config import settings +from .database import Base, SessionLocal, engine + + +def seed(force: bool = False) -> None: + db = SessionLocal() + try: + if force: + Base.metadata.drop_all(bind=engine) + print("جداول قبلی حذف شدند.") + + Base.metadata.create_all(bind=engine) + + if not force and db.query(models.Service).count() > 0: + print("دیتابیس قبلاً seed شده است. برای بازنشانی کامل: python -m app.seed --force") + return + + db.add(models.Brand(**seed_data.BRAND)) + + for item in seed_data.SERVICES: + db.add(models.Service(**item)) + for item in seed_data.STATS: + db.add(models.Stat(**item)) + for item in seed_data.PROJECTS: + db.add(models.Project(**item)) + for item in seed_data.ARTICLES: + db.add(models.Article(**item)) + for item in seed_data.TESTIMONIALS: + db.add(models.Testimonial(**item)) + for item in seed_data.FAQS: + db.add(models.Faq(**item)) + for item in seed_data.TEAM: + db.add(models.TeamMember(**item)) + for item in seed_data.TIMELINE: + db.add(models.TimelineItem(**item)) + for item in seed_data.WHY: + db.add(models.WhyUsItem(**item)) + db.add(models.BeforeAfter(**seed_data.BEFORE_AFTER)) + + if db.query(models.AdminUser).count() == 0: + db.add( + models.AdminUser( + username=settings.admin_username, + password_hash=hash_password(settings.admin_password), + ) + ) + print(f"کاربر ادمین ساخته شد: {settings.admin_username}") + + db.commit() + + counts = { + "خدمات": db.query(models.Service).count(), + "پروژه‌ها": db.query(models.Project).count(), + "مقالات": db.query(models.Article).count(), + "نظرات": db.query(models.Testimonial).count(), + "سوالات متداول": db.query(models.Faq).count(), + "تیم": db.query(models.TeamMember).count(), + "تایم‌لاین": db.query(models.TimelineItem).count(), + "آمار": db.query(models.Stat).count(), + "چرا آرامش": db.query(models.WhyUsItem).count(), + } + for label, count in counts.items(): + print(f" {label}: {count} رکورد") + print("✔ seed با موفقیت انجام شد.") + finally: + db.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Seed کردن دیتابیس آرامش پلازا") + parser.add_argument("--force", action="store_true", help="حذف همه جداول و ساخت مجدد") + args = parser.parse_args() + seed(force=args.force) diff --git a/app/seed_data.py b/app/seed_data.py new file mode 100644 index 0000000..51aeb42 --- /dev/null +++ b/app/seed_data.py @@ -0,0 +1,291 @@ +"""داده‌های اولیه سایت آرامش پلازا — برگرفته از فایل src/data/site.ts فرانت‌اند.""" + +BRAND = { + "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": "تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم", +} + +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", +] + +SERVICES = [ + {"title": "طراحی معماری", "desc": "خلق فرم‌های معاصر با نگاه ایرانی؛ از کانسپت اولیه تا نقشه‌های اجرایی و جزئیات نما.", "icon": "draft"}, + {"title": "ساخت برج‌های مسکونی", "desc": "اجرای برج‌های بلندمرتبه با استانداردهای سازه‌ای بین‌المللی و کیفیت تحویل بی‌نقص.", "icon": "tower"}, + {"title": "مجتمع‌های تجاری", "desc": "طراحی و ساخت مراکز تجاری با مطالعه دقیق جریان مخاطب و بازدهی بلندمدت سرمایه.", "icon": "mall"}, + {"title": "ویلاسازی", "desc": "ویلاهای اختصاصی هم‌نشین با طبیعت؛ محرمانگی، نور طبیعی و مصالح ماندگار.", "icon": "villa"}, + {"title": "بازسازی", "desc": "بازآفرینی ساختمان‌های موجود با ارتقای سازه، نما و تجربه زندگی در فضا.", "icon": "renew"}, + {"title": "مدیریت پروژه", "desc": "کنترل هم‌زمان زمان، هزینه و کیفیت با گزارش‌دهی شفاف در تمام مراحل ساخت.", "icon": "manage"}, + {"title": "سرمایه‌گذاری ساختمانی", "desc": "ساختارهای مشارکت شفاف، ارزیابی ریسک و طراحی سبد ملکی متناسب با سرمایه شما.", "icon": "invest"}, + {"title": "محوطه‌سازی و لنداسکیپ", "desc": "طراحی حیاط، بام سبز و مسیرهای دسترسی؛ ادامه معماری در فضای باز پروژه.", "icon": "landscape"}, +] + +STATS = [ + {"value": 21, "suffix": "+", "label": "سال تجربه ساخت"}, + {"value": 47, "suffix": "", "label": "پروژه تحویل‌شده"}, + {"value": 860, "suffix": "هزار", "label": "مترمربع زیربنا"}, + {"value": 1200, "suffix": "+", "label": "خانواده ساکن"}, +] + +PROJECTS = [ + { + "slug": "aramesh-elahieh", + "title": "برج آرامش الهیه", + "location": "تهران، الهیه", + "year": "۱۴۰۲", + "status": "done", + "image": IMAGES[0], + "gallery": [IMAGES[0], IMAGES[3], IMAGES[1]], + "area": "۲۴٬۰۰۰ متر مربع", + "floors": "۱۸ طبقه", + "units": "۵۶ واحد", + "client": "سرمایه‌گذاری خصوصی", + "summary": "یک برج مسکونی لوکس با نمای سنگ تراورتن و پوسته دوپوسته‌ای که مصرف انرژی را تا ۳۵ درصد کاهش داده است.", + "body": [ + "برج آرامش الهیه روی زمینی با شیب طبیعی شمالی طراحی شد؛ همین شیب فرصت داد تا لابی، استخر و فضاهای مشترک در سه تراز مختلف بنشینند و همه واحدها دید بی‌واسطه به شهر داشته باشند.", + "پوسته بیرونی ترکیبی از سنگ تراورتن روشن و پنجره‌های قدی با شیشه سه‌جداره است. سایه‌بان‌های عمودی مسی‌رنگ، نور مستقیم تابستان را می‌شکنند و در زمستان اجازه ورود کامل نور را می‌دهند.", + "اجرای پروژه در سی‌ودو ماه به پایان رسید و تمام واحدها پیش از تحویل کلید به فروش رسیدند.", + ], + }, + { + "slug": "parseh-mall", + "title": "مجتمع تجاری پارسه", + "location": "اصفهان، مرکز شهر", + "year": "۱۴۰۱", + "status": "done", + "image": IMAGES[1], + "gallery": [IMAGES[1], IMAGES[2], IMAGES[4]], + "area": "۳۸٬۵۰۰ متر مربع", + "floors": "۷ طبقه", + "units": "۱۴۰ واحد تجاری", + "client": "گروه سرمایه‌گذاری پارسه", + "summary": "مرکز تجاری با آتریوم مرکزی و نورگیر سقفی که جریان مخاطب را به‌صورت طبیعی در همه ترازها توزیع می‌کند.", + "body": [ + "مطالعه رفتار مخاطب پیش از طراحی نشان داد کوتاه‌ترین مسیر عمودی، کلید موفقیت تجاری این مجموعه است؛ بنابراین آتریوم مرکزی با پله‌برقی‌های قطری در قلب پلان قرار گرفت.", + "نورگیر سقفی با شیشه لو-ای، روشنایی روز را تا تراز منفی دو می‌رساند و مصرف روشنایی مصنوعی را به‌طور محسوس کاهش می‌دهد.", + "مجموعه دو ماه پیش از موعد قراردادی به بهره‌برداری رسید.", + ], + }, + { + "slug": "daryakenar-villas", + "title": "ویلاهای دریاکنار", + "location": "مازندران، نوشهر", + "year": "۱۴۰۰", + "status": "done", + "image": IMAGES[2], + "gallery": [IMAGES[2], IMAGES[4], IMAGES[0]], + "area": "۹٬۲۰۰ متر مربع", + "floors": "۲ طبقه", + "units": "۱۲ ویلا", + "client": "مجموعه خصوصی دریاکنار", + "summary": "دوازده ویلای اختصاصی با چیدمان شانه‌ای که برای هر واحد، دید مستقیم به دریا و محرمانگی کامل فراهم می‌کند.", + "body": [ + "چیدمان شانه‌ای ویلاها نتیجه ده‌ها بررسی زاویه دید بود؛ هیچ ویلایی در دید ویلای دیگر نیست و همه به دریا باز می‌شوند.", + "مصالح، پاسخی به اقلیم مرطوب شمال است: چوب ترموود، بتن اکسپوز و پوشش‌های ضد نمک برای فلزات نما.", + "محوطه با گیاهان بومی طراحی شد تا نیاز آبیاری در طول سال حداقل باشد.", + ], + }, + { + "slug": "niavaran-twins", + "title": "برج دوقلوی نیاوران", + "location": "تهران، نیاوران", + "year": "۱۳۹۹", + "status": "done", + "image": IMAGES[3], + "gallery": [IMAGES[3], IMAGES[0], IMAGES[2]], + "area": "۳۱٬۷۰۰ متر مربع", + "floors": "۲۰ طبقه", + "units": "۸۰ واحد", + "client": "مشارکت با مالکان زمین", + "summary": "دو برج همسان با پادست مشترک که فضاهای رفاهی و پارکینگ‌ها را در یک تراز واحد ادغام می‌کند.", + "body": [ + "دو برج روی یک پادست مشترک نشسته‌اند؛ این تصمیم اجازه داد فضاهای رفاهی، سالن اجتماعات و پارکینگ‌ها بین دو ساختمان مشترک شوند و سطح اشغال باغ کاهش یابد.", + "سازه با سیستم دیوار برشی و دال پس‌کشیده اجرا شد تا دهانه‌های داخلی آزاد و پلان‌ها قابل شخصی‌سازی باشند.", + ], + }, + { + "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": [ + "تراس‌های سبز پیوسته، پوسته‌ای زنده برای ساختمان می‌سازند که در اقلیم گرم جزیره، دمای سطح نما را به‌طور محسوس پایین می‌آورد.", + "در حال حاضر سازه تا تراز طبقه نهم اجرا شده و نصب نما از ضلع جنوبی آغاز شده است.", + ], + }, + { + "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": [ + "برنامه‌ریزی فضایی این مجموعه بر اساس دفاتر انعطاف‌پذیر انجام شد؛ هر تراز می‌تواند به دو، سه یا شش دفتر مستقل تقسیم شود.", + "اجرای تأسیسات مکانیکی به‌صورت هم‌زمان با سفت‌کاری پیش می‌رود تا زمان کل پروژه کوتاه‌تر شود.", + ], + }, +] + +BEFORE_AFTER = { + "title": "از کانسپت تا خلق واقعیت", + "lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل می‌شود.", + "before": "https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_before.webp", + "after": "https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_after.webp", + "before_label": "کانسپت اولیه", + "after_label": "پروژه تکمیل‌شده", +} + +TIMELINE = [ + {"year": "۱۳۸۳", "title": "آغاز مسیر", "desc": "تأسیس دفتر معماری آرامش با تیمی چهارنفره و نخستین پروژه بازسازی در تهران.", "image": IMAGES[2]}, + {"year": "۱۳۸۵", "title": "نخستین ساخت مستقل", "desc": "تحویل اولین ساختمان مسکونی هفت‌طبقه؛ آغاز همکاری با پیمانکاران سازه‌ای بزرگ.", "image": IMAGES[0]}, + {"year": "۱۳۸۷", "title": "ورود به بازار تجاری", "desc": "طراحی و اجرای اولین مجتمع تجاری با رویکرد بازدهی سرمایه و جریان مخاطب.", "image": IMAGES[1]}, + {"year": "۱۳۸۹", "title": "استاندارد بین‌المللی", "desc": "استقرار نظام کنترل کیفیت و دریافت گواهی‌نامه‌های مدیریت پروژه.", "image": IMAGES[4]}, + {"year": "۱۳۹۵", "title": "بلندمرتبه‌سازی", "desc": "آغاز ساخت نخستین برج بیست‌طبقه شرکت در شمال تهران.", "image": IMAGES[3]}, + {"year": "۱۴۰۳", "title": "آرامش پلازا امروز", "desc": "پرتفوی ۴۷ پروژه، دفاتر در سه شهر و صندوق سرمایه‌گذاری ساختمانی اختصاصی.", "image": IMAGES[0]}, +] + +WHY = [ + {"title": "کیفیت بدون مصالحه", "desc": "انتخاب مصالح و مجریان بر اساس ماندگاری چنددهه‌ای، نه سریع‌ترین تحویل."}, + {"title": "شفافیت مالی کامل", "desc": "گزارش ماهانه هزینه‌ها و پیشرفت فیزیکی برای همه شرکای پروژه."}, + {"title": "تحویل به‌موقع", "desc": "۹۴٪ پروژه‌های ما در بازه زمانی قراردادی یا زودتر تحویل داده شده‌اند."}, + {"title": "طراحی اختصاصی", "desc": "هیچ نقشه‌ای دو بار اجرا نمی‌شود؛ هر پروژه هویت معماری خودش را دارد."}, + {"title": "خدمات پس از تحویل", "desc": "پنج سال پشتیبانی فنی و نگهداری تأسیسات پس از تحویل کلید."}, + {"title": "پایداری و انرژی", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد."}, +] + +TESTIMONIALS = [ + {"name": "مهندس سارا کیانی", "role": "سرمایه‌گذار پروژه پارسه", "quote": "گزارش‌های ماهانه آرامش پلازا آن‌قدر دقیق بود که هیچ‌وقت نیاز نشد نگران سرمایه‌ام باشم. پروژه دو ماه زودتر از موعد به بهره‌برداری رسید."}, + {"name": "دکتر امیرحسین راد", "role": "ساکن برج الهیه", "quote": "کیفیت اجرا در جزئیاتی دیده می‌شود که معمولاً کسی به آن‌ها فکر نمی‌کند؛ از عایق صوتی پنجره‌ها تا نور غیرمستقیم راهروها."}, + {"name": "مریم دلاور", "role": "مدیر مجموعه ویلاهای دریاکنار", "quote": "همکاری با تیم طراحی، تجربه‌ای واقعاً حرفه‌ای بود. ایده‌های ما را شنیدند و به زبان معماری ترجمه کردند."}, +] + +TEAM = [ + {"name": "کامران آرامش", "role": "بنیان‌گذار و مدیرعامل", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه."}, + {"name": "نگار شریفی", "role": "مدیر ارشد طراحی", "bio": "معمار ارشد، فارغ‌التحصیل دانشگاه تهران و میلان."}, + {"name": "بهزاد مرادی", "role": "مدیر اجرایی پروژه‌ها", "bio": "مسئول کنترل کیفیت و زمان‌بندی همه کارگاه‌ها."}, + {"name": "الهام نیک‌آیین", "role": "مدیر سرمایه‌گذاری", "bio": "طراح ساختارهای مشارکت و تحلیل بازدهی ملکی."}, +] + +ARTICLES = [ + { + "slug": "natural-light", + "title": "معماری آرام: چرا نور طبیعی گران‌ترین مصالح ساختمان است؟", + "date": "۲۴ مرداد ۱۴۰۳", + "category": "معماری", + "image": IMAGES[1], + "read": "۶ دقیقه", + "excerpt": "نور طبیعی نه‌تنها کیفیت زندگی در فضا را تعیین می‌کند، بلکه مستقیماً بر مصرف انرژی و ارزش بلندمدت ملک اثر می‌گذارد.", + "body": [ + "در بازار ساخت‌وساز، ارزش یک واحد اغلب با متراژ سنجیده می‌شود؛ اما تجربه ما در بیست سال گذشته نشان داده کیفیت نور، تعیین‌کننده‌تر از چند متر اضافه است.", + "جهت‌گیری ساختمان، عمق دهانه و ارتفاع کف تا سقف، سه متغیر اصلی نور طبیعی هستند. تصمیم درباره هر سه، پیش از ترسیم اولین پلان گرفته می‌شود و بعد از آن تقریباً برگشت‌پذیر نیست.", + "در برج آرامش الهیه، چرخش ۱۲ درجه‌ای توده ساختمان نسبت به شمال، دو ساعت نور مفید بیشتر در روزهای زمستان به واحدها اضافه کرد؛ تصمیمی بی‌هزینه با اثری چنددهه‌ای.", + "برای انتخاب یک واحد، پیشنهاد ما ساده است: بازدید را در ساعت‌های میانی صبح و اواخر بعدازظهر انجام دهید و کیفیت نور را در همان ساعت‌هایی بسنجید که واقعاً در خانه هستید.", + ], + }, + { + "slug": "real-estate-portfolio", + "title": "راهنمای انتخاب سبد سرمایه‌گذاری ملکی در بازار امروز", + "date": "۱۰ مرداد ۱۴۰۳", + "category": "سرمایه‌گذاری", + "image": IMAGES[3], + "read": "۹ دقیقه", + "excerpt": "سه معیار کلیدی برای تشکیل سبدی متعادل از دارایی ملکی، بر اساس افق زمانی و سطح ریسک شما.", + "body": [ + "سبد ملکی متعادل، ترکیبی از دارایی‌های با نقدشوندگی متفاوت است؛ نه همه سرمایه در یک پروژه بلندمدت، نه همه در واحدهای آماده.", + "افق زمانی، نخستین معیار است. اگر برنامه شما کمتر از دو سال است، پیش‌خرید در پروژه‌ای که تازه شروع شده انتخاب پرریسکی خواهد بود.", + "معیار دوم شفافیت گزارش‌دهی است. بدون گزارش ماهانه پیشرفت فیزیکی و مالی، ارزیابی وضعیت سرمایه شما عملاً غیرممکن است.", + "معیار سوم، تنوع کاربری است. ترکیب مسکونی و تجاری، نوسان بازدهی سبد را در دوره‌های رکود محسوس کاهش می‌دهد.", + ], + }, + { + "slug": "facade-quality", + "title": "پنج نکته فنی که کیفیت نما را پس از ده سال مشخص می‌کند", + "date": "۲۸ تیر ۱۴۰۳", + "category": "اجرا", + "image": IMAGES[4], + "read": "۵ دقیقه", + "excerpt": "نمای ساختمان در سال اول زیبا به نظر می‌رسد؛ تفاوت کیفیت اجرا در سال دهم آشکار می‌شود.", + "body": [ + "اتصالات مکانیکی سنگ نما، مهم‌تر از خود سنگ است. اتصال چسبی در اقلیم سردوگرم ایران عمر کوتاهی دارد.", + "درزهای انقباضی باید در نقشه دیده شوند، نه در کارگاه تصمیم‌گیری شوند؛ نبودشان اولین دلیل ترک‌های افقی نما است.", + "آب‌بندی دور پنجره، پرتکرارترین نقطه شکست است. اجرای نوار آب‌بند پیش از نصب قرنیز، این ریسک را حذف می‌کند.", + "زیرسازی گالوانیزه گرم، در برابر زیرسازی رنگ‌شده، تفاوت قیمت اندکی دارد و تفاوت عمر بسیار زیادی.", + "در پایان، برنامه بازدید دوره‌ای سالانه نما را جدی بگیرید؛ ترمیم یک درز، هزینه‌ای در حد هیچ در برابر بازسازی کل نما است.", + ], + }, + { + "slug": "commercial-yield", + "title": "چگونه بازدهی یک پروژه تجاری را پیش از ساخت تخمین بزنیم؟", + "date": "۱۵ تیر ۱۴۰۳", + "category": "سرمایه‌گذاری", + "image": IMAGES[2], + "read": "۷ دقیقه", + "excerpt": "مدل ساده‌ای برای برآورد بازدهی، بر پایه جریان مخاطب، ترکیب واحدها و نرخ اجاره منطقه.", + "body": [ + "برآورد بازدهی تجاری با سه عدد آغاز می‌شود: جریان روزانه مخاطب، نرخ اجاره هر مترمربع در منطقه و نسبت سطح مفید به کل.", + "خطای رایج، نادیده‌گرفتن ترازهای بالاتر است. بدون طراحی درست مسیر عمودی، ترازهای سه به بالا معمولاً نیمی از نرخ تراز همکف را هم به‌دست نمی‌آورند.", + "در نهایت، سناریوی محافظه‌کارانه را هم بنویسید؛ اگر پروژه در سناریوی بدبینانه هم توجیه داشت، سرمایه‌گذاری منطقی است.", + ], + }, + { + "slug": "facade-materials", + "title": "مصالح نما: مقایسه سنگ، کامپوزیت و ترموود در اقلیم ایران", + "date": "۲ تیر ۱۴۰۳", + "category": "اجرا", + "image": IMAGES[0], + "read": "۸ دقیقه", + "excerpt": "هر مصالح نما در اقلیم متفاوتی درست کار می‌کند؛ انتخاب اشتباه، هزینه نگهداری را چند برابر می‌کند.", + "body": [ + "سنگ در اقلیم خشک، بهترین ماندگاری را دارد؛ اما وزن آن نیازمند زیرسازی دقیق و محاسبه سازه‌ای است.", + "کامپوزیت انتخاب سریع و سبکی است، مشروط بر انتخاب گرید ضد آتش و درنظرگرفتن انقباض حرارتی در طول ورق.", + "ترموود در اقلیم مرطوب شمال، بهترین پاسخ زیبایی‌شناسانه است، ولی برنامه نگهداری دوسالانه دارد.", + ], + }, + { + "slug": "renovate-or-rebuild", + "title": "بازسازی یا نوسازی؟ معیارهای تصمیم‌گیری برای مالکان", + "date": "۲۰ خرداد ۱۴۰۳", + "category": "معماری", + "image": IMAGES[1], + "read": "۶ دقیقه", + "excerpt": "اگر عمر سازه و ضوابط شهری اجازه دهند، بازسازی می‌تواند بازدهی بالاتری از تخریب کامل داشته باشد.", + "body": [ + "نخست وضعیت سازه ارزیابی می‌شود؛ اگر مقاوم‌سازی هزینه‌ای بیش از ۴۰ درصد ساخت نو دارد، نوسازی منطقی‌تر است.", + "دومین معیار، ضوابط شهری روز است. گاهی تخریب، سطح زیربنای مجاز کمتری به شما می‌دهد و این تصمیم را کاملاً تغییر می‌دهد.", + "در بازسازی، بیشترین ارزش افزوده از سه جا می‌آید: نما، ورودی و بازآرایی پلان داخلی.", + ], + }, +] + +FAQS = [ + {"question": "حداقل سرمایه برای مشارکت در پروژه‌های آرامش پلازا چقدر است؟", "answer": "ساختار مشارکت هر پروژه متفاوت است، اما معمولاً واحدهای سرمایه‌گذاری از سطح مشخصی آغاز می‌شوند و جزئیات دقیق در جلسه ارزیابی اولیه ارائه می‌شود."}, + {"question": "مدت زمان معمول ساخت یک برج مسکونی چقدر است؟", "answer": "بسته به تعداد طبقات و شرایط زمین، میانگین اجرای یک برج مسکونی بین ۲۸ تا ۴۲ ماه از زمان صدور پروانه تا تحویل کلید است."}, + {"question": "آیا امکان تغییر نقشه داخلی واحد وجود دارد؟", "answer": "بله. تا پیش از پایان مرحله سفت‌کاری، امکان شخصی‌سازی پلان داخلی با هماهنگی تیم طراحی و تأیید سازه فراهم است."}, + {"question": "خدمات پس از تحویل شامل چه مواردی می‌شود؟", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دوره‌ای سالانه و تضمین اجرای نما و عایق‌کاری."}, + {"question": "پروژه‌ها در چه شهرهایی اجرا می‌شوند؟", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژه‌ای نیز در جزیره کیش در دست ساخت است."}, +] diff --git a/requirements.txt b/requirements.txt index d679096..76b1ecc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,9 @@ -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 +fastapi>=0.115.8 +uvicorn[standard]>=0.34.0 +sqlalchemy>=2.0.36 +psycopg[binary]>=3.2.3 +pydantic>=2.10.4 +pydantic-settings>=2.6.1 +PyJWT>=2.10.1 +bcrypt>=4.2.1 +email-validator>=2.2.0 diff --git a/scripts/create_admin.py b/scripts/create_admin.py deleted file mode 100644 index 8ee6b69..0000000 --- a/scripts/create_admin.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -اسکریپت ساخت اولین کاربر ادمین. -اجرا: 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() diff --git a/scripts/seed_data.py b/scripts/seed_data.py deleted file mode 100644 index aa5f913..0000000 --- a/scripts/seed_data.py +++ /dev/null @@ -1,242 +0,0 @@ -# -*- 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()