بک‌اند: ویرایش جزئی (PATCH) و ترتیب‌بندی مجموعه‌ها برای ویرایش زنده

- PATCH /api/admin/{resource}/{id} — ویرایش جزئی؛ فقط فیلدهای ارسال‌شده
  به‌روزرسانی می‌شوند (PUT قبلاً کل رکورد را جایگزین می‌کرد و برای
  ویرایش زنده قابل استفاده نبود). اسکیماهای Patch به‌صورت خودکار از
  اسکیماهای In با create_model ساخته می‌شوند (هر ۲۴ منبع).
- ستون position برای ۱۴ جدول مجموعه‌ای (خدمات، پروژه‌ها، مقالات، نظرات،
  سوالات، تیم، تایم‌لاین، آمار، چرا آرامش، مدل‌های سرمایه‌گذاری، مراحل کار،
  دفاتر، گالری، منو) — مهاجرت سبک idempotent در startup و seed
  (ALTER TABLE ADD COLUMN IF NOT EXISTS) تا دیتابیس‌های موجود هم ارتقا یابند
- POST /api/admin/{resource}/reorder — ذخیره ترتیب جدید بر اساس لیست id
- خواندن عمومی بر اساس (position, id) مرتب می‌شود؛ رکورد جدید در انتهای لیست
- seed فقط جدول‌های خالی را با position صحیح پر می‌کند
This commit is contained in:
MMD
2026-09-06 21:57:07 +03:30
parent bf3c67f405
commit 6e422ec3dc
7 changed files with 218 additions and 57 deletions
+94 -27
View File
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
import boto3 import boto3
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -15,57 +16,82 @@ from ..database import get_db
from ..schemas import ( from ..schemas import (
ArticleIn, ArticleIn,
ArticleOut, ArticleOut,
ArticlePatch,
BeforeAfterIn, BeforeAfterIn,
BeforeAfterOut, BeforeAfterOut,
BeforeAfterPatch,
BrandIn, BrandIn,
BrandOut, BrandOut,
BrandPatch,
ContactSectionIn, ContactSectionIn,
ContactSectionOut, ContactSectionOut,
ContactSectionPatch,
ContactMessageOut, ContactMessageOut,
FaqIn, FaqIn,
FaqOut, FaqOut,
FaqPatch,
FooterIn, FooterIn,
FooterOut, FooterOut,
FooterPatch,
GalleryImageIn, GalleryImageIn,
GalleryImageOut, GalleryImageOut,
GalleryImagePatch,
HeroIn, HeroIn,
HeroOut, HeroOut,
HeroPatch,
InvestmentModelIn, InvestmentModelIn,
InvestmentModelOut, InvestmentModelOut,
InvestmentModelPatch,
LoginIn, LoginIn,
MessageReadIn, MessageReadIn,
NavItemIn, NavItemIn,
NavItemOut, NavItemOut,
NavItemPatch,
NewsletterSectionIn, NewsletterSectionIn,
NewsletterOut, NewsletterOut,
NewsletterSectionPatch,
OfficeIn, OfficeIn,
OfficeOut, OfficeOut,
OfficePatch,
PageHeroIn, PageHeroIn,
PageHeroOut, PageHeroOut,
PageHeroPatch,
ProcessStepIn, ProcessStepIn,
ProcessStepOut, ProcessStepOut,
ProcessStepPatch,
ProjectIn, ProjectIn,
ProjectOut, ProjectOut,
ProjectPatch,
ReorderIn,
SectionHeadingIn, SectionHeadingIn,
SectionHeadingOut, SectionHeadingOut,
SectionHeadingPatch,
ServiceIn, ServiceIn,
ServiceOut, ServiceOut,
ServicePatch,
StatIn, StatIn,
StatOut, StatOut,
StatPatch,
StatusLabelIn, StatusLabelIn,
StatusLabelOut, StatusLabelOut,
StatusLabelPatch,
StoryIn, StoryIn,
StoryOut, StoryOut,
StoryPatch,
SubscriberOut, SubscriberOut,
TeamMemberIn, TeamMemberIn,
TeamMemberOut, TeamMemberOut,
TeamMemberPatch,
TestimonialIn, TestimonialIn,
TestimonialOut, TestimonialOut,
TestimonialPatch,
TimelineItemIn, TimelineItemIn,
TimelineItemOut, TimelineItemOut,
TimelineItemPatch,
TokenOut, TokenOut,
WhyUsItemIn, WhyUsItemIn,
WhyUsItemOut, WhyUsItemOut,
WhyUsItemPatch,
) )
# روتر عمومی برای ورود — بدون نیاز به توکن # روتر عمومی برای ورود — بدون نیاز به توکن
@@ -86,16 +112,25 @@ def login(payload: LoginIn, db: Session = Depends(get_db)):
return TokenOut(access_token=create_access_token(user.username)) return TokenOut(access_token=create_access_token(user.username))
def register_crud(path: str, model, out_schema, create_schema) -> None: def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
"""ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت.""" """ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
حذف و ترتیب‌بندی) روی روتر مدیریت."""
@router.get(f"/{path}", response_model=list[out_schema]) @router.get(f"/{path}", response_model=list[out_schema])
def list_items(db: Session = Depends(get_db)): def list_items(db: Session = Depends(get_db)):
return db.query(model).order_by(model.id).all() query = db.query(model)
if hasattr(model, "position"):
query = query.order_by(model.position, model.id)
else:
query = query.order_by(model.id)
return query.all()
@router.post(f"/{path}", response_model=out_schema, status_code=201) @router.post(f"/{path}", response_model=out_schema, status_code=201)
def create_item(payload: create_schema, db: Session = Depends(get_db)): def create_item(payload: create_schema, db: Session = Depends(get_db)):
item = model(**payload.model_dump()) item = model(**payload.model_dump())
if hasattr(model, "position"):
# آیتم جدید در انتهای لیست قرار می‌گیرد
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
db.add(item) db.add(item)
try: try:
db.commit() db.commit()
@@ -127,6 +162,38 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
db.refresh(item) db.refresh(item)
return item return item
if patch_schema is not None:
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
def patch_item(item_id: int, payload: patch_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(exclude_unset=True).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.post(f"/{path}/reorder", response_model=list[out_schema])
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)):
"""ذخیره ترتیب جدید آیتم‌ها بر اساس لیست idهای ارسالی."""
if not hasattr(model, "position"):
raise HTTPException(status_code=400, detail="این منبع از ترتیب‌بندی پشتیبانی نمی‌کند")
items = db.query(model).filter(model.id.in_(payload.ids)).all()
if len(items) != len(payload.ids):
raise HTTPException(status_code=404, detail="برخی رکوردها یافت نشدند")
by_id = {item.id: item for item in items}
for pos, item_id in enumerate(payload.ids):
by_id[item_id].position = pos
db.commit()
return db.query(model).order_by(model.position, model.id).all()
@router.delete(f"/{path}/{{item_id}}", status_code=204) @router.delete(f"/{path}/{{item_id}}", status_code=204)
def delete_item(item_id: int, db: Session = Depends(get_db)): def delete_item(item_id: int, db: Session = Depends(get_db)):
item = db.get(model, item_id) item = db.get(model, item_id)
@@ -137,30 +204,30 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
# CRUD محتوا # CRUD محتوا
register_crud("brand", models.Brand, BrandOut, BrandIn) register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch)
register_crud("nav", models.NavItem, NavItemOut, NavItemIn) register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch)
register_crud("hero", models.Hero, HeroOut, HeroIn) register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch)
register_crud("story", models.StorySection, StoryOut, StoryIn) register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch)
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn) register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch)
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn) register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
register_crud("services", models.Service, ServiceOut, ServiceIn) register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
register_crud("projects", models.Project, ProjectOut, ProjectIn) register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
register_crud("articles", models.Article, ArticleOut, ArticleIn) register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch)
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn) register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
register_crud("faqs", models.Faq, FaqOut, FaqIn) register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn) register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn) register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn, TimelineItemPatch)
register_crud("stats", models.Stat, StatOut, StatIn) register_crud("stats", models.Stat, StatOut, StatIn, StatPatch)
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn) register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn, WhyUsItemPatch)
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn) register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn, BeforeAfterPatch)
register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn) register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn, InvestmentModelPatch)
register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn) register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn, ProcessStepPatch)
register_crud("offices", models.Office, OfficeOut, OfficeIn) register_crud("offices", models.Office, OfficeOut, OfficeIn, OfficePatch)
register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn) register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn, GalleryImagePatch)
register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn) register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn, StatusLabelPatch)
register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn) register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch)
register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn) register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch)
register_crud("footer", models.FooterContent, FooterOut, FooterIn) register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمی‌گرداند تا بک‌اند ذخیره‌سازی قابل تعویض باشد # آپلود تصویر به Object Storage (MinIO) — URL نسبی برمی‌گرداند تا بک‌اند ذخیره‌سازی قابل تعویض باشد
+23 -23
View File
@@ -37,12 +37,12 @@ def get_brand(db: Session = Depends(get_db)):
@router.get("/services", response_model=list[ServiceOut]) @router.get("/services", response_model=list[ServiceOut])
def get_services(db: Session = Depends(get_db)): def get_services(db: Session = Depends(get_db)):
return db.query(models.Service).all() return db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
@router.get("/projects", response_model=list[ProjectOut]) @router.get("/projects", response_model=list[ProjectOut])
def get_projects(db: Session = Depends(get_db)): def get_projects(db: Session = Depends(get_db)):
return db.query(models.Project).order_by(models.Project.id).all() return db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
@router.get("/projects/{slug}", response_model=ProjectOut) @router.get("/projects/{slug}", response_model=ProjectOut)
@@ -55,7 +55,7 @@ def get_project(slug: str, db: Session = Depends(get_db)):
@router.get("/articles", response_model=list[ArticleOut]) @router.get("/articles", response_model=list[ArticleOut])
def get_articles(db: Session = Depends(get_db)): def get_articles(db: Session = Depends(get_db)):
return db.query(models.Article).order_by(models.Article.id).all() return db.query(models.Article).order_by(models.Article.position, models.Article.id).all()
@router.get("/articles/{slug}", response_model=ArticleOut) @router.get("/articles/{slug}", response_model=ArticleOut)
@@ -68,32 +68,32 @@ def get_article(slug: str, db: Session = Depends(get_db)):
@router.get("/testimonials", response_model=list[TestimonialOut]) @router.get("/testimonials", response_model=list[TestimonialOut])
def get_testimonials(db: Session = Depends(get_db)): def get_testimonials(db: Session = Depends(get_db)):
return db.query(models.Testimonial).all() return db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all()
@router.get("/faqs", response_model=list[FaqOut]) @router.get("/faqs", response_model=list[FaqOut])
def get_faqs(db: Session = Depends(get_db)): def get_faqs(db: Session = Depends(get_db)):
return db.query(models.Faq).all() return db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all()
@router.get("/team", response_model=list[TeamMemberOut]) @router.get("/team", response_model=list[TeamMemberOut])
def get_team(db: Session = Depends(get_db)): def get_team(db: Session = Depends(get_db)):
return db.query(models.TeamMember).all() return db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all()
@router.get("/timeline", response_model=list[TimelineItemOut]) @router.get("/timeline", response_model=list[TimelineItemOut])
def get_timeline(db: Session = Depends(get_db)): def get_timeline(db: Session = Depends(get_db)):
return db.query(models.TimelineItem).order_by(models.TimelineItem.id).all() return db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all()
@router.get("/stats", response_model=list[StatOut]) @router.get("/stats", response_model=list[StatOut])
def get_stats(db: Session = Depends(get_db)): def get_stats(db: Session = Depends(get_db)):
return db.query(models.Stat).all() return db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all()
@router.get("/why-us", response_model=list[WhyUsItemOut]) @router.get("/why-us", response_model=list[WhyUsItemOut])
def get_why_us(db: Session = Depends(get_db)): def get_why_us(db: Session = Depends(get_db)):
return db.query(models.WhyUsItem).all() return db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all()
@router.get("/before-after", response_model=BeforeAfterOut) @router.get("/before-after", response_model=BeforeAfterOut)
@@ -109,25 +109,25 @@ def get_site(db: Session = Depends(get_db)):
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند.""" """همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند."""
return { return {
"brand": db.query(models.Brand).first(), "brand": db.query(models.Brand).first(),
"nav": db.query(models.NavItem).order_by(models.NavItem.id).all(), "nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(),
"hero": db.query(models.Hero).first(), "hero": db.query(models.Hero).first(),
"story": db.query(models.StorySection).first(), "story": db.query(models.StorySection).first(),
"section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(), "section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(),
"page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(), "page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(),
"services": db.query(models.Service).all(), "services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(),
"projects": db.query(models.Project).order_by(models.Project.id).all(), "projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(),
"articles": db.query(models.Article).order_by(models.Article.id).all(), "articles": db.query(models.Article).order_by(models.Article.position, models.Article.id).all(),
"testimonials": db.query(models.Testimonial).all(), "testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(),
"faqs": db.query(models.Faq).all(), "faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(),
"team": db.query(models.TeamMember).all(), "team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(),
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(), "timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all(),
"stats": db.query(models.Stat).all(), "stats": db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all(),
"why_us": db.query(models.WhyUsItem).all(), "why_us": db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all(),
"before_after": db.query(models.BeforeAfter).first(), "before_after": db.query(models.BeforeAfter).first(),
"investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.id).all(), "investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.position, models.InvestmentModel.id).all(),
"process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.id).all(), "process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.position, models.ProcessStep.id).all(),
"offices": db.query(models.Office).order_by(models.Office.id).all(), "offices": db.query(models.Office).order_by(models.Office.position, models.Office.id).all(),
"gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.id).all(), "gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.position, models.GalleryImage.id).all(),
"status_labels": db.query(models.StatusLabel).order_by(models.StatusLabel.id).all(), "status_labels": db.query(models.StatusLabel).order_by(models.StatusLabel.id).all(),
"contact_section": db.query(models.ContactSectionContent).first(), "contact_section": db.query(models.ContactSectionContent).first(),
"newsletter": db.query(models.NewsletterSectionContent).first(), "newsletter": db.query(models.NewsletterSectionContent).first(),
+21 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine, text
from sqlalchemy.orm import DeclarativeBase, sessionmaker from sqlalchemy.orm import DeclarativeBase, sessionmaker
from .config import settings from .config import settings
@@ -18,3 +18,23 @@ def get_db():
yield db yield db
finally: finally:
db.close() db.close()
# جدول‌های مجموعه‌ای که ستون position دارند
POSITION_TABLES = [
"services", "projects", "articles", "testimonials", "faqs", "team",
"timeline", "stats", "why_us", "investment_models", "process_steps",
"offices", "gallery_images", "nav_items",
]
def run_light_migrations() -> None:
"""مهاجرت‌های سبک idempotent — برای دیتابیس‌های موجود که create_all آن‌ها را
به‌روز نمی‌کند اجرا می‌شود (seed و startup هر دو آن را صدا می‌زنند)."""
if engine.dialect.name != "postgresql":
return
with engine.begin() as conn:
for table in POSITION_TABLES:
conn.execute(
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0")
)
+2 -1
View File
@@ -4,9 +4,10 @@ from fastapi.middleware.cors import CORSMiddleware
from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت جداول from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت جداول
from .api import admin, forms, public from .api import admin, forms, public
from .config import settings from .config import settings
from .database import Base, engine from .database import Base, engine, run_light_migrations
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_light_migrations()
app = FastAPI( app = FastAPI(
title="Aramesh Building Group API", title="Aramesh Building Group API",
+28
View File
@@ -35,6 +35,8 @@ class Brand(Base):
class Service(Base): class Service(Base):
__tablename__ = "services" __tablename__ = "services"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
desc: Mapped[str] = mapped_column(Text, default="") desc: Mapped[str] = mapped_column(Text, default="")
@@ -44,6 +46,8 @@ class Service(Base):
class Project(Base): class Project(Base):
__tablename__ = "projects" __tablename__ = "projects"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
@@ -63,6 +67,8 @@ class Project(Base):
class Article(Base): class Article(Base):
__tablename__ = "articles" __tablename__ = "articles"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
@@ -77,6 +83,8 @@ class Article(Base):
class Testimonial(Base): class Testimonial(Base):
__tablename__ = "testimonials" __tablename__ = "testimonials"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128)) name: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(String(128), default="") role: Mapped[str] = mapped_column(String(128), default="")
@@ -86,6 +94,8 @@ class Testimonial(Base):
class Faq(Base): class Faq(Base):
__tablename__ = "faqs" __tablename__ = "faqs"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
question: Mapped[str] = mapped_column(Text) question: Mapped[str] = mapped_column(Text)
answer: Mapped[str] = mapped_column(Text, default="") answer: Mapped[str] = mapped_column(Text, default="")
@@ -94,6 +104,8 @@ class Faq(Base):
class TeamMember(Base): class TeamMember(Base):
__tablename__ = "team" __tablename__ = "team"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128)) name: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(String(128), default="") role: Mapped[str] = mapped_column(String(128), default="")
@@ -103,6 +115,8 @@ class TeamMember(Base):
class TimelineItem(Base): class TimelineItem(Base):
__tablename__ = "timeline" __tablename__ = "timeline"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
year: Mapped[str] = mapped_column(String(32)) year: Mapped[str] = mapped_column(String(32))
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
@@ -113,6 +127,8 @@ class TimelineItem(Base):
class Stat(Base): class Stat(Base):
__tablename__ = "stats" __tablename__ = "stats"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[int] = mapped_column(Integer) value: Mapped[int] = mapped_column(Integer)
suffix: Mapped[str] = mapped_column(String(16), default="") suffix: Mapped[str] = mapped_column(String(16), default="")
@@ -122,6 +138,8 @@ class Stat(Base):
class WhyUsItem(Base): class WhyUsItem(Base):
__tablename__ = "why_us" __tablename__ = "why_us"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
desc: Mapped[str] = mapped_column(Text, default="") desc: Mapped[str] = mapped_column(Text, default="")
@@ -142,6 +160,8 @@ class BeforeAfter(Base):
class NavItem(Base): class NavItem(Base):
__tablename__ = "nav_items" __tablename__ = "nav_items"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str] = mapped_column(String(64)) label: Mapped[str] = mapped_column(String(64))
href: Mapped[str] = mapped_column(String(128)) href: Mapped[str] = mapped_column(String(128))
@@ -215,6 +235,8 @@ class PageHeroContent(Base):
class InvestmentModel(Base): class InvestmentModel(Base):
__tablename__ = "investment_models" __tablename__ = "investment_models"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
desc: Mapped[str] = mapped_column(Text, default="") desc: Mapped[str] = mapped_column(Text, default="")
@@ -224,6 +246,8 @@ class InvestmentModel(Base):
class ProcessStep(Base): class ProcessStep(Base):
__tablename__ = "process_steps" __tablename__ = "process_steps"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
step: Mapped[str] = mapped_column(String(16), default="") step: Mapped[str] = mapped_column(String(16), default="")
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
@@ -233,6 +257,8 @@ class ProcessStep(Base):
class Office(Base): class Office(Base):
__tablename__ = "offices" __tablename__ = "offices"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
city: Mapped[str] = mapped_column(String(64)) city: Mapped[str] = mapped_column(String(64))
address: Mapped[str] = mapped_column(Text, default="") address: Mapped[str] = mapped_column(Text, default="")
@@ -242,6 +268,8 @@ class Office(Base):
class GalleryImage(Base): class GalleryImage(Base):
__tablename__ = "gallery_images" __tablename__ = "gallery_images"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
src: Mapped[str] = mapped_column(Text, default="") src: Mapped[str] = mapped_column(Text, default="")
alt: Mapped[str] = mapped_column(Text, default="") alt: Mapped[str] = mapped_column(Text, default="")
+43 -2
View File
@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model
# ---------- پایه ---------- # ---------- پایه ----------
@@ -493,3 +493,44 @@ class FooterIn(BaseModel):
class MessageReadIn(BaseModel): class MessageReadIn(BaseModel):
is_read: bool = True is_read: bool = True
# ---------- اسکیماهای PATCH (ویرایش جزئی — همه فیلدها اختیاری) ----------
def _make_patch(name: str, model_cls: type[BaseModel]) -> type[BaseModel]:
"""از اسکیمای ورودی، اسکیمای PATCH می‌سازد؛ فیلدهای ارسال‌نشده دست‌نخورده می‌مانند."""
fields = {fname: (Optional[f.annotation], None) for fname, f in model_cls.model_fields.items()}
return create_model(name, **fields)
BrandPatch = _make_patch("BrandPatch", BrandIn)
ServicePatch = _make_patch("ServicePatch", ServiceIn)
ProjectPatch = _make_patch("ProjectPatch", ProjectIn)
ArticlePatch = _make_patch("ArticlePatch", ArticleIn)
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
FaqPatch = _make_patch("FaqPatch", FaqIn)
TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn)
TimelineItemPatch = _make_patch("TimelineItemPatch", TimelineItemIn)
StatPatch = _make_patch("StatPatch", StatIn)
WhyUsItemPatch = _make_patch("WhyUsItemPatch", WhyUsItemIn)
BeforeAfterPatch = _make_patch("BeforeAfterPatch", BeforeAfterIn)
NavItemPatch = _make_patch("NavItemPatch", NavItemIn)
HeroPatch = _make_patch("HeroPatch", HeroIn)
StoryPatch = _make_patch("StoryPatch", StoryIn)
SectionHeadingPatch = _make_patch("SectionHeadingPatch", SectionHeadingIn)
PageHeroPatch = _make_patch("PageHeroPatch", PageHeroIn)
InvestmentModelPatch = _make_patch("InvestmentModelPatch", InvestmentModelIn)
ProcessStepPatch = _make_patch("ProcessStepPatch", ProcessStepIn)
OfficePatch = _make_patch("OfficePatch", OfficeIn)
GalleryImagePatch = _make_patch("GalleryImagePatch", GalleryImageIn)
StatusLabelPatch = _make_patch("StatusLabelPatch", StatusLabelIn)
ContactSectionPatch = _make_patch("ContactSectionPatch", ContactSectionIn)
NewsletterSectionPatch = _make_patch("NewsletterSectionPatch", NewsletterSectionIn)
FooterPatch = _make_patch("FooterPatch", FooterIn)
class ReorderIn(BaseModel):
"""ترتیب جدید آیتم‌های یک مجموعه بر اساس id."""
ids: list[int]
+7 -3
View File
@@ -14,7 +14,7 @@ from typing import Any
from . import models, seed_data from . import models, seed_data
from .auth import hash_password from .auth import hash_password
from .config import settings from .config import settings
from .database import Base, SessionLocal, engine from .database import Base, SessionLocal, engine, run_light_migrations
# (مدل، داده‌های اولیه، برچسب گزارش) # (مدل، داده‌های اولیه، برچسب گزارش)
CONTENT: list[tuple[Any, list[dict], str]] = [ CONTENT: list[tuple[Any, list[dict], str]] = [
@@ -53,12 +53,16 @@ def seed(force: bool = False) -> None:
print("جداول قبلی حذف شدند.") print("جداول قبلی حذف شدند.")
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_light_migrations()
for model, rows, label in CONTENT: for model, rows, label in CONTENT:
if db.query(model).count() > 0: if db.query(model).count() > 0:
continue continue
for row in rows: for idx, row in enumerate(rows):
db.add(model(**row)) payload = dict(row)
if hasattr(model, "position") and "position" not in payload:
payload["position"] = idx
db.add(model(**payload))
if db.query(models.AdminUser).count() == 0: if db.query(models.AdminUser).count() == 0:
db.add( db.add(