From 0d2e6b782fab77e430a72220cdd23c105700c5bf Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Mon, 14 Sep 2026 12:54:31 +0330 Subject: [PATCH] feat(backend): multilingual companion_type, restrict madhhab choices, and fix orphan bookmarks - hadis: convert companion_type to multilingual JSONField with localized serialization - hadis: restrict MadhhabChoices to SHIA and SUNNI with default SHIA - bookmark: filter out orphan/phantom bookmarks and deactivate inactive content bookmarks - podcast: enhance UserPlaylistListAPIView to filter directly by bookmarked podcasts - utils/mixins: support numeric ID lookup and enhance Gone410ViewMixin / CanonicalSlugViewSetMixin - runner: optimize docker pre-build cleanup - docs: add technical migration guide for Flutter offline SQLite hadith sync --- FLUTTER_SQLITE_MIGRATION_GUIDE.md | 293 ++++++++++++++++++ apps/article/views.py | 5 + apps/bookmark/views/bookmark.py | 56 +++- ...r_hadisdatabaseversion_version_and_more.py | 28 ++ apps/hadis/models/transmitter.py | 10 +- apps/hadis/serializers/hadis.py | 7 +- apps/podcast/views.py | 29 +- apps/video/views.py | 5 +- config/settings/test.py | 2 + runner.sh | 12 +- utils/mixins.py | 18 +- 11 files changed, 425 insertions(+), 40 deletions(-) create mode 100644 FLUTTER_SQLITE_MIGRATION_GUIDE.md create mode 100644 apps/hadis/migrations/0041_alter_hadisdatabaseversion_version_and_more.py diff --git a/FLUTTER_SQLITE_MIGRATION_GUIDE.md b/FLUTTER_SQLITE_MIGRATION_GUIDE.md new file mode 100644 index 0000000..985c6ee --- /dev/null +++ b/FLUTTER_SQLITE_MIGRATION_GUIDE.md @@ -0,0 +1,293 @@ +# راهنمای فنی مهاجرت دریافت و کش احادیث به دیتابیس SQLite آفلاین (Single-File Architecture) + +> **مخاطب:** توسعه‌دهنده فلاتر (تیم فرانت‌اند / موبایل) +> **هدف:** حذف پردازش سنگین جیسون احادیث (بیش از ۳۳ مگابایت)، حذف کرش و هنگ رم در Hive، و پیاده‌سازی دریافت مستقیم فایل فشرده SQLite دیتابیس احادیث و تفکیک تفاسیر. + +--- + +## ۱. خلاصه تغییرات و چرایی مهاجرت + +### مشکل قبلی: +1. دانلود اندپوینت `/api/hadis/sync/hadis/` حجمی معادل **۳۳ مگابایت جیسون خام** داشت. +2. پارس کردن این حجم از جیسون و اینسرت در Hive باعث افت فریم، فریز شدن UI و مصرف بیش از ۲۰۰ مگابایت رم در گوشی می‌شد. +3. تفاسیر دسته‌بندی‌ها به ازای تک‌تک احادیث درون جیسون تکرار می‌شد (یک متن طولانی تفسیر ۱۰ بار در ۱۰ حدیث مختلف یک دسته کپی می‌شد!). + +### معماری جدید (سرور جنگو): +1. **فایل واحد دیتابیس آماده:** سرور یک فایل پایگاه‌داده بهینه، ایندکس‌شده و فشرده با Gzip به نام **`hadis_offline.sqlite.gz`** (حجم حدود ۲ تا ۴ مگابایت) آماده کرده است که مستقیماً از طریق Nginx دانلود می‌شود. +2. **تفکیک کامل تفاسیر:** تفاسیر دسته‌بندی در یک جدول مجزا قرار گرفته‌اند و متن‌های تکراری حذف شده‌اند. +3. **اندپوینت نسخه پایدار:** اپلیکیشن نسخه دیتابیس محلی خود را با سرور مقایسه می‌کند و فقط در صورت تغییر نسخه، فایل دیتابیس را دانلود و جایگزین می‌کند. + +--- + +## ۲. اندپوینت‌های جدید و به‌روزشده بک‌اند + +### الف) اندپوینت اختصاصی دریافت نسخه دیتابیس SQLite: +```http +GET /api/hadis/sync/database/ +``` +**پاسخ نمونه:** +```json +{ + "version": 2, + "download_url": "https://dovodi.online/media/hadis_db/hadis_offline.sqlite.gz", + "file_size": 2845620, + "checksum_md5": "d41d8cd98f00b204e9800998ecf8427e", + "hadis_count": 1050, + "categories_count": 48, + "is_active": true, + "notes": "به‌روزرسانی احادیث ماه مبارک", + "created_at": "2026-09-10T10:15:00Z" +} +``` + +### ب) اندپوینت قبلی نسخه‌بندی (Backward Compatible): +اندپوینت `/api/hadis/sync/version/` نیز حفظ شده و کلید `sqlite_database` را در بدنه خود برمی‌گرداند تا در صورتی که ساختار فعلی فلاتر به آن متصل است، کدی شکسته نشود. + +--- + +## ۳. ساختار جداول درون فایل SQLite (`hadis_offline.sqlite`) + +فایل دیتابیس دانلود شده شامل جداول استاندارد زیر است: + +### ۱. جدول `hadiths` (احادیث) +| نام ستون | نوع داده | توضیحات | +| :--- | :--- | :--- | +| `id` | INTEGER (PK) | شناسه حدیث | +| `slug` | TEXT (INDEX) | اسلاگ یکتای حدیث | +| `category_id` | INTEGER (INDEX) | شناسه دسته‌بندی | +| `category_slug` | TEXT | اسلاگ دسته | +| `title` | TEXT | عنوان چندزبانه (JSON) | +| `title_narrator` | TEXT | راوی عنوان (JSON) | +| `text` | TEXT | متن عربی حدیث | +| `translation_json` | TEXT | لیست ترجمه‌ها (JSON) | +| `address` | TEXT | آدرس منبع حدیث (JSON) | +| `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 | سلسله راویان (Transmitters) | +| `explanations_json` | TEXT | توضیحات و شروح | + +### ۲. جدول `category_interpretations` (تفاسیر دسته‌بندی - تفکیک‌شده) +| نام ستون | نوع داده | توضیحات | +| :--- | :--- | :--- | +| `id` | INTEGER (PK) | شناسه تفسیر | +| `category_id` | INTEGER (INDEX) | شناسه دسته‌بندی مربوطه | +| `title` | TEXT | عنوان تفسیر | +| `slug` | TEXT | اسلاگ تفسیر | +| `narrator` | TEXT | راوی تفسیر | +| `text` | TEXT | متن تفصیلی تفسیر | +| `translation` | TEXT | ترجمه | +| `references_json` | TEXT | کتاب‌ها و منابع تفسیر | +| `links_json` | TEXT | پیوندها | + +### ۳. جدول `hadith_corrections` (تصحیحات حدیث) +| نام ستون | نوع داده | توضیحات | +| :--- | :--- | :--- | +| `id` | INTEGER (PK) | شناسه تصحیح | +| `hadis_id` | INTEGER (INDEX) | شناسه حدیث متصل | +| `title` | TEXT | عنوان | +| `slug` | TEXT | اسلاگ | +| `narrator` | TEXT | راوی | +| `description` | TEXT | شرح | +| `translation` | TEXT | ترجمه | +| `references_json` | TEXT | منابع تصحیح | +| `images_json` | TEXT | تصاویر مدارک | +| `links_json` | TEXT | پیوندها | + +### ۴. جدول `categories` (دسته‌ها) +شامل `id`, `title`, `slug`, `source_type`, `sect_type`. + +--- + +## ۴. چرخه سناریوی دریافت و کش در فلاتر (Implementation Flow) + +``` +[Start App] + │ + ▼ +1. فراخوانی GET /api/hadis/sync/database/ + │ + ▼ +2. آیا دیتابیس محلی وجود ندارد یا version سرور > version ذخیره شده در SharedPreferences است؟ + ├─► خیر: مستقیماً از SQLite محلی لود کن (آفلاین ۱۰۰٪ در ۰ ثانیه). + │ + └─► بله: + 3. نمایش لودینگ / پروگرس‌بار دانلود دیتابیس + 4. دانلود فایل hadis_offline.sqlite.gz از Nginx با Dio (با قابلیت onReceiveProgress) + 5. بررسی سلامت فایل با هش MD5 (اختیاری ولی توصیه شده) + 6. آنزیپ کردن (decompress) فایل Gzip به hadis.sqlite در دایرکتوری getDatabasesPath() + 7. پاک کردن فایل موقت .gz + 8. ذخیره شماره نسخه جدید در SharedPreferences + 9. رفرش کردن کوبیت‌ها و آماده‌سازی نمایش +``` + +--- + +## ۵. راهنمای گام‌به‌گام پیاده‌سازی کدهای فلاتر + +### گام اول: وابستگی‌های مورد نیاز در `pubspec.yaml` +```yaml +dependencies: + sqflite: ^2.3.3+1 + path: ^1.9.0 + path_provider: ^2.1.3 + archive: ^3.6.1 # برای باز کردن فایل Gzip + crypto: ^3.0.3 # برای بررسی هش MD5 +``` + +--- + +### گام دوم: ایجاد کلاس مدیریت دیتابیس SQLite +یک فایل در مسیر `lib/src/features/dobodbi/hadith/data/datasources/local/hadith_database_helper.dart` ایجاد کنید: + +```dart +import 'dart:io'; +import 'package:path/path.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:archive/archive.dart'; + +class HadithDatabaseHelper { + static const String _dbFileName = 'hadis.sqlite'; + static Database? _database; + + static Future get database async { + if (_database != null && _database!.isOpen) return _database!; + _database = await _initDb(); + return _database!; + } + + static Future getDatabasePath() async { + final dbFolder = await getDatabasesPath(); + return join(dbFolder, _dbFileName); + } + + static Future _initDb() async { + final path = await getDatabasePath(); + return await openDatabase(path, readOnly: true); + } + + /// اکسترکت فایل فشرده دانلود شده مستقیماً در دایرکتوری دیتابیس + static Future replaceDatabaseWithGzip(File gzFile) async { + // اگر دیتابیس باز است، ابتدا بسته شود + if (_database != null && _database!.isOpen) { + await _database!.close(); + _database = null; + } + + final targetPath = await getDatabasePath(); + final bytes = await gzFile.readAsBytes(); + + // Gzip decode + final decompressedData = GZipDecoder().decodeBytes(bytes); + + // نوشتن فایل دیتابیس نهایی + final targetFile = File(targetPath); + await targetFile.writeAsBytes(decompressedData, flush: true); + + // راه‌اندازی مجدد اتصال + _database = await openDatabase(targetPath, readOnly: true); + } +} +``` + +--- + +### گام سوم: سرویس دانلود و همگام‌سازی (Sync Manager) + +در `hadith_datasource.dart` متد دانلود اضافه کنید: + +```dart +Future syncHadithDatabase({ + required String downloadUrl, + required int targetVersion, + required Function(int received, int total) onProgress, +}) async { + final tempDir = await getTemporaryDirectory(); + final tempGzPath = '${tempDir.path}/hadis_temp.sqlite.gz'; + final tempFile = File(tempGzPath); + + // دانلود مستقیم فایل با قابلیت گزارش پیشرفت + await _dio.download( + downloadUrl, + tempGzPath, + onReceiveProgress: onProgress, + ); + + // اکسترکت در دیتابیس + await HadithDatabaseHelper.replaceDatabaseWithGzip(tempFile); + + // حذف فایل تمپ + if (await tempFile.exists()) { + await tempFile.delete(); + } + + // ذخیره نسخه جدید در حافظه محلی + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('hadith_db_version', targetVersion); +} +``` + +--- + +### گام چهارم: کوئری گرفتن احادیث و تفاسیر از SQLite + +در دیتاسورس محلی (`HadithLocalDataSource`): + +#### ۱. دریافت احادیث یک دسته‌بندی با صفحه‌بندی فوق‌سریع: +```dart +Future> getHadithsByCategory(int categoryId, {int limit = 20, int offset = 0}) async { + final db = await HadithDatabaseHelper.database; + final results = await db.query( + 'hadiths', + where: 'category_id = ?', + whereArgs: [categoryId], + limit: limit, + offset: offset, + ); + + return results.map((row) => _mapRowToHadithModel(row)).toList(); +} +``` + +#### ۲. دریافت تفاسیر دسته‌بندی (تفکیک‌شده): +```dart +Future> getCategoryInterpretations(int categoryId) async { + final db = await HadithDatabaseHelper.database; + final results = await db.query( + 'category_interpretations', + where: 'category_id = ?', + whereArgs: [categoryId], + ); + + return results.map((row) => HadithInterpretationModel.fromSqliteRow(row)).toList(); +} +``` + +#### ۳. جستجوی فوق‌سریع متنی (Full Text Search): +```dart +Future> searchHadiths(String keyword) async { + final db = await HadithDatabaseHelper.database; + final results = await db.rawQuery(''' + SELECT * FROM hadiths + WHERE text LIKE ? OR translation_json LIKE ? + LIMIT 50 + ''', ['%$keyword%', '%$keyword%']); + + return results.map((row) => _mapRowToHadithModel(row)).toList(); +} +``` + +--- + +## ۶. مزایای این تغییر برای اپ فلاتر + +1. **کاهش مصرف اینترنت:** حجم دانلود کل دیتابیس از ۳۳ مگابایت به **کمتر از ۳ مگابایت** می‌رسد (۹۰٪ صرفه‌جویی). +2. **زمان سینک صفر:** دیگر نیازی به پارس کردن حلقه‌ای جیسون در فلاتر و ایجاد آبجکت‌های Hive نیست؛ به محض Unzip شدن، دیتابیس بلافاصله آماده استفاده است. +3. **مصرف رم بسیار ناچیز:** به جای نگهداری تمام احادیث در رم (Boxهای Hive)، داده‌ها با صفحه‌بندی (`LIMIT` و `OFFSET`) بر اساس نیاز اسکرول کاربر از دیسک خوانده می‌شوند. +4. **توقف کامل کرش‌های دیتابیس آفلاین در نسخه‌های ضعیف اندروید/iOS.** diff --git a/apps/article/views.py b/apps/article/views.py index 751b08a..36fae93 100755 --- a/apps/article/views.py +++ b/apps/article/views.py @@ -259,6 +259,11 @@ class ArticleDetailAPIView(Gone410ViewMixin, CanonicalSlugViewSetMixin, generics queryset = self.filter_queryset(self.get_queryset()) slug_val = self.kwargs.get(self.lookup_field or 'slug') if slug_val: + if str(slug_val).isdigit(): + obj = queryset.filter(pk=int(slug_val)).first() + if obj: + self.check_object_permissions(self.request, obj) + return obj import urllib.parse unquoted_slug = urllib.parse.unquote(slug_val) obj = queryset.filter( diff --git a/apps/bookmark/views/bookmark.py b/apps/bookmark/views/bookmark.py index f4212ab..8b51c2b 100644 --- a/apps/bookmark/views/bookmark.py +++ b/apps/bookmark/views/bookmark.py @@ -262,6 +262,39 @@ class RemoveBookmarkListView(APIView): return Response(response_data, status=status.HTTP_200_OK) +def _get_valid_content_ids(service, content_ids): + if not content_ids: + return set() + service = (service or '').lower() + try: + if service == 'library': + from apps.library.models import Book + return set(Book.objects.filter(id__in=content_ids, status=True).values_list('id', flat=True)) + elif service == 'video_playlist': + from apps.video.models import VideoPlaylist + return set(VideoPlaylist.objects.filter(id__in=content_ids, status=True).values_list('id', flat=True)) + elif service in ('video', 'video_item'): + from apps.video.models import Video + return set(Video.objects.filter(id__in=content_ids, status=True).values_list('id', flat=True)) + elif service == 'podcast': + from apps.podcast.models import Podcast, PodcastPlaylist + p_ids = set(Podcast.objects.filter(id__in=content_ids, status=True).values_list('id', flat=True)) + pl_ids = set(PodcastPlaylist.objects.filter(id__in=content_ids, status=True).values_list('id', flat=True)) + return p_ids | pl_ids + elif service == 'podcast_playlist': + from apps.podcast.models import PodcastPlaylist + return set(PodcastPlaylist.objects.filter(id__in=content_ids, status=True).values_list('id', flat=True)) + elif service in ('article', 'blog'): + from apps.article.models import Article + return set(Article.objects.filter(id__in=content_ids, status=True).values_list('id', flat=True)) + elif service in ('hadith', 'hadith_correction'): + from apps.hadith.models import Hadith + return set(Hadith.objects.filter(id__in=content_ids, status=True).values_list('id', flat=True)) + except Exception: + pass + return set(content_ids) + + class BookmarkStatusView(APIView): """ Return the count of bookmarks for each service for the current user. @@ -289,8 +322,10 @@ class BookmarkStatusView(APIView): service_code = service_choice[0] # Get the service code (e.g., 'library') service_name = service_choice[1] # Get the service display name (e.g., 'Library') - # Count bookmarks for this service - count = user_bookmarks.filter(service=service_code).count() + bms_for_service = user_bookmarks.filter(service=service_code) + content_ids = list(bms_for_service.values_list('content_id', flat=True)) + valid_ids = _get_valid_content_ids(service_code, content_ids) + count = len(valid_ids) # Add to results service_counts[service_code] = { @@ -339,6 +374,21 @@ class BookmarkListView(APIView): bookmarks = bookmarks.filter(service=service) bookmarks = bookmarks.order_by('-created_at') - serializer = BookmarkSerializer(bookmarks, many=True) + valid_bookmarks = [] + by_service = {} + for b in bookmarks: + by_service.setdefault(b.service, []).append(b) + + for srv, b_list in by_service.items(): + content_ids = [b.content_id for b in b_list] + valid_ids = _get_valid_content_ids(srv, content_ids) + for b in b_list: + if b.content_id in valid_ids: + valid_bookmarks.append(b) + else: + b.status = False + b.save(update_fields=['status']) + + serializer = BookmarkSerializer(valid_bookmarks, many=True) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/hadis/migrations/0041_alter_hadisdatabaseversion_version_and_more.py b/apps/hadis/migrations/0041_alter_hadisdatabaseversion_version_and_more.py new file mode 100644 index 0000000..d93051a --- /dev/null +++ b/apps/hadis/migrations/0041_alter_hadisdatabaseversion_version_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.30 on 2026-09-14 12:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0040_hadisdatabaseversion'), + ] + + operations = [ + migrations.AlterField( + model_name='hadisdatabaseversion', + name='version', + field=models.PositiveIntegerField(default=1, verbose_name='شماره نسخه دیتابیس'), + ), + migrations.AlterField( + model_name='transmitters', + name='companion_type', + field=models.JSONField(blank=True, default=list, null=True, verbose_name='Companion Type'), + ), + migrations.AlterField( + model_name='transmitters', + name='madhhab', + field=models.CharField(choices=[('shia', 'Shia'), ('sunni', 'Sunni')], default='shia', max_length=20, verbose_name='Madhhab/School of Thought'), + ), + ] diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index 9a5487e..dbfe554 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -138,12 +138,6 @@ class Transmitters(LowercaseSlugMixin, models.Model): class MadhhabChoices(models.TextChoices): SHIA = 'shia', _('Shia') SUNNI = 'sunni', _('Sunni') - # HANAFI = 'hanafi', _('Hanafi') - # MALIKI = 'maliki', _('Maliki') - # SHAFII = 'shafii', _('Shafi\'i') - # HANBALI = 'hanbali', _('Hanbali') - OTHER = 'other', _('Other') - UNKNOWN = 'unknown', _('Unknown') # Basic Information full_name = models.JSONField(default = list , verbose_name=_('Full Name')) @@ -178,7 +172,7 @@ class Transmitters(LowercaseSlugMixin, models.Model): # New fields tadlis = models.BooleanField(default=False, verbose_name=_('Tadlis')) ikhtilat = models.BooleanField(default=False, verbose_name=_('Ikhtilat')) - companion_type = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Companion Type')) + companion_type = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Companion Type')) # Religious & Academic Information reliability = models.ForeignKey( @@ -191,7 +185,7 @@ class Transmitters(LowercaseSlugMixin, models.Model): madhhab = models.CharField( max_length=20, choices=MadhhabChoices.choices, - default=MadhhabChoices.UNKNOWN, + default=MadhhabChoices.SHIA, verbose_name=_('Madhhab/School of Thought') ) diff --git a/apps/hadis/serializers/hadis.py b/apps/hadis/serializers/hadis.py index b9c4321..1082497 100644 --- a/apps/hadis/serializers/hadis.py +++ b/apps/hadis/serializers/hadis.py @@ -664,6 +664,8 @@ class TransmitterDetailSerializer(serializers.ModelSerializer): lived_in = LocalizedField() died_in = LocalizedField() description = LocalizedField() + companion_type = LocalizedField() + arabic_companion_type = ArabicLocalizedField(source='companion_type') reliability = serializers.SerializerMethodField() relatives = serializers.SerializerMethodField() father = serializers.SerializerMethodField() @@ -679,7 +681,7 @@ class TransmitterDetailSerializer(serializers.ModelSerializer): 'death_year_hijri','age_at_death','reliability', 'madhhab',"in_sahih_muslim","in_sahih_bukhari", "description",'generation','share_link', - 'tadlis', 'ikhtilat', 'companion_type', 'relatives', + 'tadlis', 'ikhtilat', 'companion_type', 'arabic_companion_type', 'relatives', 'father', 'sons' ] @@ -858,7 +860,8 @@ class TransmitterSyncSerializer(serializers.ModelSerializer): 'share_link': obj.share_link, 'tadlis': obj.tadlis, 'ikhtilat': obj.ikhtilat, - 'companion_type': obj.companion_type, + 'companion_type': get_localized_text(obj.companion_type, request), + 'arabic_companion_type': get_arabic_localized_text(obj.companion_type), 'relatives': self._get_relatives(obj, request), } diff --git a/apps/podcast/views.py b/apps/podcast/views.py index d1d7cbb..83103a9 100644 --- a/apps/podcast/views.py +++ b/apps/podcast/views.py @@ -302,30 +302,31 @@ class UserPlaylistListAPIView(generics.ListAPIView): return super().get(request, *args, **kwargs) def get_queryset(self): + # Filter by bookmarks if provided + is_bookmark = (self.request.query_params.get('is_bookmark') or self.request.query_params.get('is_bookmarked') or '').lower() + if is_bookmark in ('true', '1') and self.request.user.is_authenticated: + from apps.bookmark.models.bookmark import Bookmark + bookmarked_ids = Bookmark.objects.filter( + user=self.request.user, + service=Bookmark.ServiceChoices.PODCAST, + status=True + ).values_list('content_id', flat=True) + return Podcast.objects.filter( + id__in=bookmarked_ids, + status=True + ).order_by('-created_at') + # Get all active podcasts that are in the user's playlist user_playlist_podcasts = UserPlaylist.objects.filter( user=self.request.user, status=True ).values_list('podcast_id', flat=True) - queryset = Podcast.objects.filter( + return Podcast.objects.filter( id__in=user_playlist_podcasts, status=True ).order_by('-created_at') - # Filter by bookmarks if provided - is_bookmark = self.request.query_params.get('is_bookmark', '').lower() - if is_bookmark == 'true' and self.request.user.is_authenticated: - from apps.bookmark.models.bookmark import Bookmark - bookmarked_ids = Bookmark.objects.filter( - user=self.request.user, - service=Bookmark.ServiceChoices.PODCAST, - status=True - ).values_list('content_id', flat=True) - queryset = queryset.filter(id__in=bookmarked_ids) - - return queryset - class UserPlaylistCreateAPIView(generics.CreateAPIView): """ diff --git a/apps/video/views.py b/apps/video/views.py index 28b6f29..e194944 100644 --- a/apps/video/views.py +++ b/apps/video/views.py @@ -380,12 +380,15 @@ class VideoListAPIView(generics.ListAPIView): return queryset -class VideoPlaylistDetailAPIView(generics.RetrieveAPIView): +class VideoPlaylistDetailAPIView(Gone410ViewMixin, CanonicalSlugViewSetMixin, generics.RetrieveAPIView): serializer_class = VideoPlaylistDetailSerializer permission_classes = (IsAuthenticated,) authentication_classes = [TokenAuthentication] lookup_field = 'slug' + active_field_name = 'status' + active_expected_value = True + @swagger_auto_schema( operation_description="Get video playlist details by slug", tags=["Dobodbi - Video"], diff --git a/config/settings/test.py b/config/settings/test.py index 34653b3..acfbec6 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -10,3 +10,5 @@ PASSWORD_HASHERS = [ ] MEDIA_ROOT = BASE_DIR / 'test_media' +ALLOWED_HOSTS = ['*'] + diff --git a/runner.sh b/runner.sh index dcf09e0..0b21ee5 100755 --- a/runner.sh +++ b/runner.sh @@ -1,6 +1,4 @@ #!/bin/bash - -# Check if the '--dev' argument is provided if [ "$1" == "--dev" ]; then echo "Run development docker" DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 docker compose -f docker-compose.yml up -d --build @@ -27,14 +25,9 @@ else pkill -9 -f "docker-buildx.*dovodi-backend" 2>/dev/null || true pkill -9 -f "docker.*dovodi-backend" 2>/dev/null || true - echo "🧹 [1/4] Pre-build cleanup: freeing up disk space safely..." - # ۱. پاکسازی کامل تمام کش‌های بیلد داکر بدون دست زدن به کانتینرهای فعال - docker builder prune -a -f 2>/dev/null || true - - # ۲. پاکسازی ایمیج‌های قدیمی و معلق (Dangling) + echo "🧹 [1/4] Pre-build cleanup: freeing dangling images safely..." + # فقط پاکسازی ایمیج‌های معلق بدون دست زدن به لایه‌های کش بیلد (تا بیلدها در چند ثانیه تمام شوند) docker image prune -f 2>/dev/null || true - - # ۳. پاکسازی کش پکیج‌های سیستم‌عامل apt-get clean 2>/dev/null || true echo "📥 [2/4] Pulling latest backend changes from master..." @@ -46,7 +39,6 @@ else DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 docker compose -f docker-compose.prod.yml up -d --build echo "✨ [4/4] Post-build cleanup..." - # پاکسازی ایمیج قدیمی که بعد از بیلد جدید معلق شده است docker image prune -f 2>/dev/null || true echo "SUCCESS" > /tmp/dovodi_backend_deploy_status.txt diff --git a/utils/mixins.py b/utils/mixins.py index 39fa30b..f4bbed9 100644 --- a/utils/mixins.py +++ b/utils/mixins.py @@ -70,7 +70,7 @@ class CanonicalSlugViewSetMixin: def dispatch(self, request, *args, **kwargs): if request.method == 'GET': slug = kwargs.get('slug') - if slug and any(char.isupper() for char in slug): + if slug and not str(slug).isdigit() and any(char.isupper() for char in slug): resolved = request.resolver_match new_kwargs = {**resolved.kwargs, 'slug': slug.lower()} @@ -98,13 +98,27 @@ class Gone410ViewMixin: """ Mixin for DRF detail Views/ViewSets to return 410 Gone instead of 404 for inactive/soft-deleted records. + Also transparently supports lookup by numeric ID (primary key) or slug. """ active_field_name = 'status' active_expected_value = True def get_object(self): + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + lookup_val = self.kwargs.get(lookup_url_kwarg) or self.kwargs.get('slug') or self.kwargs.get('pk') + + if lookup_val is not None and str(lookup_val).isdigit(): + queryset = self.filter_queryset(self.get_queryset()) + obj = queryset.filter(pk=int(lookup_val)).first() + if obj is not None: + self.check_object_permissions(self.request, obj) + active_val = getattr(obj, self.active_field_name, None) + if active_val is not None and active_val != self.active_expected_value: + raise ResourceGone() + return obj + obj = super().get_object() active_val = getattr(obj, self.active_field_name, None) - if active_val != self.active_expected_value: + if active_val is not None and active_val != self.active_expected_value: raise ResourceGone() return obj \ No newline at end of file