نسخه ۲ بکاند آرامش پلازا — بازنویسی کامل
- API عمومی محتوای سایت (پروژهها، مقالات، خدمات و...) - فرم تماس و خبرنامه با اعتبارسنجی سرور - پنل مدیریت با JWT و CRUD کامل + مدیریت پیامها و مشترکین - اسکریپت seed با دادههای واقعی سایت - تستشده: ۳۶/۳۶ انتهاپوینت
This commit is contained in:
+13
-14
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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 ###
|
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
Reference in New Issue
Block a user