Files
aramesh-backend/backend/app/schemas.py
MMD b31124e1e4 وبلاگ و سئو: پیش‌نویس/انتشار مقاله + کلمه کلیدی سئو در دیتابیس + OG/canonical/JSON-LD و متای سمت سرور + تغییر رمز و محدودسازی ورود + تست‌ها
- مقاله‌ها ستون status (draft/published) گرفتند — GET /api/site و همه انتهای عمومی فقط منتشرشده را برمی‌گردانند؛ autosave دیگر پیش‌نویس نیمه‌کاره را زنده نمی‌کند
- کلمه کلیدی هدف چک‌لیست سئو فیلد seo_keyphrase شد (قبلاً localStorage بود)
- robots.txt و sitemap.xml از بک‌اند + سرو /blog و /projects با تزریق title/description/OG/JSON-LD از دیتابیس برای خزنده‌های بدون JS (app/seo_pages.py)
- فرانت: applySeo در seo.ts (OG/توییتر/canonical/JSON-LD)، ادغام پیش‌نویس برای ادمین، برچسب پیش‌نویس در وبلاگ، حالت خالی وبلاگ، دکمه انتشار در صفحه نوشتن
- تغییر رمز ادمین POST /api/auth/change-password + دیالوگ «رمز» در نوار مدیریت
- محدودسازی ورود: ۵ تلاش ناموفق در ۱۵ دقیقه از هر IP → 429
- ساخت دکمه + برای ردیف سئوی صفحه‌های بدون ردیف (SingletonPill)
- تست‌های pytest (۱۵ مورد) + پین‌کردن requirements + حذف package.json سرگردان فرانت
2026-09-07 04:26:18 +03:30

589 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
status: str
seo_keyphrase: str
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 SeoMetaOut(ORMModel):
id: int
page_key: str
title: str
description: 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)
class NewsletterIn(BaseModel):
email: EmailStr
# ---------- ورودی‌های پنل مدیریت ----------
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 = ""
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))
status: Literal["draft", "published"] = "draft"
seo_keyphrase: str = Field(default="", max_length=128)
_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 SeoMetaIn(BaseModel):
page_key: str = Field(min_length=1, max_length=32)
title: str = ""
description: str = ""
class SeoMetaPatch(BaseModel):
page_key: Optional[str] = None
title: Optional[str] = None
description: 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
date: Optional[str] = None
category: Optional[str] = None
image: Optional[str] = None
read: Optional[str] = None
excerpt: Optional[str] = None
body: Optional[dict] = None
status: Optional[Literal["draft", "published"]] = None
seo_keyphrase: Optional[str] = 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]