Browse Source

feat(transmitters): add SahabiStatus model, relation, and admin endpoints

- Add SahabiStatus model with multilingual title, description, and slug

- Add ForeignKey sahabi_status to Transmitters model

- Create migration 0061 to create SahabiStatus and populate from existing companion_type

- Register DovodiSahabiStatusViewSet and admin routes in urls.py

- Update RELATIONAL_MIGRATION_ROADMAP.md for sahabi_status migration
master
Mohsen Taba 1 week ago
parent
commit
1e3bc873d4
  1. 47
      RELATIONAL_MIGRATION_ROADMAP.md
  2. 116
      apps/hadis/migrations/0061_sahabi_status_and_relation.py
  3. 49
      apps/hadis/models/transmitter.py
  4. 38
      apps/hadis/serializers/serializers_admin.py
  5. 5
      apps/hadis/urls.py
  6. 20
      apps/hadis/views_admin.py

47
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`).
---

116
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),
]

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

38
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

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

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