diff --git a/apps/account/models/user.py b/apps/account/models/user.py
index 577447a..5fe258f 100644
--- a/apps/account/models/user.py
+++ b/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):
diff --git a/apps/api/admin.py b/apps/api/admin.py
index 3a6f508..5f0ec4b 100644
--- a/apps/api/admin.py
+++ b/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']
diff --git a/apps/blog/admin.py b/apps/blog/admin.py
index cd281d1..9850a9c 100644
--- a/apps/blog/admin.py
+++ b/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')
\ No newline at end of file
+ readonly_fields = ('created_at', 'updated_at')
+
+
+ @display(description=_('Title'), )
+ def title_info(self, obj):
+ return str(obj.title)
\ No newline at end of file
diff --git a/apps/blog/management/commands/seed_blog_data.py b/apps/blog/management/commands/seed_blog_data.py
new file mode 100644
index 0000000..d958a0a
--- /dev/null
+++ b/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."))
\ No newline at end of file
diff --git a/apps/blog/models.py b/apps/blog/models.py
index 9d5c59e..ba24d59 100644
--- a/apps/blog/models.py
+++ b/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'),
diff --git a/apps/blog/urls.py b/apps/blog/urls.py
index 3bf342c..64de8f3 100644
--- a/apps/blog/urls.py
+++ b/apps/blog/urls.py
@@ -18,3 +18,7 @@ urlpatterns = [
+
+
+
+
diff --git a/config/settings/base.py b/config/settings/base.py
index 7101dd1..bfc2811 100755
--- a/config/settings/base.py
+++ b/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,
diff --git a/docs/MultiLanguageJSONWidget.md b/docs/MultiLanguageJSONWidget.md
new file mode 100644
index 0000000..4420eb2
--- /dev/null
+++ b/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 دوستانه برای مدیریت محتوای چندزبانه در ادمین.
+- پیادهسازی بهگونهای است که بدون وابستگی به اپ خاص، در کل پروژه قابل استفاده باشد.
+
+
+
+
+
+
diff --git a/seeds/images/blog1.jpeg b/seeds/images/blog1.jpeg
new file mode 100644
index 0000000..0843815
Binary files /dev/null and b/seeds/images/blog1.jpeg differ
diff --git a/seeds/images/blog2.jpeg b/seeds/images/blog2.jpeg
new file mode 100644
index 0000000..0ab39ed
Binary files /dev/null and b/seeds/images/blog2.jpeg differ
diff --git a/seeds/images/blog3.jpeg b/seeds/images/blog3.jpeg
new file mode 100644
index 0000000..76c04a5
Binary files /dev/null and b/seeds/images/blog3.jpeg differ
diff --git a/templates/utils/widgets/multilang_json_widget.html b/templates/utils/widgets/multilang_json_widget.html
index 6248d21..e468e14 100644
--- a/templates/utils/widgets/multilang_json_widget.html
+++ b/templates/utils/widgets/multilang_json_widget.html
@@ -6,8 +6,8 @@
{% for code in widget.languages %}
@@ -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;
+ }