Browse Source

feat(transmitters): add TransmitterFeature model, relational features, and admin viewsets

- Introduce TransmitterFeature model with title, slug, and timestamps

- Link features relation to Transmitters and BookAuthors

- Add DovodiTransmitterFeatureViewSet and register routes in admin router

- Add migration 0060 to create table and populate from existing features data

- Update RELATIONAL_MIGRATION_ROADMAP.md with completed features migration
master
Mohsen Taba 1 week ago
parent
commit
5f886936eb
  1. 36
      RELATIONAL_MIGRATION_ROADMAP.md
  2. 112
      apps/hadis/migrations/0060_transmitter_feature_and_relations.py
  3. 6
      apps/hadis/models/reference.py
  4. 39
      apps/hadis/models/transmitter.py
  5. 92
      apps/hadis/serializers/serializers_admin.py
  6. 5
      apps/hadis/urls.py
  7. 20
      apps/hadis/views_admin.py

36
RELATIONAL_MIGRATION_ROADMAP.md

@ -7,7 +7,7 @@
- [x] **فاز ۰: سیستم شهرها و مکان‌ها (`City`)** *(انجام شده)*
- [x] **فاز ۱: کنیه‌ها (`Kunya` / `kunyas`)** — رابطه چندبه‌چند (M2M) *(تکمیل شده)*
- [ ] **فاز ۲: ویژگی‌ها (`TransmitterFeature` / `features`)** — رابطه چندبه‌چند (M2M)
- [x] **فاز ۲: ویژگی‌ها (`TransmitterFeature` / `features`)** — رابطه چندبه‌چند (M2M) *(تکمیل شده)*
- [ ] **فاز ۳: وضعیت صحابی بودن (`SahabiStatus` / `sahabi_status`)** — رابطه تک‌به‌چند (ForeignKey / M2O)
- [ ] **فاز ۴: تفکیک و اتصال رتبه‌بندی و وثاقت راوی و مؤلف (`NarratorRating` و `reliability_levels`)** — رابطه ForeignKey / M2O
- [ ] **فاز ۵: مذاهب راویان (`Madhhab` / `madhabs`)** — رابطه چندبه‌چند (M2M)
@ -58,27 +58,23 @@
---
### فاز ۲: ویژگی‌ها (`TransmitterFeature` / `features`) 🔲
* **وضعیت:** `در انتظار اجرا`
### فاز ۲: ویژگی‌ها (`TransmitterFeature` / `features`) ✅
* **وضعیت:** `تکمیل شده`
* **نوع رابطه طبق داکیومنت کارفرما:** **Many-to-Many (M2M)**
> ویژگی‌های جسمی یا توصیفی راویان و مؤلفان (مانند «أعرج»، «أعمى فی عین واحدة»، «صاحب سر النبی» و...).
* **مراحل پیاده‌سازی بک‌اند:**
1. **مدل `TransmitterFeature`:**
- فیلد `title`: چندزبانه (`JSONField`)
- فیلد `slug`: اسلاگ یکتا
- متدهای کمکی جهت دریافت عنوان به زبان‌های مختلف
2. **اتصال به راویان و مؤلفان:**
- رابطه M2M در `Transmitters.features_list = models.ManyToManyField('TransmitterFeature', blank=True, related_name='transmitters')`
- رابطه M2M در `BookAuthor.features_list = models.ManyToManyField('TransmitterFeature', blank=True, related_name='authors')`
- همگام‌سازی در متد `save()` با فیلدهای متنی قدیمی `features`.
3. **دیتامایگریشن:**
- انتقال ویژگی‌های متنی موجود در دیتابیس به رکوردهای یکتای `TransmitterFeature` و اتصال به راویان و مؤلفان.
4. **سریالایزر و API:**
- ویوست و سریالایزر `AdminTransmitterFeatureViewSet` در اندپوینت `/api/hadis/admin/transmitter-features/`.
- پشتیبانی در سریالایزرهای راوی و مؤلف.
* **مراحل پیاده‌سازی داشبورد:**
1. کامپوننت `FeatureSelect` چندانتخابی همراه با مودال ساخت سریع ویژگی.
2. قرارگیری در تب ویژگی‌های راوی و صفحه مؤلف کتاب.
* **کارهای انجام‌شده:**
1. **مدل `TransmitterFeature`:** با فیلدهای `title` (چندزبانه)، `slug` (یکتا و خودکار)، متدهای کمکی و تایم‌استمپ‌ها در `transmitter.py`.
2. **روابط M2M:** اتصال `features_list` در مدل `Transmitters` و مدل `BookAuthor` با حفظ فیلد قدیمی `features` جهت backward compatibility.
3. **دیتامایگریشن (۰۶۰):** مهاجرت و اتصال خودکار تمام ویژگی‌های راویان و مؤلفان موجود در دیتابیس به رکوردهای `TransmitterFeature`.
4. **سریالایزرها و APIها:**
- تعریف `AdminTransmitterFeatureSerializer` و ویوست `DovodiTransmitterFeatureViewSet`.
- ثبت مسیرهای `/api/hadis/admin/dovodi-transmitter-features/` و `/api/hadis/admin/transmitter-features/`.
- پشتیبانی از `features_list` و `features_detail` به همراه سینک خودکار دوطرفه در `AdminTransmitterListSerializer`, `AdminTransmitterDetailSerializer` و `AdminBookAuthorSerializer`.
5. **داشبورد (فرانت‌اند):**
- ساخت کامپوننت ماژولار و کش‌شده `FeatureSelect.tsx` با قابلیت چندانتخابی نشان‌ها (badges) و مودال ایجاد سریع ویژگی با هدر استاندارد (آیکون کره چندزبانه) و فوتر چسبان.
- ادغام در صفحه ویرایش/ساخت راوی (`TransmitterFormPage.tsx`).
- ادغام در صفحه ویرایش/ساخت مؤلف کتاب (`ReferenceAuthorFormPage.tsx`).
- افزودن تب، ستون‌ها و دیالوگ مدیریت کامل ویژگی‌ها به صفحه آرشیو راویان (`TransmittersArchivePage.tsx`).
---

