- Alt/Title تصویر شاخص اکنون ذخیره میشود (ستونهای جدید image_alt/image_title — قبلاً بیصدا دور ریخته میشد) - Alt و Title تصاویر داخل متن پس از درج هم قابل ویرایشاند (کنار کپشن) - نام فایل ذخیرهسازی خوانا: نام دلخواه هنگام درج یا نام خود فایل (بهجای UUID خالی) - پنل پرسشوپاسخ پایان مقاله (ستونهای faqs/faqs_en) با ظاهر آکاردئونی سایت و اسکیمای FAQPage - چسباندن از Outline و هر منبع خارجی: تصاویر خودکار دانلود و در Object Storage سایت میزبانی میشوند (endpoint جدید import-image با محافظ SSRF) + دکمه میزبانی مجدد روی تصاویر خارجی موجود
320 lines
16 KiB
Python
320 lines
16 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", "units_en", "VARCHAR(64)"),
|
|
("projects", "client_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 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()
|