بک‌اند: بدنه مقاله به سند ساختاریافته TipTap (JSON) + مهاجرت داده

- body مقاله از لیست پاراگراف به سند ProseMirror تغییر کرد:
  {"type": "doc", "content": [...]} — در In/Out/Patch schemas
- اعتبارسنجی ساختاری (type=doc + content لیست) با field_validator —
  امنیت رندر بر عهده رندر استاتیک TipTap در فرانت‌اند است
- app/migrations.py: مهاجرت‌های DDL و داده در یک‌جا؛ migrate_article_bodies
  ردیف‌های قدیمی (لیست پاراگراف) را idempotent به سند تبدیل می‌کند —
  در startup و seed اجرا می‌شود، دیتابیس موجود بدون از دست رفتن داده ارتقا می‌یابد
- seed بدنه‌های لیستی را قبل از درج به سند تبدیل می‌کند
- README بک‌اند: فرمت بدنه و رفتار مهاجرت مستند شد
This commit is contained in:
MMD
2026-09-06 23:32:52 +03:30
parent b7d29d1028
commit 8f11df99ea
6 changed files with 109 additions and 29 deletions
+1 -21
View File
@@ -1,4 +1,4 @@
from sqlalchemy import create_engine, text
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from .config import settings
@@ -18,23 +18,3 @@ 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")
)
+3 -2
View File
@@ -4,10 +4,11 @@ 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, run_light_migrations
from .database import Base, engine
from .migrations import run_all as run_migrations
Base.metadata.create_all(bind=engine)
run_light_migrations()
run_migrations()
app = FastAPI(
title="Aramesh Building Group API",
+64
View File
@@ -0,0 +1,64 @@
"""مهاجرت‌های سبک دیتابیس — DDL و تبدیل داده‌های قدیمی؛ همه idempotent.
هم main.py (startup) و هم seed.py این‌ها را صدا می‌زنند تا هر مسیر اجرایی
دیتابیس موجود را بدون از دست رفتن داده ارتقا دهد.
"""
from sqlalchemy import text
from .database import SessionLocal, engine
# جدول‌های مجموعه‌ای که ستون position دارند
POSITION_TABLES = [
"services", "projects", "articles", "testimonials", "faqs", "team",
"timeline", "stats", "why_us", "investment_models", "process_steps",
"offices", "gallery_images", "nav_items",
]
def run_ddl_migrations() -> None:
"""ستون‌های جدید — برای PostgreSQL با ADD COLUMN IF NOT EXISTS."""
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")
)
def paragraphs_to_doc(paragraphs: list) -> dict:
"""تبدیل لیست پاراگراف‌های متنی (فرمت قدیمی بدنه مقاله) به سند TipTap."""
content = []
for para in paragraphs:
if isinstance(para, str) and para.strip():
content.append({
"type": "paragraph",
"content": [{"type": "text", "text": para}],
})
if not content:
content = [{"type": "paragraph"}]
return {"type": "doc", "content": content}
def migrate_article_bodies() -> None:
"""تبدیل بدنه مقالات از لیست پاراگراف به سند JSON — idempotent."""
from . import models # lazy — جلوگیری از import حلقه‌ای
db = SessionLocal()
try:
changed = 0
for article in db.query(models.Article).all():
if isinstance(article.body, list):
article.body = paragraphs_to_doc(article.body)
changed += 1
db.commit()
if changed:
print(f"مقالات تبدیل‌شده به فرمت سند: {changed}")
finally:
db.close()
def run_all() -> None:
run_ddl_migrations()
migrate_article_bodies()
+26 -4
View File
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
# ---------- پایه ----------
@@ -58,7 +58,7 @@ class ArticleOut(ORMModel):
image: str
read: str
excerpt: str
body: list[str]
body: dict
class TestimonialOut(ORMModel):
@@ -318,6 +318,18 @@ class ProjectIn(BaseModel):
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)
@@ -326,7 +338,8 @@ class ArticleIn(BaseModel):
image: str = ""
read: str = ""
excerpt: str = ""
body: list[str] = []
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
_validate_body = field_validator("body")(validate_doc_body)
class TestimonialIn(BaseModel):
@@ -507,7 +520,16 @@ def _make_patch(name: str, model_cls: type[BaseModel]) -> type[BaseModel]:
BrandPatch = _make_patch("BrandPatch", BrandIn)
ServicePatch = _make_patch("ServicePatch", ServiceIn)
ProjectPatch = _make_patch("ProjectPatch", ProjectIn)
ArticlePatch = _make_patch("ArticlePatch", ArticleIn)
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)
+6 -2
View File
@@ -14,7 +14,8 @@ from typing import Any
from . import models, seed_data
from .auth import hash_password
from .config import settings
from .database import Base, SessionLocal, engine, run_light_migrations
from .database import Base, SessionLocal, engine
from .migrations import paragraphs_to_doc, run_all as run_migrations
# (مدل، داده‌های اولیه، برچسب گزارش)
CONTENT: list[tuple[Any, list[dict], str]] = [
@@ -53,7 +54,7 @@ def seed(force: bool = False) -> None:
print("جداول قبلی حذف شدند.")
Base.metadata.create_all(bind=engine)
run_light_migrations()
run_migrations()
for model, rows, label in CONTENT:
if db.query(model).count() > 0:
@@ -62,6 +63,9 @@ def seed(force: bool = False) -> None:
payload = dict(row)
if hasattr(model, "position") and "position" not in payload:
payload["position"] = idx
# بدنه مقالات در seed به‌صورت لیست پاراگراف است — به سند تبدیل می‌شود
if model is models.Article and isinstance(payload.get("body"), list):
payload["body"] = paragraphs_to_doc(payload["body"])
db.add(model(**payload))
if db.query(models.AdminUser).count() == 0: