بکاند: مدل دوزبانه — ستونهای _en، ترجمه کامل seed انگلیسی، backfill خودکار و پوسته سئوی /en/
This commit is contained in:
@@ -15,6 +15,119 @@ POSITION_TABLES = [
|
|||||||
"offices", "gallery_images", "nav_items",
|
"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:
|
def run_ddl_migrations() -> None:
|
||||||
"""ستونهای جدید — برای PostgreSQL با ADD COLUMN IF NOT EXISTS."""
|
"""ستونهای جدید — برای PostgreSQL با ADD COLUMN IF NOT EXISTS."""
|
||||||
@@ -32,6 +145,14 @@ def run_ddl_migrations() -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
||||||
)
|
)
|
||||||
|
# ستونهای انگلیسی — رشتهها با پیشفرض خالی، 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:
|
def paragraphs_to_doc(paragraphs: list) -> dict:
|
||||||
@@ -66,6 +187,110 @@ def migrate_article_bodies() -> None:
|
|||||||
db.close()
|
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/key/
|
||||||
|
page_key) با همان کلید؛ بقیه فقط وقتی سطرها با seed دستنخوردهاند (تعداد و
|
||||||
|
idها مطابق) و بهترتیب. هیچ مقدار انگلیسیِ ثبتشده بازنویسی نمیشود.
|
||||||
|
"""
|
||||||
|
from . import models, seed_data, seed_data_en # lazy — جلوگیری از import حلقهای
|
||||||
|
|
||||||
|
# (مدل، فهرست فارسی، فهرست انگلیسی، کلید تطبیق یا None برای ترتیبی)
|
||||||
|
PLAN: list[tuple[object, list[dict], list[dict], str | None]] = [
|
||||||
|
(models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], None),
|
||||||
|
(models.Hero, [seed_data.HERO], [seed_data_en.HERO], None),
|
||||||
|
(models.StorySection, [seed_data.STORY], [seed_data_en.STORY], None),
|
||||||
|
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, "key"),
|
||||||
|
(models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, "key"),
|
||||||
|
(models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, None),
|
||||||
|
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, None),
|
||||||
|
(models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, None),
|
||||||
|
(models.Office, seed_data.OFFICES, seed_data_en.OFFICES, None),
|
||||||
|
(models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, None),
|
||||||
|
(models.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, "key"),
|
||||||
|
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], None),
|
||||||
|
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], None),
|
||||||
|
(models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], None),
|
||||||
|
(models.Service, seed_data.SERVICES, seed_data_en.SERVICES, None),
|
||||||
|
(models.Stat, seed_data.STATS, seed_data_en.STATS, None),
|
||||||
|
(models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, "slug"),
|
||||||
|
(models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, "slug"),
|
||||||
|
(models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, None),
|
||||||
|
(models.Faq, seed_data.FAQS, seed_data_en.FAQS, None),
|
||||||
|
(models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, None),
|
||||||
|
(models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, None),
|
||||||
|
(models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, None),
|
||||||
|
(models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], None),
|
||||||
|
(models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, "page_key"),
|
||||||
|
]
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
total_rows = 0
|
||||||
|
total_fields = 0
|
||||||
|
skipped: list[str] = []
|
||||||
|
for model, fa_rows, en_rows, match_key in PLAN:
|
||||||
|
rows = db.query(model).all()
|
||||||
|
if not rows or not en_rows:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if match_key is not None:
|
||||||
|
en_by_key = {r[match_key]: r for r in en_rows if match_key in r}
|
||||||
|
matched = [(row, en_by_key.get(getattr(row, match_key, None))) for row in rows]
|
||||||
|
pairs = [(row, en) for row, en in matched if en is not None]
|
||||||
|
elif len(rows) == 1 and len(en_rows) == 1:
|
||||||
|
pairs = [(rows[0], en_rows[0])]
|
||||||
|
else:
|
||||||
|
# تطبیق ترتیبی فقط برای جدولهای دستنخورده — idها از ۱ پیوسته
|
||||||
|
ids = [row.id for row in rows]
|
||||||
|
if ids != list(range(1, len(rows) + 1)) or len(rows) != len(en_rows):
|
||||||
|
skipped.append(getattr(model, "__tablename__", str(model)))
|
||||||
|
continue
|
||||||
|
pairs = list(zip(rows, en_rows))
|
||||||
|
|
||||||
|
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 skipped:
|
||||||
|
print(f"جدولهای تغییرکرده (بدون backfill خودکار — از پنل مدیریت ترجمه کنید): {', '.join(skipped)}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def run_all() -> None:
|
def run_all() -> None:
|
||||||
run_ddl_migrations()
|
run_ddl_migrations()
|
||||||
migrate_article_bodies()
|
migrate_article_bodies()
|
||||||
|
backfill_en_content()
|
||||||
|
|||||||
+113
-1
@@ -10,6 +10,10 @@ def utcnow() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ستونهای *_en معادل انگلیسی فیلدهای متنیِ قابل نمایش هستند؛
|
||||||
|
# مقدار خالی یعنی «ترجمهای ثبت نشده» و فرانتاند به نسخه فارسی برمیگردد.
|
||||||
|
|
||||||
|
|
||||||
class AdminUser(Base):
|
class AdminUser(Base):
|
||||||
__tablename__ = "admin_users"
|
__tablename__ = "admin_users"
|
||||||
|
|
||||||
@@ -23,6 +27,7 @@ class Brand(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(128), default="")
|
name: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
logo: Mapped[str] = mapped_column(Text, default="")
|
logo: Mapped[str] = mapped_column(Text, default="")
|
||||||
hero: Mapped[str] = mapped_column(Text, default="")
|
hero: Mapped[str] = mapped_column(Text, default="")
|
||||||
contact_bg: Mapped[str] = mapped_column(Text, default="")
|
contact_bg: Mapped[str] = mapped_column(Text, default="")
|
||||||
@@ -30,6 +35,7 @@ class Brand(Base):
|
|||||||
phone_href: Mapped[str] = mapped_column(String(128), default="")
|
phone_href: Mapped[str] = mapped_column(String(128), default="")
|
||||||
email: Mapped[str] = mapped_column(String(128), default="")
|
email: Mapped[str] = mapped_column(String(128), default="")
|
||||||
address: Mapped[str] = mapped_column(Text, default="")
|
address: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
address_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
class Service(Base):
|
class Service(Base):
|
||||||
@@ -39,7 +45,9 @@ class Service(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
desc: Mapped[str] = mapped_column(Text, default="")
|
desc: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
icon: Mapped[str] = mapped_column(String(32), default="draft")
|
icon: Mapped[str] = mapped_column(String(32), default="draft")
|
||||||
|
|
||||||
|
|
||||||
@@ -51,17 +59,26 @@ class Project(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
location: Mapped[str] = mapped_column(String(128), default="")
|
location: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
location_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
year: Mapped[str] = mapped_column(String(64), default="")
|
year: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
year_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
status: Mapped[str] = mapped_column(String(16), default="done") # done | progress
|
status: Mapped[str] = mapped_column(String(16), default="done") # done | progress
|
||||||
image: Mapped[str] = mapped_column(Text, default="")
|
image: Mapped[str] = mapped_column(Text, default="")
|
||||||
gallery: Mapped[list] = mapped_column(JSON, default=list)
|
gallery: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
area: Mapped[str] = mapped_column(String(64), default="")
|
area: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
area_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
floors: Mapped[str] = mapped_column(String(64), default="")
|
floors: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
floors_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
units: Mapped[str] = mapped_column(String(64), default="")
|
units: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
units_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
client: Mapped[str] = mapped_column(String(128), default="")
|
client: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
client_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
summary: Mapped[str] = mapped_column(Text, default="")
|
summary: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
summary_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
body_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
|
||||||
|
|
||||||
class Article(Base):
|
class Article(Base):
|
||||||
@@ -72,12 +89,18 @@ class Article(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
date: Mapped[str] = mapped_column(String(64), default="")
|
date: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
date_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
category: Mapped[str] = mapped_column(String(64), default="")
|
category: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
category_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
image: Mapped[str] = mapped_column(Text, default="")
|
image: Mapped[str] = mapped_column(Text, default="")
|
||||||
read: Mapped[str] = mapped_column(String(32), default="")
|
read: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
read_en: Mapped[str] = mapped_column(String(32), default="")
|
||||||
excerpt: Mapped[str] = mapped_column(Text, default="")
|
excerpt: Mapped[str] = mapped_column(Text, default="")
|
||||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
excerpt_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
|
||||||
|
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
|
||||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||||
@@ -91,8 +114,11 @@ class Testimonial(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(128))
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
|
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
role: Mapped[str] = mapped_column(String(128), default="")
|
role: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
role_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
quote: Mapped[str] = mapped_column(Text, default="")
|
quote: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
quote_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
class Faq(Base):
|
class Faq(Base):
|
||||||
@@ -102,7 +128,9 @@ class Faq(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
question: Mapped[str] = mapped_column(Text)
|
question: Mapped[str] = mapped_column(Text)
|
||||||
|
question_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
answer: Mapped[str] = mapped_column(Text, default="")
|
answer: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
answer_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
class TeamMember(Base):
|
class TeamMember(Base):
|
||||||
@@ -112,8 +140,11 @@ class TeamMember(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(128))
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
|
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
role: Mapped[str] = mapped_column(String(128), default="")
|
role: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
role_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
bio: Mapped[str] = mapped_column(Text, default="")
|
bio: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
bio_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
class TimelineItem(Base):
|
class TimelineItem(Base):
|
||||||
@@ -123,8 +154,11 @@ class TimelineItem(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
year: Mapped[str] = mapped_column(String(32))
|
year: Mapped[str] = mapped_column(String(32))
|
||||||
|
year_en: Mapped[str] = mapped_column(String(32), default="")
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
desc: Mapped[str] = mapped_column(Text, default="")
|
desc: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
image: Mapped[str] = mapped_column(Text, default="")
|
image: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
@@ -136,7 +170,9 @@ class Stat(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
value: Mapped[int] = mapped_column(Integer)
|
value: Mapped[int] = mapped_column(Integer)
|
||||||
suffix: Mapped[str] = mapped_column(String(16), default="")
|
suffix: Mapped[str] = mapped_column(String(16), default="")
|
||||||
|
suffix_en: Mapped[str] = mapped_column(String(16), default="")
|
||||||
label: Mapped[str] = mapped_column(String(128))
|
label: Mapped[str] = mapped_column(String(128))
|
||||||
|
label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
|
||||||
|
|
||||||
class WhyUsItem(Base):
|
class WhyUsItem(Base):
|
||||||
@@ -146,7 +182,9 @@ class WhyUsItem(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
desc: Mapped[str] = mapped_column(Text, default="")
|
desc: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
class BeforeAfter(Base):
|
class BeforeAfter(Base):
|
||||||
@@ -154,11 +192,15 @@ class BeforeAfter(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(256), default="")
|
title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
lead: Mapped[str] = mapped_column(Text, default="")
|
lead: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
before: Mapped[str] = mapped_column(Text, default="")
|
before: Mapped[str] = mapped_column(Text, default="")
|
||||||
after: Mapped[str] = mapped_column(Text, default="")
|
after: Mapped[str] = mapped_column(Text, default="")
|
||||||
before_label: Mapped[str] = mapped_column(String(128), default="")
|
before_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
before_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
after_label: Mapped[str] = mapped_column(String(128), default="")
|
after_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
after_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
|
||||||
|
|
||||||
class NavItem(Base):
|
class NavItem(Base):
|
||||||
@@ -168,6 +210,7 @@ class NavItem(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
label: Mapped[str] = mapped_column(String(64))
|
label: Mapped[str] = mapped_column(String(64))
|
||||||
|
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
href: Mapped[str] = mapped_column(String(128))
|
href: Mapped[str] = mapped_column(String(128))
|
||||||
|
|
||||||
|
|
||||||
@@ -178,16 +221,25 @@ class Hero(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
kicker: Mapped[str] = mapped_column(Text, default="")
|
kicker: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
kicker_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
title: Mapped[str] = mapped_column(Text, default="")
|
title: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
title_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
title_highlight: Mapped[str] = mapped_column(String(128), default="")
|
title_highlight: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
title_highlight_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
subtitle: Mapped[str] = mapped_column(Text, default="")
|
subtitle: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
subtitle_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
badge_value: Mapped[str] = mapped_column(String(32), default="")
|
badge_value: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
badge_value_en: Mapped[str] = mapped_column(String(32), default="")
|
||||||
badge_label: Mapped[str] = mapped_column(Text, default="")
|
badge_label: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
badge_label_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
cta_primary_label: Mapped[str] = mapped_column(String(128), default="")
|
cta_primary_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
cta_primary_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
cta_primary_href: Mapped[str] = mapped_column(String(128), default="")
|
cta_primary_href: Mapped[str] = mapped_column(String(128), default="")
|
||||||
cta_secondary_label: Mapped[str] = mapped_column(String(128), default="")
|
cta_secondary_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
cta_secondary_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
cta_secondary_href: Mapped[str] = mapped_column(String(128), default="")
|
cta_secondary_href: Mapped[str] = mapped_column(String(128), default="")
|
||||||
scroll_label: Mapped[str] = mapped_column(String(64), default="")
|
scroll_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
scroll_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
|
||||||
|
|
||||||
class StorySection(Base):
|
class StorySection(Base):
|
||||||
@@ -197,16 +249,26 @@ class StorySection(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(256), default="")
|
title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
paragraphs: Mapped[list] = mapped_column(JSON, default=list)
|
paragraphs: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
paragraphs_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
quote: Mapped[str] = mapped_column(Text, default="")
|
quote: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
quote_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
quote_author: Mapped[str] = mapped_column(Text, default="")
|
quote_author: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
quote_author_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
stat1_value: Mapped[str] = mapped_column(String(32), default="")
|
stat1_value: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
stat1_value_en: Mapped[str] = mapped_column(String(32), default="")
|
||||||
stat1_label: Mapped[str] = mapped_column(String(128), default="")
|
stat1_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
stat1_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
stat2_value: Mapped[str] = mapped_column(String(32), default="")
|
stat2_value: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
stat2_value_en: Mapped[str] = mapped_column(String(32), default="")
|
||||||
stat2_label: Mapped[str] = mapped_column(String(128), default="")
|
stat2_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
stat2_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
link_label: Mapped[str] = mapped_column(String(128), default="")
|
link_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
link_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
link_href: Mapped[str] = mapped_column(String(128), default="")
|
link_href: Mapped[str] = mapped_column(String(128), default="")
|
||||||
bento: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}]
|
bento: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}]
|
||||||
|
bento_en: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}] انگلیسی
|
||||||
|
|
||||||
|
|
||||||
class SectionHeadingContent(Base):
|
class SectionHeadingContent(Base):
|
||||||
@@ -217,10 +279,14 @@ class SectionHeadingContent(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
label: Mapped[str] = mapped_column(String(64), default="")
|
label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
title: Mapped[str] = mapped_column(String(256), default="")
|
title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
lead: Mapped[str] = mapped_column(Text, default="")
|
lead: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
image: Mapped[str] = mapped_column(Text, default="")
|
image: Mapped[str] = mapped_column(Text, default="")
|
||||||
cta_label: Mapped[str] = mapped_column(String(128), default="")
|
cta_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
cta_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
cta_href: Mapped[str] = mapped_column(String(128), default="")
|
cta_href: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
|
||||||
|
|
||||||
@@ -232,7 +298,9 @@ class PageHeroContent(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||||
title: Mapped[str] = mapped_column(String(256), default="")
|
title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
lead: Mapped[str] = mapped_column(Text, default="")
|
lead: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
image: Mapped[str] = mapped_column(Text, default="")
|
image: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
@@ -243,8 +311,11 @@ class InvestmentModel(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
desc: Mapped[str] = mapped_column(Text, default="")
|
desc: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
points: Mapped[list] = mapped_column(JSON, default=list)
|
points: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
points_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
|
||||||
|
|
||||||
class ProcessStep(Base):
|
class ProcessStep(Base):
|
||||||
@@ -254,8 +325,11 @@ class ProcessStep(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
step: Mapped[str] = mapped_column(String(16), default="")
|
step: Mapped[str] = mapped_column(String(16), default="")
|
||||||
|
step_en: Mapped[str] = mapped_column(String(16), default="")
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
desc: Mapped[str] = mapped_column(Text, default="")
|
desc: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
class Office(Base):
|
class Office(Base):
|
||||||
@@ -265,8 +339,11 @@ class Office(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
city: Mapped[str] = mapped_column(String(64))
|
city: Mapped[str] = mapped_column(String(64))
|
||||||
|
city_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
address: Mapped[str] = mapped_column(Text, default="")
|
address: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
address_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
hours: Mapped[str] = mapped_column(String(128), default="")
|
hours: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
hours_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
|
||||||
|
|
||||||
class GalleryImage(Base):
|
class GalleryImage(Base):
|
||||||
@@ -277,6 +354,7 @@ class GalleryImage(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
src: Mapped[str] = mapped_column(Text, default="")
|
src: Mapped[str] = mapped_column(Text, default="")
|
||||||
alt: Mapped[str] = mapped_column(Text, default="")
|
alt: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
alt_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
class StatusLabel(Base):
|
class StatusLabel(Base):
|
||||||
@@ -287,6 +365,7 @@ class StatusLabel(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
key: Mapped[str] = mapped_column(String(16), unique=True, index=True)
|
key: Mapped[str] = mapped_column(String(16), unique=True, index=True)
|
||||||
label: Mapped[str] = mapped_column(String(64))
|
label: Mapped[str] = mapped_column(String(64))
|
||||||
|
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
|
||||||
|
|
||||||
class ContactSectionContent(Base):
|
class ContactSectionContent(Base):
|
||||||
@@ -296,23 +375,41 @@ class ContactSectionContent(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
label: Mapped[str] = mapped_column(String(64), default="")
|
label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
title: Mapped[str] = mapped_column(String(256), default="")
|
title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
lead: Mapped[str] = mapped_column(Text, default="")
|
lead: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
address_label: Mapped[str] = mapped_column(String(64), default="")
|
address_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
address_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
phone_label: Mapped[str] = mapped_column(String(64), default="")
|
phone_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
phone_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
email_label: Mapped[str] = mapped_column(String(64), default="")
|
email_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
email_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
name_label: Mapped[str] = mapped_column(String(64), default="")
|
name_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
name_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
name_placeholder: Mapped[str] = mapped_column(String(128), default="")
|
name_placeholder: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
name_placeholder_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
phone_field_label: Mapped[str] = mapped_column(String(64), default="")
|
phone_field_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
phone_field_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
phone_placeholder: Mapped[str] = mapped_column(String(64), default="")
|
phone_placeholder: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
phone_placeholder_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
note_label: Mapped[str] = mapped_column(String(64), default="")
|
note_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
note_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
note_optional_label: Mapped[str] = mapped_column(String(64), default="")
|
note_optional_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
note_optional_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
note_placeholder: Mapped[str] = mapped_column(String(256), default="")
|
note_placeholder: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
note_placeholder_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
submit_label: Mapped[str] = mapped_column(String(128), default="")
|
submit_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
submit_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
sending_label: Mapped[str] = mapped_column(String(64), default="")
|
sending_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
sending_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
success_title: Mapped[str] = mapped_column(String(256), default="")
|
success_title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
success_title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
success_text: Mapped[str] = mapped_column(Text, default="")
|
success_text: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
success_text_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
success_button_label: Mapped[str] = mapped_column(String(128), default="")
|
success_button_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
success_button_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
|
||||||
|
|
||||||
class NewsletterSectionContent(Base):
|
class NewsletterSectionContent(Base):
|
||||||
@@ -322,11 +419,17 @@ class NewsletterSectionContent(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(256), default="")
|
title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
lead: Mapped[str] = mapped_column(Text, default="")
|
lead: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
placeholder: Mapped[str] = mapped_column(String(128), default="")
|
placeholder: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
placeholder_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
button_label: Mapped[str] = mapped_column(String(64), default="")
|
button_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
button_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
sending_label: Mapped[str] = mapped_column(String(64), default="")
|
sending_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
sending_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
success_text: Mapped[str] = mapped_column(String(256), default="")
|
success_text: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
success_text_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
|
||||||
|
|
||||||
class FooterContent(Base):
|
class FooterContent(Base):
|
||||||
@@ -336,12 +439,19 @@ class FooterContent(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
about_text: Mapped[str] = mapped_column(Text, default="")
|
about_text: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
about_text_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
pages_heading: Mapped[str] = mapped_column(String(64), default="")
|
pages_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
pages_heading_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
services_heading: Mapped[str] = mapped_column(String(64), default="")
|
services_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
services_heading_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
contact_heading: Mapped[str] = mapped_column(String(64), default="")
|
contact_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
contact_heading_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
copyright: Mapped[str] = mapped_column(String(256), default="")
|
copyright: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
copyright_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
tagline: Mapped[str] = mapped_column(String(256), default="")
|
tagline: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
tagline_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}]
|
socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}]
|
||||||
|
socials_en: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}] انگلیسی
|
||||||
|
|
||||||
|
|
||||||
class SeoMeta(Base):
|
class SeoMeta(Base):
|
||||||
@@ -352,7 +462,9 @@ class SeoMeta(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||||
title: Mapped[str] = mapped_column(String(256), default="")
|
title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||||
description: Mapped[str] = mapped_column(Text, default="")
|
description: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
description_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
class ContactMessage(Base):
|
class ContactMessage(Base):
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ class ORMModel(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# فیلدهای *_en معادل انگلیسیاند؛ خالی بودنشان یعنی «بدون ترجمه».
|
||||||
|
|
||||||
|
|
||||||
# ---------- خروجیها ----------
|
# ---------- خروجیها ----------
|
||||||
|
|
||||||
|
|
||||||
class BrandOut(ORMModel):
|
class BrandOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
name_en: str = ""
|
||||||
logo: str
|
logo: str
|
||||||
hero: str
|
hero: str
|
||||||
contact_bg: str
|
contact_bg: str
|
||||||
@@ -23,12 +27,15 @@ class BrandOut(ORMModel):
|
|||||||
phone_href: str
|
phone_href: str
|
||||||
email: str
|
email: str
|
||||||
address: str
|
address: str
|
||||||
|
address_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ServiceOut(ORMModel):
|
class ServiceOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
desc: str
|
desc: str
|
||||||
|
desc_en: str = ""
|
||||||
icon: str
|
icon: str
|
||||||
|
|
||||||
|
|
||||||
@@ -36,29 +43,44 @@ class ProjectOut(ORMModel):
|
|||||||
id: int
|
id: int
|
||||||
slug: str
|
slug: str
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
location: str
|
location: str
|
||||||
|
location_en: str = ""
|
||||||
year: str
|
year: str
|
||||||
|
year_en: str = ""
|
||||||
status: str
|
status: str
|
||||||
image: str
|
image: str
|
||||||
gallery: list[str]
|
gallery: list[str]
|
||||||
area: str
|
area: str
|
||||||
|
area_en: str = ""
|
||||||
floors: str
|
floors: str
|
||||||
|
floors_en: str = ""
|
||||||
units: str
|
units: str
|
||||||
|
units_en: str = ""
|
||||||
client: str
|
client: str
|
||||||
|
client_en: str = ""
|
||||||
summary: str
|
summary: str
|
||||||
|
summary_en: str = ""
|
||||||
body: list[str]
|
body: list[str]
|
||||||
|
body_en: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class ArticleOut(ORMModel):
|
class ArticleOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
slug: str
|
slug: str
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
date: str
|
date: str
|
||||||
|
date_en: str = ""
|
||||||
category: str
|
category: str
|
||||||
|
category_en: str = ""
|
||||||
image: str
|
image: str
|
||||||
read: str
|
read: str
|
||||||
|
read_en: str = ""
|
||||||
excerpt: str
|
excerpt: str
|
||||||
|
excerpt_en: str = ""
|
||||||
body: dict
|
body: dict
|
||||||
|
body_en: Optional[dict] = None
|
||||||
status: str
|
status: str
|
||||||
seo_keyphrase: str
|
seo_keyphrase: str
|
||||||
|
|
||||||
@@ -66,28 +88,39 @@ class ArticleOut(ORMModel):
|
|||||||
class TestimonialOut(ORMModel):
|
class TestimonialOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
name_en: str = ""
|
||||||
role: str
|
role: str
|
||||||
|
role_en: str = ""
|
||||||
quote: str
|
quote: str
|
||||||
|
quote_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class FaqOut(ORMModel):
|
class FaqOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
question: str
|
question: str
|
||||||
|
question_en: str = ""
|
||||||
answer: str
|
answer: str
|
||||||
|
answer_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class TeamMemberOut(ORMModel):
|
class TeamMemberOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
name_en: str = ""
|
||||||
role: str
|
role: str
|
||||||
|
role_en: str = ""
|
||||||
bio: str
|
bio: str
|
||||||
|
bio_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class TimelineItemOut(ORMModel):
|
class TimelineItemOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
year: str
|
year: str
|
||||||
|
year_en: str = ""
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
desc: str
|
desc: str
|
||||||
|
desc_en: str = ""
|
||||||
image: str
|
image: str
|
||||||
|
|
||||||
|
|
||||||
@@ -95,69 +128,101 @@ class StatOut(ORMModel):
|
|||||||
id: int
|
id: int
|
||||||
value: int
|
value: int
|
||||||
suffix: str
|
suffix: str
|
||||||
|
suffix_en: str = ""
|
||||||
label: str
|
label: str
|
||||||
|
label_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class WhyUsItemOut(ORMModel):
|
class WhyUsItemOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
desc: str
|
desc: str
|
||||||
|
desc_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class BeforeAfterOut(ORMModel):
|
class BeforeAfterOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
lead: str
|
lead: str
|
||||||
|
lead_en: str = ""
|
||||||
before: str
|
before: str
|
||||||
after: str
|
after: str
|
||||||
before_label: str
|
before_label: str
|
||||||
|
before_label_en: str = ""
|
||||||
after_label: str
|
after_label: str
|
||||||
|
after_label_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class NavItemOut(ORMModel):
|
class NavItemOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
label: str
|
label: str
|
||||||
|
label_en: str = ""
|
||||||
href: str
|
href: str
|
||||||
|
|
||||||
|
|
||||||
class HeroOut(ORMModel):
|
class HeroOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
kicker: str
|
kicker: str
|
||||||
|
kicker_en: str = ""
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
title_highlight: str
|
title_highlight: str
|
||||||
|
title_highlight_en: str = ""
|
||||||
subtitle: str
|
subtitle: str
|
||||||
|
subtitle_en: str = ""
|
||||||
badge_value: str
|
badge_value: str
|
||||||
|
badge_value_en: str = ""
|
||||||
badge_label: str
|
badge_label: str
|
||||||
|
badge_label_en: str = ""
|
||||||
cta_primary_label: str
|
cta_primary_label: str
|
||||||
|
cta_primary_label_en: str = ""
|
||||||
cta_primary_href: str
|
cta_primary_href: str
|
||||||
cta_secondary_label: str
|
cta_secondary_label: str
|
||||||
|
cta_secondary_label_en: str = ""
|
||||||
cta_secondary_href: str
|
cta_secondary_href: str
|
||||||
scroll_label: str
|
scroll_label: str
|
||||||
|
scroll_label_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class StoryOut(ORMModel):
|
class StoryOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
paragraphs: list[str]
|
paragraphs: list[str]
|
||||||
|
paragraphs_en: Optional[list[str]] = None
|
||||||
quote: str
|
quote: str
|
||||||
|
quote_en: str = ""
|
||||||
quote_author: str
|
quote_author: str
|
||||||
|
quote_author_en: str = ""
|
||||||
stat1_value: str
|
stat1_value: str
|
||||||
|
stat1_value_en: str = ""
|
||||||
stat1_label: str
|
stat1_label: str
|
||||||
|
stat1_label_en: str = ""
|
||||||
stat2_value: str
|
stat2_value: str
|
||||||
|
stat2_value_en: str = ""
|
||||||
stat2_label: str
|
stat2_label: str
|
||||||
|
stat2_label_en: str = ""
|
||||||
link_label: str
|
link_label: str
|
||||||
|
link_label_en: str = ""
|
||||||
link_href: str
|
link_href: str
|
||||||
bento: list[dict]
|
bento: list[dict]
|
||||||
|
bento_en: Optional[list[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
class SectionHeadingOut(ORMModel):
|
class SectionHeadingOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
key: str
|
key: str
|
||||||
label: str
|
label: str
|
||||||
|
label_en: str = ""
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
lead: str
|
lead: str
|
||||||
|
lead_en: str = ""
|
||||||
image: str
|
image: str
|
||||||
cta_label: str
|
cta_label: str
|
||||||
|
cta_label_en: str = ""
|
||||||
cta_href: str
|
cta_href: str
|
||||||
|
|
||||||
|
|
||||||
@@ -165,91 +230,137 @@ class PageHeroOut(ORMModel):
|
|||||||
id: int
|
id: int
|
||||||
key: str
|
key: str
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
lead: str
|
lead: str
|
||||||
|
lead_en: str = ""
|
||||||
image: str
|
image: str
|
||||||
|
|
||||||
|
|
||||||
class InvestmentModelOut(ORMModel):
|
class InvestmentModelOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
desc: str
|
desc: str
|
||||||
|
desc_en: str = ""
|
||||||
points: list[str]
|
points: list[str]
|
||||||
|
points_en: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class ProcessStepOut(ORMModel):
|
class ProcessStepOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
step: str
|
step: str
|
||||||
|
step_en: str = ""
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
desc: str
|
desc: str
|
||||||
|
desc_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class OfficeOut(ORMModel):
|
class OfficeOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
city: str
|
city: str
|
||||||
|
city_en: str = ""
|
||||||
address: str
|
address: str
|
||||||
|
address_en: str = ""
|
||||||
hours: str
|
hours: str
|
||||||
|
hours_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class GalleryImageOut(ORMModel):
|
class GalleryImageOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
src: str
|
src: str
|
||||||
alt: str
|
alt: str
|
||||||
|
alt_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class StatusLabelOut(ORMModel):
|
class StatusLabelOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
key: str
|
key: str
|
||||||
label: str
|
label: str
|
||||||
|
label_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ContactSectionOut(ORMModel):
|
class ContactSectionOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
label: str
|
label: str
|
||||||
|
label_en: str = ""
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
lead: str
|
lead: str
|
||||||
|
lead_en: str = ""
|
||||||
address_label: str
|
address_label: str
|
||||||
|
address_label_en: str = ""
|
||||||
phone_label: str
|
phone_label: str
|
||||||
|
phone_label_en: str = ""
|
||||||
email_label: str
|
email_label: str
|
||||||
|
email_label_en: str = ""
|
||||||
name_label: str
|
name_label: str
|
||||||
|
name_label_en: str = ""
|
||||||
name_placeholder: str
|
name_placeholder: str
|
||||||
|
name_placeholder_en: str = ""
|
||||||
phone_field_label: str
|
phone_field_label: str
|
||||||
|
phone_field_label_en: str = ""
|
||||||
phone_placeholder: str
|
phone_placeholder: str
|
||||||
|
phone_placeholder_en: str = ""
|
||||||
note_label: str
|
note_label: str
|
||||||
|
note_label_en: str = ""
|
||||||
note_optional_label: str
|
note_optional_label: str
|
||||||
|
note_optional_label_en: str = ""
|
||||||
note_placeholder: str
|
note_placeholder: str
|
||||||
|
note_placeholder_en: str = ""
|
||||||
submit_label: str
|
submit_label: str
|
||||||
|
submit_label_en: str = ""
|
||||||
sending_label: str
|
sending_label: str
|
||||||
|
sending_label_en: str = ""
|
||||||
success_title: str
|
success_title: str
|
||||||
|
success_title_en: str = ""
|
||||||
success_text: str
|
success_text: str
|
||||||
|
success_text_en: str = ""
|
||||||
success_button_label: str
|
success_button_label: str
|
||||||
|
success_button_label_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class NewsletterOut(ORMModel):
|
class NewsletterOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
lead: str
|
lead: str
|
||||||
|
lead_en: str = ""
|
||||||
placeholder: str
|
placeholder: str
|
||||||
|
placeholder_en: str = ""
|
||||||
button_label: str
|
button_label: str
|
||||||
|
button_label_en: str = ""
|
||||||
sending_label: str
|
sending_label: str
|
||||||
|
sending_label_en: str = ""
|
||||||
success_text: str
|
success_text: str
|
||||||
|
success_text_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class FooterOut(ORMModel):
|
class FooterOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
about_text: str
|
about_text: str
|
||||||
|
about_text_en: str = ""
|
||||||
pages_heading: str
|
pages_heading: str
|
||||||
|
pages_heading_en: str = ""
|
||||||
services_heading: str
|
services_heading: str
|
||||||
|
services_heading_en: str = ""
|
||||||
contact_heading: str
|
contact_heading: str
|
||||||
|
contact_heading_en: str = ""
|
||||||
copyright: str
|
copyright: str
|
||||||
|
copyright_en: str = ""
|
||||||
tagline: str
|
tagline: str
|
||||||
|
tagline_en: str = ""
|
||||||
socials: list[dict]
|
socials: list[dict]
|
||||||
|
socials_en: Optional[list[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
class SeoMetaOut(ORMModel):
|
class SeoMetaOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
page_key: str
|
page_key: str
|
||||||
title: str
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
description: str
|
description: str
|
||||||
|
description_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ContactMessageOut(ORMModel):
|
class ContactMessageOut(ORMModel):
|
||||||
@@ -301,6 +412,7 @@ class ChangePasswordIn(BaseModel):
|
|||||||
|
|
||||||
class BrandIn(BaseModel):
|
class BrandIn(BaseModel):
|
||||||
name: str = ""
|
name: str = ""
|
||||||
|
name_en: str = ""
|
||||||
logo: str = ""
|
logo: str = ""
|
||||||
hero: str = ""
|
hero: str = ""
|
||||||
contact_bg: str = ""
|
contact_bg: str = ""
|
||||||
@@ -308,28 +420,40 @@ class BrandIn(BaseModel):
|
|||||||
phone_href: str = ""
|
phone_href: str = ""
|
||||||
email: str = ""
|
email: str = ""
|
||||||
address: str = ""
|
address: str = ""
|
||||||
|
address_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ServiceIn(BaseModel):
|
class ServiceIn(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=256)
|
title: str = Field(min_length=1, max_length=256)
|
||||||
|
title_en: str = ""
|
||||||
desc: str = ""
|
desc: str = ""
|
||||||
|
desc_en: str = ""
|
||||||
icon: str = "draft"
|
icon: str = "draft"
|
||||||
|
|
||||||
|
|
||||||
class ProjectIn(BaseModel):
|
class ProjectIn(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)
|
||||||
|
title_en: str = ""
|
||||||
location: str = ""
|
location: str = ""
|
||||||
|
location_en: str = ""
|
||||||
year: str = ""
|
year: str = ""
|
||||||
|
year_en: str = ""
|
||||||
status: Literal["done", "progress"] = "done"
|
status: Literal["done", "progress"] = "done"
|
||||||
image: str = ""
|
image: str = ""
|
||||||
gallery: list[str] = []
|
gallery: list[str] = []
|
||||||
area: str = ""
|
area: str = ""
|
||||||
|
area_en: str = ""
|
||||||
floors: str = ""
|
floors: str = ""
|
||||||
|
floors_en: str = ""
|
||||||
units: str = ""
|
units: str = ""
|
||||||
|
units_en: str = ""
|
||||||
client: str = ""
|
client: str = ""
|
||||||
|
client_en: str = ""
|
||||||
summary: str = ""
|
summary: str = ""
|
||||||
|
summary_en: str = ""
|
||||||
body: list[str] = []
|
body: list[str] = []
|
||||||
|
body_en: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||||
@@ -347,189 +471,287 @@ def validate_doc_body(value: object) -> dict:
|
|||||||
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)
|
||||||
|
title_en: str = ""
|
||||||
date: str = ""
|
date: str = ""
|
||||||
|
date_en: str = ""
|
||||||
category: str = ""
|
category: str = ""
|
||||||
|
category_en: str = ""
|
||||||
image: str = ""
|
image: str = ""
|
||||||
read: str = ""
|
read: str = ""
|
||||||
|
read_en: str = ""
|
||||||
excerpt: str = ""
|
excerpt: str = ""
|
||||||
|
excerpt_en: str = ""
|
||||||
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||||
|
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||||
status: Literal["draft", "published"] = "draft"
|
status: Literal["draft", "published"] = "draft"
|
||||||
seo_keyphrase: str = Field(default="", max_length=128)
|
seo_keyphrase: str = Field(default="", max_length=128)
|
||||||
_validate_body = field_validator("body")(validate_doc_body)
|
_validate_body = field_validator("body")(validate_doc_body)
|
||||||
|
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||||
|
|
||||||
|
|
||||||
class TestimonialIn(BaseModel):
|
class TestimonialIn(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=128)
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
name_en: str = ""
|
||||||
role: str = ""
|
role: str = ""
|
||||||
|
role_en: str = ""
|
||||||
quote: str = ""
|
quote: str = ""
|
||||||
|
quote_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class FaqIn(BaseModel):
|
class FaqIn(BaseModel):
|
||||||
question: str = Field(min_length=1)
|
question: str = Field(min_length=1)
|
||||||
|
question_en: str = ""
|
||||||
answer: str = ""
|
answer: str = ""
|
||||||
|
answer_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class TeamMemberIn(BaseModel):
|
class TeamMemberIn(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=128)
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
name_en: str = ""
|
||||||
role: str = ""
|
role: str = ""
|
||||||
|
role_en: str = ""
|
||||||
bio: str = ""
|
bio: str = ""
|
||||||
|
bio_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class TimelineItemIn(BaseModel):
|
class TimelineItemIn(BaseModel):
|
||||||
year: str = Field(min_length=1, max_length=32)
|
year: str = Field(min_length=1, max_length=32)
|
||||||
|
year_en: str = ""
|
||||||
title: str = Field(min_length=1, max_length=256)
|
title: str = Field(min_length=1, max_length=256)
|
||||||
|
title_en: str = ""
|
||||||
desc: str = ""
|
desc: str = ""
|
||||||
|
desc_en: str = ""
|
||||||
image: str = ""
|
image: str = ""
|
||||||
|
|
||||||
|
|
||||||
class StatIn(BaseModel):
|
class StatIn(BaseModel):
|
||||||
value: int
|
value: int
|
||||||
suffix: str = ""
|
suffix: str = ""
|
||||||
|
suffix_en: str = ""
|
||||||
label: str = Field(min_length=1, max_length=128)
|
label: str = Field(min_length=1, max_length=128)
|
||||||
|
label_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class WhyUsItemIn(BaseModel):
|
class WhyUsItemIn(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=256)
|
title: str = Field(min_length=1, max_length=256)
|
||||||
|
title_en: str = ""
|
||||||
desc: str = ""
|
desc: str = ""
|
||||||
|
desc_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class BeforeAfterIn(BaseModel):
|
class BeforeAfterIn(BaseModel):
|
||||||
title: str = ""
|
title: str = ""
|
||||||
|
title_en: str = ""
|
||||||
lead: str = ""
|
lead: str = ""
|
||||||
|
lead_en: str = ""
|
||||||
before: str = ""
|
before: str = ""
|
||||||
after: str = ""
|
after: str = ""
|
||||||
before_label: str = ""
|
before_label: str = ""
|
||||||
|
before_label_en: str = ""
|
||||||
after_label: str = ""
|
after_label: str = ""
|
||||||
|
after_label_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class NavItemIn(BaseModel):
|
class NavItemIn(BaseModel):
|
||||||
label: str = Field(min_length=1, max_length=64)
|
label: str = Field(min_length=1, max_length=64)
|
||||||
|
label_en: str = ""
|
||||||
href: str = Field(min_length=1, max_length=128)
|
href: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
class HeroIn(BaseModel):
|
class HeroIn(BaseModel):
|
||||||
kicker: str = ""
|
kicker: str = ""
|
||||||
|
kicker_en: str = ""
|
||||||
title: str = ""
|
title: str = ""
|
||||||
|
title_en: str = ""
|
||||||
title_highlight: str = ""
|
title_highlight: str = ""
|
||||||
|
title_highlight_en: str = ""
|
||||||
subtitle: str = ""
|
subtitle: str = ""
|
||||||
|
subtitle_en: str = ""
|
||||||
badge_value: str = ""
|
badge_value: str = ""
|
||||||
|
badge_value_en: str = ""
|
||||||
badge_label: str = ""
|
badge_label: str = ""
|
||||||
|
badge_label_en: str = ""
|
||||||
cta_primary_label: str = ""
|
cta_primary_label: str = ""
|
||||||
|
cta_primary_label_en: str = ""
|
||||||
cta_primary_href: str = ""
|
cta_primary_href: str = ""
|
||||||
cta_secondary_label: str = ""
|
cta_secondary_label: str = ""
|
||||||
|
cta_secondary_label_en: str = ""
|
||||||
cta_secondary_href: str = ""
|
cta_secondary_href: str = ""
|
||||||
scroll_label: str = ""
|
scroll_label: str = ""
|
||||||
|
scroll_label_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class StoryIn(BaseModel):
|
class StoryIn(BaseModel):
|
||||||
title: str = ""
|
title: str = ""
|
||||||
|
title_en: str = ""
|
||||||
paragraphs: list[str] = []
|
paragraphs: list[str] = []
|
||||||
|
paragraphs_en: list[str] = []
|
||||||
quote: str = ""
|
quote: str = ""
|
||||||
|
quote_en: str = ""
|
||||||
quote_author: str = ""
|
quote_author: str = ""
|
||||||
|
quote_author_en: str = ""
|
||||||
stat1_value: str = ""
|
stat1_value: str = ""
|
||||||
|
stat1_value_en: str = ""
|
||||||
stat1_label: str = ""
|
stat1_label: str = ""
|
||||||
|
stat1_label_en: str = ""
|
||||||
stat2_value: str = ""
|
stat2_value: str = ""
|
||||||
|
stat2_value_en: str = ""
|
||||||
stat2_label: str = ""
|
stat2_label: str = ""
|
||||||
|
stat2_label_en: str = ""
|
||||||
link_label: str = ""
|
link_label: str = ""
|
||||||
|
link_label_en: str = ""
|
||||||
link_href: str = ""
|
link_href: str = ""
|
||||||
bento: list[dict] = []
|
bento: list[dict] = []
|
||||||
|
bento_en: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
class SectionHeadingIn(BaseModel):
|
class SectionHeadingIn(BaseModel):
|
||||||
key: str = Field(min_length=1, max_length=64)
|
key: str = Field(min_length=1, max_length=64)
|
||||||
label: str = ""
|
label: str = ""
|
||||||
|
label_en: str = ""
|
||||||
title: str = ""
|
title: str = ""
|
||||||
|
title_en: str = ""
|
||||||
lead: str = ""
|
lead: str = ""
|
||||||
|
lead_en: str = ""
|
||||||
image: str = ""
|
image: str = ""
|
||||||
cta_label: str = ""
|
cta_label: str = ""
|
||||||
|
cta_label_en: str = ""
|
||||||
cta_href: str = ""
|
cta_href: str = ""
|
||||||
|
|
||||||
|
|
||||||
class PageHeroIn(BaseModel):
|
class PageHeroIn(BaseModel):
|
||||||
key: str = Field(min_length=1, max_length=32)
|
key: str = Field(min_length=1, max_length=32)
|
||||||
title: str = ""
|
title: str = ""
|
||||||
|
title_en: str = ""
|
||||||
lead: str = ""
|
lead: str = ""
|
||||||
|
lead_en: str = ""
|
||||||
image: str = ""
|
image: str = ""
|
||||||
|
|
||||||
|
|
||||||
class InvestmentModelIn(BaseModel):
|
class InvestmentModelIn(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=256)
|
title: str = Field(min_length=1, max_length=256)
|
||||||
|
title_en: str = ""
|
||||||
desc: str = ""
|
desc: str = ""
|
||||||
|
desc_en: str = ""
|
||||||
points: list[str] = []
|
points: list[str] = []
|
||||||
|
points_en: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class ProcessStepIn(BaseModel):
|
class ProcessStepIn(BaseModel):
|
||||||
step: str = ""
|
step: str = ""
|
||||||
|
step_en: str = ""
|
||||||
title: str = Field(min_length=1, max_length=256)
|
title: str = Field(min_length=1, max_length=256)
|
||||||
|
title_en: str = ""
|
||||||
desc: str = ""
|
desc: str = ""
|
||||||
|
desc_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class OfficeIn(BaseModel):
|
class OfficeIn(BaseModel):
|
||||||
city: str = Field(min_length=1, max_length=64)
|
city: str = Field(min_length=1, max_length=64)
|
||||||
|
city_en: str = ""
|
||||||
address: str = ""
|
address: str = ""
|
||||||
|
address_en: str = ""
|
||||||
hours: str = ""
|
hours: str = ""
|
||||||
|
hours_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class GalleryImageIn(BaseModel):
|
class GalleryImageIn(BaseModel):
|
||||||
src: str = ""
|
src: str = ""
|
||||||
alt: str = ""
|
alt: str = ""
|
||||||
|
alt_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class StatusLabelIn(BaseModel):
|
class StatusLabelIn(BaseModel):
|
||||||
key: str = Field(min_length=1, max_length=16)
|
key: str = Field(min_length=1, max_length=16)
|
||||||
label: str = Field(min_length=1, max_length=64)
|
label: str = Field(min_length=1, max_length=64)
|
||||||
|
label_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ContactSectionIn(BaseModel):
|
class ContactSectionIn(BaseModel):
|
||||||
label: str = ""
|
label: str = ""
|
||||||
|
label_en: str = ""
|
||||||
title: str = ""
|
title: str = ""
|
||||||
|
title_en: str = ""
|
||||||
lead: str = ""
|
lead: str = ""
|
||||||
|
lead_en: str = ""
|
||||||
address_label: str = ""
|
address_label: str = ""
|
||||||
|
address_label_en: str = ""
|
||||||
phone_label: str = ""
|
phone_label: str = ""
|
||||||
|
phone_label_en: str = ""
|
||||||
email_label: str = ""
|
email_label: str = ""
|
||||||
|
email_label_en: str = ""
|
||||||
name_label: str = ""
|
name_label: str = ""
|
||||||
|
name_label_en: str = ""
|
||||||
name_placeholder: str = ""
|
name_placeholder: str = ""
|
||||||
|
name_placeholder_en: str = ""
|
||||||
phone_field_label: str = ""
|
phone_field_label: str = ""
|
||||||
|
phone_field_label_en: str = ""
|
||||||
phone_placeholder: str = ""
|
phone_placeholder: str = ""
|
||||||
|
phone_placeholder_en: str = ""
|
||||||
note_label: str = ""
|
note_label: str = ""
|
||||||
|
note_label_en: str = ""
|
||||||
note_optional_label: str = ""
|
note_optional_label: str = ""
|
||||||
|
note_optional_label_en: str = ""
|
||||||
note_placeholder: str = ""
|
note_placeholder: str = ""
|
||||||
|
note_placeholder_en: str = ""
|
||||||
submit_label: str = ""
|
submit_label: str = ""
|
||||||
|
submit_label_en: str = ""
|
||||||
sending_label: str = ""
|
sending_label: str = ""
|
||||||
|
sending_label_en: str = ""
|
||||||
success_title: str = ""
|
success_title: str = ""
|
||||||
|
success_title_en: str = ""
|
||||||
success_text: str = ""
|
success_text: str = ""
|
||||||
|
success_text_en: str = ""
|
||||||
success_button_label: str = ""
|
success_button_label: str = ""
|
||||||
|
success_button_label_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class NewsletterSectionIn(BaseModel):
|
class NewsletterSectionIn(BaseModel):
|
||||||
title: str = ""
|
title: str = ""
|
||||||
|
title_en: str = ""
|
||||||
lead: str = ""
|
lead: str = ""
|
||||||
|
lead_en: str = ""
|
||||||
placeholder: str = ""
|
placeholder: str = ""
|
||||||
|
placeholder_en: str = ""
|
||||||
button_label: str = ""
|
button_label: str = ""
|
||||||
|
button_label_en: str = ""
|
||||||
sending_label: str = ""
|
sending_label: str = ""
|
||||||
|
sending_label_en: str = ""
|
||||||
success_text: str = ""
|
success_text: str = ""
|
||||||
|
success_text_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class FooterIn(BaseModel):
|
class FooterIn(BaseModel):
|
||||||
about_text: str = ""
|
about_text: str = ""
|
||||||
|
about_text_en: str = ""
|
||||||
pages_heading: str = ""
|
pages_heading: str = ""
|
||||||
|
pages_heading_en: str = ""
|
||||||
services_heading: str = ""
|
services_heading: str = ""
|
||||||
|
services_heading_en: str = ""
|
||||||
contact_heading: str = ""
|
contact_heading: str = ""
|
||||||
|
contact_heading_en: str = ""
|
||||||
copyright: str = ""
|
copyright: str = ""
|
||||||
|
copyright_en: str = ""
|
||||||
tagline: str = ""
|
tagline: str = ""
|
||||||
|
tagline_en: str = ""
|
||||||
socials: list[dict] = []
|
socials: list[dict] = []
|
||||||
|
socials_en: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
class SeoMetaIn(BaseModel):
|
class SeoMetaIn(BaseModel):
|
||||||
page_key: str = Field(min_length=1, max_length=32)
|
page_key: str = Field(min_length=1, max_length=32)
|
||||||
title: str = ""
|
title: str = ""
|
||||||
|
title_en: str = ""
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
description_en: str = ""
|
||||||
|
|
||||||
|
|
||||||
class SeoMetaPatch(BaseModel):
|
class SeoMetaPatch(BaseModel):
|
||||||
page_key: Optional[str] = None
|
page_key: Optional[str] = None
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
|
title_en: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
description_en: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class MessageReadIn(BaseModel):
|
class MessageReadIn(BaseModel):
|
||||||
@@ -551,15 +773,22 @@ ProjectPatch = _make_patch("ProjectPatch", ProjectIn)
|
|||||||
class ArticlePatch(BaseModel):
|
class ArticlePatch(BaseModel):
|
||||||
slug: Optional[str] = None
|
slug: Optional[str] = None
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
|
title_en: Optional[str] = None
|
||||||
date: Optional[str] = None
|
date: Optional[str] = None
|
||||||
|
date_en: Optional[str] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
|
category_en: Optional[str] = None
|
||||||
image: Optional[str] = None
|
image: Optional[str] = None
|
||||||
read: Optional[str] = None
|
read: Optional[str] = None
|
||||||
|
read_en: Optional[str] = None
|
||||||
excerpt: Optional[str] = None
|
excerpt: Optional[str] = None
|
||||||
|
excerpt_en: Optional[str] = None
|
||||||
body: Optional[dict] = None
|
body: Optional[dict] = None
|
||||||
|
body_en: Optional[dict] = None
|
||||||
status: Optional[Literal["draft", "published"]] = None
|
status: Optional[Literal["draft", "published"]] = None
|
||||||
seo_keyphrase: Optional[str] = None
|
seo_keyphrase: Optional[str] = None
|
||||||
_validate_body = field_validator("body")(validate_doc_body)
|
_validate_body = field_validator("body")(validate_doc_body)
|
||||||
|
_validate_body_en = field_validator("body_en")(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)
|
||||||
|
|||||||
+38
-6
@@ -11,7 +11,7 @@ seed میشوند و دادههای قبلی دستنخورده می
|
|||||||
import argparse
|
import argparse
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from . import models, seed_data
|
from . import models, seed_data, seed_data_en
|
||||||
from .auth import hash_password
|
from .auth import hash_password
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import Base, SessionLocal, engine
|
from .database import Base, SessionLocal, engine
|
||||||
@@ -46,6 +46,35 @@ CONTENT: list[tuple[Any, list[dict], str]] = [
|
|||||||
(models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"),
|
(models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ترجمه انگلیسی همان جدولها — همترتیب با CONTENT تا با zip ادغام شود
|
||||||
|
EN_CONTENT: list[list[dict]] = [
|
||||||
|
[seed_data_en.BRAND],
|
||||||
|
[seed_data_en.HERO],
|
||||||
|
[seed_data_en.STORY],
|
||||||
|
seed_data_en.SECTION_HEADINGS,
|
||||||
|
seed_data_en.PAGE_HEROES,
|
||||||
|
seed_data_en.NAV_ITEMS,
|
||||||
|
seed_data_en.INVESTMENT_MODELS,
|
||||||
|
seed_data_en.PROCESS_STEPS,
|
||||||
|
seed_data_en.OFFICES,
|
||||||
|
seed_data_en.GALLERY_IMAGES,
|
||||||
|
seed_data_en.STATUS_LABELS,
|
||||||
|
[seed_data_en.CONTACT_SECTION],
|
||||||
|
[seed_data_en.NEWSLETTER],
|
||||||
|
[seed_data_en.FOOTER],
|
||||||
|
seed_data_en.SERVICES,
|
||||||
|
seed_data_en.STATS,
|
||||||
|
seed_data_en.PROJECTS,
|
||||||
|
seed_data_en.ARTICLES,
|
||||||
|
seed_data_en.TESTIMONIALS,
|
||||||
|
seed_data_en.FAQS,
|
||||||
|
seed_data_en.TEAM,
|
||||||
|
seed_data_en.TIMELINE,
|
||||||
|
seed_data_en.WHY,
|
||||||
|
[seed_data_en.BEFORE_AFTER],
|
||||||
|
seed_data_en.SEO_META,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def seed(force: bool = False) -> None:
|
def seed(force: bool = False) -> None:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -57,16 +86,19 @@ def seed(force: bool = False) -> None:
|
|||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
run_migrations()
|
run_migrations()
|
||||||
|
|
||||||
for model, rows, label in CONTENT:
|
for (model, rows, label), en_rows in zip(CONTENT, EN_CONTENT):
|
||||||
if db.query(model).count() > 0:
|
if db.query(model).count() > 0:
|
||||||
continue
|
continue
|
||||||
for idx, row in enumerate(rows):
|
for idx, row in enumerate(rows):
|
||||||
payload = dict(row)
|
# فایل فارسی مرجع است؛ فقط فیلدهای *_en از فایل انگلیسی اضافه میشود
|
||||||
|
payload = {**dict(en_rows[idx]), **dict(row)} if idx < len(en_rows) else 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 بهصورت لیست پاراگراف است — به سند تبدیل میشود
|
# بدنه مقاله (فارسی و انگلیسی) در seed بهصورت لیست پاراگراف است — به سند تبدیل میشود
|
||||||
if model is models.Article and isinstance(payload.get("body"), list):
|
if model is models.Article:
|
||||||
payload["body"] = paragraphs_to_doc(payload["body"])
|
for body_key in ("body", "body_en"):
|
||||||
|
if isinstance(payload.get(body_key), list):
|
||||||
|
payload[body_key] = paragraphs_to_doc(payload[body_key])
|
||||||
db.add(model(**payload))
|
db.add(model(**payload))
|
||||||
|
|
||||||
if db.query(models.AdminUser).count() == 0:
|
if db.query(models.AdminUser).count() == 0:
|
||||||
|
|||||||
@@ -0,0 +1,580 @@
|
|||||||
|
"""ترجمه انگلیسی دادههای اولیه سایت — ساختار یکبهیک با seed_data.py.
|
||||||
|
|
||||||
|
هر فهرست همطول و همترتیب با معادل فارسی خود است تا seed.py بتواند با zip
|
||||||
|
ادغام کند و backfill مهاجرت بتواند سطرهای موجود را پر کند. فقط فیلدهای
|
||||||
|
`*_en` ملاک ادغاماند؛ کلیدهای همنام با فارسی (مثل title یا body) صرفاً
|
||||||
|
برای خواندن راحتتر مترجم آمدهاند و در ادغام نادیده گرفته میشوند.
|
||||||
|
فیلدهای تطبیق (slug، key، page_key، src) عیناً مقدار فارسیاند.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .seed_data import IMAGES
|
||||||
|
|
||||||
|
BRAND = {
|
||||||
|
"name": "گروه ساختمانی آرامش",
|
||||||
|
"name_en": "Aramesh Construction Group",
|
||||||
|
"address": "تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم",
|
||||||
|
"address_en": "Valiasr St., Aramesh Tower, 18th Floor, Tehran, Iran",
|
||||||
|
}
|
||||||
|
|
||||||
|
SERVICES = [
|
||||||
|
{"title": "طراحی معماری", "title_en": "Architectural Design", "desc": "خلق فرمهای معاصر با نگاه ایرانی؛ از کانسپت اولیه تا نقشههای اجرایی و جزئیات نما.", "desc_en": "Contemporary forms with an Iranian eye — from the first concept to construction drawings and facade details.", "icon": "draft"},
|
||||||
|
{"title": "ساخت برجهای مسکونی", "title_en": "Residential Towers", "desc": "اجرای برجهای بلندمرتبه با استانداردهای سازهای بینالمللی و کیفیت تحویل بینقص.", "desc_en": "High-rise execution built on international structural standards and a flawless handover quality.", "icon": "tower"},
|
||||||
|
{"title": "مجتمعهای تجاری", "title_en": "Commercial Complexes", "desc": "طراحی و ساخت مراکز تجاری با مطالعه دقیق جریان مخاطب و بازدهی بلندمدت سرمایه.", "desc_en": "Retail destinations designed and built around precise visitor-flow studies and long-term returns.", "icon": "mall"},
|
||||||
|
{"title": "ویلاسازی", "title_en": "Private Villas", "desc": "ویلاهای اختصاصی همنشین با طبیعت؛ محرمانگی، نور طبیعی و مصالح ماندگار.", "desc_en": "Bespoke villas in harmony with nature — privacy, natural light and enduring materials.", "icon": "villa"},
|
||||||
|
{"title": "بازسازی", "title_en": "Renovation", "desc": "بازآفرینی ساختمانهای موجود با ارتقای سازه، نما و تجربه زندگی در فضا.", "desc_en": "Reimagining existing buildings with upgraded structure, facades and living experience.", "icon": "renew"},
|
||||||
|
{"title": "مدیریت پروژه", "title_en": "Project Management", "desc": "کنترل همزمان زمان، هزینه و کیفیت با گزارشدهی شفاف در تمام مراحل ساخت.", "desc_en": "Controlling time, cost and quality in parallel with transparent reporting at every stage.", "icon": "manage"},
|
||||||
|
{"title": "سرمایهگذاری ساختمانی", "title_en": "Real Estate Investment", "desc": "ساختارهای مشارکت شفاف، ارزیابی ریسک و طراحی سبد ملکی متناسب با سرمایه شما.", "desc_en": "Transparent partnership structures, risk assessment and property portfolios tailored to your capital.", "icon": "invest"},
|
||||||
|
{"title": "محوطهسازی و لنداسکیپ", "title_en": "Landscape Design", "desc": "طراحی حیاط، بام سبز و مسیرهای دسترسی؛ ادامه معماری در فضای باز پروژه.", "desc_en": "Courtyards, green roofs and pathways — architecture carried on into the open air.", "icon": "landscape"},
|
||||||
|
]
|
||||||
|
|
||||||
|
STATS = [
|
||||||
|
{"value": 21, "suffix": "+", "suffix_en": "+", "label": "سال تجربه ساخت", "label_en": "Years of building experience"},
|
||||||
|
{"value": 47, "suffix": "", "suffix_en": "", "label": "پروژه تحویلشده", "label_en": "Projects delivered"},
|
||||||
|
{"value": 860, "suffix": "هزار", "suffix_en": "K", "label": "مترمربع زیربنا", "label_en": "Square meters built"},
|
||||||
|
{"value": 1200, "suffix": "+", "suffix_en": "+", "label": "خانواده ساکن", "label_en": "Resident families"},
|
||||||
|
]
|
||||||
|
|
||||||
|
PROJECTS = [
|
||||||
|
{
|
||||||
|
"slug": "aramesh-elahieh",
|
||||||
|
"title": "برج آرامش الهیه",
|
||||||
|
"title_en": "Aramesh Elahieh Tower",
|
||||||
|
"location": "تهران، الهیه",
|
||||||
|
"location_en": "Elahieh, Tehran",
|
||||||
|
"year": "۱۴۰۲",
|
||||||
|
"year_en": "2023",
|
||||||
|
"area": "۲۴٬۰۰۰ متر مربع",
|
||||||
|
"area_en": "24,000 m²",
|
||||||
|
"floors": "۱۸ طبقه",
|
||||||
|
"floors_en": "18 floors",
|
||||||
|
"units": "۵۶ واحد",
|
||||||
|
"units_en": "56 units",
|
||||||
|
"client": "سرمایهگذاری خصوصی",
|
||||||
|
"client_en": "Private investor",
|
||||||
|
"summary": "یک برج مسکونی لوکس با نمای سنگ تراورتن و پوسته دوپوستهای که مصرف انرژی را تا ۳۵ درصد کاهش داده است.",
|
||||||
|
"summary_en": "A luxury residential tower with travertine cladding and a double-skin facade that cuts energy use by up to 35%.",
|
||||||
|
"body": [],
|
||||||
|
"body_en": [
|
||||||
|
"Aramesh Elahieh Tower was designed on a site with a natural northward slope — an opportunity that placed the lobby, pool and shared spaces on three different levels and gave every unit an unobstructed view of the city.",
|
||||||
|
"The outer shell combines light travertine stone with floor-to-ceiling triple-glazed windows. Copper-toned vertical louvers break the direct summer sun while letting winter light pour in.",
|
||||||
|
"Construction was completed in thirty-two months, and every unit was sold before handover.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "parseh-mall",
|
||||||
|
"title": "مجتمع تجاری پارسه",
|
||||||
|
"title_en": "Parseh Shopping Mall",
|
||||||
|
"location": "اصفهان، مرکز شهر",
|
||||||
|
"location_en": "City center, Isfahan",
|
||||||
|
"year": "۱۴۰۱",
|
||||||
|
"year_en": "2022",
|
||||||
|
"area": "۳۸٬۵۰۰ متر مربع",
|
||||||
|
"area_en": "38,500 m²",
|
||||||
|
"floors": "۷ طبقه",
|
||||||
|
"floors_en": "7 floors",
|
||||||
|
"units": "۱۴۰ واحد تجاری",
|
||||||
|
"units_en": "140 retail units",
|
||||||
|
"client": "گروه سرمایهگذاری پارسه",
|
||||||
|
"client_en": "Parseh Investment Group",
|
||||||
|
"summary": "مرکز تجاری با آتریوم مرکزی و نورگیر سقفی که جریان مخاطب را بهصورت طبیعی در همه ترازها توزیع میکند.",
|
||||||
|
"summary_en": "A retail center with a central atrium and roof light-wells that naturally distribute visitor flow across every level.",
|
||||||
|
"body": [],
|
||||||
|
"body_en": [
|
||||||
|
"A visitor-behavior study before design showed that the shortest vertical path was the key to the complex's commercial success — so the central atrium with diagonal escalators sits at the heart of the plan.",
|
||||||
|
"Low-E glazing on the roof light-well carries daylight down to level B2 and measurably cuts artificial lighting consumption.",
|
||||||
|
"The complex opened two months ahead of the contractual schedule.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "daryakenar-villas",
|
||||||
|
"title": "ویلاهای دریاکنار",
|
||||||
|
"title_en": "Daryakenar Villas",
|
||||||
|
"location": "مازندران، نوشهر",
|
||||||
|
"location_en": "Nowshahr, Mazandaran",
|
||||||
|
"year": "۱۴۰۰",
|
||||||
|
"year_en": "2021",
|
||||||
|
"area": "۹٬۲۰۰ متر مربع",
|
||||||
|
"area_en": "9,200 m²",
|
||||||
|
"floors": "۲ طبقه",
|
||||||
|
"floors_en": "2 floors",
|
||||||
|
"units": "۱۲ ویلا",
|
||||||
|
"units_en": "12 villas",
|
||||||
|
"client": "مجموعه خصوصی دریاکنار",
|
||||||
|
"client_en": "Daryakenar Private Estate",
|
||||||
|
"summary": "دوازده ویلای اختصاصی با چیدمان شانهای که برای هر واحد، دید مستقیم به دریا و محرمانگی کامل فراهم میکند.",
|
||||||
|
"summary_en": "Twelve bespoke villas in a comb layout that gives each unit a direct sea view and complete privacy.",
|
||||||
|
"body": [],
|
||||||
|
"body_en": [
|
||||||
|
"The comb layout of the villas is the result of dozens of view-angle studies: no villa overlooks another, and all of them open onto the sea.",
|
||||||
|
"The materials answer the humid northern climate: thermowood, exposed concrete and salt-resistant coatings on the facade metals.",
|
||||||
|
"The landscape was designed with native plants to keep annual irrigation demand to a minimum.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "niavaran-twins",
|
||||||
|
"title": "برج دوقلوی نیاوران",
|
||||||
|
"title_en": "Niavaran Twin Towers",
|
||||||
|
"location": "تهران، نیاوران",
|
||||||
|
"location_en": "Niavaran, Tehran",
|
||||||
|
"year": "۱۳۹۹",
|
||||||
|
"year_en": "2020",
|
||||||
|
"area": "۳۱٬۷۰۰ متر مربع",
|
||||||
|
"area_en": "31,700 m²",
|
||||||
|
"floors": "۲۰ طبقه",
|
||||||
|
"floors_en": "20 floors",
|
||||||
|
"units": "۸۰ واحد",
|
||||||
|
"units_en": "80 units",
|
||||||
|
"client": "مشارکت با مالکان زمین",
|
||||||
|
"client_en": "Partnership with landowners",
|
||||||
|
"summary": "دو برج همسان با پادست مشترک که فضاهای رفاهی و پارکینگها را در یک تراز واحد ادغام میکند.",
|
||||||
|
"summary_en": "Two identical towers on a shared podium that merges amenities and parking into a single level.",
|
||||||
|
"body": [],
|
||||||
|
"body_en": [
|
||||||
|
"The two towers rest on a shared podium — a decision that let the amenities, event hall and parking be shared between the buildings and reduced the footprint on the garden.",
|
||||||
|
"The structure uses shear walls with post-tensioned slabs so the interior spans stay free and the floor plans remain customizable.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "kish-garden-tower",
|
||||||
|
"title": "باغبرج کیش",
|
||||||
|
"title_en": "Kish Garden Tower",
|
||||||
|
"location": "کیش، مروارید",
|
||||||
|
"location_en": "Marvàrid, Kish Island",
|
||||||
|
"year": "۱۴۰۵ (پیشبینی)",
|
||||||
|
"year_en": "2026 (expected)",
|
||||||
|
"area": "۱۸٬۴۰۰ متر مربع",
|
||||||
|
"area_en": "18,400 m²",
|
||||||
|
"floors": "۱۴ طبقه",
|
||||||
|
"floors_en": "14 floors",
|
||||||
|
"units": "۶۲ واحد اقامتی",
|
||||||
|
"units_en": "62 hospitality units",
|
||||||
|
"client": "صندوق پروژهمحور آرامش",
|
||||||
|
"client_en": "Aramesh Project Fund",
|
||||||
|
"summary": "باغبرجی اقامتی با تراسهای سبز پیوسته که در حال ساخت است؛ پیشرفت فیزیکی پروژه ۴۸ درصد.",
|
||||||
|
"summary_en": "A hospitality garden tower with continuous green terraces, currently under construction — 48% complete.",
|
||||||
|
"body": [],
|
||||||
|
"body_en": [
|
||||||
|
"The continuous green terraces form a living skin that measurably lowers the facade surface temperature in the island's hot climate.",
|
||||||
|
"The structure has reached the ninth floor, and facade installation has begun on the southern elevation.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "aramesh-business-center",
|
||||||
|
"title": "مرکز تجاری آرامش تهران",
|
||||||
|
"title_en": "Aramesh Business Center Tehran",
|
||||||
|
"location": "تهران، سعادتآباد",
|
||||||
|
"location_en": "Saadatabad, Tehran",
|
||||||
|
"year": "۱۴۰۶ (پیشبینی)",
|
||||||
|
"year_en": "2027 (expected)",
|
||||||
|
"area": "۲۷٬۳۰۰ متر مربع",
|
||||||
|
"area_en": "27,300 m²",
|
||||||
|
"floors": "۱۲ طبقه",
|
||||||
|
"floors_en": "12 floors",
|
||||||
|
"units": "۹۰ دفتر اداری",
|
||||||
|
"units_en": "90 offices",
|
||||||
|
"client": "مشارکت با هلدینگ آرامش",
|
||||||
|
"client_en": "Partnership with Aramesh Holding",
|
||||||
|
"summary": "مجتمع اداری-تجاری با هسته مرکزی شفاف و لابی دوبلکس؛ در مرحله اجرای سفتکاری.",
|
||||||
|
"summary_en": "An office-retail complex with a transparent central core and double-height lobby; currently in structural works.",
|
||||||
|
"body": [],
|
||||||
|
"body_en": [
|
||||||
|
"The space planning was built around flexible offices: every level can be divided into two, three or six independent units.",
|
||||||
|
"Mechanical installations are progressing in parallel with the structural works to shorten the overall timeline.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
BEFORE_AFTER = {
|
||||||
|
"title": "از کانسپت تا خلق واقعیت",
|
||||||
|
"title_en": "From Concept to Reality",
|
||||||
|
"lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل میشود.",
|
||||||
|
"lead_en": "Drag the handle to see how a building facade turns from a raw concept into its modern form.",
|
||||||
|
"before_label": "کانسپت اولیه",
|
||||||
|
"before_label_en": "Initial concept",
|
||||||
|
"after_label": "پروژه تکمیلشده",
|
||||||
|
"after_label_en": "Completed project",
|
||||||
|
}
|
||||||
|
|
||||||
|
TIMELINE = [
|
||||||
|
{"year": "۱۳۸۳", "year_en": "2004", "title": "آغاز مسیر", "title_en": "The Journey Begins", "desc": "تأسیس دفتر معماری آرامش با تیمی چهارنفره و نخستین پروژه بازسازی در تهران.", "desc_en": "Aramesh Architecture Office is founded by a four-person team, taking on its first renovation project in Tehran.", "image": IMAGES[2]},
|
||||||
|
{"year": "۱۳۸۵", "year_en": "2006", "title": "نخستین ساخت مستقل", "title_en": "First Independent Build", "desc": "تحویل اولین ساختمان مسکونی هفتطبقه؛ آغاز همکاری با پیمانکاران سازهای بزرگ.", "desc_en": "Delivery of the first seven-story residential building and the start of partnerships with major structural contractors.", "image": IMAGES[0]},
|
||||||
|
{"year": "۱۳۸۷", "year_en": "2008", "title": "ورود به بازار تجاری", "title_en": "Entering Commercial Market", "desc": "طراحی و اجرای اولین مجتمع تجاری با رویکرد بازدهی سرمایه و جریان مخاطب.", "desc_en": "Design and execution of the first commercial complex, driven by capital returns and visitor flow.", "image": IMAGES[1]},
|
||||||
|
{"year": "۱۳۸۹", "year_en": "2010", "title": "استاندارد بینالمللی", "title_en": "International Standards", "desc": "استقرار نظام کنترل کیفیت و دریافت گواهینامههای مدیریت پروژه.", "desc_en": "A quality-control system is established and project-management certifications are earned.", "image": IMAGES[4]},
|
||||||
|
{"year": "۱۳۹۵", "year_en": "2016", "title": "بلندمرتبهسازی", "title_en": "Going High-Rise", "desc": "آغاز ساخت نخستین برج بیستطبقه شرکت در شمال تهران.", "desc_en": "Construction begins on the company's first twenty-story tower in northern Tehran.", "image": IMAGES[3]},
|
||||||
|
{"year": "۱۴۰۳", "year_en": "Today", "title": "گروه ساختمانی آرامش امروز", "title_en": "Aramesh Construction Group Today", "desc": "پرتفوی ۴۷ پروژه، دفاتر در سه شهر و صندوق سرمایهگذاری ساختمانی اختصاصی.", "desc_en": "A portfolio of 47 projects, offices in three cities and a dedicated real estate investment fund.", "image": IMAGES[0]},
|
||||||
|
]
|
||||||
|
|
||||||
|
WHY = [
|
||||||
|
{"title": "کیفیت بدون مصالحه", "title_en": "Quality Without Compromise", "desc": "انتخاب مصالح و مجریان بر اساس ماندگاری چنددههای، نه سریعترین تحویل.", "desc_en": "Materials and subcontractors are chosen for decades of durability, not the fastest delivery."},
|
||||||
|
{"title": "شفافیت مالی کامل", "title_en": "Full Financial Transparency", "desc": "گزارش ماهانه هزینهها و پیشرفت فیزیکی برای همه شرکای پروژه.", "desc_en": "Monthly reporting of costs and physical progress for every project partner."},
|
||||||
|
{"title": "تحویل بهموقع", "title_en": "On-Time Delivery", "desc": "۹۴٪ پروژههای ما در بازه زمانی قراردادی یا زودتر تحویل داده شدهاند.", "desc_en": "94% of our projects have been delivered on or ahead of the contractual schedule."},
|
||||||
|
{"title": "طراحی اختصاصی", "title_en": "Bespoke Design", "desc": "هیچ نقشهای دو بار اجرا نمیشود؛ هر پروژه هویت معماری خودش را دارد.", "desc_en": "No drawing is ever executed twice — every project has its own architectural identity."},
|
||||||
|
{"title": "خدمات پس از تحویل", "title_en": "After-Sales Service", "desc": "پنج سال پشتیبانی فنی و نگهداری تأسیسات پس از تحویل کلید.", "desc_en": "Five years of technical support and building-systems maintenance after handover."},
|
||||||
|
{"title": "پایداری و انرژی", "title_en": "Sustainability & Energy", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد.", "desc_en": "Envelope and building systems designed to cut energy consumption by up to 35%."},
|
||||||
|
]
|
||||||
|
|
||||||
|
TESTIMONIALS = [
|
||||||
|
{"name": "مهندس سارا کیانی", "name_en": "Sara Kiani", "role": "سرمایهگذار پروژه پارسه", "role_en": "Investor, Parseh Mall project", "quote": "گزارشهای ماهانه گروه ساختمانی آرامش آنقدر دقیق بود که هیچوقت نیاز نشد نگران سرمایهام باشم. پروژه دو ماه زودتر از موعد به بهرهبرداری رسید.", "quote_en": "Aramesh's monthly reports were so precise that I never had to worry about my investment. The project opened two months ahead of schedule."},
|
||||||
|
{"name": "دکتر امیرحسین راد", "name_en": "Dr. Amirhossein Rad", "role": "ساکن برج الهیه", "role_en": "Resident, Elahieh Tower", "quote": "کیفیت اجرا در جزئیاتی دیده میشود که معمولاً کسی به آنها فکر نمیکند؛ از عایق صوتی پنجرهها تا نور غیرمستقیم راهروها.", "quote_en": "The build quality shows in details nobody usually thinks about — from the acoustic insulation of the windows to the indirect lighting in the corridors."},
|
||||||
|
{"name": "مریم دلاور", "name_en": "Maryam Delavar", "role": "مدیر مجموعه ویلاهای دریاکنار", "role_en": "Manager, Daryakenar Villas", "quote": "همکاری با تیم طراحی، تجربهای واقعاً حرفهای بود. ایدههای ما را شنیدند و به زبان معماری ترجمه کردند.", "quote_en": "Working with the design team was a truly professional experience. They listened to our ideas and translated them into architecture."},
|
||||||
|
]
|
||||||
|
|
||||||
|
TEAM = [
|
||||||
|
{"name": "کامران آرامش", "name_en": "Kamran Aramesh", "role": "بنیانگذار و مدیرعامل", "role_en": "Founder & CEO", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه.", "bio_en": "Three decades of high-rise construction experience."},
|
||||||
|
{"name": "نگار شریفی", "name_en": "Negar Sharifi", "role": "مدیر ارشد طراحی", "role_en": "Chief Design Officer", "bio": "معمار ارشد، فارغالتحصیل دانشگاه تهران و میلان.", "bio_en": "Senior architect, graduate of the University of Tehran and Milan."},
|
||||||
|
{"name": "بهزاد مرادی", "name_en": "Behzad Moradi", "role": "مدیر اجرایی پروژهها", "role_en": "Director of Project Execution", "bio": "مسئول کنترل کیفیت و زمانبندی همه کارگاهها.", "bio_en": "Responsible for quality control and scheduling across all sites."},
|
||||||
|
{"name": "الهام نیکآیین", "name_en": "Elham Nikaein", "role": "مدیر سرمایهگذاری", "role_en": "Investment Director", "bio": "طراح ساختارهای مشارکت و تحلیل بازدهی ملکی.", "bio_en": "Designs partnership structures and analyzes property returns."},
|
||||||
|
]
|
||||||
|
|
||||||
|
ARTICLES = [
|
||||||
|
{
|
||||||
|
"slug": "natural-light",
|
||||||
|
"title": "معماری آرام: چرا نور طبیعی گرانترین مصالح ساختمان است؟",
|
||||||
|
"title_en": "Quiet Architecture: Why Is Natural Light the Most Expensive Building Material?",
|
||||||
|
"date": "۲۴ مرداد ۱۴۰۳",
|
||||||
|
"date_en": "August 14, 2024",
|
||||||
|
"category": "معماری",
|
||||||
|
"category_en": "Architecture",
|
||||||
|
"read": "۶ دقیقه",
|
||||||
|
"read_en": "6 min read",
|
||||||
|
"excerpt": "نور طبیعی نهتنها کیفیت زندگی در فضا را تعیین میکند، بلکه مستقیماً بر مصرف انرژی و ارزش بلندمدت ملک اثر میگذارد.",
|
||||||
|
"excerpt_en": "Natural light not only defines the quality of life in a space — it directly affects energy consumption and the long-term value of the property.",
|
||||||
|
"body": [],
|
||||||
|
"body_en": [
|
||||||
|
"In the construction market, a unit's value is usually measured in square meters. But our experience over the past twenty years shows that the quality of light is more decisive than a few extra meters.",
|
||||||
|
"Building orientation, bay depth and floor-to-ceiling height are the three main variables of natural light. The decision on all three is made before the first plan is drawn — and is hardly reversible afterwards.",
|
||||||
|
"In Aramesh Elahieh Tower, rotating the building mass 12 degrees off north added two hours of useful winter light to the units — a cost-free decision with a decades-long effect.",
|
||||||
|
"Our advice for choosing a unit is simple: visit at mid-morning and late afternoon, and judge the quality of light at exactly the hours you are actually home.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "real-estate-portfolio",
|
||||||
|
"title": "راهنمای انتخاب سبد سرمایهگذاری ملکی در بازار امروز",
|
||||||
|
"title_en": "A Guide to Building a Real Estate Portfolio in Today's Market",
|
||||||
|
"date": "۱۰ مرداد ۱۴۰۳",
|
||||||
|
"date_en": "July 31, 2024",
|
||||||
|
"category": "سرمایهگذاری",
|
||||||
|
"category_en": "Investment",
|
||||||
|
"read": "۹ دقیقه",
|
||||||
|
"read_en": "9 min read",
|
||||||
|
"excerpt": "سه معیار کلیدی برای تشکیل سبدی متعادل از دارایی ملکی، بر اساس افق زمانی و سطح ریسک شما.",
|
||||||
|
"excerpt_en": "Three key criteria for building a balanced property portfolio, based on your time horizon and risk appetite.",
|
||||||
|
"body": [],
|
||||||
|
"body_en": [
|
||||||
|
"A balanced property portfolio mixes assets with different liquidity — not all capital in one long-term project, not all in ready units.",
|
||||||
|
"Time horizon is the first criterion. If your plan is shorter than two years, pre-purchasing in a project that has just started is a risky choice.",
|
||||||
|
"The second criterion is reporting transparency. Without monthly reports of physical and financial progress, assessing the state of your capital is practically impossible.",
|
||||||
|
"The third criterion is diversity of use. A residential–commercial mix measurably reduces the volatility of portfolio returns in downturns.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "facade-quality",
|
||||||
|
"title": "پنج نکته فنی که کیفیت نما را پس از ده سال مشخص میکند",
|
||||||
|
"title_en": "Five Technical Details That Reveal Facade Quality After Ten Years",
|
||||||
|
"date": "۲۸ تیر ۱۴۰۳",
|
||||||
|
"date_en": "July 18, 2024",
|
||||||
|
"category": "اجرا",
|
||||||
|
"category_en": "Construction",
|
||||||
|
"read": "۵ دقیقه",
|
||||||
|
"read_en": "5 min read",
|
||||||
|
"excerpt": "نمای ساختمان در سال اول زیبا به نظر میرسد؛ تفاوت کیفیت اجرا در سال دهم آشکار میشود.",
|
||||||
|
"excerpt_en": "A facade looks beautiful in its first year; the difference in workmanship shows in its tenth.",
|
||||||
|
"body": [],
|
||||||
|
"body_en": [
|
||||||
|
"Mechanical fixings of facade stone matter more than the stone itself. Adhesive fixing has a short life in Iran's hot-and-cold climate.",
|
||||||
|
"Contraction joints must be designed on the drawing, not decided on site — their absence is the first cause of horizontal facade cracks.",
|
||||||
|
"Window perimeter waterproofing is the most frequent point of failure. Installing waterproofing tape before the sill goes in removes that risk.",
|
||||||
|
"Hot-dip galvanized sub-structures cost marginally more than painted ones — and last many times longer.",
|
||||||
|
"Finally, take the annual facade inspection seriously: repairing one joint costs next to nothing compared with redoing the whole facade.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "commercial-yield",
|
||||||
|
"title": "چگونه بازدهی یک پروژه تجاری را پیش از ساخت تخمین بزنیم؟",
|
||||||
|
"title_en": "How to Estimate a Commercial Project's Yield Before Building It",
|
||||||
|
"date": "۱۵ تیر ۱۴۰۳",
|
||||||
|
"date_en": "July 5, 2024",
|
||||||
|
"category": "سرمایهگذاری",
|
||||||
|
"category_en": "Investment",
|
||||||
|
"read": "۷ دقیقه",
|
||||||
|
"read_en": "7 min read",
|
||||||
|
"excerpt": "مدل سادهای برای برآورد بازدهی، بر پایه جریان مخاطب، ترکیب واحدها و نرخ اجاره منطقه.",
|
||||||
|
"excerpt_en": "A simple yield-estimation model based on visitor flow, unit mix and local rental rates.",
|
||||||
|
"body": [],
|
||||||
|
"body_en": [
|
||||||
|
"Estimating commercial yield starts with three numbers: daily visitor flow, the rental rate per square meter in the area, and the net-to-gross floor ratio.",
|
||||||
|
"A common mistake is ignoring the upper levels. Without a well-designed vertical path, levels three and above usually fail to reach even half of the ground-floor rate.",
|
||||||
|
"Finally, write the conservative scenario too — if the project still makes sense in the pessimistic case, the investment is a rational one.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "facade-materials",
|
||||||
|
"title": "مصالح نما: مقایسه سنگ، کامپوزیت و ترموود در اقلیم ایران",
|
||||||
|
"title_en": "Facade Materials: Stone, Composite and Thermowood in Iran's Climate",
|
||||||
|
"date": "۲ تیر ۱۴۰۳",
|
||||||
|
"date_en": "June 22, 2024",
|
||||||
|
"category": "اجرا",
|
||||||
|
"category_en": "Construction",
|
||||||
|
"read": "۸ دقیقه",
|
||||||
|
"read_en": "8 min read",
|
||||||
|
"excerpt": "هر مصالح نما در اقلیم متفاوتی درست کار میکند؛ انتخاب اشتباه، هزینه نگهداری را چند برابر میکند.",
|
||||||
|
"excerpt_en": "Every facade material works in the right climate; the wrong choice multiplies maintenance costs.",
|
||||||
|
"body": [],
|
||||||
|
"body_en": [
|
||||||
|
"In a dry climate, stone has the best durability — but its weight demands a precise sub-structure and structural calculations.",
|
||||||
|
"Composite is the fast, light choice, provided you pick a fire-rated grade and account for thermal expansion along the sheet.",
|
||||||
|
"In the humid northern climate, thermowood is the best aesthetic answer — with a twice-yearly maintenance schedule.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "renovate-or-rebuild",
|
||||||
|
"title": "بازسازی یا نوسازی؟ معیارهای تصمیمگیری برای مالکان",
|
||||||
|
"title_en": "Renovate or Rebuild? Decision Criteria for Property Owners",
|
||||||
|
"date": "۲۰ خرداد ۱۴۰۳",
|
||||||
|
"date_en": "June 9, 2024",
|
||||||
|
"category": "معماری",
|
||||||
|
"category_en": "Architecture",
|
||||||
|
"read": "۶ دقیقه",
|
||||||
|
"read_en": "6 min read",
|
||||||
|
"excerpt": "اگر عمر سازه و ضوابط شهری اجازه دهند، بازسازی میتواند بازدهی بالاتری از تخریب کامل داشته باشد.",
|
||||||
|
"excerpt_en": "If the structure's age and city regulations allow, renovation can outperform full demolition and rebuild.",
|
||||||
|
"body": [],
|
||||||
|
"body_en": [
|
||||||
|
"First the structure is assessed: if seismic retrofitting costs more than 40% of a new build, rebuilding is the more rational option.",
|
||||||
|
"The second criterion is today's urban regulations. Sometimes demolition grants you less permissible floor area — which changes the decision entirely.",
|
||||||
|
"In renovation, the greatest added value comes from three places: the facade, the entrance and the re-planning of the interior.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
FAQS = [
|
||||||
|
{"question": "حداقل سرمایه برای مشارکت در پروژههای گروه ساختمانی آرامش چقدر است؟", "question_en": "What is the minimum capital to join Aramesh Construction Group projects?", "answer": "ساختار مشارکت هر پروژه متفاوت است، اما معمولاً واحدهای سرمایهگذاری از سطح مشخصی آغاز میشوند و جزئیات دقیق در جلسه ارزیابی اولیه ارائه میشود.", "answer_en": "Each project's partnership structure is different, but investment units usually start from a defined level, and the exact details are presented in the initial assessment meeting."},
|
||||||
|
{"question": "مدت زمان معمول ساخت یک برج مسکونی چقدر است؟", "question_en": "How long does it take to build a residential tower?", "answer": "بسته به تعداد طبقات و شرایط زمین، میانگین اجرای یک برج مسکونی بین ۲۸ تا ۴۲ ماه از زمان صدور پروانه تا تحویل کلید است.", "answer_en": "Depending on the number of floors and site conditions, a residential tower takes on average 28 to 42 months from permit issuance to key handover."},
|
||||||
|
{"question": "آیا امکان تغییر نقشه داخلی واحد وجود دارد؟", "question_en": "Can the unit's interior layout be customized?", "answer": "بله. تا پیش از پایان مرحله سفتکاری، امکان شخصیسازی پلان داخلی با هماهنگی تیم طراحی و تأیید سازه فراهم است.", "answer_en": "Yes. Until the structural phase is complete, the interior plan can be customized in coordination with the design team and structural approval."},
|
||||||
|
{"question": "خدمات پس از تحویل شامل چه مواردی میشود؟", "question_en": "What does the after-sales service include?", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دورهای سالانه و تضمین اجرای نما و عایقکاری.", "answer_en": "Five years of technical support for mechanical and electrical systems, an annual inspection, and warranty on facade work and insulation."},
|
||||||
|
{"question": "پروژهها در چه شهرهایی اجرا میشوند؟", "question_en": "In which cities do you build?", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژهای نیز در جزیره کیش در دست ساخت است.", "answer_en": "Tehran, Isfahan and the northern coasts are currently our main regions, and a project is also under construction on Kish Island."},
|
||||||
|
]
|
||||||
|
|
||||||
|
NAV_ITEMS = [
|
||||||
|
{"label": "صفحه اصلی", "label_en": "Home", "href": "/"},
|
||||||
|
{"label": "درباره ما", "label_en": "About", "href": "/about"},
|
||||||
|
{"label": "خدمات", "label_en": "Services", "href": "/services"},
|
||||||
|
{"label": "پروژهها", "label_en": "Projects", "href": "/projects"},
|
||||||
|
{"label": "سرمایهگذاری", "label_en": "Investment", "href": "/investment"},
|
||||||
|
{"label": "وبلاگ", "label_en": "Blog", "href": "/blog"},
|
||||||
|
{"label": "سوالات متداول", "label_en": "FAQ", "href": "/faq"},
|
||||||
|
{"label": "تماس با ما", "label_en": "Contact", "href": "/contact"},
|
||||||
|
]
|
||||||
|
|
||||||
|
HERO = {
|
||||||
|
"kicker": "از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال",
|
||||||
|
"kicker_en": "Since 2004 — Tehran, Isfahan, Northern Coasts",
|
||||||
|
"title": "ساختمانی که آرامش را معماری میکند",
|
||||||
|
"title_en": "A building that architects tranquility",
|
||||||
|
"title_highlight": "معماری",
|
||||||
|
"title_highlight_en": "architects",
|
||||||
|
"subtitle": "طراحی، ساخت و سرمایهگذاری در پروژههای مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده میشود و در سالها دوام میآورد.",
|
||||||
|
"subtitle_en": "Design, construction and investment in luxury residential and commercial projects — to a standard you can see in the details and trust for years.",
|
||||||
|
"badge_value": "۴۷",
|
||||||
|
"badge_value_en": "47",
|
||||||
|
"badge_label": "پروژه تحویلشده در بیستویک سال فعالیت حرفهای",
|
||||||
|
"badge_label_en": "Projects delivered in twenty-one years of professional work",
|
||||||
|
"cta_primary_label": "مشاهده پروژهها",
|
||||||
|
"cta_primary_label_en": "View Projects",
|
||||||
|
"cta_secondary_label": "مشاوره سرمایهگذاری",
|
||||||
|
"cta_secondary_label_en": "Investment Advisory",
|
||||||
|
"scroll_label": "اسکرول کنید",
|
||||||
|
"scroll_label_en": "Scroll",
|
||||||
|
}
|
||||||
|
|
||||||
|
STORY = {
|
||||||
|
"title": "درباره ی ما",
|
||||||
|
"title_en": "About Us",
|
||||||
|
"paragraphs": [],
|
||||||
|
"paragraphs_en": [
|
||||||
|
"Aramesh Construction Group began as a small architecture office and is today one of Iran's recognized names in luxury construction. Through all these years one principle has not changed: a building must be made for living, not for a quick sale.",
|
||||||
|
"Every project starts with a study of the land, light, climate and the residents' way of life; then the design, structural and execution teams carry it through a single process to key handover.",
|
||||||
|
],
|
||||||
|
"quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی میسازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»",
|
||||||
|
"quote_en": "\u201cA building, for us, is not just brick and concrete; it is a commitment to the quality of life of its residents. We build spaces that keep their value and identity as time passes.\u201d",
|
||||||
|
"quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
|
||||||
|
"quote_author_en": "Kamran Aramesh — CEO, Aramesh Construction Group",
|
||||||
|
"stat1_value": "۹۴٪",
|
||||||
|
"stat1_value_en": "94%",
|
||||||
|
"stat1_label": "تحویل بهموقع پروژهها",
|
||||||
|
"stat1_label_en": "On-time project delivery",
|
||||||
|
"stat2_value": "۵ سال",
|
||||||
|
"stat2_value_en": "5 years",
|
||||||
|
"stat2_label": "پشتیبانی پس از تحویل",
|
||||||
|
"stat2_label_en": "Post-handover support",
|
||||||
|
"link_label": "داستان کامل ما",
|
||||||
|
"link_label_en": "Our full story",
|
||||||
|
"bento": [],
|
||||||
|
"bento_en": [
|
||||||
|
{"src": IMAGES[0], "alt": "Facade of an Aramesh residential tower in Tehran"},
|
||||||
|
{"src": IMAGES[1], "alt": "Central atrium of Parseh Shopping Mall"},
|
||||||
|
{"src": IMAGES[2], "alt": "Villas built on the northern coasts"},
|
||||||
|
{"src": IMAGES[3], "alt": "Night view of the Niavaran Twin Towers"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION_HEADINGS = [
|
||||||
|
{"key": "services", "title_en": "Services", "lead_en": "From the first line on paper to key handover, capital management and landscape design — every stage under one roof and one quality-control system."},
|
||||||
|
{"key": "featured_projects", "title_en": "Featured Projects", "lead_en": "The work that says the most about our standard — both the delivered projects and the ones under construction today.", "cta_label_en": "All Projects"},
|
||||||
|
{"key": "gallery", "title_en": "Gallery", "lead_en": "Frames from the sites and delivered projects of Aramesh Construction Group."},
|
||||||
|
{"key": "timeline", "title_en": "Our Path", "lead_en": "From a four-person office to 47 projects. Walk through the timeline of our work."},
|
||||||
|
{"key": "why_us", "title_en": "Why Aramesh Construction Group", "lead_en": "Six reasons clients and investors come back to us a second time."},
|
||||||
|
{"key": "testimonials", "title_en": "Client Testimonials"},
|
||||||
|
{"key": "team", "title_en": "Our Team", "lead_en": "The people who make each project's key decisions — and answer for them."},
|
||||||
|
{"key": "articles", "label_en": "Blog", "title_en": "Latest Articles", "lead_en": "Notes from the architecture, execution and investment teams of Aramesh Construction Group on what's happening on site and in the market.", "cta_label_en": "All Articles"},
|
||||||
|
{"key": "faq", "title_en": "Frequently Asked Questions", "lead_en": "Short answers to the questions investors and buyers ask most."},
|
||||||
|
{"key": "investment_models", "title_en": "Partnership Models", "lead_en": "Depending on your capital, time horizon and risk appetite, one of these three paths is right for you."},
|
||||||
|
{"key": "process", "title_en": "How We Work", "lead_en": "Four transparent steps, from the first meeting to key handover."},
|
||||||
|
]
|
||||||
|
|
||||||
|
PAGE_HEROES = [
|
||||||
|
{"key": "about", "title_en": "About Us", "lead_en": "Aramesh Construction Group is a team of architects, structural engineers and project managers working in luxury construction since 2004."},
|
||||||
|
{"key": "services", "title_en": "Services", "lead_en": "Eight specialized fields covering a building project's full cycle, from idea to operation."},
|
||||||
|
{"key": "projects", "title_en": "Projects", "lead_en": "A collection of residential towers, commercial centers and private villas — from delivered projects to today's active sites."},
|
||||||
|
{"key": "investment", "title_en": "Investment", "lead_en": "Three clear paths into the luxury construction market, with transparent financial structures and regular reporting."},
|
||||||
|
{"key": "blog", "title_en": "Blog", "lead_en": "Notes from the architecture, execution and investment teams of Aramesh Construction Group on what's happening on site and in the market."},
|
||||||
|
{"key": "faq", "title_en": "Frequently Asked Questions", "lead_en": "Before you call, the answer to your question might be right here. If not, we would be glad to talk directly."},
|
||||||
|
{"key": "contact", "title_en": "Contact Us", "lead_en": "For construction consultancy, investment or a project visit, our teams in three cities are ready to respond."},
|
||||||
|
]
|
||||||
|
|
||||||
|
INVESTMENT_MODELS = [
|
||||||
|
{
|
||||||
|
"title": "مشارکت در ساخت",
|
||||||
|
"title_en": "Joint Construction Partnership",
|
||||||
|
"desc": "مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک میشوند؛ اجرا، مجوزها و فروش بر عهده ماست.",
|
||||||
|
"desc_en": "The landowner and Aramesh share the project's profit; execution, permits and sales are on us.",
|
||||||
|
"points": [],
|
||||||
|
"points_en": ["Free land assessment", "Transparent contract with performance guarantee", "Monthly progress reporting"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "صندوق پروژهمحور",
|
||||||
|
"title_en": "Project-Based Fund",
|
||||||
|
"desc": "سرمایهگذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبهشده در پایان دوره.",
|
||||||
|
"desc_en": "Investment in a specific project with purchasable units and a calculated yield at the end of the term.",
|
||||||
|
"points": [],
|
||||||
|
"points_en": ["Minimum 24-month term", "Quarterly valuation reports", "Investment units are transferable"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "پیشخرید واحد",
|
||||||
|
"title_en": "Pre-Purchase",
|
||||||
|
"desc": "خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحلهای.",
|
||||||
|
"desc_en": "Buying a residential or commercial unit in the early stages of construction, at a pre-completion price with staged payments.",
|
||||||
|
"points": [],
|
||||||
|
"points_en": ["Payments staged to match progress", "Interior layout customization", "Delivery-time guarantee"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
PROCESS_STEPS = [
|
||||||
|
{"step": "۰۱", "step_en": "01", "title": "مطالعه و امکانسنجی", "title_en": "Study & Feasibility", "desc": "بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه.", "desc_en": "Site review, urban regulations and an economic yield analysis of the project."},
|
||||||
|
{"step": "۰۲", "step_en": "02", "title": "طراحی و فاز اجرایی", "title_en": "Design & Construction Drawings", "desc": "کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشههای اجرایی.", "desc_en": "Architectural concept, structural and MEP design, and construction documentation."},
|
||||||
|
{"step": "۰۳", "step_en": "03", "title": "اجرا و کنترل کیفیت", "title_en": "Execution & Quality Control", "desc": "ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه.", "desc_en": "Construction with continuous supervision, monthly progress reports and cost control."},
|
||||||
|
{"step": "۰۴", "step_en": "04", "title": "تحویل و پشتیبانی", "title_en": "Handover & Support", "desc": "تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی.", "desc_en": "Key handover, operating walkthrough and five years of technical support."},
|
||||||
|
]
|
||||||
|
|
||||||
|
OFFICES = [
|
||||||
|
{"city": "تهران", "city_en": "Tehran", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "address_en": "Valiasr St., Aramesh Tower, 18th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸", "hours_en": "Saturday–Wednesday, 9 AM–6 PM"},
|
||||||
|
{"city": "اصفهان", "city_en": "Isfahan", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "address_en": "Chaharbagh Bala St., Parseh Complex, 6th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷", "hours_en": "Saturday–Wednesday, 9 AM–5 PM"},
|
||||||
|
{"city": "نوشهر", "city_en": "Nowshahr", "address": "بلوار دریا، دفتر پروژه دریاکنار", "address_en": "Darya Blvd., Daryakenar Project Office", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶", "hours_en": "Saturday–Thursday, 10 AM–4 PM"},
|
||||||
|
]
|
||||||
|
|
||||||
|
GALLERY_IMAGES = [
|
||||||
|
{"src": IMAGES[0], "alt_en": "Aramesh Construction Group project, No. 1"},
|
||||||
|
{"src": IMAGES[1], "alt_en": "Aramesh Construction Group project, No. 2"},
|
||||||
|
{"src": IMAGES[2], "alt_en": "Aramesh Construction Group project, No. 3"},
|
||||||
|
{"src": IMAGES[3], "alt_en": "Aramesh Construction Group project, No. 4"},
|
||||||
|
{"src": IMAGES[4], "alt_en": "Aramesh Construction Group project, No. 5"},
|
||||||
|
{"src": IMAGES[1], "alt_en": "Aramesh Construction Group project, No. 6"},
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_LABELS = [
|
||||||
|
{"key": "done", "label_en": "Completed"},
|
||||||
|
{"key": "progress", "label_en": "In Progress"},
|
||||||
|
]
|
||||||
|
|
||||||
|
CONTACT_SECTION = {
|
||||||
|
"label": "تماس با ما",
|
||||||
|
"label_en": "Contact Us",
|
||||||
|
"title": "پروژهتان را با ما در میان بگذارید",
|
||||||
|
"title_en": "Tell Us About Your Project",
|
||||||
|
"lead": "شمارهتان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس میگیرد.",
|
||||||
|
"lead_en": "Leave your number — our expert will call you within one business day for a free consultation session.",
|
||||||
|
"address_label": "دفتر مرکزی",
|
||||||
|
"address_label_en": "Head Office",
|
||||||
|
"phone_label": "تلفن",
|
||||||
|
"phone_label_en": "Phone",
|
||||||
|
"email_label": "ایمیل",
|
||||||
|
"email_label_en": "Email",
|
||||||
|
"name_label": "نام",
|
||||||
|
"name_label_en": "Name",
|
||||||
|
"name_placeholder": "نام و نام خانوادگی",
|
||||||
|
"name_placeholder_en": "Full name",
|
||||||
|
"phone_field_label": "شماره تلفن",
|
||||||
|
"phone_field_label_en": "Phone number",
|
||||||
|
"note_label": "توضیحات",
|
||||||
|
"note_label_en": "Details",
|
||||||
|
"note_optional_label": "(اختیاری)",
|
||||||
|
"note_optional_label_en": "(optional)",
|
||||||
|
"note_placeholder": "درباره پروژه یا درخواست خود بنویسید…",
|
||||||
|
"note_placeholder_en": "Tell us about your project or request…",
|
||||||
|
"submit_label": "ارسال درخواست مشاوره",
|
||||||
|
"submit_label_en": "Request a Consultation",
|
||||||
|
"sending_label": "در حال ارسال…",
|
||||||
|
"sending_label_en": "Sending…",
|
||||||
|
"success_title": "درخواست شما ثبت شد",
|
||||||
|
"success_title_en": "Your Request Was Received",
|
||||||
|
"success_text": "از اعتماد شما سپاسگزاریم. کارشناسان ما بهزودی با شما تماس میگیرند.",
|
||||||
|
"success_text_en": "Thank you for your trust. Our experts will contact you shortly.",
|
||||||
|
"success_button_label": "ثبت درخواست جدید",
|
||||||
|
"success_button_label_en": "Submit Another Request",
|
||||||
|
}
|
||||||
|
|
||||||
|
NEWSLETTER = {
|
||||||
|
"title": "خبرنامه گروه ساختمانی آرامش",
|
||||||
|
"title_en": "Aramesh Construction Group Newsletter",
|
||||||
|
"lead": "هر ماه یک ایمیل: پروژههای جدید، فرصتهای سرمایهگذاری و یادداشتهای تیم معماری.",
|
||||||
|
"lead_en": "One email a month: new projects, investment opportunities and notes from the architecture team.",
|
||||||
|
"placeholder": "نشانی ایمیل شما",
|
||||||
|
"placeholder_en": "Your email address",
|
||||||
|
"button_label": "عضویت",
|
||||||
|
"button_label_en": "Subscribe",
|
||||||
|
"sending_label": "در حال ارسال…",
|
||||||
|
"sending_label_en": "Sending…",
|
||||||
|
"success_text": "عضویت شما ثبت شد.",
|
||||||
|
"success_text_en": "Your subscription has been registered.",
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOTER = {
|
||||||
|
"about_text": "گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمانهایی میسازد که قرار است چند نسل در آنها زندگی کنند؛ معماری آرام، اجرای دقیق و سرمایهگذاری شفاف.",
|
||||||
|
"about_text_en": "Since 2004, Aramesh Construction Group has been building for generations to come: calm architecture, precise execution and transparent investment.",
|
||||||
|
"pages_heading": "صفحات",
|
||||||
|
"pages_heading_en": "Pages",
|
||||||
|
"services_heading": "خدمات",
|
||||||
|
"services_heading_en": "Services",
|
||||||
|
"contact_heading": "ارتباط با ما",
|
||||||
|
"contact_heading_en": "Contact",
|
||||||
|
"copyright": "© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.",
|
||||||
|
"copyright_en": "© 2025 Aramesh Construction Group — All rights reserved.",
|
||||||
|
"tagline": "طراحی و ساخت با دقتِ معماری",
|
||||||
|
"tagline_en": "Designed and built with architectural precision",
|
||||||
|
"socials_en": [
|
||||||
|
{"label": "Instagram", "href": "#", "icon": "instagram"},
|
||||||
|
{"label": "LinkedIn", "href": "#", "icon": "linkedin"},
|
||||||
|
{"label": "Telegram", "href": "#", "icon": "telegram"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SEO_META = [
|
||||||
|
{"page_key": "home", "title_en": "Aramesh Construction Group | Luxury Residential & Commercial Buildings", "description_en": "Design, construction and investment in luxury residential and commercial projects in Iran — 21 years of experience, 47 delivered projects."},
|
||||||
|
{"page_key": "about", "title_en": "About Us | Aramesh Construction Group", "description_en": "Architects, structural engineers and project managers in luxury construction since 2004."},
|
||||||
|
{"page_key": "services", "title_en": "Services | Aramesh Construction Group", "description_en": "Architectural design, high-rise construction, commercial complexes, villas, renovation and project management."},
|
||||||
|
{"page_key": "projects", "title_en": "Projects | Aramesh Construction Group", "description_en": "Residential towers, commercial centers and private villas — delivered projects and active sites."},
|
||||||
|
{"page_key": "investment", "title_en": "Investment | Aramesh Construction Group", "description_en": "Three transparent paths into the luxury construction market: joint construction, project-based funds and pre-purchase."},
|
||||||
|
{"page_key": "blog", "title_en": "Blog | Aramesh Construction Group", "description_en": "Articles on architecture, construction and real estate investment from the Aramesh team."},
|
||||||
|
{"page_key": "faq", "title_en": "FAQ | Aramesh Construction Group", "description_en": "Short answers to the most frequent questions from investors and buyers."},
|
||||||
|
{"page_key": "contact", "title_en": "Contact Us | Aramesh Construction Group", "description_en": "Get in touch for construction consultancy, investment or a project visit — offices in Tehran, Isfahan and Nowshahr."},
|
||||||
|
]
|
||||||
+209
-26
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
فرانتاند یک SPA است؛ خزندههای اجتماعی (تلگرام، واتساپ و...) جاوااسکریپت اجرا نمیکنند.
|
فرانتاند یک SPA است؛ خزندههای اجتماعی (تلگرام، واتساپ و...) جاوااسکریپت اجرا نمیکنند.
|
||||||
این ماژول همان index.html بیلد فرانتاند را میخواند و پیش از ارسال، تگهای
|
این ماژول همان index.html بیلد فرانتاند را میخواند و پیش از ارسال، تگهای
|
||||||
title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
||||||
بازدیدکنندههای عادی همان SPA همیشگی را میبینند و خزندهها متای درست را میخوانند.
|
بازدیدکنندههای عادی همان SPA همیشگی را میبینند و خزندهها متای درست را میخوانند.
|
||||||
|
|
||||||
|
دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو میشود؛
|
||||||
|
هر دو در sitemap با hreflang متقابل و در head هر صفحه با link alternate معرفی میشوند.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import html
|
import html
|
||||||
@@ -22,6 +25,10 @@ from .database import get_db
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"]
|
STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"]
|
||||||
|
# صفحاتی که متای آنها از seo_meta خوانده میشود — کلید همان page_key است
|
||||||
|
META_KEYS = ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
|
||||||
|
FALLBACK_FA_BRAND = "گروه ساختمانی آرامش"
|
||||||
|
FALLBACK_EN_BRAND = "Aramesh Construction Group"
|
||||||
|
|
||||||
|
|
||||||
def _absolute(path: str) -> str:
|
def _absolute(path: str) -> str:
|
||||||
@@ -33,6 +40,29 @@ def _absolute(path: str) -> str:
|
|||||||
return f"{site}{path}" if site else path
|
return f"{site}{path}" if site else path
|
||||||
|
|
||||||
|
|
||||||
|
def _en_path(path: str) -> str:
|
||||||
|
"""مسیر انگلیسی معادل یک مسیر فارسی — / و /en یکدیگرند."""
|
||||||
|
return "/en" if path == "/" else f"/en{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fa_path(path: str) -> str:
|
||||||
|
"""مسیر فارسی معادل یک مسیر انگلیسی."""
|
||||||
|
if path == "/en":
|
||||||
|
return "/"
|
||||||
|
return path[3:] if path.startswith("/en/") else path
|
||||||
|
|
||||||
|
|
||||||
|
def _alternates(path: str, lang: str) -> str:
|
||||||
|
"""تگهای hreflang متقابل فارسی/انگلیسی برای head."""
|
||||||
|
fa = path if lang == "fa" else _fa_path(path)
|
||||||
|
en = _en_path(path) if lang == "fa" else path
|
||||||
|
return (
|
||||||
|
f'<link rel="alternate" hreflang="fa" href="{html.escape(_absolute(fa))}" />\n'
|
||||||
|
f' <link rel="alternate" hreflang="en" href="{html.escape(_absolute(en))}" />\n'
|
||||||
|
f' <link rel="alternate" hreflang="x-default" href="{html.escape(_absolute(fa))}" />'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _read_index() -> str | None:
|
def _read_index() -> str | None:
|
||||||
try:
|
try:
|
||||||
return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8")
|
return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8")
|
||||||
@@ -64,17 +94,19 @@ def _shell(
|
|||||||
title: str,
|
title: str,
|
||||||
description: str,
|
description: str,
|
||||||
path: str,
|
path: str,
|
||||||
|
lang: str = "fa",
|
||||||
og_image: str = "",
|
og_image: str = "",
|
||||||
og_type: str = "website",
|
og_type: str = "website",
|
||||||
jsonld: dict | None = None,
|
jsonld: dict | None = None,
|
||||||
noscript: str = "",
|
noscript: str = "",
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""index.html فرانتاند با متای تزریقشده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
|
"""index.html فرانتاند با متای تزریقشده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
|
||||||
title = title.strip() or "گروه ساختمانی آرامش"
|
title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND)
|
||||||
description = description.strip()
|
description = description.strip()
|
||||||
canonical = _absolute(path)
|
canonical = _absolute(path)
|
||||||
image = _absolute(og_image)
|
image = _absolute(og_image)
|
||||||
esc = html.escape
|
esc = html.escape
|
||||||
|
alternates = _alternates(path, lang)
|
||||||
|
|
||||||
# تگ title جای تگ موجود index.html را میگیرد و در بلوک تکرار نمیشود
|
# تگ title جای تگ موجود index.html را میگیرد و در بلوک تکرار نمیشود
|
||||||
tags = [
|
tags = [
|
||||||
@@ -83,6 +115,7 @@ def _shell(
|
|||||||
f'<meta property="og:description" content="{esc(description)}" />',
|
f'<meta property="og:description" content="{esc(description)}" />',
|
||||||
f'<meta property="og:type" content="{esc(og_type)}" />',
|
f'<meta property="og:type" content="{esc(og_type)}" />',
|
||||||
f'<meta property="og:url" content="{esc(canonical)}" />',
|
f'<meta property="og:url" content="{esc(canonical)}" />',
|
||||||
|
f'<meta property="og:locale" content="{"en_US" if lang == "en" else "fa_IR"}" />',
|
||||||
f'<meta name="twitter:card" content="summary_large_image" />',
|
f'<meta name="twitter:card" content="summary_large_image" />',
|
||||||
]
|
]
|
||||||
if image:
|
if image:
|
||||||
@@ -95,28 +128,46 @@ def _shell(
|
|||||||
raw = _read_index()
|
raw = _read_index()
|
||||||
if raw is None:
|
if raw is None:
|
||||||
# بیلد فرانتاند سوار نشده — صفحه حداقلی با خودِ متا تا خزندهها دست خالی نمانند
|
# بیلد فرانتاند سوار نشده — صفحه حداقلی با خودِ متا تا خزندهها دست خالی نمانند
|
||||||
|
lang_attr = 'lang="en" dir="ltr"' if lang == "en" else 'lang="fa" dir="rtl"'
|
||||||
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
|
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
f'<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">'
|
f'<!doctype html><html {lang_attr}><head><meta charset="utf-8">'
|
||||||
f"<title>{esc(title)}</title>{block}</head><body>{body}</body></html>"
|
f"<title>{esc(title)}</title>{alternates}{block}</head><body>{body}</body></html>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if lang == "en":
|
||||||
|
# پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت و زبان سند عوض میشود
|
||||||
|
raw = re.sub(r'<html[^>]*>', '<html lang="en" dir="ltr">', raw, count=1)
|
||||||
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
|
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
|
||||||
raw = raw.replace("</head>", f" {block}\n </head>", 1)
|
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1)
|
||||||
if noscript:
|
if noscript:
|
||||||
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
|
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
|
||||||
return HTMLResponse(raw)
|
return HTMLResponse(raw)
|
||||||
|
|
||||||
|
|
||||||
def _brand_name(db: Session) -> str:
|
def _brand_name(db: Session, lang: str = "fa") -> str:
|
||||||
brand = db.query(models.Brand).first()
|
brand = db.query(models.Brand).first()
|
||||||
return brand.name if brand else "گروه ساختمانی آرامش"
|
if brand is None:
|
||||||
|
return FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND
|
||||||
|
if lang == "en":
|
||||||
|
return brand.name_en or brand.name or FALLBACK_EN_BRAND
|
||||||
|
return brand.name or FALLBACK_FA_BRAND
|
||||||
|
|
||||||
|
|
||||||
def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None:
|
def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None:
|
||||||
return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first()
|
return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]:
|
||||||
|
"""عنوان و توضیح سئوی یک صفحه به زبان درخواستی — با برگشت به فارسی."""
|
||||||
|
meta = _page_meta(db, page_key)
|
||||||
|
if meta is None:
|
||||||
|
return "", ""
|
||||||
|
if lang == "en":
|
||||||
|
return meta.title_en or meta.title or "", meta.description_en or meta.description or ""
|
||||||
|
return meta.title or "", meta.description or ""
|
||||||
|
|
||||||
|
|
||||||
# ---------- robots.txt و sitemap.xml ----------
|
# ---------- robots.txt و sitemap.xml ----------
|
||||||
|
|
||||||
|
|
||||||
@@ -128,26 +179,35 @@ def robots(db: Session = Depends(get_db)):
|
|||||||
return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8")
|
return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sitemap_entry(loc: str, fa: str, en: str) -> str:
|
||||||
|
esc = html.escape
|
||||||
|
return (
|
||||||
|
f" <url><loc>{esc(_absolute(loc))}</loc>\n"
|
||||||
|
f' <xhtml:link rel="alternate" hreflang="fa" href="{esc(_absolute(fa))}" />\n'
|
||||||
|
f' <xhtml:link rel="alternate" hreflang="en" href="{esc(_absolute(en))}" />\n'
|
||||||
|
f" </url>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sitemap.xml", response_class=Response)
|
@router.get("/sitemap.xml", response_class=Response)
|
||||||
def sitemap(db: Session = Depends(get_db)):
|
def sitemap(db: Session = Depends(get_db)):
|
||||||
urls = list(STATIC_PATHS)
|
fa_urls = list(STATIC_PATHS)
|
||||||
urls += [
|
fa_urls += [
|
||||||
f"/blog/{a.slug}"
|
f"/blog/{a.slug}"
|
||||||
for a in db.query(models.Article)
|
for a in db.query(models.Article)
|
||||||
.filter(models.Article.status == "published")
|
.filter(models.Article.status == "published")
|
||||||
.order_by(models.Article.position, models.Article.id)
|
.order_by(models.Article.position, models.Article.id)
|
||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
urls += [
|
fa_urls += [
|
||||||
f"/projects/{p.slug}"
|
f"/projects/{p.slug}"
|
||||||
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
||||||
]
|
]
|
||||||
items = "\n".join(
|
items = "\n".join(_sitemap_entry(u, u, _en_path(u)) for u in fa_urls)
|
||||||
f" <url><loc>{html.escape(_absolute(u))}</loc></url>" for u in urls
|
|
||||||
)
|
|
||||||
xml = (
|
xml = (
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" '
|
||||||
|
'xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'
|
||||||
f"{items}\n</urlset>\n"
|
f"{items}\n</urlset>\n"
|
||||||
)
|
)
|
||||||
return Response(content=xml, media_type="application/xml; charset=utf-8")
|
return Response(content=xml, media_type="application/xml; charset=utf-8")
|
||||||
@@ -167,6 +227,13 @@ def blog_list(db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _article_pair(article: models.Article, lang: str) -> tuple[str, str]:
|
||||||
|
"""عنوان و چکیده مقاله به زبان درخواستی — با برگشت به فارسی."""
|
||||||
|
if lang == "en":
|
||||||
|
return article.title_en or article.title, article.excerpt_en or article.excerpt
|
||||||
|
return article.title, article.excerpt
|
||||||
|
|
||||||
|
|
||||||
@router.get("/blog/{slug}", response_class=HTMLResponse)
|
@router.get("/blog/{slug}", response_class=HTMLResponse)
|
||||||
def blog_detail(slug: str, db: Session = Depends(get_db)):
|
def blog_detail(slug: str, db: Session = Depends(get_db)):
|
||||||
article = (
|
article = (
|
||||||
@@ -179,22 +246,23 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}")
|
return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}")
|
||||||
|
|
||||||
brand = _brand_name(db)
|
brand = _brand_name(db)
|
||||||
|
title, excerpt = _article_pair(article, "fa")
|
||||||
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
||||||
jsonld = {
|
jsonld = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
"headline": article.title,
|
"headline": title,
|
||||||
"description": article.excerpt,
|
"description": excerpt,
|
||||||
"image": _absolute(article.image),
|
"image": _absolute(article.image),
|
||||||
"inLanguage": "fa",
|
"inLanguage": "fa",
|
||||||
"author": {"@type": "Organization", "name": brand},
|
"author": {"@type": "Organization", "name": brand},
|
||||||
"publisher": {"@type": "Organization", "name": brand},
|
"publisher": {"@type": "Organization", "name": brand},
|
||||||
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
||||||
}
|
}
|
||||||
noscript = f"<h1>{html.escape(article.title)}</h1><p>{html.escape(first_text)}</p>"
|
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||||
return _shell(
|
return _shell(
|
||||||
title=f"{article.title} | {brand}",
|
title=f"{title} | {brand}",
|
||||||
description=article.excerpt,
|
description=excerpt,
|
||||||
path=f"/blog/{article.slug}",
|
path=f"/blog/{article.slug}",
|
||||||
og_image=article.image,
|
og_image=article.image,
|
||||||
og_type="article",
|
og_type="article",
|
||||||
@@ -223,17 +291,132 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}")
|
return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}")
|
||||||
|
|
||||||
brand = _brand_name(db)
|
brand = _brand_name(db)
|
||||||
return _shell(
|
jsonld = {
|
||||||
title=f"{project.title} | {brand}",
|
|
||||||
description=project.summary,
|
|
||||||
path=f"/projects/{project.slug}",
|
|
||||||
og_image=project.image,
|
|
||||||
jsonld={
|
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
"name": project.title,
|
"name": project.title,
|
||||||
"description": project.summary,
|
"description": project.summary,
|
||||||
"image": _absolute(project.image),
|
"image": _absolute(project.image),
|
||||||
"inLanguage": "fa",
|
"inLanguage": "fa",
|
||||||
},
|
}
|
||||||
|
return _shell(
|
||||||
|
title=f"{project.title} | {brand}",
|
||||||
|
description=project.summary,
|
||||||
|
path=f"/projects/{project.slug}",
|
||||||
|
og_image=project.image,
|
||||||
|
jsonld=jsonld,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- صفحات انگلیسی (/en/...) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
||||||
|
brand = _brand_name(db, "en")
|
||||||
|
title, excerpt = _article_pair(article, "en")
|
||||||
|
body = article.body_en if isinstance(article.body_en, dict) else article.body
|
||||||
|
first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2])
|
||||||
|
jsonld = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Article",
|
||||||
|
"headline": title,
|
||||||
|
"description": excerpt,
|
||||||
|
"image": _absolute(article.image),
|
||||||
|
"inLanguage": "en",
|
||||||
|
"author": {"@type": "Organization", "name": brand},
|
||||||
|
"publisher": {"@type": "Organization", "name": brand},
|
||||||
|
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
|
||||||
|
}
|
||||||
|
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||||
|
return _shell(
|
||||||
|
title=f"{title} | {brand}",
|
||||||
|
description=excerpt,
|
||||||
|
path=f"/en/blog/{article.slug}",
|
||||||
|
lang="en",
|
||||||
|
og_image=article.image,
|
||||||
|
og_type="article",
|
||||||
|
jsonld=jsonld,
|
||||||
|
noscript=noscript,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/en", response_class=HTMLResponse)
|
||||||
|
@router.get("/en/", response_class=HTMLResponse)
|
||||||
|
def en_home(db: Session = Depends(get_db)):
|
||||||
|
title, description = _meta_for(db, "home", "en")
|
||||||
|
if not title:
|
||||||
|
title = _brand_name(db, "en")
|
||||||
|
return _shell(title=title, description=description, path="/en", lang="en")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/en/blog", response_class=HTMLResponse)
|
||||||
|
def en_blog_list(db: Session = Depends(get_db)):
|
||||||
|
meta = _page_meta(db, "blog")
|
||||||
|
title = meta.title_en or meta.title if meta else ""
|
||||||
|
description = meta.description_en or meta.description if meta else ""
|
||||||
|
if not title:
|
||||||
|
title = "Blog | " + _brand_name(db, "en")
|
||||||
|
return _shell(title=title, description=description, path="/en/blog", lang="en")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/en/blog/{slug}", response_class=HTMLResponse)
|
||||||
|
def en_blog_detail(slug: str, db: Session = Depends(get_db)):
|
||||||
|
article = (
|
||||||
|
db.query(models.Article)
|
||||||
|
.filter(models.Article.slug == slug, models.Article.status == "published")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if article is None:
|
||||||
|
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en")
|
||||||
|
return _en_blog_detail(article, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/en/projects", response_class=HTMLResponse)
|
||||||
|
def en_projects_list(db: Session = Depends(get_db)):
|
||||||
|
meta = _page_meta(db, "projects")
|
||||||
|
title = meta.title_en or meta.title if meta else ""
|
||||||
|
description = meta.description_en or meta.description if meta else ""
|
||||||
|
if not title:
|
||||||
|
title = "Projects | " + _brand_name(db, "en")
|
||||||
|
return _shell(title=title, description=description, path="/en/projects", lang="en")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/en/projects/{slug}", response_class=HTMLResponse)
|
||||||
|
def en_project_detail(slug: str, db: Session = Depends(get_db)):
|
||||||
|
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
||||||
|
if project is None:
|
||||||
|
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/projects/{slug}", lang="en")
|
||||||
|
|
||||||
|
brand = _brand_name(db, "en")
|
||||||
|
title = project.title_en or project.title
|
||||||
|
summary = project.summary_en or project.summary
|
||||||
|
jsonld = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebPage",
|
||||||
|
"name": title,
|
||||||
|
"description": summary,
|
||||||
|
"image": _absolute(project.image),
|
||||||
|
"inLanguage": "en",
|
||||||
|
}
|
||||||
|
return _shell(
|
||||||
|
title=f"{title} | {brand}",
|
||||||
|
description=summary,
|
||||||
|
path=f"/en/projects/{project.slug}",
|
||||||
|
lang="en",
|
||||||
|
og_image=project.image,
|
||||||
|
jsonld=jsonld,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/en/{path:path}", response_class=HTMLResponse)
|
||||||
|
def en_page(path: str, db: Session = Depends(get_db)):
|
||||||
|
"""بقیه مسیرهای انگلیسی (about، services، ...) — متا از seo_meta به انگلیسی."""
|
||||||
|
page_key = path.split("/")[0] if path else ""
|
||||||
|
title, description = _meta_for(db, page_key, "en") if page_key in META_KEYS else ("", "")
|
||||||
|
brand = _brand_name(db, "en")
|
||||||
|
if not title:
|
||||||
|
# عنوان پیشفرض از برچسب ناوبری انگلیسی همان صفحه
|
||||||
|
nav = db.query(models.NavItem).filter(models.NavItem.href == f"/{page_key}").first() if page_key else None
|
||||||
|
label = (nav.label_en or nav.label) if nav else ""
|
||||||
|
title = f"{label} | {brand}" if label else brand
|
||||||
|
return _shell(title=title, description=description, path=f"/en/{path}".rstrip("/"), lang="en")
|
||||||
|
|||||||
@@ -180,3 +180,91 @@ def test_head_not_injected_for_draft(client):
|
|||||||
def test_head_replaces_title_once(client):
|
def test_head_replaces_title_once(client):
|
||||||
res = client.get("/blog/published-post")
|
res = client.get("/blog/published-post")
|
||||||
assert len(re.findall(r"<title>", res.text)) == 1
|
assert len(re.findall(r"<title>", res.text)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- دوزبانه ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bilingual_project_and_en_seo_shell(auth_client, client):
|
||||||
|
created = auth_client.post(
|
||||||
|
"/api/admin/projects",
|
||||||
|
json={
|
||||||
|
"slug": "bilingual-tower",
|
||||||
|
"title": "برج دوزبانه",
|
||||||
|
"title_en": "Bilingual Tower",
|
||||||
|
"summary": "خلاصه فارسی",
|
||||||
|
"summary_en": "English summary",
|
||||||
|
"body": [],
|
||||||
|
"body_en": ["English paragraph"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
pid = created.json()["id"]
|
||||||
|
|
||||||
|
# GET /api/site هر دو زبان را برمیگرداند
|
||||||
|
project = next(p for p in client.get("/api/site").json()["projects"] if p["slug"] == "bilingual-tower")
|
||||||
|
assert project["title"] == "برج دوزبانه"
|
||||||
|
assert project["title_en"] == "Bilingual Tower"
|
||||||
|
assert project["body_en"] == ["English paragraph"]
|
||||||
|
|
||||||
|
# پوسته سئوی انگلیسی — جهت و متای انگلیسی و hreflang متقابل
|
||||||
|
res = client.get("/en/projects/bilingual-tower")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert 'lang="en" dir="ltr"' in res.text
|
||||||
|
assert "Bilingual Tower" in res.text
|
||||||
|
assert "English summary" in res.text
|
||||||
|
assert 'hreflang="fa"' in res.text
|
||||||
|
assert "https://test.example/projects/bilingual-tower" in res.text
|
||||||
|
assert "https://test.example/en/projects/bilingual-tower" in res.text
|
||||||
|
|
||||||
|
# sitemap دو زبانه با alternates
|
||||||
|
sm = client.get("/sitemap.xml").text
|
||||||
|
assert "xmlns:xhtml" in sm
|
||||||
|
assert 'hreflang="en"' in sm
|
||||||
|
assert "/en/projects/bilingual-tower" in sm
|
||||||
|
|
||||||
|
# پوسته فارسی هم alternates دارد
|
||||||
|
assert 'hreflang="en"' in client.get("/blog/published-post").text
|
||||||
|
|
||||||
|
# صفحه استاتیک انگلیسی از مسیر catch-all سرو میشود
|
||||||
|
res = client.get("/en/about")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert 'lang="en"' in res.text
|
||||||
|
|
||||||
|
assert auth_client.delete(f"/api/admin/projects/{pid}").status_code == 204
|
||||||
|
assert "bilingual-tower" not in client.get("/sitemap.xml").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_bilingual_article_en_shell(auth_client, client):
|
||||||
|
articles = auth_client.get("/api/admin/articles").json()
|
||||||
|
article = next(a for a in articles if a["slug"] == "published-post")
|
||||||
|
res = auth_client.patch(
|
||||||
|
f"/api/admin/articles/{article['id']}",
|
||||||
|
json={
|
||||||
|
"title_en": "Published EN",
|
||||||
|
"excerpt_en": "English excerpt",
|
||||||
|
"body_en": DOC,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200, res.text
|
||||||
|
|
||||||
|
res = client.get("/en/blog/published-post")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "Published EN" in res.text
|
||||||
|
assert "English excerpt" in res.text
|
||||||
|
assert '"inLanguage": "en"' in res.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_en_backfill_fills_only_empty_fields(auth_client, client):
|
||||||
|
"""backfill ترجمههای خالی را پر میکند و مقدار ثبتشده را بازنویسی نمیکند."""
|
||||||
|
from app.migrations import backfill_en_content
|
||||||
|
|
||||||
|
backfill_en_content()
|
||||||
|
brand = client.get("/api/site").json()["brand"]
|
||||||
|
assert brand["name_en"] == "Aramesh Construction Group"
|
||||||
|
|
||||||
|
# مقدار دستیِ ادمین محترم شمرده میشود
|
||||||
|
res = auth_client.patch(f"/api/admin/brand/{brand['id']}", json={"name_en": "Custom EN Name"})
|
||||||
|
assert res.status_code == 200
|
||||||
|
backfill_en_content()
|
||||||
|
assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
||||||
- traefik.http.routers.api.entrypoints=websecure
|
- traefik.http.routers.api.entrypoints=websecure
|
||||||
- traefik.http.routers.api.tls.certresolver=le
|
- traefik.http.routers.api.tls.certresolver=le
|
||||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
||||||
- traefik.http.routers.seo.entrypoints=websecure
|
- traefik.http.routers.seo.entrypoints=websecure
|
||||||
- traefik.http.routers.seo.tls.certresolver=le
|
- traefik.http.routers.seo.tls.certresolver=le
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -122,7 +122,8 @@ services:
|
|||||||
- traefik.http.routers.api.priority=100
|
- traefik.http.routers.api.priority=100
|
||||||
- traefik.http.services.api.loadbalancer.server.port=8000
|
- traefik.http.services.api.loadbalancer.server.port=8000
|
||||||
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
|
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
|
||||||
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
|
# مسیرهای /en/... هم همه به بکاند میروند تا متای سئوی انگلیسی و html انگلیسی تزریق شود
|
||||||
|
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
|
||||||
- traefik.http.routers.seo.entrypoints=web
|
- traefik.http.routers.seo.entrypoints=web
|
||||||
- traefik.http.routers.seo.priority=50
|
- traefik.http.routers.seo.priority=50
|
||||||
- traefik.http.routers.seo.service=api
|
- traefik.http.routers.seo.service=api
|
||||||
|
|||||||
Reference in New Issue
Block a user