بکاند: بدنه مقاله به سند ساختاریافته 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:
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user