Browse Source

pref seed data

master
mortezaei 8 months ago
parent
commit
0a556b0fd1
  1. 1
      apps/account/models/user.py
  2. 28
      apps/api/admin.py
  3. 23
      apps/blog/admin.py
  4. 367
      apps/blog/management/commands/seed_blog_data.py
  5. 2
      apps/blog/models.py
  6. 4
      apps/blog/urls.py
  7. 27
      config/settings/base.py
  8. 63
      docs/MultiLanguageJSONWidget.md
  9. BIN
      seeds/images/blog1.jpeg
  10. BIN
      seeds/images/blog2.jpeg
  11. BIN
      seeds/images/blog3.jpeg
  12. 160
      templates/utils/widgets/multilang_json_widget.html
  13. 15
      utils/multilang_json_widget.py

1
apps/account/models/user.py

@ -78,6 +78,7 @@ class User(AbstractUser):
number = str(random.randint(1000000000, 9999999999))
self.phone_number = f'{self.phone_number}:deleted{number}'
self.email = f'{self.email}:deleted{number}' if self.email else None
self.device_id = f'{self.device_id}:deleted{number}' if self.device_id else None
self.save()
def save(self, *args, **kwargs):

28
apps/api/admin.py

@ -8,6 +8,8 @@ from django.utils.html import format_html
from filer.models.thumbnailoptionmodels import ThumbnailOption
# from filer.admin.thumbnailoptionmodels import ThumbnailOptionAdmin as OriginalThumbnailOptionAdmin
from .models import Comment, AppVersion
admin.site.unregister(ThumbnailOption)
@ -73,3 +75,29 @@ class ThumbnailOptionAdmin(ModelAdmin):
from utils.admin import project_admin_site
project_admin_site.register(ThumbnailOption, ThumbnailOptionAdmin)
@admin.register(Comment, site=project_admin_site)
class CommentAdmin(ModelAdmin):
list_display = [
'user_fullname',
'language',
'order',
'created_at',
]
search_fields = ['user_fullname', 'comment_text']
list_filter = ['language', 'created_at']
ordering = ['order', '-created_at']
@admin.register(AppVersion, site=project_admin_site)
class AppVersionAdmin(ModelAdmin):
list_display = [
'version',
'app_type',
'is_active',
'created_at',
]
search_fields = ['version', 'description']
list_filter = ['app_type', 'is_active', 'created_at']
ordering = ['-created_at']

23
apps/blog/admin.py

@ -1,5 +1,6 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from unfold.decorators import display
from unfold.admin import ModelAdmin, TabularInline, StackedInline
from unfold.contrib.forms.widgets import WysiwygWidget
from unfold.widgets import UnfoldAdminTextareaWidget, UnfoldAdminTextInputWidget, UnfoldAdminExpandableTextareaWidget
@ -16,9 +17,8 @@ class BlogContentForm(forms.ModelForm):
model = BlogContent
fields = '__all__'
widgets = {
'title': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminTextInputWidget),
'content': MultiLanguageJSONWidget(input_widget_class=WysiwygWidget),
'slug': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminTextInputWidget),
'title': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminExpandableTextareaWidget),
}
class BlogAdminForm(forms.ModelForm):
class Meta:
@ -50,10 +50,10 @@ class BlogAdmin(ModelAdmin):
Admin interface for Blog model using Django unfold
"""
form = BlogAdminForm
list_display = ('title', 'slogan', 'views_count', 'created_at', 'updated_at')
list_display = ('title_info', 'slogan', 'views_count', 'created_at', 'updated_at')
list_filter = ('created_at', 'updated_at')
search_fields = ('title', 'slogan', 'summary')
prepopulated_fields = {'slug': ('title',)}
# prepopulated_fields = {'slug': ('title',)}
readonly_fields = ('views_count', 'created_at', 'updated_at')
fieldsets = (
@ -75,6 +75,10 @@ class BlogAdmin(ModelAdmin):
inlines = [BlogContentInline]
@display(description=_('Title'), )
def title_info(self, obj):
return str(obj.title)
def get_queryset(self, request):
queryset = super().get_queryset(request)
print(f'--get_queryset-->{queryset}')
@ -89,7 +93,7 @@ class BlogContentAdmin(ModelAdmin):
Admin interface for BlogContent model using Django unfold
"""
form = BlogContentForm
list_display = ('title', 'blog', 'order', 'created_at', 'updated_at')
list_display = ('title_info', 'blog', 'order', 'created_at', 'updated_at')
list_filter = ('blog', 'created_at', 'updated_at')
search_fields = ('title', 'content', 'blog__title')
list_select_related = ('blog',)
@ -107,4 +111,9 @@ class BlogContentAdmin(ModelAdmin):
}),
)
readonly_fields = ('created_at', 'updated_at')
readonly_fields = ('created_at', 'updated_at')
@display(description=_('Title'), )
def title_info(self, obj):
return str(obj.title)

367
apps/blog/management/commands/seed_blog_data.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."))

2
apps/blog/models.py

@ -9,7 +9,7 @@ class Blog(models.Model):
"""
Blog model with title, thumbnail, slogan, summary, views count and timestamps
"""
title = models.JSONField(default=list, null=True, blank=True, verbose_name=_('title'))
title = models.JSONField(default=list, null=True, blank=True, verbose_name=_('title')) # [{"title": "", "language_code": "en"},{"title": "", "language_code": "fa"},...]
thumbnail = models.ImageField(
upload_to='blog/thumbnails/%Y/%m/',
verbose_name=_('Thumbnail'),

4
apps/blog/urls.py

@ -18,3 +18,7 @@ urlpatterns = [

27
config/settings/base.py

@ -683,6 +683,33 @@ UNFOLD = {
]
},
{
"title": _("Blog"),
"collapsible": True,
"separator": True,
"items": [
{
"title": _("Comments"),
"icon": "comment",
"link": reverse_lazy("admin:api_comment_changelist"),
},
{
"title": _("Blogs"),
"icon": "article",
"link": reverse_lazy("admin:blog_blog_changelist"),
},
]
},
{
"title": _(""),
"items": [
{
"title": _("App Versions"),
"icon": "system_update",
"link": reverse_lazy("admin:api_appversion_changelist"),
},
],
},
{
"title": _("Articles"),
"collapsible": True,

63
docs/MultiLanguageJSONWidget.md

@ -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 دوستانه برای مدیریت محتوای چندزبانه در ادمین.
- پیاده‌سازی به‌گونه‌ای است که بدون وابستگی به اپ خاص، در کل پروژه قابل استفاده باشد.

BIN
seeds/images/blog1.jpeg

After

Width: 588  |  Height: 536  |  Size: 156 KiB

BIN
seeds/images/blog2.jpeg

After

Width: 588  |  Height: 536  |  Size: 118 KiB

BIN
seeds/images/blog3.jpeg

After

Width: 588  |  Height: 536  |  Size: 68 KiB

160
templates/utils/widgets/multilang_json_widget.html

@ -6,8 +6,8 @@
{% for code in widget.languages %}
<button type="button"
class="lang-btn px-3 py-1.5 rounded-md border transition-all duration-150 text-xs font-medium
border-base-200 text-font-default-light
dark:border-base-700 dark:text-font-default-dark{% if widget.has_value_codes and code in widget.has_value_codes %} has-value{% endif %}"
border-gray-200 text-gray-700 hover:bg-gray-50 hover:border-gray-300
dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:border-gray-600{% if widget.has_value_codes and code in widget.has_value_codes %} has-value{% endif %}"
data-lang-code="{{ code }}">
{{ code|upper }}
</button>
@ -52,26 +52,81 @@
.scrollbar-hover:hover::-webkit-scrollbar-thumb:hover { background: rgb(var(--color-base-400)); }
.dark .scrollbar-hover:hover::-webkit-scrollbar-thumb:hover { background: rgb(var(--color-base-500)); }
.lang-btn { background: transparent; }
.lang-btn:hover { background: rgb(var(--color-base-50)); border-color: rgb(var(--color-base-300)); }
.dark .lang-btn:hover { background: rgb(var(--color-base-800)); border-color: rgb(var(--color-base-600)); }
.lang-btn.is-active { border-color: rgb(var(--color-primary-500)); background: rgb(var(--color-primary-50)); color: rgb(var(--color-primary-700)); box-shadow: 0 0 0 1px rgb(var(--color-primary-200)); }
.dark .lang-btn.is-active { border-color: rgb(var(--color-primary-600)); background: rgb(var(--color-primary-900)); color: rgb(var(--color-primary-300)); box-shadow: 0 0 0 1px rgb(var(--color-primary-700)); }
.lang-btn.has-value { border-color: rgb(var(--color-primary-400)); color: rgb(var(--color-primary-600)); }
.dark .lang-btn.has-value { border-color: rgb(var(--color-primary-600)); color: rgb(var(--color-primary-300)); }
.lang-btn.is-active:hover { background: rgb(var(--color-primary-50)); border-color: rgb(var(--color-primary-500)); }
.dark .lang-btn.is-active:hover { background: rgb(var(--color-primary-900)); border-color: rgb(var(--color-primary-600)); }
.lang-btn {
background: transparent;
cursor: pointer;
transition: all 0.15s ease;
}
.lang-btn:hover {
background: #f8f9fa;
border-color: #dee2e6;
}
.dark .lang-btn:hover {
background: #343a40;
border-color: #6c757d;
}
.lang-btn.is-active {
border-color: #3b82f6;
background: #eff6ff;
color: #1d4ed8;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.25);
}
.dark .lang-btn.is-active {
border-color: #2563eb;
background: #1e3a8a;
color: #dbeafe;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.5);
}
.lang-btn.has-value {
border-color: #3b82f6;
color: #1d4ed8;
}
.dark .lang-btn.has-value {
border-color: #3b82f6;
color: #dbeafe;
}
.lang-btn.is-active:hover {
background: #eff6ff;
border-color: #3b82f6;
}
.dark .lang-btn.is-active:hover {
background: #1e3a8a;
border-color: #2563eb;
}
/* Ensure hidden class works properly */
[data-input-wrapper].hidden {
display: none !important;
}
[data-input-wrapper]:not(.hidden) {
display: block !important;
}
</style>
<script>
(function () {
function init(root) {
console.log('Initializing multilang widget:', root);
var fieldName = root.getAttribute("data-field-name");
if (!fieldName) return;
if (!fieldName) {
console.log('No field name found');
return;
}
var buttons = root.querySelectorAll(".lang-btn[data-lang-code]");
var inputsRoot = root.querySelector("[data-inputs]");
if (!inputsRoot) return;
if (!inputsRoot) {
console.log('No inputs root found');
return;
}
console.log('Found', buttons.length, 'language buttons');
// First, hide all wrappers
inputsRoot.querySelectorAll('[data-input-wrapper]').forEach(function (w) {
w.classList.add("hidden");
});
var hasActiveLanguage = false;
var withValue = [];
@ -91,6 +146,7 @@
btn.classList.add("is-active");
wrapper.classList.remove("hidden");
hasActiveLanguage = true;
console.log('Initializing with active language:', code);
}
} else {
withoutValue.push(btn);
@ -104,6 +160,7 @@
if (firstWrapper) {
firstBtn.classList.add("is-active");
firstWrapper.classList.remove("hidden");
console.log('Initializing with first language:', firstCode);
}
}
@ -116,27 +173,51 @@
buttons.forEach(function (btn) {
btn.addEventListener("click", function () {
console.log('Language button clicked:', btn.getAttribute("data-lang-code"));
var code = btn.getAttribute("data-lang-code");
var wrapper = inputsRoot.querySelector('[data-input-wrapper][data-lang-code="' + code + '"]');
if (!wrapper) return;
if (!wrapper) {
console.log('No wrapper found for code:', code);
return;
}
var isActive = btn.classList.contains("is-active");
console.log('Button is active:', isActive);
// Remove active class from all buttons
buttons.forEach(function (b) { b.classList.remove("is-active"); });
inputsRoot.querySelectorAll('[data-input-wrapper]').forEach(function (w) { w.classList.add("hidden"); });
if (!isActive) {
btn.classList.add("is-active");
wrapper.classList.remove("hidden");
var input = wrapper.querySelector('input, textarea');
if (input) { setTimeout(function(){ input.focus(); }, 50); }
var hidden = root.querySelector('input[type="hidden"][name="' + fieldName + '"]');
if (hidden) {
var result = [];
inputsRoot.querySelectorAll('[data-input-wrapper]').forEach(function (w) {
var c = w.getAttribute('data-lang-code');
var inp = w.querySelector('input, textarea');
if (inp && inp.value && inp.value.trim() !== '') { result.push({ language_code: c, title: inp.value }); }
});
try { hidden.value = JSON.stringify(result); } catch (e) {}
// Hide all input wrappers
inputsRoot.querySelectorAll('[data-input-wrapper]').forEach(function (w) {
w.classList.add("hidden");
console.log('Hiding wrapper for:', w.getAttribute('data-lang-code'));
});
// Always show the clicked wrapper and hide others
btn.classList.add("is-active");
wrapper.classList.remove("hidden");
console.log('Showing wrapper for:', code);
var input = wrapper.querySelector('input, textarea');
if (input) {
setTimeout(function(){ input.focus(); }, 50);
}
var hidden = root.querySelector('input[type="hidden"][name="' + fieldName + '"]');
if (hidden) {
var result = [];
inputsRoot.querySelectorAll('[data-input-wrapper]').forEach(function (w) {
var c = w.getAttribute('data-lang-code');
var inp = w.querySelector('input, textarea');
if (inp && inp.value && inp.value.trim() !== '') {
result.push({ language_code: c, title: inp.value });
}
});
try {
hidden.value = JSON.stringify(result);
console.log('Updated hidden field value:', hidden.value);
} catch (e) {
console.error('JSON stringify error:', e);
}
}
});
@ -159,15 +240,28 @@
if (btn2) btn2.classList.remove('has-value');
}
});
try { hidden.value = JSON.stringify(result); } catch (e) {}
try { hidden.value = JSON.stringify(result); } catch (e) { console.error('JSON stringify error:', e); }
});
});
}
}
document.addEventListener("DOMContentLoaded", function () {
function initializeWidgets() {
console.log('Initializing all multilang widgets');
document.querySelectorAll('[data-multilang-json]').forEach(init);
});
}
// Try multiple initialization methods
if (document.readyState === 'loading') {
document.addEventListener("DOMContentLoaded", initializeWidgets);
} else {
// DOM is already loaded
initializeWidgets();
}
// Also try after a short delay to ensure everything is ready
setTimeout(initializeWidgets, 100);
document.addEventListener("formset:added", function (event) {
var newFormset = event.detail.formsetRow;
if (newFormset) {
@ -179,3 +273,5 @@

15
utils/multilang_json_widget.py

@ -5,6 +5,7 @@ import json
from django.conf import settings
from django.forms.widgets import Media, Widget
from django.template.loader import get_template
try:
from dj_language.models import Language # type: ignore
@ -171,5 +172,19 @@ class MultiLanguageJSONWidget(Widget):
prefix = f"{name}__"
return not any(k.startswith(prefix) for k in data.keys())
def render(self, name, value, attrs=None, renderer=None):
"""
Override render method to use regular Django template loader
instead of form renderer
"""
if value is None:
value = ''
context = self.get_context(name, value, attrs)
template = get_template(self.template_name)
return template.render(context)
Loading…
Cancel
Save