From 6e422ec3dca3798ef659bd84308edf66b5f12016 Mon Sep 17 00:00:00 2001 From: MMD Date: Sun, 6 Sep 2026 21:57:07 +0330 Subject: [PATCH] =?UTF-8?q?=D8=A8=DA=A9=E2=80=8C=D8=A7=D9=86=D8=AF:=20?= =?UTF-8?q?=D9=88=DB=8C=D8=B1=D8=A7=DB=8C=D8=B4=20=D8=AC=D8=B2=D8=A6=DB=8C?= =?UTF-8?q?=20(PATCH)=20=D9=88=20=D8=AA=D8=B1=D8=AA=DB=8C=D8=A8=E2=80=8C?= =?UTF-8?q?=D8=A8=D9=86=D8=AF=DB=8C=20=D9=85=D8=AC=D9=85=D9=88=D8=B9=D9=87?= =?UTF-8?q?=E2=80=8C=D9=87=D8=A7=20=D8=A8=D8=B1=D8=A7=DB=8C=20=D9=88=DB=8C?= =?UTF-8?q?=D8=B1=D8=A7=DB=8C=D8=B4=20=D8=B2=D9=86=D8=AF=D9=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 صحیح پر می‌کند --- backend/app/api/admin.py | 121 +++++++++++++++++++++++++++++--------- backend/app/api/public.py | 46 +++++++-------- backend/app/database.py | 22 ++++++- backend/app/main.py | 3 +- backend/app/models.py | 28 +++++++++ backend/app/schemas.py | 45 +++++++++++++- backend/app/seed.py | 10 +++- 7 files changed, 218 insertions(+), 57 deletions(-) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 4c84321..91e2a1c 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone import boto3 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from sqlalchemy import func from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -15,57 +16,82 @@ from ..database import get_db from ..schemas import ( ArticleIn, ArticleOut, + ArticlePatch, BeforeAfterIn, BeforeAfterOut, + BeforeAfterPatch, BrandIn, BrandOut, + BrandPatch, ContactSectionIn, ContactSectionOut, + ContactSectionPatch, ContactMessageOut, FaqIn, FaqOut, + FaqPatch, FooterIn, FooterOut, + FooterPatch, GalleryImageIn, GalleryImageOut, + GalleryImagePatch, HeroIn, HeroOut, + HeroPatch, InvestmentModelIn, InvestmentModelOut, + InvestmentModelPatch, LoginIn, MessageReadIn, NavItemIn, NavItemOut, + NavItemPatch, NewsletterSectionIn, NewsletterOut, + NewsletterSectionPatch, OfficeIn, OfficeOut, + OfficePatch, PageHeroIn, PageHeroOut, + PageHeroPatch, ProcessStepIn, ProcessStepOut, + ProcessStepPatch, ProjectIn, ProjectOut, + ProjectPatch, + ReorderIn, SectionHeadingIn, SectionHeadingOut, + SectionHeadingPatch, ServiceIn, ServiceOut, + ServicePatch, StatIn, StatOut, + StatPatch, StatusLabelIn, StatusLabelOut, + StatusLabelPatch, StoryIn, StoryOut, + StoryPatch, SubscriberOut, TeamMemberIn, TeamMemberOut, + TeamMemberPatch, TestimonialIn, TestimonialOut, + TestimonialPatch, TimelineItemIn, TimelineItemOut, + TimelineItemPatch, TokenOut, WhyUsItemIn, WhyUsItemOut, + WhyUsItemPatch, ) # روتر عمومی برای ورود — بدون نیاز به توکن @@ -86,16 +112,25 @@ def login(payload: LoginIn, db: Session = Depends(get_db)): return TokenOut(access_token=create_access_token(user.username)) -def register_crud(path: str, model, out_schema, create_schema) -> None: - """ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت.""" +def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None: + """ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی، + حذف و ترتیب‌بندی) روی روتر مدیریت.""" @router.get(f"/{path}", response_model=list[out_schema]) def list_items(db: Session = Depends(get_db)): - return db.query(model).order_by(model.id).all() + 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) def create_item(payload: create_schema, db: Session = Depends(get_db)): item = model(**payload.model_dump()) + if hasattr(model, "position"): + # آیتم جدید در انتهای لیست قرار می‌گیرد + item.position = (db.query(func.max(model.position)).scalar() or 0) + 1 db.add(item) try: db.commit() @@ -127,6 +162,38 @@ def register_crud(path: str, model, out_schema, create_schema) -> None: db.refresh(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) def delete_item(item_id: int, db: Session = Depends(get_db)): item = db.get(model, item_id) @@ -137,30 +204,30 @@ def register_crud(path: str, model, out_schema, create_schema) -> None: # CRUD محتوا -register_crud("brand", models.Brand, BrandOut, BrandIn) -register_crud("nav", models.NavItem, NavItemOut, NavItemIn) -register_crud("hero", models.Hero, HeroOut, HeroIn) -register_crud("story", models.StorySection, StoryOut, StoryIn) -register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn) -register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn) -register_crud("services", models.Service, ServiceOut, ServiceIn) -register_crud("projects", models.Project, ProjectOut, ProjectIn) -register_crud("articles", models.Article, ArticleOut, ArticleIn) -register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn) -register_crud("faqs", models.Faq, FaqOut, FaqIn) -register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn) -register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn) -register_crud("stats", models.Stat, StatOut, StatIn) -register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn) -register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn) -register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn) -register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn) -register_crud("offices", models.Office, OfficeOut, OfficeIn) -register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn) -register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn) -register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn) -register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn) -register_crud("footer", models.FooterContent, FooterOut, FooterIn) +register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch) +register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch) +register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch) +register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch) +register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch) +register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch) +register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch) +register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch) +register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch) +register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch) +register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch) +register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch) +register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn, TimelineItemPatch) +register_crud("stats", models.Stat, StatOut, StatIn, StatPatch) +register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn, WhyUsItemPatch) +register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn, BeforeAfterPatch) +register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn, InvestmentModelPatch) +register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn, ProcessStepPatch) +register_crud("offices", models.Office, OfficeOut, OfficeIn, OfficePatch) +register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn, GalleryImagePatch) +register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn, StatusLabelPatch) +register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch) +register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch) +register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch) # آپلود تصویر به Object Storage (MinIO) — URL نسبی برمی‌گرداند تا بک‌اند ذخیره‌سازی قابل تعویض باشد diff --git a/backend/app/api/public.py b/backend/app/api/public.py index c070bda..fb45794 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -37,12 +37,12 @@ def get_brand(db: Session = Depends(get_db)): @router.get("/services", response_model=list[ServiceOut]) 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]) 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) @@ -55,7 +55,7 @@ def get_project(slug: str, db: Session = Depends(get_db)): @router.get("/articles", response_model=list[ArticleOut]) def get_articles(db: Session = Depends(get_db)): - return db.query(models.Article).order_by(models.Article.id).all() + return db.query(models.Article).order_by(models.Article.position, models.Article.id).all() @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]) 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]) 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]) 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]) 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]) 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]) 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) @@ -109,25 +109,25 @@ def get_site(db: Session = Depends(get_db)): """همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند.""" return { "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(), "story": db.query(models.StorySection).first(), "section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(), "page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(), - "services": db.query(models.Service).all(), - "projects": db.query(models.Project).order_by(models.Project.id).all(), - "articles": db.query(models.Article).order_by(models.Article.id).all(), - "testimonials": db.query(models.Testimonial).all(), - "faqs": db.query(models.Faq).all(), - "team": db.query(models.TeamMember).all(), - "timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(), - "stats": db.query(models.Stat).all(), - "why_us": db.query(models.WhyUsItem).all(), + "services": db.query(models.Service).order_by(models.Service.position, models.Service.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.position, models.Article.id).all(), + "testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(), + "faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(), + "team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(), + "timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all(), + "stats": db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all(), + "why_us": db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all(), "before_after": db.query(models.BeforeAfter).first(), - "investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.id).all(), - "process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.id).all(), - "offices": db.query(models.Office).order_by(models.Office.id).all(), - "gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.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.position, models.ProcessStep.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.position, models.GalleryImage.id).all(), "status_labels": db.query(models.StatusLabel).order_by(models.StatusLabel.id).all(), "contact_section": db.query(models.ContactSectionContent).first(), "newsletter": db.query(models.NewsletterSectionContent).first(), diff --git a/backend/app/database.py b/backend/app/database.py index e1eee4c..b49405e 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from sqlalchemy.orm import DeclarativeBase, sessionmaker from .config import settings @@ -18,3 +18,23 @@ def get_db(): yield db finally: 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") + ) diff --git a/backend/app/main.py b/backend/app/main.py index a5c8a02..1dd190d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,9 +4,10 @@ from fastapi.middleware.cors import CORSMiddleware from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت جداول from .api import admin, forms, public from .config import settings -from .database import Base, engine +from .database import Base, engine, run_light_migrations Base.metadata.create_all(bind=engine) +run_light_migrations() app = FastAPI( title="Aramesh Building Group API", diff --git a/backend/app/models.py b/backend/app/models.py index 3a9d97d..ea25788 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -35,6 +35,8 @@ class Brand(Base): class Service(Base): __tablename__ = "services" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(256)) desc: Mapped[str] = mapped_column(Text, default="") @@ -44,6 +46,8 @@ class Service(Base): class Project(Base): __tablename__ = "projects" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True) title: Mapped[str] = mapped_column(String(256)) @@ -63,6 +67,8 @@ class Project(Base): class Article(Base): __tablename__ = "articles" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True) title: Mapped[str] = mapped_column(String(256)) @@ -77,6 +83,8 @@ class Article(Base): class Testimonial(Base): __tablename__ = "testimonials" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(128)) role: Mapped[str] = mapped_column(String(128), default="") @@ -86,6 +94,8 @@ class Testimonial(Base): class Faq(Base): __tablename__ = "faqs" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) question: Mapped[str] = mapped_column(Text) answer: Mapped[str] = mapped_column(Text, default="") @@ -94,6 +104,8 @@ class Faq(Base): class TeamMember(Base): __tablename__ = "team" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(128)) role: Mapped[str] = mapped_column(String(128), default="") @@ -103,6 +115,8 @@ class TeamMember(Base): class TimelineItem(Base): __tablename__ = "timeline" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) year: Mapped[str] = mapped_column(String(32)) title: Mapped[str] = mapped_column(String(256)) @@ -113,6 +127,8 @@ class TimelineItem(Base): class Stat(Base): __tablename__ = "stats" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) value: Mapped[int] = mapped_column(Integer) suffix: Mapped[str] = mapped_column(String(16), default="") @@ -122,6 +138,8 @@ class Stat(Base): class WhyUsItem(Base): __tablename__ = "why_us" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(256)) desc: Mapped[str] = mapped_column(Text, default="") @@ -142,6 +160,8 @@ class BeforeAfter(Base): class NavItem(Base): __tablename__ = "nav_items" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) label: Mapped[str] = mapped_column(String(64)) href: Mapped[str] = mapped_column(String(128)) @@ -215,6 +235,8 @@ class PageHeroContent(Base): class InvestmentModel(Base): __tablename__ = "investment_models" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(256)) desc: Mapped[str] = mapped_column(Text, default="") @@ -224,6 +246,8 @@ class InvestmentModel(Base): class ProcessStep(Base): __tablename__ = "process_steps" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) step: Mapped[str] = mapped_column(String(16), default="") title: Mapped[str] = mapped_column(String(256)) @@ -233,6 +257,8 @@ class ProcessStep(Base): class Office(Base): __tablename__ = "offices" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) city: Mapped[str] = mapped_column(String(64)) address: Mapped[str] = mapped_column(Text, default="") @@ -242,6 +268,8 @@ class Office(Base): class GalleryImage(Base): __tablename__ = "gallery_images" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) src: Mapped[str] = mapped_column(Text, default="") alt: Mapped[str] = mapped_column(Text, default="") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ad5c098..0e1efe0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,7 +1,7 @@ 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): 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] diff --git a/backend/app/seed.py b/backend/app/seed.py index 059358e..89f2cab 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -14,7 +14,7 @@ from typing import Any from . import models, seed_data from .auth import hash_password 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]] = [ @@ -53,12 +53,16 @@ def seed(force: bool = False) -> None: print("جداول قبلی حذف شدند.") Base.metadata.create_all(bind=engine) + run_light_migrations() for model, rows, label in CONTENT: if db.query(model).count() > 0: continue - for row in rows: - db.add(model(**row)) + for idx, row in enumerate(rows): + 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: db.add(