Browse Source
feat(backend): multilingual companion_type, restrict madhhab choices, and fix orphan bookmarks
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 syncmaster
11 changed files with 425 additions and 40 deletions
-
293FLUTTER_SQLITE_MIGRATION_GUIDE.md
-
5apps/article/views.py
-
56apps/bookmark/views/bookmark.py
-
28apps/hadis/migrations/0041_alter_hadisdatabaseversion_version_and_more.py
-
10apps/hadis/models/transmitter.py
-
7apps/hadis/serializers/hadis.py
-
29apps/podcast/views.py
-
5apps/video/views.py
-
2config/settings/test.py
-
12runner.sh
-
18utils/mixins.py
@ -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.** |
||||
@ -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'), |
||||
|
), |
||||
|
] |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue