Browse Source

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
master
Mohsen Taba 5 days ago
parent
commit
0d2e6b782f
  1. 293
      FLUTTER_SQLITE_MIGRATION_GUIDE.md
  2. 5
      apps/article/views.py
  3. 56
      apps/bookmark/views/bookmark.py
  4. 28
      apps/hadis/migrations/0041_alter_hadisdatabaseversion_version_and_more.py
  5. 10
      apps/hadis/models/transmitter.py
  6. 7
      apps/hadis/serializers/hadis.py
  7. 29
      apps/podcast/views.py
  8. 5
      apps/video/views.py
  9. 2
      config/settings/test.py
  10. 12
      runner.sh
  11. 18
      utils/mixins.py

293
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<Database> get database async {
if (_database != null && _database!.isOpen) return _database!;
_database = await _initDb();
return _database!;
}
static Future<String> getDatabasePath() async {
final dbFolder = await getDatabasesPath();
return join(dbFolder, _dbFileName);
}
static Future<Database> _initDb() async {
final path = await getDatabasePath();
return await openDatabase(path, readOnly: true);
}
/// اکسترکت فایل فشرده دانلود شده مستقیماً در دایرکتوری دیتابیس
static Future<void> 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<void> 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<List<HadithModel>> 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<List<HadithInterpretationModel>> 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<List<HadithModel>> 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.**

5
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(

56
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)

28
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'),
),
]

10
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')
)

7
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),
}

29
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):
"""

5
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"],

2
config/settings/test.py

@ -10,3 +10,5 @@ PASSWORD_HASHERS = [
]
MEDIA_ROOT = BASE_DIR / 'test_media'
ALLOWED_HOSTS = ['*']

12
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

18
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
Loading…
Cancel
Save