112
apps/hadis/migrations/0060_transmitter_feature_and_relations.py

@ -0,0 +1,112 @@
# Generated by Django 4.2.30 on 2026-10-20 14:18
from django.db import migrations, models
from django.utils.text import slugify
def populate_features_from_existing_records(apps, schema_editor):
TransmitterFeature = apps.get_model('hadis', 'TransmitterFeature')
Transmitters = apps.get_model('hadis', 'Transmitters')
BookAuthor = apps.get_model('hadis', 'BookAuthor')
feature_by_text = {}
for f in TransmitterFeature.objects.all():
if isinstance(f.title, list):
for entry in f.title:
if isinstance(entry, dict):
txt = (entry.get('text') or entry.get('title') or '').strip().lower()
if txt:
feature_by_text[txt] = f
def get_or_create_feature(json_list):
if not json_list or not isinstance(json_list, list):
return None
cleaned = []
lookup_keys = []
for entry in json_list:
if isinstance(entry, dict):
lang = (entry.get('language_code') or '').strip()
txt = (entry.get('text') or entry.get('title') or '').strip()
if lang and txt:
cleaned.append({'language_code': lang, 'text': txt, 'title': txt})
lookup_keys.append(txt.lower())
if not cleaned:
return None
for key in lookup_keys:
if key in feature_by_text:
existing = feature_by_text[key]
existing_langs = {
item.get('language_code'): item
for item in (existing.title or [])
if isinstance(item, dict) and item.get('language_code')
}
updated = False
for item in cleaned:
if item['language_code'] not in existing_langs:
existing_langs[item['language_code']] = item
updated = True
if updated:
existing.title = list(existing_langs.values())
existing.save(update_fields=['title'])
for k in lookup_keys:
feature_by_text[k] = existing
return existing
first_text = cleaned[0]['text']
slug_val = slugify(first_text, allow_unicode=True)[:240] or 'feature'
feat_obj = TransmitterFeature.objects.create(title=cleaned, slug=slug_val)
for k in lookup_keys:
feature_by_text[k] = feat_obj
return feat_obj
for tr in Transmitters.objects.all().iterator():
if tr.features and isinstance(tr.features, list):
f_obj = get_or_create_feature(tr.features)
if f_obj:
tr.features_list.add(f_obj)
for author in BookAuthor.objects.all().iterator():
if author.features and isinstance(author.features, list):
f_obj = get_or_create_feature(author.features)
if f_obj:
author.features_list.add(f_obj)
class Migration(migrations.Migration):
dependencies = [
('hadis', '0059_kunya_and_relations'),
]
operations = [
migrations.CreateModel(
name='TransmitterFeature',
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, null=True, verbose_name='slug')),
('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': 'Transmitter Feature',
'verbose_name_plural': 'Transmitter Features',
'ordering': ('-created_at', '-id'),
},
),
migrations.AddField(
model_name='bookauthor',
name='features_list',
field=models.ManyToManyField(blank=True, related_name='authors', to='hadis.transmitterfeature', verbose_name='Features (M2M)'),
),
migrations.AddField(
model_name='transmitters',
name='features_list',
field=models.ManyToManyField(blank=True, related_name='transmitters', to='hadis.transmitterfeature', verbose_name='Features (M2M)'),
),
migrations.RunPython(
populate_features_from_existing_records,
reverse_code=migrations.RunPython.noop,
),
]

