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