diff --git a/RELATIONAL_MIGRATION_ROADMAP.md b/RELATIONAL_MIGRATION_ROADMAP.md index f8b0431..6219a9d 100644 --- a/RELATIONAL_MIGRATION_ROADMAP.md +++ b/RELATIONAL_MIGRATION_ROADMAP.md @@ -8,7 +8,7 @@ - [x] **فاز ۰: سیستم شهرها و مکان‌ها (`City`)** *(انجام شده)* - [x] **فاز ۱: کنیه‌ها (`Kunya` / `kunyas`)** — رابطه چندبه‌چند (M2M) *(تکمیل شده)* - [x] **فاز ۲: ویژگی‌ها (`TransmitterFeature` / `features`)** — رابطه چندبه‌چند (M2M) *(تکمیل شده)* -- [ ] **فاز ۳: وضعیت صحابی بودن (`SahabiStatus` / `sahabi_status`)** — رابطه تک‌به‌چند (ForeignKey / M2O) +- [x] **فاز ۳: وضعیت صحابی بودن (`SahabiStatus` / `sahabi_status`)** — رابطه تک‌به‌چند (ForeignKey / M2O) *(تکمیل شده)* - [ ] **فاز ۴: تفکیک و اتصال رتبه‌بندی و وثاقت راوی و مؤلف (`NarratorRating` و `reliability_levels`)** — رابطه ForeignKey / M2O - [ ] **فاز ۵: مذاهب راویان (`Madhhab` / `madhabs`)** — رابطه چندبه‌چند (M2M) - [ ] **فاز ۶: داینامیک‌سازی انواع نسبت‌های خویشاوندی (`RelationType` / `relatives`)** — رابطه ForeignKey در `TransmitterRelative` @@ -78,30 +78,29 @@ --- -### فاز ۳: وضعیت صحابی بودن (`SahabiStatus` / `companion_type`) 🔲 -* **وضعیت:** `در انتظار اجرا` +### فاز ۳: وضعیت صحابی بودن (`SahabiStatus` / `companion_type`) ✅ +* **وضعیت:** `تکمیل شده` * **نوع رابطه طبق داکیومنت کارفرما:** **Many-to-One / ForeignKey** - > راوی دارای یک وضعیت مشخص صحابی بودن است. -* **مراحل پیاده‌سازی بک‌اند:** - 1. **مدل `SahabiStatus`:** - - فیلد `code` یا `slug`: کد شناسه (مانند `sahabi`, `saw_prophet`, `contemporary`, `minor_sahabi`, `mukhadram`, `unproven`, `disputed`) - - فیلد `title`: چندزبانه (`JSONField`) به ۴ زبان بر اساس ۷ رکورد استاندارد داکیومنت کارفرما: - 1. صحابی (сподвижник) - 2. رؤیت پیامبر (видел) - 3. درک زمان پیامبر (застал) - 4. صحابی صغیر (малый) - 5. مخضرم (мухадрам) - 6. اثبات‌نشده (недоказано) - 7. اختلافی / محل بحث (спорный) - 2. **اتصال به `Transmitters`:** - - فیلد `sahabi_status = models.ForeignKey(SahabiStatus, null=True, blank=True, on_delete=models.SET_NULL, related_name='transmitters')` - - سینک خودکار با فیلد متنی قدیمی `companion_type`. - 3. **مایگریشن و Seeding:** - - اعمال مایگریشن و ایجاد خودکار ۷ رکورد پیش‌فرض فوق به همراه ترجمه عربی، فارسی، انگلیسی و روسی. - 4. **API و سریالایزر:** - - اندپوینت `/api/hadis/admin/sahabi-statuses/` و پوشش در جزئیات راوی. -* **مراحل پیاده‌سازی داشبورد:** - 1. افزودن دراپ‌داون تک‌انتخابی وضعیت صحابی به فرم راوی با امکان پاک کردن یا تغییر وضعیت. + > راوی دارای یک وضعیت مشخص صحابی بودن است (صحابی، رؤیت، درک زمان، صحابی صغیر، مخضرم، اثبات‌نشده، اختلافی). +* **کارهای انجام‌شده:** + 1. **مدل `SahabiStatus`:** با فیلدهای `title` (چندزبانه به ۴ زبان: fa, ar, en, ru)، `slug` (یکتا و خودکار)، متدهای هلپر `get_title()` و تایم‌استمپ‌ها. + 2. **اتصال به `Transmitters`:** افزودن کلید خارجی `sahabi_status = models.ForeignKey('hadis.SahabiStatus', on_delete=models.SET_NULL, null=True, blank=True, related_name='transmitters')` با حفظ و همگام‌سازی خودکار فیلد قدیمی `companion_type`. + 3. **مایگریشن و Seeding (۰۶۱):** ساخت جدول و ایجاد خودکار ۷ رکورد استاندارد داکیومنت کارفرما به زبان‌های عربی، فارسی، انگلیسی و روسی: + 1. صحابی (`companion` / `сподвижник`) + 2. رؤیت پیامبر (`saw-the-prophet` / `видел`) + 3. درک زمان پیامبر (`contemporary` / `застал`) + 4. صحابی صغیر (`minor-companion` / `малый`) + 5. مخضرم (`mukhadram` / `мухадрам`) + 6. اثبات‌نشده (`unproven` / `недоказано`) + 7. اختلافی / محل بحث (`disputed` / `спорный`) + 4. **سریالایزرها و APIها:** + - ساخت `AdminSahabiStatusSerializer` و `DovodiSahabiStatusViewSet`. + - ثبت در روت‌های `/api/hadis/admin/dovodi-sahabi-statuses/` و `/api/hadis/admin/sahabi-statuses/`. + - پشتیبانی در `AdminTransmitterListSerializer` و `AdminTransmitterDetailSerializer` همراه با همگام‌سازی دوطرفه. + 5. **داشبورد (فرانت‌اند):** + - ساخت کامپوننت ماژولار تک‌انتخابی `SahabiStatusSelect.tsx` (با کش مشترک در سطح حافظه، قابلیت جستجو و پاک کردن انتخاب، همراه با مودال ساخت سریع با هدر چندزبانه استاندارد و فوتر چسبان). + - قرارگیری فیلد وضعیت صحابی در فرم راوی (`TransmitterFormPage.tsx`). + - افزودن تب، ستون‌ها و دیالوگ مدیریت کامل «وضعیت صحابی» به صفحه آرشیو راویان (`TransmittersArchivePage.tsx`). --- diff --git a/apps/hadis/migrations/0061_sahabi_status_and_relation.py b/apps/hadis/migrations/0061_sahabi_status_and_relation.py new file mode 100644 index 0000000..006538b --- /dev/null +++ b/apps/hadis/migrations/0061_sahabi_status_and_relation.py @@ -0,0 +1,116 @@ +# Generated by Django 4.2.30 on 2026-10-20 14:49 + +from django.db import migrations, models +import django.db.models.deletion + + +SAHABI_STATUS_DATA = [ + { + "slug": "companion", + "title": [ + {"language_code": "ar", "text": "صحابي", "title": "صحابي"}, + {"language_code": "fa", "text": "صحابی", "title": "صحابی"}, + {"language_code": "en", "text": "Companion", "title": "Companion"}, + {"language_code": "ru", "text": "Сподвижник", "title": "Сподвижник"}, + ], + }, + { + "slug": "saw-the-prophet", + "title": [ + {"language_code": "ar", "text": "رأى النبي", "title": "رأى النبي"}, + {"language_code": "fa", "text": "رؤیت پیامبر", "title": "رؤیت پیامبر"}, + {"language_code": "en", "text": "Saw the Prophet", "title": "Saw the Prophet"}, + {"language_code": "ru", "text": "Видел Пророка", "title": "Видел Пророка"}, + ], + }, + { + "slug": "contemporary", + "title": [ + {"language_code": "ar", "text": "أدرك زمان النبي", "title": "أدرك زمان النبي"}, + {"language_code": "fa", "text": "درک زمان پیامبر", "title": "درک زمان پیامبر"}, + {"language_code": "en", "text": "Contemporary of the Prophet", "title": "Contemporary of the Prophet"}, + {"language_code": "ru", "text": "Застал время Пророка", "title": "Застал время Пророка"}, + ], + }, + { + "slug": "minor-companion", + "title": [ + {"language_code": "ar", "text": "صحابي صغير", "title": "صحابي صغير"}, + {"language_code": "fa", "text": "صحابی صغیر", "title": "صحابی صغیر"}, + {"language_code": "en", "text": "Minor Companion", "title": "Minor Companion"}, + {"language_code": "ru", "text": "Малый сподвижник", "title": "Малый сподвижник"}, + ], + }, + { + "slug": "mukhadram", + "title": [ + {"language_code": "ar", "text": "مخضرم", "title": "مخضرم"}, + {"language_code": "fa", "text": "مخضرم", "title": "مخضرم"}, + {"language_code": "en", "text": "Mukhadram", "title": "Mukhadram"}, + {"language_code": "ru", "text": "Мухадрам", "title": "Мухадрам"}, + ], + }, + { + "slug": "unproven", + "title": [ + {"language_code": "ar", "text": "غير مثبت", "title": "غير مثبت"}, + {"language_code": "fa", "text": "اثبات‌نشده", "title": "اثبات‌نشده"}, + {"language_code": "en", "text": "Unproven", "title": "Unproven"}, + {"language_code": "ru", "text": "Не доказано", "title": "Не доказано"}, + ], + }, + { + "slug": "disputed", + "title": [ + {"language_code": "ar", "text": "مختلف فيه", "title": "مختلف فيه"}, + {"language_code": "fa", "text": "اختلافی / محل بحث", "title": "اختلافی / محل بحث"}, + {"language_code": "en", "text": "Disputed", "title": "Disputed"}, + {"language_code": "ru", "text": "Спорный", "title": "Спорный"}, + ], + }, +] + + +def seed_sahabi_statuses(apps, schema_editor): + SahabiStatus = apps.get_model('hadis', 'SahabiStatus') + for item in SAHABI_STATUS_DATA: + SahabiStatus.objects.get_or_create( + slug=item["slug"], + defaults={"title": item["title"]}, + ) + + +def reverse_seed(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0060_transmitter_feature_and_relations'), + ] + + operations = [ + migrations.CreateModel( + name='SahabiStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.JSONField(default=list, verbose_name='Title')), + ('slug', models.SlugField(allow_unicode=True, blank=True, max_length=255, unique=True, verbose_name='slug')), + ('description', models.JSONField(blank=True, default=list, verbose_name='Description')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ], + options={ + 'verbose_name': 'Sahabi Status', + 'verbose_name_plural': 'Sahabi Statuses', + 'ordering': ['id'], + }, + ), + migrations.AddField( + model_name='transmitters', + name='sahabi_status', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transmitters', to='hadis.sahabistatus', verbose_name='Sahabi Status'), + ), + migrations.RunPython(seed_sahabi_statuses, reverse_seed), + ] diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index a68c68c..952eb2c 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -248,6 +248,47 @@ class TransmitterFeature(LowercaseSlugMixin, models.Model): + + +class SahabiStatus(LowercaseSlugMixin, models.Model): + slug_source_field = 'title' + + title = models.JSONField(default=list, verbose_name=_('Title')) + slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True, unique=True, allow_unicode=True) + description = models.JSONField(default=list, blank=True, verbose_name=_('Description')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated At')) + + class Meta: + verbose_name = _('Sahabi Status') + verbose_name_plural = _('Sahabi Statuses') + ordering = ['id'] + + def __str__(self): + if not self.title or not isinstance(self.title, list): + return str(self.id) + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') or tr.get('title', '') + first = self.title[0] + if isinstance(first, dict): + return first.get('text', '') or first.get('title', '') + return str(self.id) + + def get_title(self, lang: str = 'en') -> Optional[str]: + if not self.title or not isinstance(self.title, list): + return None + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') or tr.get('title', '') + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') or tr.get('title', '') + if isinstance(self.title[0], dict): + return self.title[0].get('text', '') or self.title[0].get('title', '') + return None + + class Transmitters(LowercaseSlugMixin, models.Model): slug_source_field = 'full_name' # class ReliabilityLevel(models.TextChoices): @@ -342,6 +383,14 @@ class Transmitters(LowercaseSlugMixin, models.Model): tadlis = models.BooleanField(default=False, verbose_name=_('Tadlis')) ikhtilat = models.BooleanField(default=False, verbose_name=_('Ikhtilat')) companion_type = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Companion Type')) + sahabi_status = models.ForeignKey( + 'hadis.SahabiStatus', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='transmitters', + verbose_name=_('Sahabi Status'), + ) # Religious & Academic Information reliability = models.ForeignKey( diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index 0b745fb..2aeabb3 100644 --- a/apps/hadis/serializers/serializers_admin.py +++ b/apps/hadis/serializers/serializers_admin.py @@ -10,7 +10,7 @@ from ..models import ( Hadis, HadisCategory, HadisStatus, HadisTag, HadisCorrection, HadisInterpretation, HadisCollection, HadisSect, HadisInCollection, HadisTransmitter, HadisReference, ReferenceImage, BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAuthorRutba, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, BookResearcher, BookEditor, Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, ScholarlyOpinion, TransmitterOpinion, TransmitterOriginalText, - City, Kunya, TransmitterFeature, YearValue, NarratorDate, + City, Kunya, TransmitterFeature, SahabiStatus, YearValue, NarratorDate, CorrectionReference, CorrectionReferenceImage, InterpretationReference, InterpretationReferenceImage, OriginalTextReference, OriginalTextReferenceImage, ContentRelease @@ -2046,6 +2046,18 @@ class AdminTransmitterFeatureSerializer(serializers.ModelSerializer): read_only_fields = ["id", "slug", "created_at", "updated_at"] + + +class AdminSahabiStatusSerializer(serializers.ModelSerializer): + title = serializers.JSONField() + description = serializers.JSONField(required=False, default=list) + + class Meta: + model = SahabiStatus + fields = ["id", "title", "slug", "description", "created_at", "updated_at"] + read_only_fields = ["id", "slug", "created_at", "updated_at"] + + def _build_admin_date_preview(obj, date_type: str, raw_val: str): if not raw_val or not str(raw_val).strip(): return None @@ -2060,6 +2072,7 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer): reliability_detail = AdminTransmitterReliabilitySerializer(source="reliability", read_only=True) kunyas_detail = AdminKunyaSerializer(source="kunyas", many=True, read_only=True) features_detail = AdminTransmitterFeatureSerializer(source="features_list", many=True, read_only=True) + sahabi_status_detail = AdminSahabiStatusSerializer(source="sahabi_status", read_only=True) full_name = serializers.JSONField() known_as = serializers.JSONField() nickname = serializers.JSONField() @@ -2086,6 +2099,8 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer): "nickname", "kunyas_detail", "features_detail", + "sahabi_status", + "sahabi_status_detail", "reliability", "reliability_detail", "madhhab", @@ -2124,6 +2139,13 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): required=False, ) features_detail = AdminTransmitterFeatureSerializer(source="features_list", many=True, read_only=True) + sahabi_status = serializers.PrimaryKeyRelatedField( + queryset=SahabiStatus.objects.all(), + required=False, + allow_null=True, + ) + sahabi_status_detail = AdminSahabiStatusSerializer(source="sahabi_status", read_only=True) + companion_type = serializers.JSONField(required=False, default=list) known_as = serializers.JSONField() nickname = serializers.JSONField() origin = serializers.JSONField(required=False, allow_null=True) @@ -2181,7 +2203,11 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): "kunyas_detail", "features", "features_list", - "features_detail", + "features_detail", "sahabi_status_detail", + "sahabi_status", + "sahabi_status_detail", + "companion_type", + "sahabi_status", "known_as", "nickname", "gender", @@ -2381,6 +2407,10 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): else: instance.features = [] instance.save(update_fields=['features']) + if "sahabi_status" in validated_data: + st = validated_data.get("sahabi_status") + instance.companion_type = st.title if st else [] + instance.save(update_fields=['companion_type']) return instance def update(self, instance, validated_data): @@ -2413,6 +2443,10 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): else: instance.features = [] instance.save(update_fields=['features']) + if "sahabi_status" in validated_data: + st = validated_data.get("sahabi_status") + instance.companion_type = st.title if st else [] + instance.save(update_fields=['companion_type']) return instance diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index 3b0b4c0..3c321c0 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -46,7 +46,8 @@ from .views_admin import ( DovodiCityViewSet, DovodiKunyaViewSet, - DovodiTransmitterFeatureViewSet + DovodiTransmitterFeatureViewSet, + DovodiSahabiStatusViewSet ) @@ -149,6 +150,8 @@ admin_router.register(r'dovodi-kunyas', DovodiKunyaViewSet, basename='dovodi-kun admin_router.register(r'kunyas', DovodiKunyaViewSet, basename='admin-kunyas') admin_router.register(r'dovodi-transmitter-features', DovodiTransmitterFeatureViewSet, basename='dovodi-transmitter-features') admin_router.register(r'transmitter-features', DovodiTransmitterFeatureViewSet, basename='admin-transmitter-features') +admin_router.register(r'dovodi-sahabi-statuses', DovodiSahabiStatusViewSet, basename='dovodi-sahabi-statuses') +admin_router.register(r'sahabi-statuses', DovodiSahabiStatusViewSet, basename='admin-sahabi-statuses') diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index 0684796..122d96c 100644 --- a/apps/hadis/views_admin.py +++ b/apps/hadis/views_admin.py @@ -40,6 +40,7 @@ from .models import ( Kunya, TransmitterFeature, + SahabiStatus, ) @@ -129,6 +130,7 @@ from .serializers.serializers_admin import ( AdminKunyaSerializer, AdminTransmitterFeatureSerializer, + AdminSahabiStatusSerializer, ) @@ -2937,3 +2939,21 @@ class DovodiTransmitterFeatureViewSet(ModelViewSet): | Q(slug__icontains=search_query) ) return queryset + + +class DovodiSahabiStatusViewSet(ModelViewSet): + serializer_class = AdminSahabiStatusSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = SahabiStatus.objects.all().order_by("id") + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter( + Q(title__icontains=search_query) + | Q(slug__icontains=search_query) + ) + return queryset