13 changed files with 650 additions and 40 deletions
-
1apps/account/models/user.py
-
28apps/api/admin.py
-
23apps/blog/admin.py
-
367apps/blog/management/commands/seed_blog_data.py
-
2apps/blog/models.py
-
4apps/blog/urls.py
-
27config/settings/base.py
-
63docs/MultiLanguageJSONWidget.md
-
BINseeds/images/blog1.jpeg
-
BINseeds/images/blog2.jpeg
-
BINseeds/images/blog3.jpeg
-
160templates/utils/widgets/multilang_json_widget.html
-
15utils/multilang_json_widget.py
@ -0,0 +1,367 @@ |
|||||
|
import os |
||||
|
import random |
||||
|
import uuid |
||||
|
from typing import List, Dict |
||||
|
|
||||
|
from django.conf import settings |
||||
|
from django.core.management.base import BaseCommand |
||||
|
from django.core.files import File |
||||
|
|
||||
|
from apps.blog.models import Blog, BlogContent |
||||
|
|
||||
|
|
||||
|
def build_multilang_list(values: Dict[str, str], value_key: str = "title") -> List[Dict[str, str]]: |
||||
|
""" |
||||
|
Convert a dict like {'en': '...', 'fa': '...', 'ru': '...'} into the project's |
||||
|
JSONField list schema: [{'language_code': 'en', 'title': '...'}, ...] |
||||
|
value_key controls whether we store under 'title' (for titles) or 'text' (for content). |
||||
|
""" |
||||
|
return [{"language_code": code, value_key: text} for code, text in values.items()] |
||||
|
|
||||
|
|
||||
|
def get_seed_images() -> List[str]: |
||||
|
""" |
||||
|
Load available image file paths from BASE_DIR/seeds/images/ |
||||
|
""" |
||||
|
base = os.path.join(settings.BASE_DIR, "seeds", "images") |
||||
|
if not os.path.isdir(base): |
||||
|
return [] |
||||
|
files = [] |
||||
|
for name in os.listdir(base): |
||||
|
lower = name.lower() |
||||
|
if lower.endswith((".jpg", ".jpeg", ".png", ".webp")): |
||||
|
files.append(os.path.join(base, name)) |
||||
|
return files |
||||
|
|
||||
|
|
||||
|
def pick_image_path(images: List[str]) -> str: |
||||
|
""" |
||||
|
Randomly pick an image path from the provided list. |
||||
|
""" |
||||
|
if not images: |
||||
|
return "" |
||||
|
return random.choice(images) |
||||
|
|
||||
|
|
||||
|
def generate_topics() -> List[Dict[str, Dict[str, str]]]: |
||||
|
""" |
||||
|
Build 20 topics based on prophets and imams to satisfy the requested domains. |
||||
|
Each topic is a mapping for three languages: en, fa, ru. |
||||
|
""" |
||||
|
prophets = [ |
||||
|
{"en": "Prophet Muhammad", "fa": "حضرت محمد (ص)", "ru": "Пророк Мухаммад"}, |
||||
|
{"en": "Prophet Musa", "fa": "حضرت موسی (ع)", "ru": "Пророк Муса"}, |
||||
|
{"en": "Prophet Isa", "fa": "حضرت عیسی (ع)", "ru": "Пророк Иса"}, |
||||
|
{"en": "Prophet Ibrahim", "fa": "حضرت ابراهیم (ع)", "ru": "Пророк Ибрахим"}, |
||||
|
{"en": "Prophet Nuh", "fa": "حضرت نوح (ع)", "ru": "Пророк Нух"}, |
||||
|
{"en": "Prophet Yusuf", "fa": "حضرت یوسف (ع)", "ru": "Пророк Юсуф"}, |
||||
|
{"en": "Prophet Yaqub", "fa": "حضرت یعقوب (ع)", "ru": "Пророк Якуб"}, |
||||
|
{"en": "Prophet Dawud", "fa": "حضرت داوود (ع)", "ru": "Пророк Давуд"}, |
||||
|
] |
||||
|
imams = [ |
||||
|
{"en": "Imam Ali", "fa": "امام علی (ع)", "ru": "Имам Али"}, |
||||
|
{"en": "Imam Hasan", "fa": "امام حسن (ع)", "ru": "Имам Хасан"}, |
||||
|
{"en": "Imam Husayn", "fa": "امام حسین (ع)", "ru": "Имам Хусейн"}, |
||||
|
{"en": "Imam Sajjad", "fa": "امام سجاد (ع)", "ru": "Имам Саджад"}, |
||||
|
{"en": "Imam Baqir", "fa": "امام باقر (ع)", "ru": "Имам Бакир"}, |
||||
|
{"en": "Imam Sadiq", "fa": "امام صادق (ع)", "ru": "Имам Садык"}, |
||||
|
{"en": "Imam Kadhim", "fa": "امام کاظم (ع)", "ru": "Имам Казим"}, |
||||
|
{"en": "Imam Reza", "fa": "امام رضا (ع)", "ru": "Имам Реза"}, |
||||
|
{"en": "Imam Jawad", "fa": "امام جواد (ع)", "ru": "Имам Джавад"}, |
||||
|
{"en": "Imam Hadi", "fa": "امام هادی (ع)", "ru": "Имам Хади"}, |
||||
|
{"en": "Imam Askari", "fa": "امام عسکری (ع)", "ru": "Имам Аскари"}, |
||||
|
{"en": "Imam Mahdi", "fa": "امام مهدی (عج)", "ru": "Имам Махди"}, |
||||
|
] |
||||
|
topics = prophets + imams |
||||
|
return topics[:20] |
||||
|
|
||||
|
|
||||
|
def content_sections(name_en: str, name_fa: str, name_ru: str) -> List[Dict[str, Dict[str, str]]]: |
||||
|
""" |
||||
|
Build 10 narrative anecdotal content sections per blog, tailored to the blog's subject (prophet/imam), |
||||
|
with rich multilingual texts (fa, en, ru). Each section is a self-contained story (حکایت/История). |
||||
|
""" |
||||
|
sections = [] |
||||
|
|
||||
|
sections.append({ |
||||
|
"title": { |
||||
|
"en": f"Anecdote: Early Life Kindness of {name_en}", |
||||
|
"fa": f"حکایت: مهربانی در کودکی {name_fa}", |
||||
|
"ru": f"История: Доброе сердце в детстве {name_ru}", |
||||
|
}, |
||||
|
"text": { |
||||
|
"en": f"As a child, {name_en} was noted for uncommon kindness. One cold morning a neighbor had no bread, " |
||||
|
f"so {name_en} shared the family portion and said, 'Provision grows when shared.' " |
||||
|
f"The town remembered this as a lesson that compassion is the seed of community.", |
||||
|
"fa": f"{name_fa} از همان کودکی به مهربانی شناخته میشد. صبحی سرد، همسایهای نان نداشت؛ " |
||||
|
f"{name_fa} سهم خانواده را بخشید و گفت: «روزی وقتی تقسیم شود، افزون میگردد.» " |
||||
|
f"آن رفتار درسی شد برای شهر که شفقت، بذر اجتماع است.", |
||||
|
"ru": f"С детства {name_ru} отличался редкой добротой. В холодное утро у соседа не было хлеба, " |
||||
|
f"и {name_ru} поделился семейной долей, сказав: «Истинный удел умножается, когда им делятся». " |
||||
|
f"Так люди усвоили урок о сострадании как основе общины.", |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
sections.append({ |
||||
|
"title": { |
||||
|
"en": f"Anecdote: First Signs of Wisdom of {name_en}", |
||||
|
"fa": f"حکایت: نشانههای نخستین حکمت {name_fa}", |
||||
|
"ru": f"История: Первые признаки мудрости {name_ru}", |
||||
|
}, |
||||
|
"text": { |
||||
|
"en": f"In youth, a dispute arose over a simple matter. While others raised their voices, " |
||||
|
f"{name_en} asked both sides to repeat their words slowly. " |
||||
|
f"By listening with fairness, {name_en} settled the matter gently and taught that calm clarity reveals truth.", |
||||
|
"fa": f"در جوانی، نزاعی بر سر مسئلهای ساده درگرفت. هنگامی که دیگران صدا بلند کرده بودند، " |
||||
|
f"{name_fa} از هر دو طرف خواست آرام و دقیق سخن بگویند. " |
||||
|
f"با گوش سپردن منصفانه، نزاع به نرمی پایان یافت و روشن شد که آرامش، حقیقت را آشکار میکند.", |
||||
|
"ru": f"В юности возник спор по пустяку. Пока голоса накалялись, " |
||||
|
f"{name_ru} попросил обе стороны говорить медленно и ясно. " |
||||
|
f"Выслушав справедливо, {name_ru} примирил спорящих и показал, что спокойная ясность открывает истину.", |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
sections.append({ |
||||
|
"title": { |
||||
|
"en": f"Anecdote: Compassion for the Poor by {name_en}", |
||||
|
"fa": f"حکایت: شفقت بر نیازمندان از سوی {name_fa}", |
||||
|
"ru": f"История: Сострадание к нуждающимся от {name_ru}", |
||||
|
}, |
||||
|
"text": { |
||||
|
"en": f"A traveler arrived hungry and ashamed. {name_en} prepared food with their own hands and invited the traveler " |
||||
|
f"to sit as an honored guest. People learned that dignity grows where compassion leads.", |
||||
|
"fa": f"مسافری گرسنه و شرمسار فرا رسید. {name_fa} خود دست به کار شد، طعامی مهیا کرد و مسافر را " |
||||
|
f"چون مهمانی گرامی نشاند. مردم آموختند که کرامت، در سایهٔ پیشگامیِ شفقت میروید.", |
||||
|
"ru": f"Пришел путник голодный и смущенный. {name_ru} собственноручно приготовил еду и усадил его как почётного гостя. " |
||||
|
f"Люди поняли, что достоинство расцветает там, где впереди идет сострадание.", |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
sections.append({ |
||||
|
"title": { |
||||
|
"en": f"Anecdote: Patience Under Trial of {name_en}", |
||||
|
"fa": f"حکایت: صبر در امتحان {name_fa}", |
||||
|
"ru": f"История: Терпение в испытании {name_ru}", |
||||
|
}, |
||||
|
"text": { |
||||
|
"en": f"Hard days came with whispers and blame. {name_en} answered with patience, refusing to return harshness with harshness. " |
||||
|
f"In time, those who criticized felt softened and sought forgiveness.", |
||||
|
"fa": f"روزهای دشوار با زمزمهها و سرزنشها همراه شد. {name_fa} با صبر پاسخ گفت و به تندی، تندی نکرد. " |
||||
|
f"با گذر زمان، دلِ ملامتگران نرم شد و پوزش خواستند.", |
||||
|
"ru": f"Настали трудные дни с шепотом упреков. {name_ru} отвечал терпением и не платил жесткостью за жесткость. " |
||||
|
f"Со временем сердца порицавших смягчились, и они попросили прощения.", |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
sections.append({ |
||||
|
"title": { |
||||
|
"en": f"Anecdote: Justice in a Dispute by {name_en}", |
||||
|
"fa": f"حکایت: عدالت در یک نزاع به روایت {name_fa}", |
||||
|
"ru": f"История: Справедливость в споре у {name_ru}", |
||||
|
}, |
||||
|
"text": { |
||||
|
"en": f"Two neighbors quarreled over a wall. {name_en} measured the ground, heard each claim, and decided " |
||||
|
f"with equity—neither fully winning nor losing. They accepted, seeing justice as balance, not bias.", |
||||
|
"fa": f"دو همسایه بر سر دیواری به نزاع افتادند. {name_fa} زمین را اندازه گرفت، سخن هر دو را شنید " |
||||
|
f"و به گونهای حکم کرد که نه این پیروزِ مطلق باشد و نه آن؛ عدالت را توازن دیدند نه جانبداری.", |
||||
|
"ru": f"Двое соседей спорили из‑за стены. {name_ru} измерил участок, выслушал обе стороны и вынес решение, " |
||||
|
f"где ни один не выиграл полностью и не проиграл. Так они увидели справедливость как равновесие, а не пристрастие.", |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
sections.append({ |
||||
|
"title": { |
||||
|
"en": f"Anecdote: A Miraculous Sign with {name_en}", |
||||
|
"fa": f"حکایت: نشانهای شگفت با {name_fa}", |
||||
|
"ru": f"История: Чудесный знак с {name_ru}", |
||||
|
}, |
||||
|
"text": { |
||||
|
"en": f"In a moment of fear, a small sign appeared—unexpected help arrived at the right time. " |
||||
|
f"People said, 'It was a mercy,' and {name_en} reminded them that signs awaken gratitude and responsibility.", |
||||
|
"fa": f"در لحظهای هراسانگیز، نشانهای پدیدار شد؛ یاریِ ناگهانی در زمانِ درست. " |
||||
|
f"مردم گفتند: «رحمتی بود»، و {name_fa} یادآور شد که نشانهها سپاس و مسئولیت میآموزند.", |
||||
|
"ru": f"В миг страха явился маленький знак — помощь пришла вовремя. " |
||||
|
f"Люди сказали: «Это была милость», а {name_ru} напомнил, что знамения пробуждают благодарность и ответственность.", |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
sections.append({ |
||||
|
"title": { |
||||
|
"en": f"Anecdote: Teaching with Gentle Words of {name_en}", |
||||
|
"fa": f"حکایت: تعلیم با سخن نرم از {name_fa}", |
||||
|
"ru": f"История: Наставление мягким словом от {name_ru}", |
||||
|
}, |
||||
|
"text": { |
||||
|
"en": f"A young student erred while reading. {name_en} corrected without humiliation, " |
||||
|
f"explaining with care until understanding bloomed. Knowledge, they said, enters where hearts feel safe.", |
||||
|
"fa": f"شاگردی در خواندن خطا کرد. {name_fa} بیآنکه او را خوار کند، با دلسوزی توضیح داد تا فهم شکوفا شد. " |
||||
|
f"گفت: دانش، جایی وارد میشود که دلها امن باشند.", |
||||
|
"ru": f"Юный ученик ошибся в чтении. {name_ru} исправил без унижения и терпеливо объяснил, пока не пришло понимание. " |
||||
|
f"Знание входит туда, где сердце в безопасности.", |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
sections.append({ |
||||
|
"title": { |
||||
|
"en": f"Anecdote: Night Prayer and Humility of {name_en}", |
||||
|
"fa": f"حکایت: نماز شب و فروتنی {name_fa}", |
||||
|
"ru": f"История: Ночная молитва и смирение {name_ru}", |
||||
|
}, |
||||
|
"text": { |
||||
|
"en": f"In the stillness of the night, {name_en} stood in prayer, whispering gratitude and seeking guidance. " |
||||
|
f"Those who saw learned that inner strength is born from humble devotion.", |
||||
|
"fa": f"در سکوت شب، {name_fa} به نماز ایستاد؛ شکر میگفت و راه میجست. " |
||||
|
f"بینندگان آموختند که قوت درون از بندگی فروتنانه زاده میشود.", |
||||
|
"ru": f"В тишине ночи {name_ru} стоял в молитве, шепча благодарность и прося наставления. " |
||||
|
f"Те, кто видел, поняли: внутренняя сила рождается из смиренного поклонения.", |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
sections.append({ |
||||
|
"title": { |
||||
|
"en": f"Anecdote: Generosity Without Expectation by {name_en}", |
||||
|
"fa": f"حکایت: بخشش بیمنت از {name_fa}", |
||||
|
"ru": f"История: Щедрость без ожиданий от {name_ru}", |
||||
|
}, |
||||
|
"text": { |
||||
|
"en": f"A poor family hid their need out of modesty. {name_en} discreetly sent provisions for days, " |
||||
|
f"asking no thanks. True giving, they taught, seeks no witness but the All‑Seeing.", |
||||
|
"fa": f"خانوادهای نیاز خود را از شرم پنهان میکردند. {name_fa} بیصدا آذوقهٔ چند روزشان را رساند " |
||||
|
f"و هیچ سپاسی نخواست؛ آموخت که بخششِ راستین، جز دیدهٔ حق گواهی نمیطلبد.", |
||||
|
"ru": f"Бедная семья скрывала нужду из скромности. {name_ru} тайно прислал им припасы на несколько дней " |
||||
|
f"и не просил благодарности. Истинная щедрость не ищет свидетелей, кроме Всевидящего.", |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
sections.append({ |
||||
|
"title": { |
||||
|
"en": f"Anecdote: Legacy That Inspires of {name_en}", |
||||
|
"fa": f"حکایت: میراث الهامبخشِ {name_fa}", |
||||
|
"ru": f"История: Наследие, которое вдохновляет {name_ru}", |
||||
|
}, |
||||
|
"text": { |
||||
|
"en": f"Years later, children repeated the sayings of {name_en} and neighbors kept the customs of mercy, justice, and truth. " |
||||
|
f"The legacy was not stone or gold, but transformed hearts.", |
||||
|
"fa": f"سالها بعد، کودکان سخنانِ {name_fa} را بازمیگفتند و همسایگان آیینِ رحمت، عدالت و راستی را نگه میداشتند. " |
||||
|
f"میراث، سنگ و زر نبود؛ دلهای دگرگونشده بود.", |
||||
|
"ru": f"Спустя годы дети повторяли изречения {name_ru}, а соседи хранили обычаи милости, справедливости и истины. " |
||||
|
f"Их наследие было не в камне и золоте, а в преображенных сердцах.", |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
return sections |
||||
|
|
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
help = "Seed 20 blogs with 10 related contents each in fa, en, ru languages. Images are randomly assigned from seeds/images." |
||||
|
|
||||
|
def add_arguments(self, parser): |
||||
|
parser.add_argument("--blogs", type=int, default=20, help="Number of blogs to create") |
||||
|
parser.add_argument("--contents", type=int, default=10, help="Number of contents per blog") |
||||
|
parser.add_argument("--commit", action="store_true", help="Persist changes to the database. If omitted, runs in dry-run mode.") |
||||
|
parser.add_argument("--images-dir", type=str, default="", help="Override images directory (defaults to BASE_DIR/seeds/images)") |
||||
|
|
||||
|
def handle(self, *args, **options): |
||||
|
blogs_count = int(options.get("blogs") or 20) |
||||
|
contents_count = int(options.get("contents") or 10) |
||||
|
commit = bool(options.get("commit")) |
||||
|
images_dir_opt = options.get("images_dir") |
||||
|
|
||||
|
# Load image candidates |
||||
|
images = [] |
||||
|
if images_dir_opt: |
||||
|
base = images_dir_opt |
||||
|
if os.path.isdir(base): |
||||
|
for name in os.listdir(base): |
||||
|
lower = name.lower() |
||||
|
if lower.endswith((".jpg", ".jpeg", ".png", ".webp")): |
||||
|
images.append(os.path.join(base, name)) |
||||
|
else: |
||||
|
images = get_seed_images() |
||||
|
|
||||
|
if not images: |
||||
|
self.stdout.write(self.style.WARNING("No seed images found under seeds/images/. Thumbnails and content images will be empty.")) |
||||
|
|
||||
|
topics = generate_topics() |
||||
|
if blogs_count > len(topics): |
||||
|
blogs_count = len(topics) |
||||
|
|
||||
|
created_blogs = 0 |
||||
|
created_contents = 0 |
||||
|
|
||||
|
for idx in range(blogs_count): |
||||
|
topic = topics[idx] |
||||
|
name_en = topic["en"] |
||||
|
name_fa = topic["fa"] |
||||
|
name_ru = topic["ru"] |
||||
|
|
||||
|
title_values = {"en": f"Biography: {name_en}", "fa": f"زندگینامه: {name_fa}", "ru": f"Биография: {name_ru}"} |
||||
|
slogan_values = {"en": f"Stories and lessons from {name_en}", "fa": f"حکایتها و درسها از {name_fa}", "ru": f"Истории и уроки о {name_ru}"} |
||||
|
summary_values = { |
||||
|
"en": f"A curated collection of chapters about {name_en}, covering life, teachings, and legacy.", |
||||
|
"fa": f"مجموعهای منتخب از فصلها درباره {name_fa} شامل زندگی، تعالیم و میراث.", |
||||
|
"ru": f"Подборка глав о {name_ru}, охватывающих жизнь, учение и наследие.", |
||||
|
} |
||||
|
|
||||
|
blog = Blog( |
||||
|
title=build_multilang_list(title_values, "title"), |
||||
|
slogan=build_multilang_list(slogan_values, "title"), |
||||
|
summary=build_multilang_list(summary_values, "text"), |
||||
|
) |
||||
|
|
||||
|
# Assign a random thumbnail image if available |
||||
|
thumb_path = pick_image_path(images) |
||||
|
if thumb_path: |
||||
|
ext = os.path.splitext(thumb_path)[1].lower() |
||||
|
fname = f"seed_thumb_{uuid.uuid4().hex}{ext}" |
||||
|
if commit: |
||||
|
with open(thumb_path, "rb") as f: |
||||
|
blog.thumbnail.save(fname, File(f), save=False) |
||||
|
else: |
||||
|
# Dry-run: simulate |
||||
|
blog.thumbnail.name = os.path.join("blog/thumbnails", fname) |
||||
|
|
||||
|
self.stdout.write(f"[{'COMMIT' if commit else 'DRY'}] Preparing blog {idx+1}: {name_en}") |
||||
|
|
||||
|
contents_payload = content_sections(name_en, name_fa, name_ru) |
||||
|
# Limit to requested count |
||||
|
contents_payload = contents_payload[:contents_count] |
||||
|
|
||||
|
if commit: |
||||
|
blog.save() |
||||
|
created_blogs += 1 |
||||
|
|
||||
|
# Create related contents |
||||
|
order = 1 |
||||
|
for section in contents_payload: |
||||
|
title_list = build_multilang_list(section["title"], "title") |
||||
|
text_list = build_multilang_list(section["text"], "text") |
||||
|
content_image_path = pick_image_path(images) |
||||
|
bc = BlogContent( |
||||
|
blog=blog, |
||||
|
title=title_list, |
||||
|
content=text_list, |
||||
|
slug=title_list, # allow slug generation from multilingual titles |
||||
|
order=order, |
||||
|
) |
||||
|
order += 1 |
||||
|
|
||||
|
if content_image_path: |
||||
|
ext = os.path.splitext(content_image_path)[1].lower() |
||||
|
fname = f"seed_content_{uuid.uuid4().hex}{ext}" |
||||
|
if commit: |
||||
|
with open(content_image_path, "rb") as f: |
||||
|
bc.image.save(fname, File(f), save=False) |
||||
|
else: |
||||
|
bc.image = None # do not assign filesystem in dry-run |
||||
|
|
||||
|
if commit: |
||||
|
bc.save() |
||||
|
created_contents += 1 |
||||
|
|
||||
|
self.stdout.write(self.style.SUCCESS(f"Prepared {len(contents_payload)} contents for blog '{name_en}'")) |
||||
|
|
||||
|
mode = "COMMIT" if commit else "DRY-RUN" |
||||
|
self.stdout.write(self.style.SUCCESS(f"{mode} finished. Blogs prepared: {created_blogs}, Contents prepared: {created_contents}")) |
||||
|
if not commit: |
||||
|
self.stdout.write(self.style.WARNING("Run again with --commit to persist the changes.")) |
||||
@ -0,0 +1,63 @@ |
|||||
|
## MultiLanguageJSONWidget – توضیحات توصیفی (بدون کد) |
||||
|
|
||||
|
### هدف |
||||
|
- ساخت یک ویجت سفارشی سازگار با Django Unfold برای مدیریت فیلدهای JSON چندزبانه در ادمین. |
||||
|
- مدل داده: لیستی از آبجکتها با کلیدهای `language_code` و `title`. |
||||
|
- تجربه کاربری روان، هماهنگ با پالت رنگی و تمهای Unfold (لایت/دارک)، و قابل استفاده در تمام اپها. |
||||
|
|
||||
|
### خلاصهی کارهایی که انجام شد |
||||
|
- ایجاد ویجت چندزبانهای که: |
||||
|
- کدهای زبان فعال را از مدل `dj_language.models.Language` (فقط `status=True`) میخواند و در صورت نبود، از `settings.LANGUAGES` استفاده میکند. |
||||
|
- کدهای زبان را بهصورت افقی نمایش میدهد؛ با اسکرول افقی که فقط روی hover ظاهر میشود (مانند سایدبار Unfold). |
||||
|
- برای زبانهای دارای مقدار، دکمهی زبان را «پررنگتر» نشان میدهد و این زبانها را در ابتدای نوار قرار میدهد. |
||||
|
- حالت فعال (Active) را با رنگهای primary مطابق پالت UNFOLD نمایش میدهد تا فعال بودن زبان واضح باشد. |
||||
|
- برای هر زبان یک ورودی رندر میکند که «نوع ورودی» آن قابل تنظیم است: `TextInput`/`Textarea`/`Wysiwyg` (همگی نسخههای Unfold). |
||||
|
- مقادیر موجود را شناسایی و در ورودیهای مربوطه پیشنمایش میدهد. |
||||
|
|
||||
|
### ذخیرهسازی و سازگاری با JSONField |
||||
|
- برای سازگاری کامل با `JSONField`، مقدار نهایی در یک input پنهان بهصورت «رشتهی JSON» نگهداری و ارسال میشود. |
||||
|
- `value_from_datadict` رشتهی JSON معتبر تولید میکند تا خطای «نوع لیست» در پردازش فرم رخ ندهد. |
||||
|
- ورودی اولیه میتواند یکی از حالتهای زیر باشد و نرمالسازی میشود: |
||||
|
- `list[dict]` مانند: `[{'language_code': 'fa', 'title': '...'}]` |
||||
|
- `dict` تکی یا نگاشت کدزبان→مقدار |
||||
|
- `str` شامل JSON که ابتدا parse میشود. |
||||
|
|
||||
|
### هماهنگی کامل با Unfold |
||||
|
- استفاده از کلاسهای استایل Unfold برای ورودیها و وضعیتها. |
||||
|
- احترام به متغیرهای رنگی UNFOLD (base/primary/secondary/font) و تغییر خودکار استایل در لایت/دارک. |
||||
|
- اسکرولبار با استایل هماهنگ و نمایش فقط هنگام hover. |
||||
|
|
||||
|
### قابل استفاده در تمام اپها (ماژولار) |
||||
|
- کلاس ویجت به ماژول `utils` منتقل شد تا در هر اپی فقط با import قابل استفاده باشد. |
||||
|
- تمپلیت ویجت به مسیر سراسری `templates/` منتقل شد تا وابستگی به اپ خاصی نداشته باشد. |
||||
|
- اسکریپتهای موردنیاز در همان تمپلیت اینلاین شدهاند؛ نیازی به فایل JS جدا نیست. |
||||
|
|
||||
|
### نحوهی استفاده (مفهومی – بدون کد) |
||||
|
- کلاس ویجت را از ماژول ابزارها ایمپورت کنید. |
||||
|
- در فرم ادمین (Meta.widgets)، برای هر فیلد JSON موردنظر، ویجت را تنظیم کنید و «نوع ورودی» دلخواه را مشخص کنید (TextInput/Textarea/Wysiwyg نسخه Unfold). |
||||
|
- پس از ذخیره، مقدار فیلد JSON بهصورت `list[{'language_code', 'title'}]` تولید/بهروزرسانی میشود. |
||||
|
|
||||
|
### نکات UX و رفتار |
||||
|
- نمایش افقی کدهای زبان با اسکرول افقی روی hover. |
||||
|
- حالت فعال با بوردر/پسزمینهی primary مطابق پالت رنگی پروژه. |
||||
|
- وقتی زبانِ خاصی مقدار دارد، دکمهی آن پررنگتر نمایش داده و به ابتدای لیست منتقل میشود. |
||||
|
- فوکوس خودکار ورودی پس از فعالسازی زبان برای سرعت در ویرایش. |
||||
|
|
||||
|
### سناریوهای پشتیبانیشده |
||||
|
- تعداد زیاد زبانها (اسکرول افقی و بدون شکستن چیدمان). |
||||
|
- مقداردهی اولیه از انواع مختلف (list/dict/string JSON). |
||||
|
- تم تیره/روشن و تغییر خودکار رنگها. |
||||
|
|
||||
|
### محدودیتها و ملاحظات |
||||
|
- فرض بر این است که ساختار دادهی هدف، لیست آبجکتهای `{'language_code', 'title'}` است. |
||||
|
- برای ورودیهای WYSIWYG، سیاست پاکسازی/اعتبارسنجی محتوای HTML به لایههای دیگر سپرده شده است. |
||||
|
|
||||
|
### نتیجه |
||||
|
- یک ویجت چندبارمصرف، سازگار با Unfold، با UX دوستانه برای مدیریت محتوای چندزبانه در ادمین. |
||||
|
- پیادهسازی بهگونهای است که بدون وابستگی به اپ خاص، در کل پروژه قابل استفاده باشد. |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
After Width: 588 | Height: 536 | Size: 156 KiB |
|
After Width: 588 | Height: 536 | Size: 118 KiB |
|
After Width: 588 | Height: 536 | Size: 68 KiB |
Write
Preview
Loading…
Cancel
Save
Reference in new issue