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; + }