from datetime import datetime from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator # ---------- پایه ---------- class ORMModel(BaseModel): model_config = ConfigDict(from_attributes=True) # ---------- خروجی‌ها ---------- class BrandOut(ORMModel): id: int name: str logo: str hero: str contact_bg: str phone: str phone_href: str email: str address: str class ServiceOut(ORMModel): id: int title: str desc: str icon: str class ProjectOut(ORMModel): id: int slug: str title: str location: str year: str status: str image: str gallery: list[str] area: str floors: str units: str client: str summary: str body: list[str] class ArticleOut(ORMModel): id: int slug: str title: str date: str category: str image: str read: str excerpt: str body: dict class TestimonialOut(ORMModel): id: int name: str role: str quote: str class FaqOut(ORMModel): id: int question: str answer: str class TeamMemberOut(ORMModel): id: int name: str role: str bio: str class TimelineItemOut(ORMModel): id: int year: str title: str desc: str image: str class StatOut(ORMModel): id: int value: int suffix: str label: str class WhyUsItemOut(ORMModel): id: int title: str desc: str class BeforeAfterOut(ORMModel): id: int title: str lead: str before: str after: str before_label: str after_label: str class NavItemOut(ORMModel): id: int label: str href: str class HeroOut(ORMModel): id: int kicker: str title: str title_highlight: str subtitle: str badge_value: str badge_label: str cta_primary_label: str cta_primary_href: str cta_secondary_label: str cta_secondary_href: str scroll_label: str class StoryOut(ORMModel): id: int title: str paragraphs: list[str] quote: str quote_author: str stat1_value: str stat1_label: str stat2_value: str stat2_label: str link_label: str link_href: str bento: list[dict] class SectionHeadingOut(ORMModel): id: int key: str label: str title: str lead: str image: str cta_label: str cta_href: str class PageHeroOut(ORMModel): id: int key: str title: str lead: str image: str class InvestmentModelOut(ORMModel): id: int title: str desc: str points: list[str] class ProcessStepOut(ORMModel): id: int step: str title: str desc: str class OfficeOut(ORMModel): id: int city: str address: str hours: str class GalleryImageOut(ORMModel): id: int src: str alt: str class StatusLabelOut(ORMModel): id: int key: str label: str class ContactSectionOut(ORMModel): id: int label: str title: str lead: str address_label: str phone_label: str email_label: str name_label: str name_placeholder: str phone_field_label: str phone_placeholder: str note_label: str note_optional_label: str note_placeholder: str submit_label: str sending_label: str success_title: str success_text: str success_button_label: str class NewsletterOut(ORMModel): id: int title: str lead: str placeholder: str button_label: str sending_label: str success_text: str class FooterOut(ORMModel): id: int about_text: str pages_heading: str services_heading: str contact_heading: str copyright: str tagline: str socials: list[dict] class ContactMessageOut(ORMModel): id: int name: str phone: str note: str is_read: bool created_at: datetime class SubscriberOut(ORMModel): id: int email: str is_active: bool created_at: datetime # ---------- ورودی فرم‌ها ---------- class ContactIn(BaseModel): name: str = Field(min_length=3, max_length=128) phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$") note: str = Field(default="", max_length=2000) class NewsletterIn(BaseModel): email: EmailStr # ---------- ورودی‌های پنل مدیریت ---------- class LoginIn(BaseModel): username: str password: str class TokenOut(BaseModel): access_token: str token_type: str = "bearer" class BrandIn(BaseModel): name: str = "" logo: str = "" hero: str = "" contact_bg: str = "" phone: str = "" phone_href: str = "" email: str = "" address: str = "" class ServiceIn(BaseModel): title: str = Field(min_length=1, max_length=256) desc: str = "" icon: str = "draft" class ProjectIn(BaseModel): slug: str = Field(min_length=1, max_length=128) title: str = Field(min_length=1, max_length=256) location: str = "" year: str = "" status: Literal["done", "progress"] = "done" image: str = "" gallery: list[str] = [] area: str = "" floors: str = "" units: str = "" client: str = "" summary: str = "" body: list[str] = [] EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]} def validate_doc_body(value: object) -> dict: """بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد.""" if not value: return dict(EMPTY_DOC) if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list): raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}") return value class ArticleIn(BaseModel): slug: str = Field(min_length=1, max_length=128) title: str = Field(min_length=1, max_length=256) date: str = "" category: str = "" image: str = "" read: str = "" excerpt: str = "" body: dict = Field(default_factory=lambda: dict(EMPTY_DOC)) _validate_body = field_validator("body")(validate_doc_body) class TestimonialIn(BaseModel): name: str = Field(min_length=1, max_length=128) role: str = "" quote: str = "" class FaqIn(BaseModel): question: str = Field(min_length=1) answer: str = "" class TeamMemberIn(BaseModel): name: str = Field(min_length=1, max_length=128) role: str = "" bio: str = "" class TimelineItemIn(BaseModel): year: str = Field(min_length=1, max_length=32) title: str = Field(min_length=1, max_length=256) desc: str = "" image: str = "" class StatIn(BaseModel): value: int suffix: str = "" label: str = Field(min_length=1, max_length=128) class WhyUsItemIn(BaseModel): title: str = Field(min_length=1, max_length=256) desc: str = "" class BeforeAfterIn(BaseModel): title: str = "" lead: str = "" before: str = "" after: str = "" before_label: str = "" after_label: str = "" class NavItemIn(BaseModel): label: str = Field(min_length=1, max_length=64) href: str = Field(min_length=1, max_length=128) class HeroIn(BaseModel): kicker: str = "" title: str = "" title_highlight: str = "" subtitle: str = "" badge_value: str = "" badge_label: str = "" cta_primary_label: str = "" cta_primary_href: str = "" cta_secondary_label: str = "" cta_secondary_href: str = "" scroll_label: str = "" class StoryIn(BaseModel): title: str = "" paragraphs: list[str] = [] quote: str = "" quote_author: str = "" stat1_value: str = "" stat1_label: str = "" stat2_value: str = "" stat2_label: str = "" link_label: str = "" link_href: str = "" bento: list[dict] = [] class SectionHeadingIn(BaseModel): key: str = Field(min_length=1, max_length=64) label: str = "" title: str = "" lead: str = "" image: str = "" cta_label: str = "" cta_href: str = "" class PageHeroIn(BaseModel): key: str = Field(min_length=1, max_length=32) title: str = "" lead: str = "" image: str = "" class InvestmentModelIn(BaseModel): title: str = Field(min_length=1, max_length=256) desc: str = "" points: list[str] = [] class ProcessStepIn(BaseModel): step: str = "" title: str = Field(min_length=1, max_length=256) desc: str = "" class OfficeIn(BaseModel): city: str = Field(min_length=1, max_length=64) address: str = "" hours: str = "" class GalleryImageIn(BaseModel): src: str = "" alt: str = "" class StatusLabelIn(BaseModel): key: str = Field(min_length=1, max_length=16) label: str = Field(min_length=1, max_length=64) class ContactSectionIn(BaseModel): label: str = "" title: str = "" lead: str = "" address_label: str = "" phone_label: str = "" email_label: str = "" name_label: str = "" name_placeholder: str = "" phone_field_label: str = "" phone_placeholder: str = "" note_label: str = "" note_optional_label: str = "" note_placeholder: str = "" submit_label: str = "" sending_label: str = "" success_title: str = "" success_text: str = "" success_button_label: str = "" class NewsletterSectionIn(BaseModel): title: str = "" lead: str = "" placeholder: str = "" button_label: str = "" sending_label: str = "" success_text: str = "" class FooterIn(BaseModel): about_text: str = "" pages_heading: str = "" services_heading: str = "" contact_heading: str = "" copyright: str = "" tagline: str = "" socials: list[dict] = [] 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) class ArticlePatch(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[dict] = None _validate_body = field_validator("body")(validate_doc_body) 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]