6
apps/hadis/models/reference.py

@ -481,6 +481,12 @@ class BookAuthor(LowercaseSlugMixin, models.Model):
verbose_name=_('Rutba Status')
)
features = models.JSONField(default=list, blank=True, verbose_name=_('Features'))
features_list = models.ManyToManyField(
'hadis.TransmitterFeature',
blank=True,
related_name='authors',
verbose_name=_('Features (M2M)')
)
# Geographic Information
origin = models.JSONField(default=list, blank=True, verbose_name=_('Origin'))

39
apps/hadis/models/transmitter.py

@ -166,6 +166,7 @@ class City(LowercaseSlugMixin, models.Model):
return self.title[0].get('text', '') or self.title[0].get('title', '')
return None
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
try:
@ -213,6 +214,38 @@ class Kunya(LowercaseSlugMixin, models.Model):
return None
class TransmitterFeature(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, null=True, allow_unicode=True)
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:
ordering = ('-created_at', '-id')
verbose_name = _('Transmitter Feature')
verbose_name_plural = _('Transmitter Features')
def __str__(self):
if self.title and isinstance(self.title, list) and len(self.title) > 0 and isinstance(self.title[0], dict):
return self.title[0].get('text') or self.title[0].get('title') or str(self.id)
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):
@ -262,6 +295,12 @@ class Transmitters(LowercaseSlugMixin, models.Model):
freed_slave_of = models.JSONField(default=list, blank=True, verbose_name=_('Freed Slave Of'))
occupation = models.JSONField(default=list, blank=True, verbose_name=_('Occupation'))
features = models.JSONField(default=list, blank=True, verbose_name=_('Features'))
features_list = models.ManyToManyField(
'hadis.TransmitterFeature',
blank=True,
related_name='transmitters',
verbose_name=_('Features (M2M)')
)
tags = models.JSONField(default=list, blank=True, verbose_name=_('Tags'))
# Geographic Information

92
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, YearValue, NarratorDate,
City, Kunya, TransmitterFeature, YearValue, NarratorDate,
CorrectionReference, CorrectionReferenceImage, InterpretationReference, InterpretationReferenceImage,
OriginalTextReference, OriginalTextReferenceImage,
ContentRelease
@ -2035,6 +2035,17 @@ class AdminKunyaSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "slug", "created_at", "updated_at"]
class AdminTransmitterFeatureSerializer(serializers.ModelSerializer):
title = serializers.JSONField()
class Meta:
model = TransmitterFeature
fields = ["id", "title", "slug", "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
@ -2048,6 +2059,7 @@ def _build_admin_date_preview(obj, date_type: str, raw_val: str):
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)
full_name = serializers.JSONField()
known_as = serializers.JSONField()
nickname = serializers.JSONField()
@ -2073,6 +2085,7 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer):
"known_as",
"nickname",
"kunyas_detail",
"features_detail",
"reliability",
"reliability_detail",
"madhhab",
@ -2104,6 +2117,13 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
required=False,
)
kunyas_detail = AdminKunyaSerializer(source="kunyas", many=True, read_only=True)
features = serializers.JSONField(required=False, default=list)
features_list = serializers.PrimaryKeyRelatedField(
queryset=TransmitterFeature.objects.all(),
many=True,
required=False,
)
features_detail = AdminTransmitterFeatureSerializer(source="features_list", many=True, read_only=True)
known_as = serializers.JSONField()
nickname = serializers.JSONField()
origin = serializers.JSONField(required=False, allow_null=True)
@ -2159,6 +2179,9 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
"kunya",
"kunyas",
"kunyas_detail",
"features",
"features_list",
"features_detail",
"known_as",
"nickname",
"gender",
@ -2202,7 +2225,7 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
"reliability_detail", "father_detail", "sons",
"birth_year_preview", "death_year_preview", "age_at_death_preview",
"origin_city_detail", "lived_in_city_detail", "died_in_city_detail",
"kunyas_detail",
"kunyas_detail", "features_detail",
]
def get_birth_year_preview(self, obj):
@ -2251,6 +2274,7 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
for json_field in [
"full_name",
"kunya",
"features",
"known_as",
"nickname",
"origin",
@ -2300,6 +2324,14 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
except ValueError:
pass
if "features_list" in data:
val = data["features_list"]
if isinstance(val, str):
try:
data["features_list"] = json.loads(val)
except ValueError:
pass
return super().to_internal_value(data)
def validate(self, attrs):
@ -2327,6 +2359,7 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
validated_data.pop("remove_thumbnail", False)
sons_ids = validated_data.pop("sons_ids", None)
kunyas_objs = validated_data.pop("kunyas", None)
features_objs = validated_data.pop("features_list", None)
instance = super().create(validated_data)
if sons_ids is not None:
Transmitters.objects.filter(id__in=sons_ids).update(father=instance)
@ -2339,12 +2372,22 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
else:
instance.kunya = []
instance.save(update_fields=['kunya'])
if features_objs is not None:
instance.features_list.set(features_objs)
if features_objs:
first_feat = features_objs[0]
instance.features = first_feat.title or []
instance.save(update_fields=['features'])
else:
instance.features = []
instance.save(update_fields=['features'])
return instance
def update(self, instance, validated_data):
remove_thumbnail = validated_data.pop("remove_thumbnail", False)
sons_ids = validated_data.pop("sons_ids", None)
kunyas_objs = validated_data.pop("kunyas", None)
features_objs = validated_data.pop("features_list", None)
if remove_thumbnail and instance.thumbnail:
instance.thumbnail.delete(save=False)
instance.thumbnail = None
@ -2361,6 +2404,15 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
else:
instance.kunya = []
instance.save(update_fields=['kunya'])
if features_objs is not None:
instance.features_list.set(features_objs)
if features_objs:
first_feat = features_objs[0]
instance.features = first_feat.title or []
instance.save(update_fields=['features'])
else:
instance.features = []
instance.save(update_fields=['features'])
return instance
@ -2396,6 +2448,12 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
allow_null=True,
)
rutba_detail = AdminBookAuthorRutbaSerializer(source="rutba_status", read_only=True)
features_list = serializers.PrimaryKeyRelatedField(
queryset=TransmitterFeature.objects.all(),
many=True,
required=False,
)
features_detail = AdminTransmitterFeatureSerializer(source="features_list", many=True, read_only=True)
book_references = serializers.PrimaryKeyRelatedField(
many=True,
required=False,
@ -2450,6 +2508,8 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
"rutba_status",
"rutba_detail",
"features",
"features_list",
"features_detail",
"origin",
"origin_city",
"origin_city_detail",
@ -2470,6 +2530,7 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
"origin_city_detail",
"lived_in_city_detail",
"died_in_city_detail",
"features_detail",
]
def get_birth_year_preview(self, obj):
@ -2510,6 +2571,13 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
mutable_data[field] = None
elif field in ("birth_year_hijri", "death_year_hijri", "age_at_death") and val is not None:
mutable_data[field] = str(val).strip()
if "features_list" in mutable_data:
val = mutable_data["features_list"]
if isinstance(val, str):
try:
mutable_data["features_list"] = json.loads(val)
except ValueError:
pass
return super().to_internal_value(mutable_data)
def validate(self, attrs):
@ -2536,16 +2604,27 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
def create(self, validated_data):
validated_data.pop("remove_image", False)
book_references = validated_data.pop("book_references", None)
features_objs = validated_data.pop("features_list", None)
author = super().create(validated_data)
if book_references:
for book in book_references:
book.author = author
book.save(update_fields=["author"])
if features_objs is not None:
author.features_list.set(features_objs)
if features_objs:
first_feat = features_objs[0]
author.features = first_feat.title or []
author.save(update_fields=['features'])
else:
author.features = []
author.save(update_fields=['features'])
return author
def update(self, instance, validated_data):
remove_image = validated_data.pop("remove_image", False)
book_references = validated_data.pop("book_references", None)
features_objs = validated_data.pop("features_list", None)
if remove_image and instance.image:
instance.image.delete(save=False)
instance.image = None
@ -2559,6 +2638,15 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
to_add = new_ids - current_ids
if to_add:
BookReference.objects.filter(id__in=to_add).update(author=author)
if features_objs is not None:
author.features_list.set(features_objs)
if features_objs:
first_feat = features_objs[0]
author.features = first_feat.title or []
author.save(update_fields=['features'])
else:
author.features = []
author.save(update_fields=['features'])
return author

5
apps/hadis/urls.py

@ -45,7 +45,8 @@ from .views_admin import (
AdminContentReleaseViewSet,
DovodiCityViewSet,
DovodiKunyaViewSet
DovodiKunyaViewSet,
DovodiTransmitterFeatureViewSet
)
@ -146,6 +147,8 @@ admin_router.register(r'dovodi-cities', DovodiCityViewSet, basename='dovodi-citi
admin_router.register(r'cities', DovodiCityViewSet, basename='admin-cities')
admin_router.register(r'dovodi-kunyas', DovodiKunyaViewSet, basename='dovodi-kunyas')
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')

20
apps/hadis/views_admin.py

@ -39,6 +39,7 @@ from .models import (
City,
Kunya,
TransmitterFeature,
)
@ -127,6 +128,7 @@ from .serializers.serializers_admin import (
AdminCitySerializer,
AdminKunyaSerializer,
AdminTransmitterFeatureSerializer,
)
@ -2917,3 +2919,21 @@ class DovodiKunyaViewSet(ModelViewSet):
| Q(slug__icontains=search_query)
)
return queryset
class DovodiTransmitterFeatureViewSet(ModelViewSet):
serializer_class = AdminTransmitterFeatureSerializer
permission_classes = [IsAuthenticated, IsSuperAdmin]
authentication_classes = [TokenAuthentication]
pagination_class = OptionalPagination
parser_classes = (JSONParser, MultiPartParser, FormParser)
def get_queryset(self):
queryset = TransmitterFeature.objects.all().order_by("slug")
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