From 8f11df99ea69239cc5a270fd042dd92ec0b08515 Mon Sep 17 00:00:00 2001 From: MMD Date: Sun, 6 Sep 2026 23:32:52 +0330 Subject: [PATCH] =?UTF-8?q?=D8=A8=DA=A9=E2=80=8C=D8=A7=D9=86=D8=AF:=20?= =?UTF-8?q?=D8=A8=D8=AF=D9=86=D9=87=20=D9=85=D9=82=D8=A7=D9=84=D9=87=20?= =?UTF-8?q?=D8=A8=D9=87=20=D8=B3=D9=86=D8=AF=20=D8=B3=D8=A7=D8=AE=D8=AA?= =?UTF-8?q?=D8=A7=D8=B1=DB=8C=D8=A7=D9=81=D8=AA=D9=87=20TipTap=20(JSON)=20?= =?UTF-8?q?+=20=D9=85=D9=87=D8=A7=D8=AC=D8=B1=D8=AA=20=D8=AF=D8=A7=D8=AF?= =?UTF-8?q?=D9=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 بک‌اند: فرمت بدنه و رفتار مهاجرت مستند شد --- backend/README.md | 9 ++++++ backend/app/database.py | 22 +------------- backend/app/main.py | 5 +-- backend/app/migrations.py | 64 +++++++++++++++++++++++++++++++++++++++ backend/app/schemas.py | 30 +++++++++++++++--- backend/app/seed.py | 8 +++-- 6 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 backend/app/migrations.py diff --git a/backend/README.md b/backend/README.md index 4431439..d51a165 100644 --- a/backend/README.md +++ b/backend/README.md @@ -129,6 +129,15 @@ Authorization: Bearer | DELETE | `/api/admin/subscribers/{id}` | حذف مشترک | | 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 به دیتابیس‌های موجود اضافه می‌شود. ### تصاویر و MinIO diff --git a/backend/app/database.py b/backend/app/database.py index b49405e..e1eee4c 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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") - ) diff --git a/backend/app/main.py b/backend/app/main.py index 1dd190d..e63b138 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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", diff --git a/backend/app/migrations.py b/backend/app/migrations.py new file mode 100644 index 0000000..afcaf0c --- /dev/null +++ b/backend/app/migrations.py @@ -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() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0e1efe0..b0891ba 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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) diff --git a/backend/app/seed.py b/backend/app/seed.py index 89f2cab..618ddfd 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -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: