نسخه ۲ بک‌اند آرامش پلازا — بازنویسی کامل

- API عمومی محتوای سایت (پروژه‌ها، مقالات، خدمات و...)
- فرم تماس و خبرنامه با اعتبارسنجی سرور
- پنل مدیریت با JWT و CRUD کامل + مدیریت پیام‌ها و مشترکین
- اسکریپت seed با داده‌های واقعی سایت
- تست‌شده: ۳۶/۳۶ انتهاپوینت
This commit is contained in:
Behrad
2026-08-29 12:55:13 +03:30
parent 854309f976
commit ed14435485
30 changed files with 1237 additions and 1655 deletions
+13 -14
View File
@@ -1,18 +1,17 @@
# ========================================== # کپی کنید و نام آن را بگذارید ‎.env‎ — مقادیر را با تنظیمات خودتان جایگزین کنید
# آدرس اتصال به دیتابیس PostgreSQL
# فرمت: postgresql://USERNAME:PASSWORD@HOST:PORT/DBNAME
# ==========================================
DATABASE_URL=postgresql://aramesh_user:aramesh_pass@localhost:5432/aramesh_db
# ========================================== # رشته اتصال به PostgreSQL (پسورد، نام کاربری و نام دیتابیس را تنظیم کنید)
# کلید مخفی برای امضای توکن‌های JWT DATABASE_URL=postgresql+psycopg://postgres:YOUR_PASSWORD@localhost:5432/arameshplaza_api
# حتماً یک رشته‌ی رندوم و طولانی بذار (برای پروداکشن)
# با این دستور می‌تونی یکی بسازی: openssl rand -hex 32
# ==========================================
SECRET_KEY=change-this-to-a-long-random-secret-string
# مدت اعتبار توکن ورود ادمین (به دقیقه) # کلید امضای توکن JWT — حتماً یک مقدار تصادفی طولانی بگذارید
SECRET_KEY=change-me-to-a-long-random-string
# مدت اعتبار توکن مدیریت بر حسب دقیقه (پیش‌فرض: ۲۴ ساعت)
ACCESS_TOKEN_EXPIRE_MINUTES=1440 ACCESS_TOKEN_EXPIRE_MINUTES=1440
# آدرس(های) فرانت‌اند که اجازه دارن به این بک‌اند وصل بشن (با کاما جدا کن) # آدرس‌های مجاز برای CORS (لیست JSON — آدرس فرانت‌اند خود را اضافه کنید)
CORS_ORIGINS=http://localhost:5173,https://aramesh-six.vercel.app CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
# مشخصات ادمین پیش‌فرض که هنگام seed ساخته می‌شود
ADMIN_USERNAME=admin
ADMIN_PASSWORD=aramesh123
+17 -3
View File
@@ -1,6 +1,20 @@
.env # Python
__pycache__/ __pycache__/
*.pyc *.py[cod]
*.egg-info/
.venv/ .venv/
venv/ venv/
*.egg-info/ env/
# Environment
.env
# IDE / OS
.idea/
.vscode/
.DS_Store
Thumbs.db
# Database
*.sqlite3
*.db
+112 -135
View File
@@ -1,177 +1,154 @@
# بک‌اند آرامش پلازا (FastAPI + PostgreSQL) # بک‌اند آرامش پلازا (ArameshPlaza API)
این بک‌اند برای سایت **آرامش پلازا** ساخته شده و شامل: بک‌اند سایت آرامش پلازا — ساخته‌شده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT**.
- API کامل برای مدیریت محتوا: پروژه‌ها، مقالات، خدمات، تیم، نظرات مشتریان، تایم‌لاین، آمار، سوالات متداول، اطلاعات برند
- فرم تماس (ذخیره در دیتابیس)
- فرم خبرنامه (ذخیره در دیتابیس)
- ورود ادمین با JWT برای مدیریت محتوا (ایجاد/ویرایش/حذف)
- مستندات خودکار API (Swagger) روی آدرس `/docs`
قبلاً همه‌ی این محتوا داخل فایل `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` ازت می‌پرسه؛ یادت بمونه)
برای چک کردن نصب بودنشون: - Python 3.11 یا جدیدتر
```bash - PostgreSQL در حال اجرا (پورت پیش‌فرض 5432)
python --version
psql --version
```
--- ### ۲. نصب وابستگی‌ها
## قدم ۱: ساخت دیتابیس
بعد از نصب PostgreSQL، یک ترمینال باز کن و وارد psql شو:
```bash ```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 pip install -r requirements.txt
``` ```
--- ### ۳. تنظیمات
## قدم ۳: تنظیم فایل `.env` فایل `.env.example` را به نام `.env` کپی کنید و مقادیر را تنظیم کنید:
فایل `.env.example` رو کپی کن و اسمش رو بذار `.env`:
```bash ```bash
# ویندوز (Command Prompt): cp .env.example .env # لینوکس / مک
copy .env.example .env copy .env.example .env # ویندوز
# مک/لینوکس:
cp .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` رو هم به یک رشته‌ی رندوم و طولانی تغییر بده (هر متن دلخواه طولانی کافیه، برای پروداکشن بهتره واقعاً رندوم باشه). ### ۴. ساخت دیتابیس و Seed
---
## قدم ۴: ساخت جدول‌های دیتابیس (Migration)
```bash ```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 ```bash
uvicorn app.main:app --reload 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 <access_token>
```
> نام کاربری و رمز عبور پیش‌فرض از تنظیمات `.env` خوانده می‌شود — حتماً در محیط تولید تغییرشان دهید.
## فهرست انتهاپوینت‌ها
### عمومی (بدون نیاز به توکن)
| متد | مسیر | توضیح |
|---|---|---| |---|---|---|
| پروژه‌ها | `GET /api/projects` | لیست همه پروژه‌ها | | GET | `/api/health` | وضعیت سرور |
| پروژه‌ها | `GET /api/projects/{slug}` | جزئیات یک پروژه | | GET | `/api/brand` | اطلاعات برند و تماس |
| مقالات | `GET /api/articles` | لیست همه مقالات | | GET | `/api/services` | خدمات |
| مقالات | `GET /api/articles/{slug}` | جزئیات یک مقاله | | GET | `/api/projects` | همه پروژه‌ها |
| خدمات | `GET /api/services` | لیست خدمات | | GET | `/api/projects/{slug}` | جزئیات یک پروژه |
| تیم | `GET /api/team` | اعضای تیم | | GET | `/api/articles` | همه مقالات |
| نظرات مشتریان | `GET /api/testimonials` | نظرات | | GET | `/api/articles/{slug}` | جزئیات یک مقاله |
| تایم‌لاین | `GET /api/timeline` | تاریخچه شرکت | | GET | `/api/testimonials` | نظرات مشتریان |
| چرا ما | `GET /api/why-us` | نکات "چرا آرامش پلازا" | | GET | `/api/faqs` | سوالات متداول |
| آمار | `GET /api/stats` | آمار صفحه اصلی | | GET | `/api/team` | اعضای تیم |
| سوالات متداول | `GET /api/faqs` | لیست FAQ | | GET | `/api/timeline` | تایم‌لاین شرکت |
| برند | `GET /api/brand` | اطلاعات تماس/لوگو/آدرس | | GET | `/api/stats` | آمار |
| تماس | `POST /api/contact` | ثبت فرم تماس (عمومی) | | GET | `/api/why-us` | چرا آرامش پلازا |
| خبرنامه | `POST /api/newsletter` | عضویت در خبرنامه (عمومی) | | GET | `/api/before-after` | بخش قبل/بعد |
| ورود ادمین | `POST /api/auth/login` | گرفتن توکن JWT | | GET | `/api/site` | **همه محتوا در یک درخواست** |
| POST | `/api/contact` | ثبت پیام تماس |
| POST | `/api/newsletter` | عضویت در خبرنامه |
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن |
تمام درخواست‌های `GET` روی این منابع **عمومی** هستن (فرانت‌اند بدون نیاز به ورود می‌تونه بخونتشون). ### مدیریت (نیاز به توکن Bearer)
تمام درخواست‌های `POST` / `PUT` / `DELETE` (به‌جز فرم تماس و خبرنامه) نیاز به توکن ادمین دارن — یعنی باید هدر زیر رو بفرستی:
``` برای هر یک از این مسیرها، CRUD کامل در دسترس است:
Authorization: Bearer <توکنی-که-از-لاگین-گرفتی> `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')`) app/
2. فرم `ContactSection.tsx` و `Newsletter.tsx` به‌جای فقط تغییر state داخلی، باید یک درخواست `POST` به `/api/contact` و `/api/newsletter` بفرستن ├── 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/
- **Railway** یا **Render**: خیلی راحت از روی گیت‌هاب دیپلوی می‌کنن و PostgreSQL هم بهت می‌دن ├── public.py # انتهای عمومی محتوا
- سرور شخصی با Docker یا مستقیم با `uvicorn`/`gunicorn` ├── forms.py # فرم تماس و خبرنامه
└── admin.py # ورود و پنل مدیریت
نکته مهم امنیتی: قبل از دیپلوی روی سرور واقعی، حتماً: ```
1. یک `SECRET_KEY` رندوم و قوی بذار (نه همون مقدار پیش‌فرض)
2. آدرس دقیق دامنه‌ی فرانت‌اندت (مثلاً `https://aramesh-six.vercel.app`) رو توی `CORS_ORIGINS` بذار
3. رمز عبور دیتابیس رو قوی انتخاب کن
-38
View File
@@ -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
-50
View File
@@ -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()
-24
View File
@@ -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"}
@@ -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 ###
+163
View File
@@ -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()
+42
View File
@@ -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
+122
View File
@@ -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(),
}
+23 -30
View File
@@ -1,59 +1,52 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings from .config import settings
from app.database import get_db from .database import get_db
from app.models import AdminUser from .models import AdminUser
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") 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: 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: def verify_password(password: str, hashed: str) -> bool:
to_encode = data.copy() return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
def authenticate_admin(db: Session, username: str, password: str) -> AdminUser | None: def create_access_token(username: str) -> str:
user = db.query(AdminUser).filter(AdminUser.username == username).first() expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
if not user or not verify_password(password, user.hashed_password): payload = {"sub": username, "exp": expire}
return None return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
return user
def get_current_admin( def get_current_admin(
token: str = Depends(oauth2_scheme), token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> AdminUser: ) -> AdminUser:
credentials_exception = HTTPException( """وابستگی محافظت‌شده — فقط با توکن JWT معتبر دسترسی می‌دهد."""
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="توکن نامعتبر یا منقضی شده است", detail="احراز هویت نامعتبر است",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
try: try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
username: str | None = payload.get("sub") except jwt.PyJWTError:
if username is None: raise credentials_error
raise credentials_exception
except JWTError: username = payload.get("sub")
raise credentials_exception if not username:
raise credentials_error
user = db.query(AdminUser).filter(AdminUser.username == username).first() user = db.query(AdminUser).filter(AdminUser.username == username).first()
if user is None: if user is None:
raise credentials_exception raise credentials_error
return user return user
+13 -8
View File
@@ -2,16 +2,21 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
DATABASE_URL: str """تنظیمات برنامه — از متغیرهای محیطی یا فایل .env خوانده می‌شود."""
SECRET_KEY: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
CORS_ORIGINS: str = "http://localhost:5173"
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
@property database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api"
def cors_origins_list(self) -> list[str]: secret_key: str = "dev-secret-change-me-in-production"
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()] algorithm: str = "HS256"
access_token_expire_minutes: int = 1440
cors_origins: list[str] = ["*"]
admin_username: str = "admin"
admin_password: str = "aramesh123"
settings = Settings() settings = Settings()
-81
View File
@@ -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
+5 -4
View File
@@ -1,10 +1,10 @@
from sqlalchemy import create_engine 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) engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase): class Base(DeclarativeBase):
@@ -12,6 +12,7 @@ class Base(DeclarativeBase):
def get_db(): def get_db():
"""وابستگی FastAPI برای تزریق نشست دیتابیس."""
db = SessionLocal() db = SessionLocal()
try: try:
yield db yield db
+25 -30
View File
@@ -1,47 +1,42 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت جداول
from app.routers import auth, projects, articles, brand, contact, newsletter from .api import admin, forms, public
from app.routers.simple_resources import ( from .config import settings
services_router, from .database import Base, engine
stats_router,
timeline_router, Base.metadata.create_all(bind=engine)
why_us_router,
testimonials_router,
team_router,
faqs_router,
)
app = FastAPI( app = FastAPI(
title="ArameshPlaza API", title="ArameshPlaza API",
description="بک‌اند سایت آرامش پلازا — مدیریت محتوا، فرم تماس و خبرنامه", description=(
"بک‌اند سایت آرامش پلازا — API محتوای سایت، فرم تماس و خبرنامه، "
"و پنل مدیریت با احراز هویت JWT."
),
version="1.0.0", version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
) )
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins_list, allow_origins=settings.cors_origins,
allow_credentials=True, allow_credentials=False,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(auth.router) app.include_router(public.router, prefix="/api", tags=["عمومی"])
app.include_router(brand.router) app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"])
app.include_router(services_router) app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
app.include_router(projects.router) app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"])
app.include_router(stats_router)
app.include_router(timeline_router)
app.include_router(why_us_router)
app.include_router(testimonials_router)
app.include_router(team_router)
app.include_router(articles.router)
app.include_router(faqs_router)
app.include_router(contact.router)
app.include_router(newsletter.router)
@app.get("/", tags=["Health"]) @app.get("/")
def health_check(): def root():
return {"status": "ok", "message": "Aramesh Plaza API در حال اجراست"} return {
"app": "ArameshPlaza API",
"docs": "/docs",
"health": "/api/health",
}
+111 -112
View File
@@ -1,160 +1,159 @@
import enum from datetime import datetime, timezone
from datetime import datetime
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 sqlalchemy.orm import Mapped, mapped_column
from app.database import Base from .database import Base
class ProjectStatus(str, enum.Enum): def utcnow() -> datetime:
done = "done" return datetime.now(timezone.utc)
progress = "progress"
class AdminUser(Base): class AdminUser(Base):
__tablename__ = "admin_users" __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) username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Brand(Base): class Brand(Base):
"""اطلاعات کلی برند - همیشه فقط یک رکورد (id=1) دارد."""
__tablename__ = "brand" __tablename__ = "brand"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), default="") name: Mapped[str] = mapped_column(String(128), default="")
logo: Mapped[str] = mapped_column(String(1024), default="") logo: Mapped[str] = mapped_column(Text, default="")
hero: Mapped[str] = mapped_column(String(1024), default="") hero: Mapped[str] = mapped_column(Text, default="")
contact_bg: Mapped[str] = mapped_column(String(1024), default="") contact_bg: Mapped[str] = mapped_column(Text, default="")
phone: Mapped[str] = mapped_column(String(64), default="") phone: Mapped[str] = mapped_column(String(64), default="")
phone_href: Mapped[str] = mapped_column(String(64), default="") phone_href: Mapped[str] = mapped_column(String(128), default="")
email: Mapped[str] = mapped_column(String(255), default="") email: Mapped[str] = mapped_column(String(128), default="")
address: Mapped[str] = mapped_column(String(1024), default="") address: Mapped[str] = mapped_column(Text, default="")
class Service(Base): class Service(Base):
__tablename__ = "services" __tablename__ = "services"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(256))
desc: Mapped[str] = mapped_column(Text) desc: Mapped[str] = mapped_column(Text, default="")
icon: Mapped[str] = mapped_column(String(64)) icon: Mapped[str] = mapped_column(String(32), default="draft")
order: Mapped[int] = mapped_column(Integer, default=0)
class Project(Base): class Project(Base):
__tablename__ = "projects" __tablename__ = "projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
title: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(256))
location: Mapped[str] = mapped_column(String(255)) location: Mapped[str] = mapped_column(String(128), default="")
year: Mapped[str] = mapped_column(String(64)) year: Mapped[str] = mapped_column(String(64), default="")
status: Mapped[ProjectStatus] = mapped_column(Enum(ProjectStatus), default=ProjectStatus.progress) status: Mapped[str] = mapped_column(String(16), default="done") # done | progress
image: Mapped[str] = mapped_column(String(1024)) image: Mapped[str] = mapped_column(Text, default="")
gallery: Mapped[list] = mapped_column(JSON, default=list) gallery: Mapped[list] = mapped_column(JSON, default=list)
area: Mapped[str] = mapped_column(String(128)) area: Mapped[str] = mapped_column(String(64), default="")
floors: Mapped[str] = mapped_column(String(128)) floors: Mapped[str] = mapped_column(String(64), default="")
units: Mapped[str] = mapped_column(String(128)) units: Mapped[str] = mapped_column(String(64), default="")
client: Mapped[str] = mapped_column(String(255)) client: Mapped[str] = mapped_column(String(128), default="")
summary: Mapped[str] = mapped_column(Text) summary: Mapped[str] = mapped_column(Text, default="")
body: Mapped[list] = mapped_column(JSON, default=list) 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): class Article(Base):
__tablename__ = "articles" __tablename__ = "articles"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
title: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(256))
date: Mapped[str] = mapped_column(String(64)) date: Mapped[str] = mapped_column(String(64), default="")
category: Mapped[str] = mapped_column(String(128)) category: Mapped[str] = mapped_column(String(64), default="")
image: Mapped[str] = mapped_column(String(1024)) image: Mapped[str] = mapped_column(Text, default="")
read: Mapped[str] = mapped_column(String(32)) read: Mapped[str] = mapped_column(String(32), default="")
excerpt: Mapped[str] = mapped_column(Text) excerpt: Mapped[str] = mapped_column(Text, default="")
body: Mapped[list] = mapped_column(JSON, default=list) 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): class Faq(Base):
__tablename__ = "faqs" __tablename__ = "faqs"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
q: Mapped[str] = mapped_column(String(512)) question: Mapped[str] = mapped_column(Text)
a: Mapped[str] = mapped_column(Text) answer: Mapped[str] = mapped_column(Text, default="")
order: Mapped[int] = mapped_column(Integer, default=0)
class ContactLead(Base): class TeamMember(Base):
__tablename__ = "contact_leads" __tablename__ = "team"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), default="") name: Mapped[str] = mapped_column(String(128))
phone: Mapped[str] = mapped_column(String(64)) 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="") note: Mapped[str] = mapped_column(Text, default="")
is_read: Mapped[bool] = mapped_column(Boolean, default=False) 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): class Subscriber(Base):
__tablename__ = "newsletter_subscribers" __tablename__ = "subscribers"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True) email: Mapped[str] = mapped_column(String(256), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
-70
View File
@@ -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
-25
View File
@@ -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)
-38
View File
@@ -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
-59
View File
@@ -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
-48
View File
@@ -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
-70
View File
@@ -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
-65
View File
@@ -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,
)
+206 -283
View File
@@ -1,23 +1,160 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
from app.models import ProjectStatus from pydantic import BaseModel, ConfigDict, EmailStr, Field
# ---------- پایه ----------
# ---------- Auth ---------- class ORMModel(BaseModel):
class Token(BaseModel): model_config = ConfigDict(from_attributes=True)
access_token: str
token_type: str = "bearer"
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 username: str
password: str password: str
# ---------- Brand ---------- class TokenOut(BaseModel):
class BrandBase(BaseModel): access_token: str
token_type: str = "bearer"
class BrandIn(BaseModel):
name: str = "" name: str = ""
logo: str = "" logo: str = ""
hero: str = "" hero: str = ""
@@ -28,296 +165,82 @@ class BrandBase(BaseModel):
address: str = "" address: str = ""
class BrandOut(BrandBase): class ServiceIn(BaseModel):
model_config = ConfigDict(from_attributes=True) title: str = Field(min_length=1, max_length=256)
id: int desc: str = ""
icon: str = "draft"
class BrandUpdate(BrandBase): class ProjectIn(BaseModel):
pass slug: str = Field(min_length=1, max_length=128)
title: str = Field(min_length=1, max_length=256)
location: str = ""
# ---------- Service ---------- year: str = ""
class ServiceBase(BaseModel): status: Literal["done", "progress"] = "done"
title: str image: str = ""
desc: str
icon: str
order: int = 0
class ServiceCreate(ServiceBase):
pass
class ServiceUpdate(BaseModel):
title: Optional[str] = None
desc: Optional[str] = None
icon: Optional[str] = None
order: Optional[int] = None
class ServiceOut(ServiceBase):
model_config = ConfigDict(from_attributes=True)
id: int
# ---------- Project ----------
class ProjectBase(BaseModel):
slug: str
title: str
location: str
year: str
status: ProjectStatus
image: str
gallery: list[str] = [] gallery: list[str] = []
area: str area: str = ""
floors: str floors: str = ""
units: str units: str = ""
client: str client: str = ""
summary: str summary: str = ""
body: list[str] = [] body: list[str] = []
order: int = 0
class ProjectCreate(ProjectBase): class ArticleIn(BaseModel):
pass 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): class TestimonialIn(BaseModel):
slug: Optional[str] = None name: str = Field(min_length=1, max_length=128)
title: Optional[str] = None role: str = ""
location: Optional[str] = None quote: str = ""
year: Optional[str] = None
status: Optional[ProjectStatus] = None
image: Optional[str] = None
gallery: Optional[list[str]] = None
area: Optional[str] = None
floors: Optional[str] = None
units: Optional[str] = None
client: Optional[str] = None
summary: Optional[str] = None
body: Optional[list[str]] = None
order: Optional[int] = None
class ProjectOut(ProjectBase): class FaqIn(BaseModel):
model_config = ConfigDict(from_attributes=True) question: str = Field(min_length=1)
id: int answer: str = ""
# ---------- Stat ---------- class TeamMemberIn(BaseModel):
class StatBase(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 value: int
suffix: str = "" suffix: str = ""
label: str label: str = Field(min_length=1, max_length=128)
order: int = 0
class StatCreate(StatBase): class WhyUsItemIn(BaseModel):
pass title: str = Field(min_length=1, max_length=256)
desc: str = ""
class StatUpdate(BaseModel): class BeforeAfterIn(BaseModel):
value: Optional[int] = None title: str = ""
suffix: Optional[str] = None lead: str = ""
label: Optional[str] = None before: str = ""
order: Optional[int] = None after: str = ""
before_label: str = ""
after_label: str = ""
class StatOut(StatBase): class MessageReadIn(BaseModel):
model_config = ConfigDict(from_attributes=True) is_read: bool = 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
+84
View File
@@ -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)
+291
View File
@@ -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": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژه‌ای نیز در جزیره کیش در دست ساخت است."},
]
+9 -13
View File
@@ -1,13 +1,9 @@
fastapi==0.115.0 fastapi>=0.115.8
uvicorn[standard]==0.30.6 uvicorn[standard]>=0.34.0
sqlalchemy==2.0.35 sqlalchemy>=2.0.36
psycopg2-binary==2.9.10 psycopg[binary]>=3.2.3
alembic==1.13.2 pydantic>=2.10.4
pydantic==2.9.2 pydantic-settings>=2.6.1
email-validator==2.2.0 PyJWT>=2.10.1
pydantic-settings==2.5.2 bcrypt>=4.2.1
python-jose[cryptography]==3.3.0 email-validator>=2.2.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-multipart==0.0.9
python-dotenv==1.0.1
-44
View File
@@ -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()
-242
View File
@@ -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()