from datetime import datetime from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator from .sanitize import sanitize_doc # ---------- پایه ---------- class ORMModel(BaseModel): model_config = ConfigDict(from_attributes=True) # فیلدهای *_en معادل انگلیسی‌اند؛ خالی بودنشان یعنی «بدون ترجمه». # ---------- خروجی‌ها ---------- class BrandOut(ORMModel): id: int name: str name_en: str = "" logo: str hero: str contact_bg: str phone: str phone_href: str email: str address: str address_en: str = "" class ServiceOut(ORMModel): id: int title: str title_en: str = "" desc: str desc_en: str = "" icon: str class ProjectOut(ORMModel): id: int slug: str title: str title_en: str = "" location: str location_en: str = "" year: str year_en: str = "" status: str image: str gallery: list[str] area: str area_en: str = "" floors: str floors_en: str = "" units: str units_en: str = "" client: str client_en: str = "" summary: str summary_en: str = "" body: list[str] body_en: Optional[list[str]] = None class ArticleOut(ORMModel): id: int slug: str title: str title_en: str = "" date: str date_en: str = "" category: str category_en: str = "" image: str read: str read_en: str = "" excerpt: str excerpt_en: str = "" body: dict body_en: Optional[dict] = None status: str seo_keyphrase: str class TestimonialOut(ORMModel): id: int name: str name_en: str = "" role: str role_en: str = "" quote: str quote_en: str = "" class FaqOut(ORMModel): id: int question: str question_en: str = "" answer: str answer_en: str = "" class TeamMemberOut(ORMModel): id: int name: str name_en: str = "" role: str role_en: str = "" bio: str bio_en: str = "" class TimelineItemOut(ORMModel): id: int year: str year_en: str = "" title: str title_en: str = "" desc: str desc_en: str = "" image: str class StatOut(ORMModel): id: int value: int suffix: str suffix_en: str = "" label: str label_en: str = "" class WhyUsItemOut(ORMModel): id: int title: str title_en: str = "" desc: str desc_en: str = "" class BeforeAfterOut(ORMModel): id: int title: str title_en: str = "" lead: str lead_en: str = "" before: str after: str before_label: str before_label_en: str = "" after_label: str after_label_en: str = "" class NavItemOut(ORMModel): id: int label: str label_en: str = "" href: str class HeroOut(ORMModel): id: int kicker: str kicker_en: str = "" title: str title_en: str = "" title_highlight: str title_highlight_en: str = "" subtitle: str subtitle_en: str = "" badge_value: str badge_value_en: str = "" badge_label: str badge_label_en: str = "" cta_primary_label: str cta_primary_label_en: str = "" cta_primary_href: str cta_secondary_label: str cta_secondary_label_en: str = "" cta_secondary_href: str scroll_label: str scroll_label_en: str = "" class StoryOut(ORMModel): id: int title: str title_en: str = "" paragraphs: list[str] paragraphs_en: Optional[list[str]] = None quote: str quote_en: str = "" quote_author: str quote_author_en: str = "" stat1_value: str stat1_value_en: str = "" stat1_label: str stat1_label_en: str = "" stat2_value: str stat2_value_en: str = "" stat2_label: str stat2_label_en: str = "" link_label: str link_label_en: str = "" link_href: str bento: list[dict] bento_en: Optional[list[dict]] = None class SectionHeadingOut(ORMModel): id: int key: str label: str label_en: str = "" title: str title_en: str = "" lead: str lead_en: str = "" image: str cta_label: str cta_label_en: str = "" cta_href: str class PageHeroOut(ORMModel): id: int key: str title: str title_en: str = "" lead: str lead_en: str = "" image: str class InvestmentModelOut(ORMModel): id: int title: str title_en: str = "" desc: str desc_en: str = "" points: list[str] points_en: Optional[list[str]] = None class ProcessStepOut(ORMModel): id: int step: str step_en: str = "" title: str title_en: str = "" desc: str desc_en: str = "" class OfficeOut(ORMModel): id: int city: str city_en: str = "" address: str address_en: str = "" hours: str hours_en: str = "" class GalleryImageOut(ORMModel): id: int src: str alt: str alt_en: str = "" class StatusLabelOut(ORMModel): id: int key: str label: str label_en: str = "" class ContactSectionOut(ORMModel): id: int label: str label_en: str = "" title: str title_en: str = "" lead: str lead_en: str = "" address_label: str address_label_en: str = "" phone_label: str phone_label_en: str = "" email_label: str email_label_en: str = "" name_label: str name_label_en: str = "" name_placeholder: str name_placeholder_en: str = "" phone_field_label: str phone_field_label_en: str = "" phone_placeholder: str phone_placeholder_en: str = "" note_label: str note_label_en: str = "" note_optional_label: str note_optional_label_en: str = "" note_placeholder: str note_placeholder_en: str = "" submit_label: str submit_label_en: str = "" sending_label: str sending_label_en: str = "" success_title: str success_title_en: str = "" success_text: str success_text_en: str = "" success_button_label: str success_button_label_en: str = "" class NewsletterOut(ORMModel): id: int title: str title_en: str = "" lead: str lead_en: str = "" placeholder: str placeholder_en: str = "" button_label: str button_label_en: str = "" sending_label: str sending_label_en: str = "" success_text: str success_text_en: str = "" class FooterOut(ORMModel): id: int about_text: str about_text_en: str = "" pages_heading: str pages_heading_en: str = "" services_heading: str services_heading_en: str = "" contact_heading: str contact_heading_en: str = "" copyright: str copyright_en: str = "" tagline: str tagline_en: str = "" socials: list[dict] socials_en: Optional[list[dict]] = None class SeoMetaOut(ORMModel): id: int page_key: str title: str title_en: str = "" description: str description_en: str = "" class ContactMessageOut(ORMModel): id: int name: str phone: str note: str is_read: bool created_at: datetime class SubscriberOut(ORMModel): id: int email: str is_active: bool created_at: datetime # ---------- ورودی فرم‌ها ---------- class ContactIn(BaseModel): name: str = Field(min_length=3, max_length=128) phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$") note: str = Field(default="", max_length=2000) # honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمی‌کند website: str = Field(default="", max_length=128) class NewsletterIn(BaseModel): email: EmailStr # honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمی‌کند website: str = Field(default="", max_length=128) # ---------- ورودی‌های پنل مدیریت ---------- class LoginIn(BaseModel): username: str password: str class TokenOut(BaseModel): access_token: str token_type: str = "bearer" class ChangePasswordIn(BaseModel): current_password: str new_password: str = Field(min_length=8, max_length=128) class BrandIn(BaseModel): name: str = "" name_en: str = "" logo: str = "" hero: str = "" contact_bg: str = "" phone: str = "" phone_href: str = "" email: str = "" address: str = "" address_en: str = "" class ServiceIn(BaseModel): title: str = Field(min_length=1, max_length=256) title_en: str = "" desc: str = "" desc_en: 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) title_en: str = "" location: str = "" location_en: str = "" year: str = "" year_en: str = "" status: Literal["done", "progress"] = "done" image: str = "" gallery: list[str] = [] area: str = "" area_en: str = "" floors: str = "" floors_en: str = "" units: str = "" units_en: str = "" client: str = "" client_en: str = "" summary: str = "" summary_en: str = "" body: list[str] = [] body_en: list[str] = [] EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]} def validate_doc_body(value: object) -> dict: """بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد و آدرس‌های href/src آن به اسکیم‌های امن محدود شوند.""" 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 sanitize_doc(value) class ArticleIn(BaseModel): slug: str = Field(min_length=1, max_length=128) title: str = Field(min_length=1, max_length=256) title_en: str = "" date: str = "" date_en: str = "" category: str = "" category_en: str = "" image: str = "" read: str = "" read_en: str = "" excerpt: str = "" excerpt_en: str = "" body: dict = Field(default_factory=lambda: dict(EMPTY_DOC)) body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC)) status: Literal["draft", "published"] = "draft" seo_keyphrase: str = Field(default="", max_length=128) _validate_body = field_validator("body")(validate_doc_body) _validate_body_en = field_validator("body_en")(validate_doc_body) class TestimonialIn(BaseModel): name: str = Field(min_length=1, max_length=128) name_en: str = "" role: str = "" role_en: str = "" quote: str = "" quote_en: str = "" class FaqIn(BaseModel): question: str = Field(min_length=1) question_en: str = "" answer: str = "" answer_en: str = "" class TeamMemberIn(BaseModel): name: str = Field(min_length=1, max_length=128) name_en: str = "" role: str = "" role_en: str = "" bio: str = "" bio_en: str = "" class TimelineItemIn(BaseModel): year: str = Field(min_length=1, max_length=32) year_en: str = "" title: str = Field(min_length=1, max_length=256) title_en: str = "" desc: str = "" desc_en: str = "" image: str = "" class StatIn(BaseModel): value: int suffix: str = "" suffix_en: str = "" label: str = Field(min_length=1, max_length=128) label_en: str = "" class WhyUsItemIn(BaseModel): title: str = Field(min_length=1, max_length=256) title_en: str = "" desc: str = "" desc_en: str = "" class BeforeAfterIn(BaseModel): title: str = "" title_en: str = "" lead: str = "" lead_en: str = "" before: str = "" after: str = "" before_label: str = "" before_label_en: str = "" after_label: str = "" after_label_en: str = "" class NavItemIn(BaseModel): label: str = Field(min_length=1, max_length=64) label_en: str = "" href: str = Field(min_length=1, max_length=128) class HeroIn(BaseModel): kicker: str = "" kicker_en: str = "" title: str = "" title_en: str = "" title_highlight: str = "" title_highlight_en: str = "" subtitle: str = "" subtitle_en: str = "" badge_value: str = "" badge_value_en: str = "" badge_label: str = "" badge_label_en: str = "" cta_primary_label: str = "" cta_primary_label_en: str = "" cta_primary_href: str = "" cta_secondary_label: str = "" cta_secondary_label_en: str = "" cta_secondary_href: str = "" scroll_label: str = "" scroll_label_en: str = "" class StoryIn(BaseModel): title: str = "" title_en: str = "" paragraphs: list[str] = [] paragraphs_en: list[str] = [] quote: str = "" quote_en: str = "" quote_author: str = "" quote_author_en: str = "" stat1_value: str = "" stat1_value_en: str = "" stat1_label: str = "" stat1_label_en: str = "" stat2_value: str = "" stat2_value_en: str = "" stat2_label: str = "" stat2_label_en: str = "" link_label: str = "" link_label_en: str = "" link_href: str = "" bento: list[dict] = [] bento_en: list[dict] = [] class SectionHeadingIn(BaseModel): key: str = Field(min_length=1, max_length=64) label: str = "" label_en: str = "" title: str = "" title_en: str = "" lead: str = "" lead_en: str = "" image: str = "" cta_label: str = "" cta_label_en: str = "" cta_href: str = "" class PageHeroIn(BaseModel): key: str = Field(min_length=1, max_length=32) title: str = "" title_en: str = "" lead: str = "" lead_en: str = "" image: str = "" class InvestmentModelIn(BaseModel): title: str = Field(min_length=1, max_length=256) title_en: str = "" desc: str = "" desc_en: str = "" points: list[str] = [] points_en: list[str] = [] class ProcessStepIn(BaseModel): step: str = "" step_en: str = "" title: str = Field(min_length=1, max_length=256) title_en: str = "" desc: str = "" desc_en: str = "" class OfficeIn(BaseModel): city: str = Field(min_length=1, max_length=64) city_en: str = "" address: str = "" address_en: str = "" hours: str = "" hours_en: str = "" class GalleryImageIn(BaseModel): src: str = "" alt: str = "" alt_en: str = "" class StatusLabelIn(BaseModel): key: str = Field(min_length=1, max_length=16) label: str = Field(min_length=1, max_length=64) label_en: str = "" class ContactSectionIn(BaseModel): label: str = "" label_en: str = "" title: str = "" title_en: str = "" lead: str = "" lead_en: str = "" address_label: str = "" address_label_en: str = "" phone_label: str = "" phone_label_en: str = "" email_label: str = "" email_label_en: str = "" name_label: str = "" name_label_en: str = "" name_placeholder: str = "" name_placeholder_en: str = "" phone_field_label: str = "" phone_field_label_en: str = "" phone_placeholder: str = "" phone_placeholder_en: str = "" note_label: str = "" note_label_en: str = "" note_optional_label: str = "" note_optional_label_en: str = "" note_placeholder: str = "" note_placeholder_en: str = "" submit_label: str = "" submit_label_en: str = "" sending_label: str = "" sending_label_en: str = "" success_title: str = "" success_title_en: str = "" success_text: str = "" success_text_en: str = "" success_button_label: str = "" success_button_label_en: str = "" class NewsletterSectionIn(BaseModel): title: str = "" title_en: str = "" lead: str = "" lead_en: str = "" placeholder: str = "" placeholder_en: str = "" button_label: str = "" button_label_en: str = "" sending_label: str = "" sending_label_en: str = "" success_text: str = "" success_text_en: str = "" class FooterIn(BaseModel): about_text: str = "" about_text_en: str = "" pages_heading: str = "" pages_heading_en: str = "" services_heading: str = "" services_heading_en: str = "" contact_heading: str = "" contact_heading_en: str = "" copyright: str = "" copyright_en: str = "" tagline: str = "" tagline_en: str = "" socials: list[dict] = [] socials_en: list[dict] = [] class SeoMetaIn(BaseModel): page_key: str = Field(min_length=1, max_length=32) title: str = "" title_en: str = "" description: str = "" description_en: str = "" class SeoMetaPatch(BaseModel): page_key: Optional[str] = None title: Optional[str] = None title_en: Optional[str] = None description: Optional[str] = None description_en: Optional[str] = None 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 title_en: Optional[str] = None date: Optional[str] = None date_en: Optional[str] = None category: Optional[str] = None category_en: Optional[str] = None image: Optional[str] = None read: Optional[str] = None read_en: Optional[str] = None excerpt: Optional[str] = None excerpt_en: Optional[str] = None body: Optional[dict] = None body_en: Optional[dict] = None status: Optional[Literal["draft", "published"]] = None seo_keyphrase: Optional[str] = None _validate_body = field_validator("body")(validate_doc_body) _validate_body_en = field_validator("body_en")(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]