diff --git a/apps/hadis/admin/version.py b/apps/hadis/admin/version.py index 270892b..a7b7f3c 100644 --- a/apps/hadis/admin/version.py +++ b/apps/hadis/admin/version.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from unfold.admin import ModelAdmin from utils.admin import dovoodi_admin_site -from ..models import ContentRelease +from ..models import ContentRelease, HadisDatabaseVersion class ContentReleaseAdmin(ModelAdmin): @@ -25,5 +25,22 @@ class ContentReleaseAdmin(ModelAdmin): ) -# Register model with the custom admin site +class HadisDatabaseVersionAdmin(ModelAdmin): + """Admin for HadisDatabaseVersion SQLite files""" + list_display = ('version', 'file_size_display', 'hadis_count', 'categories_count', 'is_active', 'created_at') + list_filter = ('is_active', 'created_at') + search_fields = ('version', 'notes', 'checksum_md5') + readonly_fields = ('created_at', 'checksum_md5', 'file_size') + ordering = ('-version',) + + def file_size_display(self, obj): + if obj.file_size: + return f"{obj.file_size / (1024 * 1024):.2f} MB" + return "0 MB" + file_size_display.short_description = _("حجم فایل") + + +# Register models with the custom admin site dovoodi_admin_site.register(ContentRelease, ContentReleaseAdmin) +dovoodi_admin_site.register(HadisDatabaseVersion, HadisDatabaseVersionAdmin) + diff --git a/apps/hadis/management/commands/export_hadis_sqlite.py b/apps/hadis/management/commands/export_hadis_sqlite.py new file mode 100644 index 0000000..67b79cd --- /dev/null +++ b/apps/hadis/management/commands/export_hadis_sqlite.py @@ -0,0 +1,415 @@ +import os +import gzip +import json +import sqlite3 +import hashlib +from pathlib import Path +from django.core.management.base import BaseCommand +from django.conf import settings +from django.db import transaction +from apps.hadis.models import ( + Hadis, + HadisCategory, + HadisInterpretation, + HadisCorrection, + HadisDatabaseVersion +) + + +class Command(BaseCommand): + help = "Export Hadis and Categories into an optimized, compressed SQLite database for offline mobile sync." + + def add_arguments(self, parser): + parser.add_argument( + '--notes', + type=str, + default='', + help='Release notes for this SQLite database version' + ) + parser.add_argument( + '--force-version', + type=int, + default=None, + help='Force a specific version number' + ) + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS("Starting Hadis SQLite database generation (Single-File Architecture)...")) + + # 1. Determine destination and version number + latest_ver = HadisDatabaseVersion.objects.order_by('-version').first() + if options['force_version'] is not None: + new_version = options['force_version'] + else: + new_version = (latest_ver.version + 1) if latest_ver else 1 + + output_dir = Path(settings.MEDIA_ROOT) / 'hadis_db' + output_dir.mkdir(parents=True, exist_ok=True) + + temp_db_path = output_dir / "temp_hadis_build.sqlite" + final_gz_name = "hadis_offline.sqlite.gz" + final_gz_path = output_dir / final_gz_name + + if temp_db_path.exists(): + temp_db_path.unlink() + + # 2. Initialize SQLite database + conn = sqlite3.connect(str(temp_db_path)) + cursor = conn.cursor() + + # Optimize SQLite for bulk writes + cursor.execute("PRAGMA synchronous = OFF;") + cursor.execute("PRAGMA journal_mode = MEMORY;") + cursor.execute("PRAGMA encoding = 'UTF-8';") + + self.stdout.write("Creating SQLite tables and indexes...") + self._create_tables(cursor) + + # 3. Export data + categories_count = self._export_categories(cursor) + interp_count = self._export_category_interpretations(cursor) + hadis_count, corr_count = self._export_hadiths_and_corrections(cursor) + + conn.commit() + + # Create indexes after insertion for maximum performance + self.stdout.write("Building indexes...") + self._create_indexes(cursor) + conn.commit() + conn.close() + + # 4. Gzip compress directly to the single target file + self.stdout.write(f"Compressing database to single file: {final_gz_name}...") + hasher = hashlib.md5() + temp_gz_path = output_dir / "temp_hadis_build.sqlite.gz" + + with open(temp_db_path, 'rb') as f_in: + with gzip.open(temp_gz_path, 'wb', compresslevel=9) as f_out: + while chunk := f_in.read(1024 * 1024): + f_out.write(chunk) + hasher.update(chunk) + + checksum = hasher.hexdigest() + temp_size = temp_db_path.stat().st_size + + # Atomic replacement: replace final_gz_path with temp_gz_path + if final_gz_path.exists(): + final_gz_path.unlink() + temp_gz_path.rename(final_gz_path) + file_size = final_gz_path.stat().st_size + + # Clean up any remaining temp files in output_dir + temp_db_path.unlink(missing_ok=True) + for extra_file in output_dir.iterdir(): + if extra_file.name != final_gz_name: + try: + extra_file.unlink(missing_ok=True) + except OSError: + pass + + # 5. Record version in database (Single Active Record) + with transaction.atomic(): + # Delete old records to keep DB clean + HadisDatabaseVersion.objects.all().delete() + db_version = HadisDatabaseVersion.objects.create( + version=new_version, + file=f"hadis_db/{final_gz_name}", + file_size=file_size, + checksum_md5=checksum, + hadis_count=hadis_count, + categories_count=categories_count, + is_active=True, + notes=options['notes'], + ) + + self.stdout.write(self.style.SUCCESS( + f"Successfully updated single Hadis SQLite DB to v{new_version}!\n" + f" - Uncompressed size: {temp_size / (1024*1024):.2f} MB\n" + f" - Gzipped size: {file_size / (1024*1024):.2f} MB\n" + f" - Hadiths: {hadis_count}\n" + f" - Categories: {categories_count}\n" + f" - Interpretations: {interp_count}\n" + f" - Corrections: {corr_count}\n" + f" - MD5 Checksum: {checksum}\n" + f" - File path: {final_gz_path}" + )) + + def _create_tables(self, cursor): + cursor.executescript(""" + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY, + title TEXT, + slug TEXT UNIQUE, + source_type TEXT, + sect_type TEXT + ); + + CREATE TABLE IF NOT EXISTS category_interpretations ( + id INTEGER PRIMARY KEY, + category_id INTEGER, + title TEXT, + slug TEXT, + narrator TEXT, + text TEXT, + translation TEXT, + references_json TEXT, + links_json TEXT, + FOREIGN KEY (category_id) REFERENCES categories (id) + ); + + CREATE TABLE IF NOT EXISTS hadiths ( + id INTEGER PRIMARY KEY, + slug TEXT UNIQUE, + category_id INTEGER, + category_slug TEXT, + title TEXT, + title_narrator TEXT, + text TEXT, + translation_json TEXT, + address TEXT, + share_link TEXT, + hadis_status_id INTEGER, + status_title TEXT, + status_color TEXT, + status_main_color_code TEXT, + status_text TEXT, + tags_json TEXT, + references_json TEXT, + reference_images_json TEXT, + narrators_json TEXT, + explanations_json TEXT, + FOREIGN KEY (category_id) REFERENCES categories (id) + ); + + CREATE TABLE IF NOT EXISTS hadith_corrections ( + id INTEGER PRIMARY KEY, + hadis_id INTEGER, + title TEXT, + slug TEXT, + narrator TEXT, + description TEXT, + translation TEXT, + share_link TEXT, + address TEXT, + images_json TEXT, + references_json TEXT, + links_json TEXT, + FOREIGN KEY (hadis_id) REFERENCES hadiths (id) + ); + """) + + def _create_indexes(self, cursor): + cursor.executescript(""" + CREATE INDEX IF NOT EXISTS idx_cat_slug ON categories(slug); + CREATE INDEX IF NOT EXISTS idx_interp_cat ON category_interpretations(category_id); + CREATE INDEX IF NOT EXISTS idx_hadith_cat ON hadiths(category_id); + CREATE INDEX IF NOT EXISTS idx_hadith_slug ON hadiths(slug); + CREATE INDEX IF NOT EXISTS idx_corr_hadis ON hadith_corrections(hadis_id); + """) + + def _export_categories(self, cursor): + cats = HadisCategory.objects.all() + rows = [ + ( + c.id, + json.dumps(c.title, ensure_ascii=False) if isinstance(c.title, (dict, list)) else (c.title or ''), + c.slug, + getattr(c, 'source_type', None), + getattr(c, 'sect_type', None), + ) + for c in cats + ] + cursor.executemany( + "INSERT INTO categories (id, title, slug, source_type, sect_type) VALUES (?, ?, ?, ?, ?)", + rows + ) + return len(rows) + + def _export_category_interpretations(self, cursor): + interps = HadisInterpretation.objects.select_related('category').prefetch_related( + 'references__book_reference', 'references__images' + ).all() + + rows = [] + for inp in interps: + refs_list = [] + for ref in inp.references.all(): + book = ref.book_reference + refs_list.append({ + 'id': ref.id, + 'book_title': getattr(book, 'title', None), + 'address': getattr(ref, 'address', None), + 'edition': getattr(ref, 'edition_id', None), + 'volume': getattr(ref, 'volume_id', None), + 'pages': getattr(ref, 'pages', None), + 'hadith_number': getattr(ref, 'hadith_number', None), + }) + + rows.append(( + inp.id, + inp.category_id, + json.dumps(inp.title, ensure_ascii=False) if isinstance(inp.title, (dict, list)) else (inp.title or ''), + inp.slug, + inp.narrator or '', + inp.text or '', + json.dumps(inp.translation, ensure_ascii=False) if isinstance(inp.translation, (dict, list)) else (inp.translation or ''), + json.dumps(refs_list, ensure_ascii=False), + json.dumps(inp.links or [], ensure_ascii=False), + )) + + cursor.executemany( + """INSERT INTO category_interpretations + (id, category_id, title, slug, narrator, text, translation, references_json, links_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + rows + ) + return len(rows) + + def _export_hadiths_and_corrections(self, cursor): + hadiths = ( + Hadis.objects + .filter(status=True) + .select_related('category', 'hadis_status') + .prefetch_related( + 'tags', + 'references__book_reference__author', + 'references__images', + 'transmitters__transmitter__reliability', + 'transmitters__uncertain_transmitters__reliability', + 'transmitters__narrator_layer', + 'hadiscorrection_set__references__images', + ) + .order_by('id') + ) + + hadis_rows = [] + correction_rows = [] + + for h in hadiths: + # 1. Status Block + status_id = h.hadis_status.id if h.hadis_status else None + status_title = h.hadis_status.title if h.hadis_status else None + status_color = h.hadis_status.color if h.hadis_status else None + status_code = h.hadis_status.main_color_code if h.hadis_status else None + + # 2. Tags + tags_list = [{'id': t.id, 'title': t.title} for t in h.tags.all()] + + # 3. References & Reference Images + refs_list = [] + ref_imgs_list = [] + for ref in h.references.all(): + book = ref.book_reference + authors = [{'id': book.author.id, 'name': book.author.name}] if book and book.author else [] + refs_list.append({ + 'id': ref.id, + 'title': book.title if book else None, + 'authors': authors, + 'share_link': getattr(book, 'share_link', None) if book else None, + }) + for img in ref.images.all(): + ref_imgs_list.append({ + 'id': img.id, + 'thumbnail': img.thumbnail.url if img.thumbnail else None, + 'priority': img.priority, + }) + + # 4. Narrators + transmitters_data = [] + for tr in h.transmitters.all(): + t = tr.transmitter + rel = t.reliability if t else None + transmitters_data.append({ + 'id': t.id if t else None, + 'name': t.full_name if t else None, + 'slug': t.slug if t else None, + 'layer_id': tr.narrator_layer_id, + 'is_uncertain': tr.is_uncertain, + 'reliability': { + 'id': rel.id, + 'title': rel.title, + 'slug': rel.slug, + 'color': rel.color, + 'main_color_code': rel.main_color_code, + } if rel else None, + }) + + # 5. Explanations + explanations_data = [] + if hasattr(h, 'explanations') and h.explanations: + for exp in h.explanations.all(): + explanations_data.append({ + 'title': exp.title, + 'detail': getattr(exp, 'detail', '') + }) + + hadis_rows.append(( + h.id, + h.slug, + h.category_id, + h.category.slug if h.category else None, + json.dumps(h.title, ensure_ascii=False) if isinstance(h.title, (dict, list)) else (h.title or ''), + json.dumps(h.title_narrator, ensure_ascii=False) if isinstance(h.title_narrator, (dict, list)) else (h.title_narrator or ''), + h.text or '', + json.dumps(h.translation or [], ensure_ascii=False), + json.dumps(h.address, ensure_ascii=False) if isinstance(h.address, (dict, list)) else (h.address or ''), + h.share_link or '', + status_id, + json.dumps(status_title, ensure_ascii=False) if isinstance(status_title, (dict, list)) else (status_title or ''), + status_color or '', + status_code or '', + json.dumps(h.hadis_status_text, ensure_ascii=False) if isinstance(h.hadis_status_text, (dict, list)) else (h.hadis_status_text or ''), + json.dumps(tags_list, ensure_ascii=False), + json.dumps(refs_list, ensure_ascii=False), + json.dumps(ref_imgs_list, ensure_ascii=False), + json.dumps({'transmitters': transmitters_data}, ensure_ascii=False), + json.dumps(explanations_data, ensure_ascii=False), + )) + + # 6. Corrections + for corr in h.hadiscorrection_set.all(): + corr_imgs = [] + corr_refs = [] + for cref in corr.references.all(): + for cimg in cref.images.all(): + if cimg.image: + corr_imgs.append({'id': cimg.id, 'image': cimg.image.url}) + corr_refs.append({ + 'id': cref.id, + 'address': getattr(cref, 'address', None), + }) + + correction_rows.append(( + corr.id, + h.id, + json.dumps(corr.title, ensure_ascii=False) if isinstance(corr.title, (dict, list)) else (corr.title or ''), + corr.slug, + corr.narrator or '', + corr.text or '', + json.dumps(corr.translation, ensure_ascii=False) if isinstance(corr.translation, (dict, list)) else (corr.translation or ''), + corr.share_link or '', + corr.address if hasattr(corr, 'address') else '', + json.dumps(corr_imgs, ensure_ascii=False), + json.dumps(corr_refs, ensure_ascii=False), + json.dumps(corr.links or [], ensure_ascii=False), + )) + + cursor.executemany( + """INSERT INTO hadiths ( + id, slug, category_id, category_slug, title, title_narrator, text, + translation_json, address, share_link, hadis_status_id, status_title, + status_color, status_main_color_code, status_text, tags_json, + references_json, reference_images_json, narrators_json, explanations_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + hadis_rows + ) + + cursor.executemany( + """INSERT INTO hadith_corrections ( + id, hadis_id, title, slug, narrator, description, translation, + share_link, address, images_json, references_json, links_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + correction_rows + ) + + return len(hadis_rows), len(correction_rows) diff --git a/apps/hadis/migrations/0040_hadisdatabaseversion.py b/apps/hadis/migrations/0040_hadisdatabaseversion.py new file mode 100644 index 0000000..2d6f29e --- /dev/null +++ b/apps/hadis/migrations/0040_hadisdatabaseversion.py @@ -0,0 +1,34 @@ +# Generated by Django on 2026-09-10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0039_bookvolume_file'), + ] + + operations = [ + migrations.CreateModel( + name='HadisDatabaseVersion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveIntegerField(unique=True, verbose_name='نسخه دیتابیس')), + ('file', models.FileField(upload_to='hadis_db/', verbose_name='فایل فشرده دیتابیس (.sqlite.gz)')), + ('file_size', models.BigIntegerField(default=0, verbose_name='حجم فایل (بایت)')), + ('checksum_md5', models.CharField(blank=True, max_length=64, verbose_name='هش MD5')), + ('hadis_count', models.PositiveIntegerField(default=0, verbose_name='تعداد احادیث')), + ('categories_count', models.PositiveIntegerField(default=0, verbose_name='تعداد دسته‌بندی‌ها')), + ('is_active', models.BooleanField(default=True, verbose_name='نسخه فعال')), + ('notes', models.TextField(blank=True, verbose_name='یادداشت‌های نسخه')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='آخرین به‌روزرسانی')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='زمان ایجاد')), + ], + options={ + 'verbose_name': 'نسخه دیتابیس SQLite احادیث', + 'verbose_name_plural': 'نسخه‌های دیتابیس SQLite احادیث', + 'ordering': ['-version'], + }, + ), + ] diff --git a/apps/hadis/models/version.py b/apps/hadis/models/version.py index 09b92c0..e9572c6 100644 --- a/apps/hadis/models/version.py +++ b/apps/hadis/models/version.py @@ -1,13 +1,41 @@ -from django.db import models -from django.utils.translation import gettext_lazy as _ - -class ContentRelease(models.Model): - version_name = models.CharField(max_length=50, verbose_name=_('Version Name')) # e.g., "v1.2 - Muharram Update" - published_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Published Date")) - description = models.TextField(blank=True, verbose_name=_("Release Description")) - - # Optional: is_active to easily rollback updates if something breaks - is_active = models.BooleanField(default=True, verbose_name=_("Active")) - - class Meta: - ordering = ['-published_at'] \ No newline at end of file +from django.db import models +from django.utils.translation import gettext_lazy as _ +import os + + +class ContentRelease(models.Model): + version_name = models.CharField(max_length=50, verbose_name=_('Version Name')) # e.g., "v1.2 - Muharram Update" + published_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Published Date")) + description = models.TextField(blank=True, verbose_name=_("Release Description")) + + # Optional: is_active to easily rollback updates if something breaks + is_active = models.BooleanField(default=True, verbose_name=_("Active")) + + class Meta: + ordering = ['-published_at'] + + +class HadisDatabaseVersion(models.Model): + """مدل نگهداری نسخه فعلی فایل فشرده SQLite احادیث (Single-File Architecture)""" + version = models.PositiveIntegerField(default=1, verbose_name=_("شماره نسخه دیتابیس")) + file = models.FileField(upload_to='hadis_db/', verbose_name=_("فایل فشرده دیتابیس (.sqlite.gz)")) + file_size = models.BigIntegerField(default=0, verbose_name=_("حجم فایل (بایت)")) + checksum_md5 = models.CharField(max_length=64, blank=True, verbose_name=_("هش MD5")) + hadis_count = models.PositiveIntegerField(default=0, verbose_name=_("تعداد احادیث")) + categories_count = models.PositiveIntegerField(default=0, verbose_name=_("تعداد دسته‌بندی‌ها")) + is_active = models.BooleanField(default=True, verbose_name=_("نسخه فعال")) + notes = models.TextField(blank=True, verbose_name=_("یادداشت‌های نسخه")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("آخرین به‌روزرسانی")) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("زمان ایجاد")) + + class Meta: + ordering = ['-version'] + verbose_name = _("نسخه دیتابیس SQLite احادیث") + verbose_name_plural = _("نسخه‌های دیتابیس SQLite احادیث") + + def __str__(self): + return f"Hadis SQLite DB v{self.version} ({self.hadis_count} احادیث)" + + @classmethod + def get_latest_active(cls): + return cls.objects.filter(is_active=True).order_by('-version').first() \ No newline at end of file diff --git a/apps/hadis/serializers/hadis.py b/apps/hadis/serializers/hadis.py index 9358968..db69b99 100644 --- a/apps/hadis/serializers/hadis.py +++ b/apps/hadis/serializers/hadis.py @@ -128,7 +128,7 @@ class HadisSyncSerializer(serializers.ModelSerializer): model = Hadis fields = [ # header (no-extend) - 'id', 'number', 'slug', 'category_id', 'category_slug', 'title', 'title_narrator', 'text', 'translation', + 'id', 'slug', 'category_id', 'category_slug', 'title', 'title_narrator', 'text', 'translation', # grouped sections 'detail', 'narrators', 'explanations', 'corrections', 'interpretations' ] @@ -170,7 +170,7 @@ class HadisSyncSerializer(serializers.ModelSerializer): {'id': a.id, 'name': get_localized_text(a.name, request)} for a in authors ], - 'description': book.description if book else None, + 'description': None, 'share_link': book.share_link if book else None, }) @@ -184,17 +184,9 @@ class HadisSyncSerializer(serializers.ModelSerializer): 'priority': img.priority, }) - # Process address_details - address_details_list = [] - if hasattr(obj, 'address_details') and obj.address_details and isinstance(obj.address_details, list): - address_details_list = sorted( - obj.address_details, - key=lambda x: x.get('priority', 0) - ) - return { 'address': get_localized_text(obj.address, request), - 'address_details': address_details_list, + 'address_details': [], 'hadis_status': status_block, 'status_text': get_localized_text(obj.hadis_status_text, request), 'share_link': obj.share_link, @@ -295,18 +287,17 @@ class HadisSyncSerializer(serializers.ModelSerializer): return explanations_list if explanations_list else None def get_corrections(self, obj): - """گرفتن لیست کامل تصحیحات با تمام جزئیات و رفرنس‌ها""" - # استفاده از سریالایزر Detail که در مرحله قبل ساختیم - return HadisCorrectionDetailSerializer( + """گرفتن لیست تصحیحات بهینه‌شده مخصوص سینک""" + return HadisSyncCorrectionSerializer( obj.hadiscorrection_set.all(), many=True, context=self.context ).data def get_interpretations(self, obj): - """گرفتن لیست کامل تفاسیر متصل به دسته‌بندی این حدیث با جزئیات کامل""" + """گرفتن لیست تفاسیر بهینه‌شده مخصوص سینک""" if obj.category: - return HadisInterpretationDetailSerializer( + return HadisSyncInterpretationSerializer( obj.category.interpretations.all(), many=True, context=self.context @@ -1737,6 +1728,18 @@ class HadisInterpretationDetailSerializer(serializers.ModelSerializer): return images_list +class HadisSyncCorrectionSerializer(HadisCorrectionDetailSerializer): + """نسخه سبک و بهینه تصحیحات مخصوص سینک بدون hadis_info""" + class Meta(HadisCorrectionDetailSerializer.Meta): + fields = ['id', 'title', 'slug', 'narrator', 'description', 'translation', 'share_link', 'address', 'images', 'references', 'links'] + + +class HadisSyncInterpretationSerializer(HadisInterpretationDetailSerializer): + """نسخه سبک و بهینه تفاسیر مخصوص سینک بدون category_info""" + class Meta(HadisInterpretationDetailSerializer.Meta): + fields = ['id', 'title', 'slug', 'narrator', 'text', 'translation', 'address', 'images', 'links', 'references'] + + class TransmitterOriginalTextDetailSerializer(serializers.ModelSerializer): title = LocalizedField() text = LocalizedField() diff --git a/apps/hadis/serializers/version.py b/apps/hadis/serializers/version.py index 4eb6036..2fab706 100644 --- a/apps/hadis/serializers/version.py +++ b/apps/hadis/serializers/version.py @@ -1,9 +1,38 @@ from rest_framework import serializers -from ..models import ContentRelease +from ..models import ContentRelease, HadisDatabaseVersion +from ..utils.urls import absolute_https_url + + +class HadisDatabaseVersionSerializer(serializers.ModelSerializer): + """سریالایزر دریافت آخرین اطلاعات فایل فشرده دیتابیس SQLite جهت دانلود و سینک آفلاین""" + download_url = serializers.SerializerMethodField() + + class Meta: + model = HadisDatabaseVersion + fields = [ + 'version', + 'download_url', + 'file_size', + 'checksum_md5', + 'hadis_count', + 'categories_count', + 'is_active', + 'notes', + 'created_at', + ] + + def get_download_url(self, obj): + if obj.file: + request = self.context.get('request') + if request: + return absolute_https_url(obj.file.url, request) + return obj.file.url + return None class ContentReleaseSyncSerializer(serializers.ModelSerializer): - """Serializer for syncing content release data for offline mode""" + """Serializer for syncing content release data for offline mode with SQLite support""" + sqlite_database = serializers.SerializerMethodField() class Meta: model = ContentRelease @@ -12,5 +41,12 @@ class ContentReleaseSyncSerializer(serializers.ModelSerializer): 'version_name', 'published_at', 'description', - 'is_active' + 'is_active', + 'sqlite_database', ] + + def get_sqlite_database(self, obj): + latest_db = HadisDatabaseVersion.get_latest_active() + if latest_db: + return HadisDatabaseVersionSerializer(latest_db, context=self.context).data + return None diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index c1dd5d2..7b6bebf 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -11,7 +11,7 @@ from .views.transmitter import ( ) from .views.reference import BookDetailView, BookReferencesView, BookReferenceSyncView, BookAttributeView, ReferenceExcerptsListView from .views.reference_v2 import BookReferenceV2DetailView, BookReferenceV2SyncView, BookReferenceV2ListView -from .views.version import ContentReleaseSyncView +from .views.version import ContentReleaseSyncView, HadisDatabaseVersionView from .views.info import HadisInfoView from .views_admin import ( AdminHadisCategoryViewSet, AdminHadisViewSet, AdminHadisCorrectionViewSet, AdminCorrectionReferenceViewSet, AdminHadisInterpretationViewSet, AdminHadisStatusViewSet, AdminHadisTagViewSet, AdminHadisCollectionViewSet, @@ -90,6 +90,7 @@ urlpatterns = [ path('sync/references/', cached_view(BookReferenceSyncView.as_view()), name='reference-sync'), path('v2/sync/references/', cached_view(BookReferenceV2SyncView.as_view()), name='reference-sync-v2'), path('sync/version/', ContentReleaseSyncView.as_view(), name='content-release-sync'), + path('sync/database/', HadisDatabaseVersionView.as_view(), name='hadis-database-sync'), path('sync/filters/', cached_view(HadisFiltersSyncAPIView.as_view()), name='hadis-filters-sync'), path('info/', cached_view(HadisInfoView.as_view()), name='hadis-info'), diff --git a/apps/hadis/views/version.py b/apps/hadis/views/version.py index c8d0dab..af9d9eb 100644 --- a/apps/hadis/views/version.py +++ b/apps/hadis/views/version.py @@ -1,8 +1,9 @@ from rest_framework.generics import RetrieveAPIView from rest_framework.response import Response +from rest_framework import status -from ..models import ContentRelease -from ..serializers import ContentReleaseSyncSerializer +from ..models import ContentRelease, HadisDatabaseVersion +from ..serializers.version import ContentReleaseSyncSerializer, HadisDatabaseVersionSerializer from ..docs import content_release_sync_swagger @@ -18,3 +19,23 @@ class ContentReleaseSyncView(RetrieveAPIView): def get_object(self): return ContentRelease.objects.filter(is_active=True).order_by('-published_at').first() + + +class HadisDatabaseVersionView(RetrieveAPIView): + """ + API view to get the latest pre-built SQLite database info and direct Nginx download URL + """ + serializer_class = HadisDatabaseVersionSerializer + + def get_object(self): + return HadisDatabaseVersion.get_latest_active() + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + if not instance: + return Response( + {"detail": "No active SQLite database version found."}, + status=status.HTTP_404_NOT_FOUND + ) + serializer = self.get_serializer(instance) + return Response(serializer.data) diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index 330e7c8..f4ae14a 100644 --- a/apps/hadis/views_admin.py +++ b/apps/hadis/views_admin.py @@ -990,6 +990,9 @@ class AdminContentReleaseViewSet(ModelViewSet): from .signals import invalidate_hadis_cache invalidate_hadis_cache() + # تریگر خودکار ساخت و فشرده‌سازی دیتابیس SQLite در پس‌زمینه + self._trigger_sqlite_export(notes=f"Auto-generated for version {version_name}") + return Response({ "status": "success", "message": f"Version incremented to {version_name}", @@ -997,10 +1000,30 @@ class AdminContentReleaseViewSet(ModelViewSet): "next_suggested_version": compute_next_version(version_name) }) + def _trigger_sqlite_export(self, notes=""): + """اجرای پس‌زمینه دستور تولید دیتابیس SQLite بدون متوقف کردن پاسخ HTTP""" + import threading + import logging + from django.core.management import call_command + + logger = logging.getLogger(__name__) + + def _task(): + try: + logger.info("Starting background SQLite export...") + call_command('export_hadis_sqlite', notes=notes) + logger.info("Background SQLite export completed successfully.") + except Exception as e: + logger.error(f"Failed to export SQLite in background: {e}", exc_info=True) + + thread = threading.Thread(target=_task, daemon=True) + thread.start() + def perform_create(self, serializer): - super().perform_create(serializer) + instance = serializer.save() from .signals import invalidate_hadis_cache invalidate_hadis_cache() + self._trigger_sqlite_export(notes=f"Auto-generated for created version {instance.version_name}") def perform_update(self, serializer): super().perform_update(serializer)