Files
aramesh-backend/backend/app/migrations.py
T

346 lines
17 KiB
Python

"""مهاجرت‌های سبک دیتابیس — 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",
]
# ستون‌های انگلیسی — (جدول، ستون، نوع). ستون‌های JSON بدون default اند تا
# سطرهای موجود NULL بگیرند (یعنی «ترجمه ثبت نشده») و فرانت به فارسی برگردد.
EN_COLUMNS: list[tuple[str, str, str]] = [
("brand", "name_en", "VARCHAR(128)"),
("brand", "address_en", "TEXT"),
("services", "title_en", "VARCHAR(256)"),
("services", "desc_en", "TEXT"),
("projects", "title_en", "VARCHAR(256)"),
("projects", "location_en", "VARCHAR(128)"),
("projects", "year_en", "VARCHAR(64)"),
("projects", "area_en", "VARCHAR(64)"),
("projects", "floors_en", "VARCHAR(64)"),
("projects", "land_area", "VARCHAR(64)"),
("projects", "pilot_floors", "VARCHAR(32)"),
("projects", "units_residential", "VARCHAR(32)"),
("projects", "units_commercial", "VARCHAR(32)"),
("projects", "units_office", "VARCHAR(32)"),
("projects", "client_en", "VARCHAR(128)"),
("projects", "project_type", "VARCHAR(128)"),
("projects", "project_type_en", "VARCHAR(128)"),
("projects", "usage", "VARCHAR(128)"),
("projects", "usage_en", "VARCHAR(128)"),
("projects", "summary_en", "TEXT"),
("projects", "body_en", "JSON"),
("articles", "title_en", "VARCHAR(256)"),
("articles", "date_en", "VARCHAR(64)"),
("articles", "category_en", "VARCHAR(64)"),
("articles", "read_en", "VARCHAR(32)"),
("articles", "excerpt_en", "TEXT"),
("articles", "body_en", "JSON"),
("testimonials", "name_en", "VARCHAR(128)"),
("testimonials", "role_en", "VARCHAR(128)"),
("testimonials", "quote_en", "TEXT"),
("faqs", "question_en", "TEXT"),
("faqs", "answer_en", "TEXT"),
("team", "name_en", "VARCHAR(128)"),
("team", "role_en", "VARCHAR(128)"),
("team", "bio_en", "TEXT"),
("timeline", "year_en", "VARCHAR(32)"),
("timeline", "title_en", "VARCHAR(256)"),
("timeline", "desc_en", "TEXT"),
("stats", "suffix_en", "VARCHAR(16)"),
("stats", "label_en", "VARCHAR(128)"),
("why_us", "title_en", "VARCHAR(256)"),
("why_us", "desc_en", "TEXT"),
("before_after", "title_en", "VARCHAR(256)"),
("before_after", "lead_en", "TEXT"),
("before_after", "before_label_en", "VARCHAR(128)"),
("before_after", "after_label_en", "VARCHAR(128)"),
("nav_items", "label_en", "VARCHAR(64)"),
("hero", "kicker_en", "TEXT"),
("hero", "title_en", "TEXT"),
("hero", "title_highlight_en", "VARCHAR(128)"),
("hero", "subtitle_en", "TEXT"),
("hero", "badge_value_en", "VARCHAR(32)"),
("hero", "badge_label_en", "TEXT"),
("hero", "cta_primary_label_en", "VARCHAR(128)"),
("hero", "cta_secondary_label_en", "VARCHAR(128)"),
("hero", "scroll_label_en", "VARCHAR(64)"),
("story", "title_en", "VARCHAR(256)"),
("story", "paragraphs_en", "JSON"),
("story", "quote_en", "TEXT"),
("story", "quote_author_en", "TEXT"),
("story", "stat1_value_en", "VARCHAR(32)"),
("story", "stat1_label_en", "VARCHAR(128)"),
("story", "stat2_value_en", "VARCHAR(32)"),
("story", "stat2_label_en", "VARCHAR(128)"),
("story", "link_label_en", "VARCHAR(128)"),
("story", "bento_en", "JSON"),
("section_headings", "label_en", "VARCHAR(64)"),
("section_headings", "title_en", "VARCHAR(256)"),
("section_headings", "lead_en", "TEXT"),
("section_headings", "cta_label_en", "VARCHAR(128)"),
("page_heroes", "title_en", "VARCHAR(256)"),
("page_heroes", "lead_en", "TEXT"),
("investment_models", "title_en", "VARCHAR(256)"),
("investment_models", "desc_en", "TEXT"),
("investment_models", "points_en", "JSON"),
("process_steps", "step_en", "VARCHAR(16)"),
("process_steps", "title_en", "VARCHAR(256)"),
("process_steps", "desc_en", "TEXT"),
("offices", "city_en", "VARCHAR(64)"),
("offices", "address_en", "TEXT"),
("offices", "hours_en", "VARCHAR(128)"),
("gallery_images", "alt_en", "TEXT"),
("status_labels", "label_en", "VARCHAR(64)"),
("contact_section", "label_en", "VARCHAR(64)"),
("contact_section", "title_en", "VARCHAR(256)"),
("contact_section", "lead_en", "TEXT"),
("contact_section", "address_label_en", "VARCHAR(64)"),
("contact_section", "phone_label_en", "VARCHAR(64)"),
("contact_section", "email_label_en", "VARCHAR(64)"),
("contact_section", "name_label_en", "VARCHAR(64)"),
("contact_section", "name_placeholder_en", "VARCHAR(128)"),
("contact_section", "phone_field_label_en", "VARCHAR(64)"),
("contact_section", "phone_placeholder_en", "VARCHAR(64)"),
("contact_section", "note_label_en", "VARCHAR(64)"),
("contact_section", "note_optional_label_en", "VARCHAR(64)"),
("contact_section", "note_placeholder_en", "VARCHAR(256)"),
("contact_section", "submit_label_en", "VARCHAR(128)"),
("contact_section", "sending_label_en", "VARCHAR(64)"),
("contact_section", "success_title_en", "VARCHAR(256)"),
("contact_section", "success_text_en", "TEXT"),
("contact_section", "success_button_label_en", "VARCHAR(128)"),
("newsletter", "title_en", "VARCHAR(256)"),
("newsletter", "lead_en", "TEXT"),
("newsletter", "placeholder_en", "VARCHAR(128)"),
("newsletter", "button_label_en", "VARCHAR(64)"),
("newsletter", "sending_label_en", "VARCHAR(64)"),
("newsletter", "success_text_en", "VARCHAR(256)"),
("footer", "about_text_en", "TEXT"),
("footer", "pages_heading_en", "VARCHAR(64)"),
("footer", "services_heading_en", "VARCHAR(64)"),
("footer", "contact_heading_en", "VARCHAR(64)"),
("footer", "copyright_en", "VARCHAR(256)"),
("footer", "tagline_en", "VARCHAR(256)"),
("footer", "socials_en", "JSON"),
("seo_meta", "title_en", "VARCHAR(256)"),
("seo_meta", "description_en", "TEXT"),
]
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")
)
# مقالات موجود منتشر شده‌اند؛ فقط مقاله‌های تازه می‌توانند پیش‌نویس باشند
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'published'")
)
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
)
# نام نویسنده و توضیحات سئوی اختصاصی مقاله — فیلدهایی که پنل قبلاً نمایش می‌داد ولی در دیتابیس نبودند
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS author VARCHAR(128) NOT NULL DEFAULT ''")
)
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS meta_description TEXT NOT NULL DEFAULT ''")
)
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS meta_description_en TEXT NOT NULL DEFAULT ''")
)
# نقش‌ها — 'admin' همه‌کاره، 'creator' فقط نویسندهٔ محتوا
conn.execute(
text("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin'")
)
# نویسندهٔ مقاله — نقش creator فقط مقاله‌های خودش را می‌بیند/ویرایش می‌کند
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS author_id INTEGER")
)
# متادیتای تصویر شاخص مقاله + پرسش‌وپاسخ‌های پایان مقاله (سئو)
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
)
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_title VARCHAR(256) NOT NULL DEFAULT ''")
)
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs JSON"))
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs_en JSON"))
# سطرهای موجود ستون تازه را NULL می‌گیرند — آرایه خالی یعنی «بدون پرسش‌وپاسخ»
conn.execute(text("UPDATE articles SET faqs = '[]'::json WHERE faqs IS NULL"))
# ستون‌های انگلیسی — رشته‌ها با پیش‌فرض خالی، JSON بدون پیش‌فرض (NULL = بدون ترجمه)
for table, column, col_type in EN_COLUMNS:
if col_type == "JSON":
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} JSON"))
else:
conn.execute(
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} {col_type} NOT NULL DEFAULT ''")
)
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 _is_empty(value: object) -> bool:
"""مقدار خالی انگلیسی — یعنی هنوز ترجمه‌ای ثبت نشده و قابل پر کردن است."""
if value is None or value == "" or value == [] or value == {}:
return True
if isinstance(value, dict) and value.get("content") == [{"type": "paragraph"}]:
return True
return False
def _backfill_row(row, en_fields: dict) -> int:
"""پر کردن فیلدهای خالی *_en یک سطر از دیکشنری ترجمه — تعداد فیلدهای پرشده."""
filled = 0
for key, value in en_fields.items():
if not key.endswith("_en"):
continue
if not _is_empty(getattr(row, key, None)):
continue
setattr(row, key, value)
filled += 1
return filled
def backfill_en_content() -> None:
"""پر کردن ترجمه‌های انگلیسی سطرهای موجود از seed_data_en — idempotent.
تطبیق سطرها: جدول‌های تک‌سطری مستقیم؛ بقیه با کلیدهای طبیعی/متنی فارسی
(مثلاً slug پروژه، href منو، عنوان خدمت، متن پرسش) — اگر ادمین متن فارسی را
ویرایش کرده باشد سطر تطبیق نمی‌خورد و ترجمه‌اش خالی می‌ماند تا از پنل پر شود.
هیچ مقدار انگلیسیِ ثبت‌شده بازنویسی نمی‌شود.
"""
from . import models, seed_data, seed_data_en # lazy — جلوگیری از import حلقه‌ای
# (مدل، فهرست فارسی، فهرست انگلیسی، کلیدهای تطبیق — خالی برای تک‌سطری‌ها)
PLAN: list[tuple[object, list[dict], list[dict], tuple[str, ...]]] = [
(models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], ()),
(models.Hero, [seed_data.HERO], [seed_data_en.HERO], ()),
(models.StorySection, [seed_data.STORY], [seed_data_en.STORY], ()),
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, ("key", "title")),
(models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, ("key", "title")),
(models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, ("href", "label")),
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, ("title",)),
(models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, ("title",)),
(models.Office, seed_data.OFFICES, seed_data_en.OFFICES, ("city",)),
(models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, ("alt",)),
(models.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, ("key",)),
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], ()),
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], ()),
(models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], ()),
(models.Service, seed_data.SERVICES, seed_data_en.SERVICES, ("title",)),
(models.Stat, seed_data.STATS, seed_data_en.STATS, ("label",)),
(models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, ("slug", "title")),
(models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, ("slug", "title")),
(models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, ("name",)),
(models.Faq, seed_data.FAQS, seed_data_en.FAQS, ("question",)),
(models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, ("name",)),
(models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, ("title",)),
(models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, ("title",)),
(models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], ()),
(models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, ("page_key",)),
]
db = SessionLocal()
try:
total_rows = 0
total_fields = 0
unmatched: list[str] = []
for model, fa_rows, en_rows, match_keys in PLAN:
rows = db.query(model).all()
if not rows or not en_rows:
continue
table_name = getattr(model, "__tablename__", str(model))
if not match_keys:
if len(rows) != 1 or len(en_rows) != 1:
unmatched.append(table_name)
continue
pairs = [(rows[0], en_rows[0])]
else:
# برای هر کلید، نگاشت مقدار → سطر ترجمه (اولین کلید برنده است)
en_lookups: list[dict[object, dict]] = []
for key in match_keys:
en_lookups.append({r[key]: r for r in en_rows if r.get(key) is not None})
pairs = []
matched_en_ids: set[int] = set()
for row in rows:
en_row = None
for key, lookup in zip(match_keys, en_lookups):
en_row = lookup.get(getattr(row, key, None))
if en_row is not None:
break
if en_row is not None and id(en_row) not in matched_en_ids:
matched_en_ids.add(id(en_row))
pairs.append((row, en_row))
for row, en_row in pairs:
# بدنه مقاله انگلیسی هم مثل فارسی از پاراگراف به سند تبدیل می‌شود
en_fields = dict(en_row)
if model is models.Article and isinstance(en_fields.get("body_en"), list):
en_fields["body_en"] = paragraphs_to_doc(en_fields["body_en"])
filled = _backfill_row(row, en_fields)
total_fields += filled
if filled:
total_rows += 1
db.commit()
if total_fields:
print(f"ترجمه انگلیسی پر شد: {total_fields} فیلد در {total_rows} سطر.")
if unmatched:
print("سطرهایی که با seed تطبیق نکردند (از پنل مدیریت ترجمه کنید):")
for name in unmatched:
print(f" - {name}")
finally:
db.close()
def run_all() -> None:
run_ddl_migrations()
migrate_article_bodies()
backfill_en_content()