بک‌اند: بدنه مقاله به سند ساختاریافته 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
+9
View File
@@ -129,6 +129,15 @@ Authorization: Bearer <access_token>
| DELETE | `/api/admin/subscribers/{id}` | حذف مشترک | | DELETE | `/api/admin/subscribers/{id}` | حذف مشترک |
| POST | `/api/admin/upload` | آپلود تصویر (multipart، حداکثر ۱۰MB — jpg/png/webp/gif/avif) → `{"url": "/media/..."}` | | POST | `/api/admin/upload` | آپلود تصویر (multipart، حداکثر ۱۰MB — jpg/png/webp/gif/avif) → `{"url": "/media/..."}` |
### بدنه مقاله — سند TipTap
`body` مقاله یک سند ساختاریافته TipTap (ProseMirror JSON) است: `{"type": "doc", "content": [...]}`.
- فقط ساختار اعتبارسنجی می‌شود (`type: "doc"` + `content` لیست)؛ امنیت رندر بر عهده
رندر استاتیک TipTap در فرانت‌اند است که فقط تگ‌های شناخته‌شده تولید می‌کند
- ردیف‌های قدیمی (لیست پاراگراف) با مهاجرت idempotent `migrate_article_bodies`
در startup/seed خودکار به سند تبدیل می‌شوند
- seed هم بدنه‌های لیستی را قبل از درج تبدیل می‌کند
منابعی که `reorder` دارند (ستون `position`): `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `investment-models`، `process-steps`، `offices`، `gallery`، `nav`. ستون با مهاجرت سبک idempotent هنگام startup/seed به دیتابیس‌های موجود اضافه می‌شود. منابعی که `reorder` دارند (ستون `position`): `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `investment-models`، `process-steps`، `offices`، `gallery`، `nav`. ستون با مهاجرت سبک idempotent هنگام startup/seed به دیتابیس‌های موجود اضافه می‌شود.
### تصاویر و MinIO ### تصاویر و MinIO
+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 sqlalchemy.orm import DeclarativeBase, sessionmaker
from .config import settings from .config import settings
@@ -18,23 +18,3 @@ def get_db():
yield db yield db
finally: finally:
db.close() 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 . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت جداول
from .api import admin, forms, public from .api import admin, forms, public
from .config import settings 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) Base.metadata.create_all(bind=engine)
run_light_migrations() run_migrations()
app = FastAPI( app = FastAPI(
title="Aramesh Building Group API", 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 datetime import datetime
from typing import Literal, Optional 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 image: str
read: str read: str
excerpt: str excerpt: str
body: list[str] body: dict
class TestimonialOut(ORMModel): class TestimonialOut(ORMModel):
@@ -318,6 +318,18 @@ class ProjectIn(BaseModel):
body: list[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): class ArticleIn(BaseModel):
slug: str = Field(min_length=1, max_length=128) slug: str = Field(min_length=1, max_length=128)
title: str = Field(min_length=1, max_length=256) title: str = Field(min_length=1, max_length=256)
@@ -326,7 +338,8 @@ class ArticleIn(BaseModel):
image: str = "" image: str = ""
read: str = "" read: str = ""
excerpt: 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): class TestimonialIn(BaseModel):
@@ -507,7 +520,16 @@ def _make_patch(name: str, model_cls: type[BaseModel]) -> type[BaseModel]:
BrandPatch = _make_patch("BrandPatch", BrandIn) BrandPatch = _make_patch("BrandPatch", BrandIn)
ServicePatch = _make_patch("ServicePatch", ServiceIn) ServicePatch = _make_patch("ServicePatch", ServiceIn)
ProjectPatch = _make_patch("ProjectPatch", ProjectIn) 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) TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
FaqPatch = _make_patch("FaqPatch", FaqIn) FaqPatch = _make_patch("FaqPatch", FaqIn)
TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn) TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn)
+6 -2
View File
@@ -14,7 +14,8 @@ from typing import Any
from . import models, seed_data from . import models, seed_data
from .auth import hash_password from .auth import hash_password
from .config import settings 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]] = [ CONTENT: list[tuple[Any, list[dict], str]] = [
@@ -53,7 +54,7 @@ def seed(force: bool = False) -> None:
print("جداول قبلی حذف شدند.") print("جداول قبلی حذف شدند.")
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_light_migrations() run_migrations()
for model, rows, label in CONTENT: for model, rows, label in CONTENT:
if db.query(model).count() > 0: if db.query(model).count() > 0:
@@ -62,6 +63,9 @@ def seed(force: bool = False) -> None:
payload = dict(row) payload = dict(row)
if hasattr(model, "position") and "position" not in payload: if hasattr(model, "position") and "position" not in payload:
payload["position"] = idx 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)) db.add(model(**payload))
if db.query(models.AdminUser).count() == 0: if db.query(models.AdminUser).count() == 0: