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)) number = str(random.randint(1000000000, 9999999999))
self.phone_number = f'{self.phone_number}:deleted{number}' self.phone_number = f'{self.phone_number}:deleted{number}'
self.email = f'{self.email}:deleted{number}' if self.email else None 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() self.save()
def save(self, *args, **kwargs): 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.models.thumbnailoptionmodels import ThumbnailOption
# from filer.admin.thumbnailoptionmodels import ThumbnailOptionAdmin as OriginalThumbnailOptionAdmin # from filer.admin.thumbnailoptionmodels import ThumbnailOptionAdmin as OriginalThumbnailOptionAdmin
from .models import Comment, AppVersion
admin.site.unregister(ThumbnailOption) admin.site.unregister(ThumbnailOption)
@ -73,3 +75,29 @@ class ThumbnailOptionAdmin(ModelAdmin):
from utils.admin import project_admin_site from utils.admin import project_admin_site
project_admin_site.register(ThumbnailOption, ThumbnailOptionAdmin) 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.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from unfold.decorators import display
from unfold.admin import ModelAdmin, TabularInline, StackedInline from unfold.admin import ModelAdmin, TabularInline, StackedInline
from unfold.contrib.forms.widgets import WysiwygWidget from unfold.contrib.forms.widgets import WysiwygWidget
from unfold.widgets import UnfoldAdminTextareaWidget, UnfoldAdminTextInputWidget, UnfoldAdminExpandableTextareaWidget from unfold.widgets import UnfoldAdminTextareaWidget, UnfoldAdminTextInputWidget, UnfoldAdminExpandableTextareaWidget
@ -16,9 +17,8 @@ class BlogContentForm(forms.ModelForm):
model = BlogContent model = BlogContent
fields = '__all__' fields = '__all__'
widgets = { 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 BlogAdminForm(forms.ModelForm):
class Meta: class Meta:
@ -50,10 +50,10 @@ class BlogAdmin(ModelAdmin):
Admin interface for Blog model using Django unfold Admin interface for Blog model using Django unfold
""" """
form = BlogAdminForm 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') list_filter = ('created_at', 'updated_at')
search_fields = ('title', 'slogan', 'summary') search_fields = ('title', 'slogan', 'summary')
prepopulated_fields = {'slug': ('title',)}
# prepopulated_fields = {'slug': ('title',)}
readonly_fields = ('views_count', 'created_at', 'updated_at') readonly_fields = ('views_count', 'created_at', 'updated_at')
fieldsets = ( fieldsets = (
@ -75,6 +75,10 @@ class BlogAdmin(ModelAdmin):
inlines = [BlogContentInline] inlines = [BlogContentInline]
@display(description=_('Title'), )
def title_info(self, obj):
return str(obj.title)
def get_queryset(self, request): def get_queryset(self, request):
queryset = super().get_queryset(request) queryset = super().get_queryset(request)
print(f'--get_queryset-->{queryset}') print(f'--get_queryset-->{queryset}')
@ -89,7 +93,7 @@ class BlogContentAdmin(ModelAdmin):
Admin interface for BlogContent model using Django unfold Admin interface for BlogContent model using Django unfold
""" """
form = BlogContentForm 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') list_filter = ('blog', 'created_at', 'updated_at')
search_fields = ('title', 'content', 'blog__title') search_fields = ('title', 'content', 'blog__title')
list_select_related = ('blog',) 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 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( thumbnail = models.ImageField(
upload_to='blog/thumbnails/%Y/%m/', upload_to='blog/thumbnails/%Y/%m/',
verbose_name=_('Thumbnail'), 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"), "title": _("Articles"),
"collapsible": True, "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 %} {% for code in widget.languages %}
<button type="button" <button type="button"
class="lang-btn px-3 py-1.5 rounded-md border transition-all duration-150 text-xs font-medium 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 }}"> data-lang-code="{{ code }}">
{{ code|upper }} {{ code|upper }}
</button> </button>
@ -52,26 +52,81 @@
.scrollbar-hover:hover::-webkit-scrollbar-thumb:hover { background: rgb(var(--color-base-400)); } .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)); } .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> </style>
<script> <script>
(function () { (function () {
function init(root) { function init(root) {
console.log('Initializing multilang widget:', root);
var fieldName = root.getAttribute("data-field-name"); 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 buttons = root.querySelectorAll(".lang-btn[data-lang-code]");
var inputsRoot = root.querySelector("[data-inputs]"); 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 hasActiveLanguage = false;
var withValue = []; var withValue = [];
@ -91,6 +146,7 @@
btn.classList.add("is-active"); btn.classList.add("is-active");
wrapper.classList.remove("hidden"); wrapper.classList.remove("hidden");
hasActiveLanguage = true; hasActiveLanguage = true;
console.log('Initializing with active language:', code);
} }
} else { } else {
withoutValue.push(btn); withoutValue.push(btn);
@ -104,6 +160,7 @@
if (firstWrapper) { if (firstWrapper) {
firstBtn.classList.add("is-active"); firstBtn.classList.add("is-active");
firstWrapper.classList.remove("hidden"); firstWrapper.classList.remove("hidden");
console.log('Initializing with first language:', firstCode);
} }
} }
@ -116,27 +173,51 @@
buttons.forEach(function (btn) { buttons.forEach(function (btn) {
btn.addEventListener("click", function () { btn.addEventListener("click", function () {
console.log('Language button clicked:', btn.getAttribute("data-lang-code"));
var code = btn.getAttribute("data-lang-code"); var code = btn.getAttribute("data-lang-code");
var wrapper = inputsRoot.querySelector('[data-input-wrapper][data-lang-code="' + 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"); 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"); }); 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'); 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); 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) { document.addEventListener("formset:added", function (event) {
var newFormset = event.detail.formsetRow; var newFormset = event.detail.formsetRow;
if (newFormset) { 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.conf import settings
from django.forms.widgets import Media, Widget from django.forms.widgets import Media, Widget
from django.template.loader import get_template
try: try:
from dj_language.models import Language # type: ignore from dj_language.models import Language # type: ignore
@ -171,5 +172,19 @@ class MultiLanguageJSONWidget(Widget):
prefix = f"{name}__" prefix = f"{name}__"
return not any(k.startswith(prefix) for k in data.keys()) 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