From 5f886936eb7ece0fb09c8b9dcd5e3082955fe816 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Tue, 20 Oct 2026 14:46:55 +0330 Subject: [PATCH] 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 --- RELATIONAL_MIGRATION_ROADMAP.md | 36 +- .../0060_transmitter_feature_and_relations.py | 112 + apps/hadis/models/reference.py | 6 + apps/hadis/models/transmitter.py | 2263 +++++++++-------- apps/hadis/serializers/serializers_admin.py | 92 +- apps/hadis/urls.py | 5 +- apps/hadis/views_admin.py | 20 + 7 files changed, 1399 insertions(+), 1135 deletions(-) create mode 100644 apps/hadis/migrations/0060_transmitter_feature_and_relations.py diff --git a/RELATIONAL_MIGRATION_ROADMAP.md b/RELATIONAL_MIGRATION_ROADMAP.md index c26d5e1..f8b0431 100644 --- a/RELATIONAL_MIGRATION_ROADMAP.md +++ b/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`). --- diff --git a/apps/hadis/migrations/0060_transmitter_feature_and_relations.py b/apps/hadis/migrations/0060_transmitter_feature_and_relations.py new file mode 100644 index 0000000..4f16d75 --- /dev/null +++ b/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, + ), + ] diff --git a/apps/hadis/models/reference.py b/apps/hadis/models/reference.py index f53b0de..d62b422 100644 --- a/apps/hadis/models/reference.py +++ b/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')) diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index 4b886f9..a68c68c 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -1,137 +1,137 @@ - - -from tabnanny import verbose -from django.db import models -from django.utils.translation import gettext_lazy as _ -from django.utils.text import slugify -from typing import Optional -from utils.slug import generate_smart_slug -from django.conf import settings -from utils.mixins import ColorPaletteMixin, LowercaseSlugMixin - - - -class NarratorLayer(LowercaseSlugMixin, models.Model): - slug_source_field = 'name' - """ - Model for narrator layers/classes (Tabaqat) - Represents the classification level of narrators in a specific hadis chain - """ - hadis = models.ForeignKey( - "hadis.Hadis", - on_delete=models.CASCADE, - related_name="narrator_layers", - verbose_name=_("hadis"), - null=True, - blank=True, - ) - name = models.JSONField(default=list, verbose_name=_('Name')) - number = models.PositiveIntegerField(verbose_name=_('layer number')) - description = models.JSONField(default=list, verbose_name=_('Description')) - 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: - verbose_name = _('Narrator Layer') - verbose_name_plural = _('Narrator Layers') - ordering = ['hadis', 'number', 'id'] - - def __str__(self): - name = self.name[0]['text'] if self.name else f"Layer {self.number}" - return f"{_('Layer')} {self.number} - {name}" - - def get_description(self,lang): - """ - Get title for a specific language - """ - - if not self.description or not isinstance(self.description, list): - return None - - for tr in self.description: - if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') - - for tr in self.description: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') - return None - - def get_name(self,lang): - """ - Get title for a specific language - """ - - if not self.name or not isinstance(self.name, list): - return None - - for tr in self.name: - if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') - - for tr in self.name: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') - return None - - def save(self, *args, **kwargs): - if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''): - import uuid - hadis_prefix = f"hadis-{self.hadis_id}-" if self.hadis_id else "" - base_text = "" - if self.name and isinstance(self.name, list) and len(self.name) > 0: - first_item = self.name[0] - if isinstance(first_item, dict): - base_text = first_item.get('text', '').strip() - - slug_base = slugify(base_text) if base_text else f"layer-{self.number}" - self.slug = f"{hadis_prefix}l{self.number}-{slug_base}-{uuid.uuid4().hex[:6]}" - super().save(*args, **kwargs) - -class TransmitterReliability(ColorPaletteMixin, 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) - description = models.JSONField(default=list, verbose_name=_('Description')) - - def __str__(self): - return self.title[0]['text'] if self.title else str(self.id) - - def get_title(self,lang): - """ - Get title for a specific language - """ - - 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', '') - - for tr in self.title: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') - return None - - def get_description(self, lang): - if not self.description or not isinstance(self.description, list): - return None - - for tr in self.description: - if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') or tr.get('title', '') - - for tr in self.description: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') or tr.get('title', '') - return None - - class Meta: - verbose_name = _('Transmitter Reliability') + + +from tabnanny import verbose +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils.text import slugify +from typing import Optional +from utils.slug import generate_smart_slug +from django.conf import settings +from utils.mixins import ColorPaletteMixin, LowercaseSlugMixin + + + +class NarratorLayer(LowercaseSlugMixin, models.Model): + slug_source_field = 'name' + """ + Model for narrator layers/classes (Tabaqat) + Represents the classification level of narrators in a specific hadis chain + """ + hadis = models.ForeignKey( + "hadis.Hadis", + on_delete=models.CASCADE, + related_name="narrator_layers", + verbose_name=_("hadis"), + null=True, + blank=True, + ) + name = models.JSONField(default=list, verbose_name=_('Name')) + number = models.PositiveIntegerField(verbose_name=_('layer number')) + description = models.JSONField(default=list, verbose_name=_('Description')) + 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: + verbose_name = _('Narrator Layer') + verbose_name_plural = _('Narrator Layers') + ordering = ['hadis', 'number', 'id'] + + def __str__(self): + name = self.name[0]['text'] if self.name else f"Layer {self.number}" + return f"{_('Layer')} {self.number} - {name}" + + def get_description(self,lang): + """ + Get title for a specific language + """ + + if not self.description or not isinstance(self.description, list): + return None + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_name(self,lang): + """ + Get title for a specific language + """ + + if not self.name or not isinstance(self.name, list): + return None + + for tr in self.name: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.name: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def save(self, *args, **kwargs): + if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''): + import uuid + hadis_prefix = f"hadis-{self.hadis_id}-" if self.hadis_id else "" + base_text = "" + if self.name and isinstance(self.name, list) and len(self.name) > 0: + first_item = self.name[0] + if isinstance(first_item, dict): + base_text = first_item.get('text', '').strip() + + slug_base = slugify(base_text) if base_text else f"layer-{self.number}" + self.slug = f"{hadis_prefix}l{self.number}-{slug_base}-{uuid.uuid4().hex[:6]}" + super().save(*args, **kwargs) + +class TransmitterReliability(ColorPaletteMixin, 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) + description = models.JSONField(default=list, verbose_name=_('Description')) + + def __str__(self): + return self.title[0]['text'] if self.title else str(self.id) + + def get_title(self,lang): + """ + Get title for a specific language + """ + + 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', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_description(self, lang): + if not self.description or not isinstance(self.description, list): + return None + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') or tr.get('title', '') + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') or tr.get('title', '') + return None + + class Meta: + verbose_name = _('Transmitter Reliability') verbose_name_plural = _('Transmitter Reliabilities') @@ -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: @@ -211,62 +212,100 @@ class Kunya(LowercaseSlugMixin, models.Model): 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): - # VERY_RELIABLE = 'very_reliable', _('Very Reliable') - # RELIABLE = 'reliable', _('Reliable') - # ACCEPTABLE = 'acceptable', _('Acceptable') - # WEAK = 'weak', _('Weak') - # VERY_WEAK = 'very_weak', _('Very Weak') - # UNKNOWN = 'unknown', _('Unknown') - - class MadhhabChoices(models.TextChoices): - SHIA = 'shia', _('Shia') - SUNNI = 'sunni', _('Sunni') - - class GenderChoices(models.TextChoices): - MALE = 'male', _('Male') - FEMALE = 'female', _('Female') - - # Basic Information - full_name = models.JSONField(default = list , verbose_name=_('Full Name')) + + +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): + slug_source_field = 'full_name' + # class ReliabilityLevel(models.TextChoices): + # VERY_RELIABLE = 'very_reliable', _('Very Reliable') + # RELIABLE = 'reliable', _('Reliable') + # ACCEPTABLE = 'acceptable', _('Acceptable') + # WEAK = 'weak', _('Weak') + # VERY_WEAK = 'very_weak', _('Very Weak') + # UNKNOWN = 'unknown', _('Unknown') + + class MadhhabChoices(models.TextChoices): + SHIA = 'shia', _('Shia') + SUNNI = 'sunni', _('Sunni') + + class GenderChoices(models.TextChoices): + MALE = 'male', _('Male') + FEMALE = 'female', _('Female') + + # Basic Information + full_name = models.JSONField(default = list , verbose_name=_('Full Name')) kunya = models.JSONField(default = list , verbose_name=_('Kunya')) kunyas = models.ManyToManyField( 'hadis.Kunya', blank=True, related_name='transmitters', verbose_name=_('Kunyas') - ) - known_as = models.JSONField(default = list , verbose_name=_('Known as')) - nickname = models.JSONField(default = list , verbose_name=_('Nick Name')) - gender = models.CharField( - max_length=10, - choices=GenderChoices.choices, - default=GenderChoices.MALE, - verbose_name=_('Gender') - ) - slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True, allow_unicode=True) - legacy_number = models.PositiveIntegerField(unique=True, null=True, blank=True, db_index=True) - legacy_id = models.CharField(max_length=255, unique=True, null=True, blank=True, db_index=True) - relatives_raw = models.JSONField(default=dict, blank=True, verbose_name=_('Raw Relatives Data')) - father = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children_as_father', verbose_name=_('Father')) - mother = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children_as_mother', verbose_name=_('Mother')) - # ManyToMany for siblings allows bi-directional sibling relationships - siblings = models.ManyToManyField('self', blank=True, verbose_name=_('Siblings')) - - 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')) - tags = models.JSONField(default=list, blank=True, verbose_name=_('Tags')) - - # Geographic Information - origin = models.JSONField(default = list , verbose_name=_('Origin')) - lived_in = models.JSONField(default = list , verbose_name=_('Lived in')) + ) + known_as = models.JSONField(default = list , verbose_name=_('Known as')) + nickname = models.JSONField(default = list , verbose_name=_('Nick Name')) + gender = models.CharField( + max_length=10, + choices=GenderChoices.choices, + default=GenderChoices.MALE, + verbose_name=_('Gender') + ) + slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True, allow_unicode=True) + legacy_number = models.PositiveIntegerField(unique=True, null=True, blank=True, db_index=True) + legacy_id = models.CharField(max_length=255, unique=True, null=True, blank=True, db_index=True) + relatives_raw = models.JSONField(default=dict, blank=True, verbose_name=_('Raw Relatives Data')) + father = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children_as_father', verbose_name=_('Father')) + mother = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children_as_mother', verbose_name=_('Mother')) + # ManyToMany for siblings allows bi-directional sibling relationships + siblings = models.ManyToManyField('self', blank=True, verbose_name=_('Siblings')) + + 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 + origin = models.JSONField(default = list , verbose_name=_('Origin')) + lived_in = models.JSONField(default = list , verbose_name=_('Lived in')) died_in = models.JSONField(default = list , verbose_name=_('Died in')) origin_city = models.ForeignKey( 'hadis.City', @@ -291,75 +330,75 @@ class Transmitters(LowercaseSlugMixin, models.Model): blank=True, related_name='died_in_transmitters', verbose_name=_('Died in City'), - ) - - # Date Information - birth_year_hijri = models.CharField(max_length=100, verbose_name=_("Birth Year (Hijri)"), null=True, blank=True) - death_year_hijri = models.CharField(max_length=100, verbose_name=_("Death Year (Hijri)"), null=True, blank=True) - age_at_death = models.CharField(max_length=100, verbose_name=_('Age at Death'), blank=True, null=True) - generation = models.PositiveIntegerField(verbose_name=_('Generation'), blank=True, null=True) - - # New fields - 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')) - - # Religious & Academic Information - reliability = models.ForeignKey( - TransmitterReliability, - on_delete=models.CASCADE, - verbose_name=_('reliability'), - related_name='transmitters', - default=12 # ID of 'Unknown' reliability - ) - madhhab = models.CharField( - max_length=20, - choices=MadhhabChoices.choices, - default=MadhhabChoices.SHIA, - verbose_name=_('Madhhab/School of Thought') - ) - - # Presence in Famous Collections - in_sahih_muslim = models.BooleanField( - default=False, - verbose_name=_('In Sahih Muslim'), - help_text=_('Is this narrator present in Sahih Muslim?') - ) - in_sahih_bukhari = models.BooleanField( - default=False, - verbose_name=_('In Sahih Bukhari'), - help_text=_('Is this narrator present in Sahih Bukhari?') - ) - - # Additional Information - description = models.JSONField(default = list , verbose_name=_('Description')) - thumbnail = models.ImageField(upload_to='hadis/transmitter_thumbnails/', null=True, blank=True, help_text=_('image allowed')) - - created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) - updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) - normalized_text = models.TextField(blank=True, null=True, verbose_name=_('normalized text for search')) - - class Meta: - indexes = [ - # For ordering in sync API - models.Index(fields=['id']), - ] - verbose_name = _('Transmitter') - verbose_name_plural = _('Transmitters') - ordering = ('full_name',) - - def save(self, *args, **kwargs): - if not self.legacy_id or (isinstance(self.legacy_id, str) and not self.legacy_id.strip()): - self.legacy_id = None - if not self.legacy_number: - self.legacy_number = None - - for date_field in ('birth_year_hijri', 'death_year_hijri', 'age_at_death'): - val = getattr(self, date_field, None) - if val is not None: - val_str = str(val).strip() - setattr(self, date_field, val_str if val_str else None) - + ) + + # Date Information + birth_year_hijri = models.CharField(max_length=100, verbose_name=_("Birth Year (Hijri)"), null=True, blank=True) + death_year_hijri = models.CharField(max_length=100, verbose_name=_("Death Year (Hijri)"), null=True, blank=True) + age_at_death = models.CharField(max_length=100, verbose_name=_('Age at Death'), blank=True, null=True) + generation = models.PositiveIntegerField(verbose_name=_('Generation'), blank=True, null=True) + + # New fields + 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')) + + # Religious & Academic Information + reliability = models.ForeignKey( + TransmitterReliability, + on_delete=models.CASCADE, + verbose_name=_('reliability'), + related_name='transmitters', + default=12 # ID of 'Unknown' reliability + ) + madhhab = models.CharField( + max_length=20, + choices=MadhhabChoices.choices, + default=MadhhabChoices.SHIA, + verbose_name=_('Madhhab/School of Thought') + ) + + # Presence in Famous Collections + in_sahih_muslim = models.BooleanField( + default=False, + verbose_name=_('In Sahih Muslim'), + help_text=_('Is this narrator present in Sahih Muslim?') + ) + in_sahih_bukhari = models.BooleanField( + default=False, + verbose_name=_('In Sahih Bukhari'), + help_text=_('Is this narrator present in Sahih Bukhari?') + ) + + # Additional Information + description = models.JSONField(default = list , verbose_name=_('Description')) + thumbnail = models.ImageField(upload_to='hadis/transmitter_thumbnails/', null=True, blank=True, help_text=_('image allowed')) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + normalized_text = models.TextField(blank=True, null=True, verbose_name=_('normalized text for search')) + + class Meta: + indexes = [ + # For ordering in sync API + models.Index(fields=['id']), + ] + verbose_name = _('Transmitter') + verbose_name_plural = _('Transmitters') + ordering = ('full_name',) + + def save(self, *args, **kwargs): + if not self.legacy_id or (isinstance(self.legacy_id, str) and not self.legacy_id.strip()): + self.legacy_id = None + if not self.legacy_number: + self.legacy_number = None + + for date_field in ('birth_year_hijri', 'death_year_hijri', 'age_at_death'): + val = getattr(self, date_field, None) + if val is not None: + val_str = str(val).strip() + setattr(self, date_field, val_str if val_str else None) + if self.origin_city_id and self.origin_city: self.origin = self.origin_city.title or [] if self.lived_in_city_id and self.lived_in_city: @@ -367,862 +406,862 @@ class Transmitters(LowercaseSlugMixin, models.Model): if self.died_in_city_id and self.died_in_city: self.died_in = self.died_in_city.title or [] - from utils.text_normalizer import build_search_blob - self.normalized_text = build_search_blob( - self.full_name, - self.kunya, - self.known_as, - self.nickname - ) - - super().save(*args, **kwargs) - try: - self.sync_narrator_dates() - except Exception: - pass - - def sync_narrator_dates(self): - """ - Synchronizes birth_year_hijri, death_year_hijri, and age_at_death - with YearValue and NarratorDate tables. - """ - if not self.pk: - return - - date_mapping = { - 'birth': self.birth_year_hijri, - 'death': self.death_year_hijri, - 'age': self.age_at_death, - } - - for date_type, raw_val in date_mapping.items(): - if not raw_val or not str(raw_val).strip(): - NarratorDate.objects.filter(transmitter=self, date_type=date_type).delete() - continue - - raw_str = str(raw_val).strip() - yv = YearValue.get_or_create_from_canonical(category=date_type, raw_value=raw_str) - NarratorDate.objects.update_or_create( - transmitter=self, - date_type=date_type, - defaults={ - 'year_value': yv, - 'raw_value': yv.raw_value, - } - ) - - def get_formatted_date(self, date_type: str, lang: str = 'ru') -> Optional[str]: - field_map = { - 'birth': 'birth_year_hijri', - 'death': 'death_year_hijri', - 'age': 'age_at_death', - } - field_name = field_map.get(date_type) - if not field_name: - return None - raw_val = getattr(self, field_name, None) - if raw_val is None or str(raw_val).strip() == '': - return None - raw_str = str(raw_val).strip() - - target_lang = (lang or 'ru').split('-')[0].lower() - - # Check prefetched narrator_dates first if available - if hasattr(self, '_prefetched_objects_cache') and 'narrator_dates' in self._prefetched_objects_cache: - for nd in self._prefetched_objects_cache['narrator_dates']: - if nd.date_type == date_type and nd.year_value_id and nd.year_value: - txt = nd.year_value.get_translation(target_lang) - if txt: - return txt - - # If context tag (#...), check YearValue in DB for custom translations - if raw_str.startswith('#'): - yv = YearValue.objects.filter(raw_value=raw_str).first() - if yv: - txt = yv.get_translation(target_lang) - if txt: - return txt - - from ..utils.date_engine import format_canonical_date - engine_lang = target_lang if target_lang in ('ru', 'en', 'ar') else 'en' - return format_canonical_date(raw_str, category=date_type, lang=engine_lang) - - def get_formatted_birth_year(self, lang: str = 'ru') -> Optional[str]: - return self.get_formatted_date('birth', lang=lang) - - def get_formatted_death_year(self, lang: str = 'ru') -> Optional[str]: - return self.get_formatted_date('death', lang=lang) - - def get_formatted_age(self, lang: str = 'ru') -> Optional[str]: - return self.get_formatted_date('age', lang=lang) - - @property - def share_link(self): - if self.slug: - return f"{settings.DOVODI_DOMAIN}/arguments/narrators/{self.slug}" - return None - - - - def _get_json_field(self, field_name: str, lang: Optional[str]=None , fallback: str = "en"): - """ - Generic getter for JSONField in our [{text, language_code}] format. - Usage: self._get_json_field('title', 'fa') - """ - if lang is None: - lang = fallback - - value = getattr(self, field_name, None) - if not value or not isinstance(value, list): - return None - - # 1) exact language - for item in value: - if isinstance(item, dict) and item.get("language_code") == lang: - return item.get("text", "") - - # 2) fallback language - if fallback and fallback != lang: - for item in value: - if isinstance(item, dict) and item.get("language_code") == fallback: - return item.get("text", "") - - # 3) first available - item = value[0] - print(item) - return item.get("text", "") if isinstance(item, dict) else None - - def get_full_name(self, lang): - return self._get_json_field("full_name" , lang) - - def get_kunya(self, lang): - return self._get_json_field("kunya" , lang) - - def get_nickname(self, lang): - return self._get_json_field("nickname" , lang) - - def get_origin(self, lang): - return self._get_json_field("origin" , lang) - - def get_lived_in(self,lang): - return self._get_json_field("lived_in" , lang) - - def get_died_in(self, lang): - return self._get_json_field("died_in" , lang) - - def get_description(self, lang): - return self._get_json_field("description" , lang) - - - def __str__(self): - if self.full_name and len(self.full_name) > 0: - return self.full_name[0].get('text', str(self.id)) - return str(self.id) - - - -class HadisTransmitter(models.Model): - hadis = models.ForeignKey( - "hadis.Hadis", - on_delete=models.CASCADE, - verbose_name=_('hadis'), - related_name='transmitters' - ) - transmitter = models.ForeignKey( - Transmitters, - on_delete=models.SET_NULL, - verbose_name=_('transmitter'), - related_name='hadises', - null=True, - blank=True - ) - is_uncertain = models.BooleanField( - default=False, - verbose_name=_('is uncertain/doubtful'), - help_text=_('Whether this transmission node is doubtful between multiple candidate narrators') - ) - uncertain_transmitters = models.ManyToManyField( - Transmitters, - blank=True, - related_name='uncertain_hadis_nodes', - verbose_name=_('uncertain candidate transmitters'), - help_text=_('Candidate narrators when is_uncertain is True') - ) - - chain_index = models.PositiveIntegerField( - default=0, - verbose_name=_('Chain Index'), - help_text=_('Which chain/branch this belongs to (0, 1, 2...)') - ) - - narrator_layer = models.ForeignKey( - NarratorLayer, - on_delete=models.SET_NULL, - verbose_name=_('narrator layer'), - related_name='transmitters', - null=True, - blank=True, - help_text=_('The layer/class (Tabaqah) this narrator belongs to') - ) - status = models.ForeignKey( - TransmitterReliability, - on_delete=models.SET_NULL, - verbose_name=_('reliability status'), - related_name='hadis_transmitters', - null=True, - blank=True, - help_text=_('Reliability status of the narrator') - ) - order = models.PositiveIntegerField( - default=0, - verbose_name=_('Order'), - help_text=_('Order in the chain of transmission') - ) - is_gap = models.BooleanField(default=False, verbose_name=_('is gap')) - created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) - - - class Meta: - indexes = [ - # Speeds up fetching transmitters for a specific hadis in order - models.Index(fields=['hadis', 'order']), - ] - verbose_name = _('Hadis Transmitter') - verbose_name_plural = _('Hadis Transmitters') - ordering = ('hadis', 'chain_index', 'order') # Update ordering - - # 2. UPDATE UNIQUE TOGETHER - # A specific position in a specific chain of a specific hadith can only have one narrator - unique_together = ('hadis', 'chain_index', 'order') - - def __str__(self): - return f'{self.hadis.number} - Chain {self.chain_index} - {self.transmitter} (Pos: {self.order})' - - -class OpinionStatus(ColorPaletteMixin, 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) - description = models.JSONField(default=list, verbose_name=_('Description')) - - def __str__(self): - return self.title[0]['text'] if self.title else str(self.id) - - def get_title(self,lang): - """ - Get title for a specific language - """ - - 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', '') - - for tr in self.title: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') - return None - - def get_description(self, lang): - if not self.description or not isinstance(self.description, list): - return None - - for tr in self.description: - if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') or tr.get('title', '') - - for tr in self.description: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') or tr.get('title', '') - return None - - class Meta: - verbose_name = _('Opinion Status') - verbose_name_plural = _('Opinion Statuses') - - -class ScholarlyOpinion(models.Model): - """ - Standalone reusable scholarly opinion object containing status, Arabic text, and translations. - Can be connected to multiple transmitters via TransmitterOpinion. - """ - opinion_text = models.JSONField(default=list, verbose_name=_('Opinion Text')) - status = models.ForeignKey( - OpinionStatus, - on_delete=models.CASCADE, - verbose_name=_('opinion status'), - related_name='scholarly_opinions', - blank=True, - null=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: - verbose_name = _('Scholarly Opinion') - verbose_name_plural = _('Scholarly Opinions') - ordering = ('-id',) - - def __str__(self): - if self.opinion_text and isinstance(self.opinion_text, list) and len(self.opinion_text) > 0: - first_text = self.opinion_text[0].get('text', '') if isinstance(self.opinion_text[0], dict) else '' - return f"{first_text[:50]} ({self.status})" if first_text else f"Opinion #{self.id}" - return f"Opinion #{self.id}" - - def get_opinion_tex(self, lang): - if not self.opinion_text or not isinstance(self.opinion_text, list): - return None - for tr in self.opinion_text: - if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') - for tr in self.opinion_text: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') - return None - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - # Keep all connected TransmitterOpinion records synced for 100% backward compatibility - TransmitterOpinion.objects.filter(opinion_id=self.id).update( - opinion_text=self.opinion_text, - status_id=self.status_id, - ) - - -class TransmitterOpinion(models.Model): - """ - Model for scholarly opinions about transmitters - """ - # class OpinionStatus(models.TextChoices): - # CONFIRMED = 'confirmed', _('Confirmed') - # MIXED = 'mixed', _('Mixed') - # REJECTED = 'rejected', _('Rejected') - - transmitter = models.ForeignKey( - Transmitters, - on_delete=models.CASCADE, - verbose_name=_('transmitter'), - related_name='opinions' - ) - scholar = models.ForeignKey( - 'hadis.BookAuthor', - on_delete=models.SET_NULL, - verbose_name=_('Scholar'), - related_name='transmitter_opinions', - null=True, - blank=True - ) - opinion = models.ForeignKey( - ScholarlyOpinion, - on_delete=models.CASCADE, - verbose_name=_('Scholarly Opinion'), - related_name='transmitter_opinions', - null=True, - blank=True - ) - scholar_name = models.JSONField(default = list , verbose_name=_('Scholar Name')) - opinion_text = models.JSONField(default = list , verbose_name=_('Opinion Text')) - status = models.ForeignKey( - OpinionStatus, - on_delete=models.CASCADE, - verbose_name=_('opinion status'), - related_name='opinions', - # default=1, - blank = True, - null=True - ) - order = models.IntegerField(default=0, verbose_name=_('order')) - 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: - indexes = [ - # For filtering by transmitter + ordering - models.Index(fields=['transmitter']), - ] - verbose_name = _('Transmitter Opinion') - verbose_name_plural = _('Transmitter Opinions') - ordering = ('order', 'id') - - def save(self, *args, **kwargs): - if self.opinion_id and self.opinion: - self.opinion_text = self.opinion.opinion_text - self.status_id = self.opinion.status_id - if self.scholar_id and self.scholar and not self.scholar_name: - if isinstance(self.scholar.name, list): - self.scholar_name = self.scholar.name - super().save(*args, **kwargs) - - def __str__(self): - scholar = self.scholar_name[0]['text'] if self.scholar_name else "Unknown Scholar" - return f"{scholar}'s opinion on {self.transmitter} ({self.status})" - - def get_scholar_name(self,lang): - """ - Get title for a specific language - """ - - if not self.scholar_name or not isinstance(self.scholar_name, list): - return None - - for tr in self.scholar_name: - if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') - - for tr in self.scholar_name: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') - return None - - def get_opinion_tex(self,lang): - """ - Get title for a specific language - """ - source_text = self.opinion.opinion_text if (self.opinion_id and self.opinion) else self.opinion_text - if not source_text or not isinstance(source_text, list): - return None - - for tr in source_text: - if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') - - for tr in source_text: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') - return None - -class TransmitterOriginalText(models.Model): - transmitter = models.ForeignKey( - Transmitters, - on_delete=models.CASCADE, - verbose_name=_('transmitter'), - related_name='originaltexts' - ) - slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True, allow_unicode=True) - title = models.JSONField(default = list , verbose_name=_('Title')) - text = models.TextField(default='', blank=True, verbose_name=_('Text')) - translation = models.JSONField(verbose_name=_('translation'), default=list) - share_link = models.CharField(max_length=255, verbose_name=_('share link'), null=True, blank=True) - embedded_in = models.JSONField(default=list, blank=True) - order = models.IntegerField(default=0, verbose_name=_('order')) - - class Meta: - indexes = [ - # For filtering by transmitter + ordering - models.Index(fields=['transmitter']), - ] - verbose_name = _('Transmitter Original Text') - verbose_name_plural = _('Transmitter Original Text') - ordering = ('order', 'id') - - def __str__(self): - title = None - if self.title: - first_item = self.title[0] if isinstance(self.title, list) and self.title else self.title - if isinstance(first_item, dict): - title = first_item.get('text') or first_item.get('value') or first_item.get('title') - elif isinstance(first_item, str): - title = first_item - if not title: - title = "Untitled" - return f"{title} by {self.transmitter}" - - def save(self, *args, **kwargs): - """ - Override save to automatically generate smart slugs. - """ - from utils.slug import build_slug_for_instance - build_slug_for_instance(self, source_field='title') - - # Generate/update share_link before saving - if self.slug and self.transmitter and self.transmitter.slug: - self.share_link = f"{settings.DOVODI_DOMAIN}/arguments/narrators/{self.transmitter.slug}/original-texts/{self.slug}" - - # Reset embedded_in if text or translation changes - if self.pk: - old_instance = TransmitterOriginalText.objects.get(pk=self.pk) - if (old_instance.text != self.text or - old_instance.translation != self.translation): - self.embedded_in = [] # Reset! - - super().save(*args, **kwargs) - - - def get_title(self,lang): - """ - Get title for a specific language - """ - - 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', '') - - for tr in self.title: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') - return None - - def get_opinion_tex(self,lang): - - """ - Get title for a specific language - """ - - if not self.opinion_text or not isinstance(self.opinion_text, list): - return None - - for tr in self.opinion_text: - if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') - - for tr in self.opinion_text: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') - return None -from .reference import BookReference -class OriginalTextReference(models.Model): - """Links an Original Text (Excerpt) to a specific Book Edition""" - original_text = models.ForeignKey( - TransmitterOriginalText, - on_delete=models.CASCADE, - related_name='references' - ) - book_reference = models.ForeignKey('hadis.BookReference', on_delete=models.SET_NULL, null=True, blank=True) - - # 👇 فیلدهای جدید برای هماهنگی با سیستم Edition و Volume - edition = models.ForeignKey('hadis.BookEdition', on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Edition')) - book_volume = models.ForeignKey('hadis.BookVolume', on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Volume Record')) - - hadith_number = models.CharField(max_length=100, null=True, blank=True, verbose_name=_('Hadith / Entry Number')) - # فیلدهای متنی قدیمی برای سادگی یا مواردی که هنوز ولوم ساخته نشده است - volume = models.CharField(max_length=100, null=True, blank=True, verbose_name=_('Volume Text')) - pages = models.CharField(max_length=100, null=True, blank=True) # 👈 تغییر page به pages - address = models.TextField(null=True, blank=True, verbose_name=_('Address')) - url = models.URLField(max_length=1000, null=True, blank=True) # 👈 مچ کردن طول با بقیه مدل‌ها (1000) -class OriginalTextReferenceImage(models.Model): - """Stores the screenshots for a specific Original Text Book Reference""" - reference = models.ForeignKey(OriginalTextReference, related_name='images', on_delete=models.CASCADE) - image = models.ImageField(upload_to='hadis/original_text_images/') - priority = models.IntegerField(default=0) - - def save(self, *args, **kwargs): - from utils.watermark import apply_watermark_to_field_file - apply_watermark_to_field_file(self.image, position="bottom_left") - super().save(*args, **kwargs) - - -RELATION_TYPE_MAP = { - 'stepfather': [{'language_code': 'ru', 'text': 'Отчим'}, {'language_code': 'ar', 'text': 'زوج الأم'}, {'language_code': 'en', 'text': 'Stepfather'}], - 'stepmother': [{'language_code': 'ru', 'text': 'Мачеха'}, {'language_code': 'ar', 'text': 'زوجة الأب'}, {'language_code': 'en', 'text': 'Stepmother'}], - 'uncles_and_aunts': [{'language_code': 'ru', 'text': 'Дяди и тёти'}, {'language_code': 'ar', 'text': 'الأعمام والعمات والأخوال والخالات'}, {'language_code': 'en', 'text': 'Uncles and aunts'}], - 'siblings': [{'language_code': 'ru', 'text': 'Сиблинги'}, {'language_code': 'ar', 'text': 'الإخوة'}, {'language_code': 'en', 'text': 'Siblings'}], - 'parents': [{'language_code': 'ru', 'text': 'Родители'}, {'language_code': 'ar', 'text': 'الوالدان'}, {'language_code': 'en', 'text': 'Parents'}], - 'nephews_and_nieces': [{'language_code': 'ru', 'text': 'Племянники'}, {'language_code': 'ar', 'text': 'أبناء الإخوة'}, {'language_code': 'en', 'text': 'Nephews and nieces'}], - 'children': [{'language_code': 'ru', 'text': 'Дети'}, {'language_code': 'ar', 'text': 'الأولاد'}, {'language_code': 'en', 'text': 'Children'}], - 'stepchildren': [{'language_code': 'ru', 'text': 'Пасынки и падчерицы'}, {'language_code': 'ar', 'text': 'الربائب'}, {'language_code': 'en', 'text': 'Stepchildren'}], - 'spouses': [{'language_code': 'ru', 'text': 'Супруги'}, {'language_code': 'ar', 'text': 'الأزواج'}, {'language_code': 'en', 'text': 'Spouses'}], - 'cousins': [{'language_code': 'ru', 'text': 'Двоюродные братья и сестры'}, {'language_code': 'ar', 'text': 'أبناء العمومة والخؤولة'}, {'language_code': 'en', 'text': 'Cousins'}], - 'granduncles_and_grandaunts': [{'language_code': 'ru', 'text': 'Великие дяди и тети'}, {'language_code': 'ar', 'text': 'إخوة الأجداد'}, {'language_code': 'en', 'text': 'Granduncles and grandaunts'}], - 'grandnephews_and_grandnieces': [{'language_code': 'ru', 'text': 'Внучатые племянники'}, {'language_code': 'ar', 'text': 'أحفاد الإخوة'}, {'language_code': 'en', 'text': 'Grandnephews and grandnieces'}], - 'grandchildren_and_great_grandchildren': [{'language_code': 'ru', 'text': 'Внуки и правнуки'}, {'language_code': 'ar', 'text': 'الأحفاد وأبناء الأحفاد'}, {'language_code': 'en', 'text': 'Grandchildren and great-grandchildren'}], - 'grandparents': [{'language_code': 'ru', 'text': 'Прародители'}, {'language_code': 'ar', 'text': 'الأجداد'}, {'language_code': 'en', 'text': 'Grandparents'}], - 'foster_mother': [{'language_code': 'ru', 'text': 'Молочная мать'}, {'language_code': 'ar', 'text': 'المرضعة'}, {'language_code': 'en', 'text': 'Foster mother'}], - 'tribe': [{'language_code': 'ru', 'text': 'Племя'}, {'language_code': 'ar', 'text': 'القبيلة'}, {'language_code': 'en', 'text': 'Tribe'}], - 'foster_children': [{'language_code': 'ru', 'text': 'Молочные дети'}, {'language_code': 'ar', 'text': 'الرضاء'}, {'language_code': 'en', 'text': 'Foster children'}], - 'in_laws': [{'language_code': 'ru', 'text': 'Свойственники'}, {'language_code': 'ar', 'text': 'الأصهار والأختان'}, {'language_code': 'en', 'text': 'In-laws'}], - 'students': [{'language_code': 'ru', 'text': 'Ученики'}, {'language_code': 'ar', 'text': 'التلاميذ'}, {'language_code': 'en', 'text': 'Students'}], - 'owners_and_masters': [{'language_code': 'ru', 'text': 'Хозяева и владельцы'}, {'language_code': 'ar', 'text': 'الملاك'}, {'language_code': 'en', 'text': 'Owners and masters'}], - 'other_relatives': [{'language_code': 'ru', 'text': 'Другие родственники'}, {'language_code': 'ar', 'text': 'الأقارب'}, {'language_code': 'en', 'text': 'Other relatives'}], - 'friends_and_companions': [{'language_code': 'ru', 'text': 'Друзья и сподвижники'}, {'language_code': 'ar', 'text': 'الأصحاب'}, {'language_code': 'en', 'text': 'Friends and companions'}], - 'allies': [{'language_code': 'ru', 'text': 'Союзники'}, {'language_code': 'ar', 'text': 'الحلفاء'}, {'language_code': 'en', 'text': 'Allies'}], - 'neighbors': [{'language_code': 'ru', 'text': 'Соседи'}, {'language_code': 'ar', 'text': 'الجيران'}, {'language_code': 'en', 'text': 'Neighbors'}], - 'wards_and_proteges': [{'language_code': 'ru', 'text': 'Подопечные и воспитанники'}, {'language_code': 'ar', 'text': 'المحضونون'}, {'language_code': 'en', 'text': 'Wards and proteges'}], - 'cousins_of_parents': [{'language_code': 'ru', 'text': 'Двоюродные дяди и тети'}, {'language_code': 'ar', 'text': 'أبناء عمومة الآباء'}, {'language_code': 'en', 'text': 'Cousins of parents'}], - 'cousins_of_grandparents': [{'language_code': 'ru', 'text': 'Двоюродные дедушки и бабушки'}, {'language_code': 'ar', 'text': 'أبناء عمومة الأجداد'}, {'language_code': 'en', 'text': 'Cousins of grandparents'}], - 'cousins_children': [{'language_code': 'ru', 'text': 'Двоюродные племянники'}, {'language_code': 'ar', 'text': 'أبناء أبناء العمومة'}, {'language_code': 'en', 'text': 'Cousins children'}], - 'cousins_grandchildren_and_great_grandchildren': [{'language_code': 'ru', 'text': 'Двоюродные внуки и правнуки'}, {'language_code': 'ar', 'text': 'أحفاد أبناء العمومة'}, {'language_code': 'en', 'text': 'Cousins grandchildren and great-grandchildren'}], -} - -class TransmitterRelative(models.Model): - class RelationTypeChoices(models.TextChoices): - STEPFATHER = 'stepfather', _('Stepfather') - STEPMOTHER = 'stepmother', _('Stepmother') - UNCLES_AND_AUNTS = 'uncles_and_aunts', _('Uncles and aunts') - SIBLINGS = 'siblings', _('Siblings') - PARENTS = 'parents', _('Parents') - NEPHEWS_AND_NIECES = 'nephews_and_nieces', _('Nephews and nieces') - CHILDREN = 'children', _('Children') - STEPCHILDREN = 'stepchildren', _('Stepchildren') - SPOUSES = 'spouses', _('Spouses') - COUSINS = 'cousins', _('Cousins') - GRANDUNCLES_AND_GRANDAUNTS = 'granduncles_and_grandaunts', _('Granduncles and grandaunts') - GRANDNEPHEWS_AND_GRANDNIECES = 'grandnephews_and_grandnieces', _('Grandnephews and grandnieces') - GRANDCHILDREN_AND_GREAT_GRANDCHILDREN = 'grandchildren_and_great_grandchildren', _('Grandchildren and great-grandchildren') - GRANDPARENTS = 'grandparents', _('Grandparents') - FOSTER_MOTHER = 'foster_mother', _('Foster mother') - TRIBE = 'tribe', _('Tribe') - FOSTER_CHILDREN = 'foster_children', _('Foster children') - IN_LAWS = 'in_laws', _('In-laws') - STUDENTS = 'students', _('Students') - OWNERS_AND_MASTERS = 'owners_and_masters', _('Owners and masters') - OTHER_RELATIVES = 'other_relatives', _('Other relatives') - FRIENDS_AND_COMPANIONS = 'friends_and_companions', _('Friends and companions') - ALLIES = 'allies', _('Allies') - NEIGHBORS = 'neighbors', _('Neighbors') - WARDS_AND_PROTEGES = 'wards_and_proteges', _('Wards and proteges') - COUSINS_OF_PARENTS = 'cousins_of_parents', _('Cousins of parents') - COUSINS_OF_GRANDPARENTS = 'cousins_of_grandparents', _('Cousins of grandparents') - COUSINS_CHILDREN = 'cousins_children', _('Cousins children') - COUSINS_GRANDCHILDREN_AND_GREAT_GRANDCHILDREN = 'cousins_grandchildren_and_great_grandchildren', _('Cousins grandchildren and great-grandchildren') - - related_to = models.ForeignKey( - Transmitters, - on_delete=models.CASCADE, - verbose_name=_('Related To'), - related_name='relatives' - ) - relation_type = models.CharField( - max_length=50, - choices=RelationTypeChoices.choices, - verbose_name=_('Relation Type') - ) - relation_value = models.JSONField(default=list, verbose_name=_('Relation Value')) - name = models.CharField(max_length=255, verbose_name=_('Name')) - narrator = models.ForeignKey( - Transmitters, - on_delete=models.SET_NULL, - null=True, - blank=True, - verbose_name=_('Narrator'), - related_name='relative_of' - ) - - 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 = _('Transmitter Relative') - verbose_name_plural = _('Transmitter Relatives') - ordering = ['relation_type', 'name'] - - def save(self, *args, **kwargs): - if self.relation_type and not self.relation_value: - self.relation_value = RELATION_TYPE_MAP.get(self.relation_type, []) - super().save(*args, **kwargs) - - def __str__(self): - return f"{self.name} ({self.get_relation_type_display()} of {self.related_to})" - - -class YearValue(models.Model): - """ - Stores unique canonical Hijri dates (birth, death) and ages (age), - along with parsed numeric fields for filtering/sorting and localized translations (ru, en, ar). - Based on client specification: Коллекции дат (year_values). - """ - class CategoryChoices(models.TextChoices): - BIRTH = 'birth', _('Birth') - DEATH = 'death', _('Death') - AGE = 'age', _('Age') - - class ParseTypeChoices(models.TextChoices): - EXACT = 'exact', _('Exact') - RANGE = 'range', _('Range') - CIRCA = 'circa', _('Circa (~)') - GREATER_THAN = 'greater_than', _('Greater Than (>)') - LESS_THAN = 'less_than', _('Less Than (<)') - ALTERNATIVES = 'alternatives', _('Alternatives (2)') - ALTERNATIVES_MULTI = 'alternatives_multi', _('Alternatives (3+)') - ALTERNATIVES_RANGE = 'alternatives_range', _('Alternative Ranges (2)') - ALTERNATIVES_RANGE_MULTI = 'alternatives_range_multi', _('Alternative Ranges (3+)') - CONTEXT = 'context', _('Context (#tag)') - UNPARSED = 'unparsed', _('Unparsed') - - category = models.CharField( - max_length=20, - choices=CategoryChoices.choices, - db_index=True, - verbose_name=_('Category') - ) - raw_value = models.CharField( - max_length=100, - db_index=True, - verbose_name=_('Canonical Raw Value') - ) - parse_type = models.CharField( - max_length=40, - choices=ParseTypeChoices.choices, - default=ParseTypeChoices.EXACT, - db_index=True, - verbose_name=_('Parse Type') - ) - value = models.IntegerField(null=True, blank=True, db_index=True, verbose_name=_('Exact Value')) - value_min = models.IntegerField(null=True, blank=True, db_index=True, verbose_name=_('Min Value')) - value_max = models.IntegerField(null=True, blank=True, db_index=True, verbose_name=_('Max Value')) - values_list = models.JSONField(default=list, blank=True, verbose_name=_('Values List')) - translations = models.JSONField(default=list, blank=True, verbose_name=_('Translations (ru, en, ar)')) - - 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 = _('Year Value') - verbose_name_plural = _('Year Values') - unique_together = ('category', 'raw_value') - indexes = [ - models.Index(fields=['category', 'value']), - models.Index(fields=['category', 'value_min', 'value_max']), - models.Index(fields=['parse_type']), - ] - ordering = ('category', 'value_min', 'raw_value') - - def __str__(self): - return f"[{self.category}] {self.raw_value} ({self.parse_type})" - - def get_translation(self, lang: str = 'ru') -> Optional[str]: - if not self.translations or not isinstance(self.translations, list): - return None - target = (lang or 'ru').split('-')[0].lower() - for item in self.translations: - if isinstance(item, dict) and item.get('language_code') == target: - txt = item.get('text') or item.get('title') - if txt: - return str(txt) - for fallback in ('en', 'ru', 'ar'): - for item in self.translations: - if isinstance(item, dict) and item.get('language_code') == fallback: - txt = item.get('text') or item.get('title') - if txt: - return str(txt) - return None - - @classmethod - def get_or_create_from_canonical(cls, category: str, raw_value: str) -> "YearValue": - from ..utils.date_engine import parse_canonical_date, build_translations_for_parsed - - raw_clean = str(raw_value).strip() - parsed = parse_canonical_date(raw_clean, category=category) - canonical_str = parsed.raw_value if parsed.is_valid else raw_clean - - existing = cls.objects.filter(category=category, raw_value=canonical_str).first() - if existing: - # If it's a non-context value and translations are empty, populate them - if existing.parse_type != 'context' and not existing.translations and parsed.is_valid: - existing.translations = build_translations_for_parsed(parsed, category=category) - existing.save(update_fields=['translations', 'updated_at']) - return existing - - # If it is a context tag (#...), check if another category already has custom translations for this tag - custom_translations = None - custom_min = parsed.value_min - custom_max = parsed.value_max - if canonical_str.startswith('#'): - sibling_ctx = cls.objects.filter(raw_value=canonical_str, parse_type='context').first() - if sibling_ctx: - if sibling_ctx.translations: - custom_translations = sibling_ctx.translations - if sibling_ctx.value_min is not None: - custom_min = sibling_ctx.value_min - if sibling_ctx.value_max is not None: - custom_max = sibling_ctx.value_max - - if not custom_translations: - if parsed.is_valid: - custom_translations = build_translations_for_parsed(parsed, category=category) - else: - custom_translations = [ - {"language_code": "ru", "text": canonical_str}, - {"language_code": "en", "text": canonical_str}, - {"language_code": "ar", "text": canonical_str}, - ] - - obj = cls.objects.create( - category=category, - raw_value=canonical_str, - parse_type=parsed.parse_type if parsed.is_valid else 'unparsed', - value=parsed.value if parsed.is_valid else None, - value_min=custom_min if parsed.is_valid else None, - value_max=custom_max if parsed.is_valid else None, - values_list=parsed.values_list if parsed.is_valid else [], - translations=custom_translations, - ) - return obj - - @classmethod - def ensure_default_context_tags(cls): - """ - Ensures the standard #context tags (#hijra_habasha, #early_islam, #umayyad_era) - exist in YearValue so admins can see and select/edit them immediately. - """ - from ..utils.date_engine import DEFAULT_CONTEXT_TAGS - for tag, lang_map in DEFAULT_CONTEXT_TAGS.items(): - translations = [ - {"language_code": "ru", "text": lang_map.get("ru", tag)}, - {"language_code": "en", "text": lang_map.get("en", tag)}, - {"language_code": "ar", "text": lang_map.get("ar", tag)}, - ] - for cat in ('birth', 'death'): - cls.objects.get_or_create( - category=cat, - raw_value=tag, - defaults={ - 'parse_type': 'context', - 'translations': translations, - } - ) - - -class NarratorDate(models.Model): - """ - Links a Narrator (Transmitters) to a parsed YearValue for birth, death, or age. - Based on client specification: Коллекции дат (narrator_dates). - """ - class DateTypeChoices(models.TextChoices): - BIRTH = 'birth', _('Birth') - DEATH = 'death', _('Death') - AGE = 'age', _('Age') - - transmitter = models.ForeignKey( - Transmitters, - on_delete=models.CASCADE, - related_name='narrator_dates', - verbose_name=_('Transmitter') - ) - date_type = models.CharField( - max_length=20, - choices=DateTypeChoices.choices, - db_index=True, - verbose_name=_('Date Type') - ) - year_value = models.ForeignKey( - YearValue, - on_delete=models.CASCADE, - related_name='narrator_dates', - verbose_name=_('Year Value') - ) - raw_value = models.CharField( - max_length=100, - verbose_name=_('Canonical Raw Value') - ) - - 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 = _('Narrator Date') - verbose_name_plural = _('Narrator Dates') - unique_together = ('transmitter', 'date_type') - indexes = [ - models.Index(fields=['date_type', 'year_value']), - models.Index(fields=['transmitter', 'date_type']), - ] - ordering = ('transmitter_id', 'date_type') - - def __str__(self): - return f"{self.transmitter_id} - {self.date_type}: {self.raw_value}" \ No newline at end of file + from utils.text_normalizer import build_search_blob + self.normalized_text = build_search_blob( + self.full_name, + self.kunya, + self.known_as, + self.nickname + ) + + super().save(*args, **kwargs) + try: + self.sync_narrator_dates() + except Exception: + pass + + def sync_narrator_dates(self): + """ + Synchronizes birth_year_hijri, death_year_hijri, and age_at_death + with YearValue and NarratorDate tables. + """ + if not self.pk: + return + + date_mapping = { + 'birth': self.birth_year_hijri, + 'death': self.death_year_hijri, + 'age': self.age_at_death, + } + + for date_type, raw_val in date_mapping.items(): + if not raw_val or not str(raw_val).strip(): + NarratorDate.objects.filter(transmitter=self, date_type=date_type).delete() + continue + + raw_str = str(raw_val).strip() + yv = YearValue.get_or_create_from_canonical(category=date_type, raw_value=raw_str) + NarratorDate.objects.update_or_create( + transmitter=self, + date_type=date_type, + defaults={ + 'year_value': yv, + 'raw_value': yv.raw_value, + } + ) + + def get_formatted_date(self, date_type: str, lang: str = 'ru') -> Optional[str]: + field_map = { + 'birth': 'birth_year_hijri', + 'death': 'death_year_hijri', + 'age': 'age_at_death', + } + field_name = field_map.get(date_type) + if not field_name: + return None + raw_val = getattr(self, field_name, None) + if raw_val is None or str(raw_val).strip() == '': + return None + raw_str = str(raw_val).strip() + + target_lang = (lang or 'ru').split('-')[0].lower() + + # Check prefetched narrator_dates first if available + if hasattr(self, '_prefetched_objects_cache') and 'narrator_dates' in self._prefetched_objects_cache: + for nd in self._prefetched_objects_cache['narrator_dates']: + if nd.date_type == date_type and nd.year_value_id and nd.year_value: + txt = nd.year_value.get_translation(target_lang) + if txt: + return txt + + # If context tag (#...), check YearValue in DB for custom translations + if raw_str.startswith('#'): + yv = YearValue.objects.filter(raw_value=raw_str).first() + if yv: + txt = yv.get_translation(target_lang) + if txt: + return txt + + from ..utils.date_engine import format_canonical_date + engine_lang = target_lang if target_lang in ('ru', 'en', 'ar') else 'en' + return format_canonical_date(raw_str, category=date_type, lang=engine_lang) + + def get_formatted_birth_year(self, lang: str = 'ru') -> Optional[str]: + return self.get_formatted_date('birth', lang=lang) + + def get_formatted_death_year(self, lang: str = 'ru') -> Optional[str]: + return self.get_formatted_date('death', lang=lang) + + def get_formatted_age(self, lang: str = 'ru') -> Optional[str]: + return self.get_formatted_date('age', lang=lang) + + @property + def share_link(self): + if self.slug: + return f"{settings.DOVODI_DOMAIN}/arguments/narrators/{self.slug}" + return None + + + + def _get_json_field(self, field_name: str, lang: Optional[str]=None , fallback: str = "en"): + """ + Generic getter for JSONField in our [{text, language_code}] format. + Usage: self._get_json_field('title', 'fa') + """ + if lang is None: + lang = fallback + + value = getattr(self, field_name, None) + if not value or not isinstance(value, list): + return None + + # 1) exact language + for item in value: + if isinstance(item, dict) and item.get("language_code") == lang: + return item.get("text", "") + + # 2) fallback language + if fallback and fallback != lang: + for item in value: + if isinstance(item, dict) and item.get("language_code") == fallback: + return item.get("text", "") + + # 3) first available + item = value[0] + print(item) + return item.get("text", "") if isinstance(item, dict) else None + + def get_full_name(self, lang): + return self._get_json_field("full_name" , lang) + + def get_kunya(self, lang): + return self._get_json_field("kunya" , lang) + + def get_nickname(self, lang): + return self._get_json_field("nickname" , lang) + + def get_origin(self, lang): + return self._get_json_field("origin" , lang) + + def get_lived_in(self,lang): + return self._get_json_field("lived_in" , lang) + + def get_died_in(self, lang): + return self._get_json_field("died_in" , lang) + + def get_description(self, lang): + return self._get_json_field("description" , lang) + + + def __str__(self): + if self.full_name and len(self.full_name) > 0: + return self.full_name[0].get('text', str(self.id)) + return str(self.id) + + + +class HadisTransmitter(models.Model): + hadis = models.ForeignKey( + "hadis.Hadis", + on_delete=models.CASCADE, + verbose_name=_('hadis'), + related_name='transmitters' + ) + transmitter = models.ForeignKey( + Transmitters, + on_delete=models.SET_NULL, + verbose_name=_('transmitter'), + related_name='hadises', + null=True, + blank=True + ) + is_uncertain = models.BooleanField( + default=False, + verbose_name=_('is uncertain/doubtful'), + help_text=_('Whether this transmission node is doubtful between multiple candidate narrators') + ) + uncertain_transmitters = models.ManyToManyField( + Transmitters, + blank=True, + related_name='uncertain_hadis_nodes', + verbose_name=_('uncertain candidate transmitters'), + help_text=_('Candidate narrators when is_uncertain is True') + ) + + chain_index = models.PositiveIntegerField( + default=0, + verbose_name=_('Chain Index'), + help_text=_('Which chain/branch this belongs to (0, 1, 2...)') + ) + + narrator_layer = models.ForeignKey( + NarratorLayer, + on_delete=models.SET_NULL, + verbose_name=_('narrator layer'), + related_name='transmitters', + null=True, + blank=True, + help_text=_('The layer/class (Tabaqah) this narrator belongs to') + ) + status = models.ForeignKey( + TransmitterReliability, + on_delete=models.SET_NULL, + verbose_name=_('reliability status'), + related_name='hadis_transmitters', + null=True, + blank=True, + help_text=_('Reliability status of the narrator') + ) + order = models.PositiveIntegerField( + default=0, + verbose_name=_('Order'), + help_text=_('Order in the chain of transmission') + ) + is_gap = models.BooleanField(default=False, verbose_name=_('is gap')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + + + class Meta: + indexes = [ + # Speeds up fetching transmitters for a specific hadis in order + models.Index(fields=['hadis', 'order']), + ] + verbose_name = _('Hadis Transmitter') + verbose_name_plural = _('Hadis Transmitters') + ordering = ('hadis', 'chain_index', 'order') # Update ordering + + # 2. UPDATE UNIQUE TOGETHER + # A specific position in a specific chain of a specific hadith can only have one narrator + unique_together = ('hadis', 'chain_index', 'order') + + def __str__(self): + return f'{self.hadis.number} - Chain {self.chain_index} - {self.transmitter} (Pos: {self.order})' + + +class OpinionStatus(ColorPaletteMixin, 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) + description = models.JSONField(default=list, verbose_name=_('Description')) + + def __str__(self): + return self.title[0]['text'] if self.title else str(self.id) + + def get_title(self,lang): + """ + Get title for a specific language + """ + + 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', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_description(self, lang): + if not self.description or not isinstance(self.description, list): + return None + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') or tr.get('title', '') + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') or tr.get('title', '') + return None + + class Meta: + verbose_name = _('Opinion Status') + verbose_name_plural = _('Opinion Statuses') + + +class ScholarlyOpinion(models.Model): + """ + Standalone reusable scholarly opinion object containing status, Arabic text, and translations. + Can be connected to multiple transmitters via TransmitterOpinion. + """ + opinion_text = models.JSONField(default=list, verbose_name=_('Opinion Text')) + status = models.ForeignKey( + OpinionStatus, + on_delete=models.CASCADE, + verbose_name=_('opinion status'), + related_name='scholarly_opinions', + blank=True, + null=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: + verbose_name = _('Scholarly Opinion') + verbose_name_plural = _('Scholarly Opinions') + ordering = ('-id',) + + def __str__(self): + if self.opinion_text and isinstance(self.opinion_text, list) and len(self.opinion_text) > 0: + first_text = self.opinion_text[0].get('text', '') if isinstance(self.opinion_text[0], dict) else '' + return f"{first_text[:50]} ({self.status})" if first_text else f"Opinion #{self.id}" + return f"Opinion #{self.id}" + + def get_opinion_tex(self, lang): + if not self.opinion_text or not isinstance(self.opinion_text, list): + return None + for tr in self.opinion_text: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + for tr in self.opinion_text: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + # Keep all connected TransmitterOpinion records synced for 100% backward compatibility + TransmitterOpinion.objects.filter(opinion_id=self.id).update( + opinion_text=self.opinion_text, + status_id=self.status_id, + ) + + +class TransmitterOpinion(models.Model): + """ + Model for scholarly opinions about transmitters + """ + # class OpinionStatus(models.TextChoices): + # CONFIRMED = 'confirmed', _('Confirmed') + # MIXED = 'mixed', _('Mixed') + # REJECTED = 'rejected', _('Rejected') + + transmitter = models.ForeignKey( + Transmitters, + on_delete=models.CASCADE, + verbose_name=_('transmitter'), + related_name='opinions' + ) + scholar = models.ForeignKey( + 'hadis.BookAuthor', + on_delete=models.SET_NULL, + verbose_name=_('Scholar'), + related_name='transmitter_opinions', + null=True, + blank=True + ) + opinion = models.ForeignKey( + ScholarlyOpinion, + on_delete=models.CASCADE, + verbose_name=_('Scholarly Opinion'), + related_name='transmitter_opinions', + null=True, + blank=True + ) + scholar_name = models.JSONField(default = list , verbose_name=_('Scholar Name')) + opinion_text = models.JSONField(default = list , verbose_name=_('Opinion Text')) + status = models.ForeignKey( + OpinionStatus, + on_delete=models.CASCADE, + verbose_name=_('opinion status'), + related_name='opinions', + # default=1, + blank = True, + null=True + ) + order = models.IntegerField(default=0, verbose_name=_('order')) + 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: + indexes = [ + # For filtering by transmitter + ordering + models.Index(fields=['transmitter']), + ] + verbose_name = _('Transmitter Opinion') + verbose_name_plural = _('Transmitter Opinions') + ordering = ('order', 'id') + + def save(self, *args, **kwargs): + if self.opinion_id and self.opinion: + self.opinion_text = self.opinion.opinion_text + self.status_id = self.opinion.status_id + if self.scholar_id and self.scholar and not self.scholar_name: + if isinstance(self.scholar.name, list): + self.scholar_name = self.scholar.name + super().save(*args, **kwargs) + + def __str__(self): + scholar = self.scholar_name[0]['text'] if self.scholar_name else "Unknown Scholar" + return f"{scholar}'s opinion on {self.transmitter} ({self.status})" + + def get_scholar_name(self,lang): + """ + Get title for a specific language + """ + + if not self.scholar_name or not isinstance(self.scholar_name, list): + return None + + for tr in self.scholar_name: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.scholar_name: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_opinion_tex(self,lang): + """ + Get title for a specific language + """ + source_text = self.opinion.opinion_text if (self.opinion_id and self.opinion) else self.opinion_text + if not source_text or not isinstance(source_text, list): + return None + + for tr in source_text: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in source_text: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + +class TransmitterOriginalText(models.Model): + transmitter = models.ForeignKey( + Transmitters, + on_delete=models.CASCADE, + verbose_name=_('transmitter'), + related_name='originaltexts' + ) + slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True, allow_unicode=True) + title = models.JSONField(default = list , verbose_name=_('Title')) + text = models.TextField(default='', blank=True, verbose_name=_('Text')) + translation = models.JSONField(verbose_name=_('translation'), default=list) + share_link = models.CharField(max_length=255, verbose_name=_('share link'), null=True, blank=True) + embedded_in = models.JSONField(default=list, blank=True) + order = models.IntegerField(default=0, verbose_name=_('order')) + + class Meta: + indexes = [ + # For filtering by transmitter + ordering + models.Index(fields=['transmitter']), + ] + verbose_name = _('Transmitter Original Text') + verbose_name_plural = _('Transmitter Original Text') + ordering = ('order', 'id') + + def __str__(self): + title = None + if self.title: + first_item = self.title[0] if isinstance(self.title, list) and self.title else self.title + if isinstance(first_item, dict): + title = first_item.get('text') or first_item.get('value') or first_item.get('title') + elif isinstance(first_item, str): + title = first_item + if not title: + title = "Untitled" + return f"{title} by {self.transmitter}" + + def save(self, *args, **kwargs): + """ + Override save to automatically generate smart slugs. + """ + from utils.slug import build_slug_for_instance + build_slug_for_instance(self, source_field='title') + + # Generate/update share_link before saving + if self.slug and self.transmitter and self.transmitter.slug: + self.share_link = f"{settings.DOVODI_DOMAIN}/arguments/narrators/{self.transmitter.slug}/original-texts/{self.slug}" + + # Reset embedded_in if text or translation changes + if self.pk: + old_instance = TransmitterOriginalText.objects.get(pk=self.pk) + if (old_instance.text != self.text or + old_instance.translation != self.translation): + self.embedded_in = [] # Reset! + + super().save(*args, **kwargs) + + + def get_title(self,lang): + """ + Get title for a specific language + """ + + 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', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_opinion_tex(self,lang): + + """ + Get title for a specific language + """ + + if not self.opinion_text or not isinstance(self.opinion_text, list): + return None + + for tr in self.opinion_text: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.opinion_text: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None +from .reference import BookReference +class OriginalTextReference(models.Model): + """Links an Original Text (Excerpt) to a specific Book Edition""" + original_text = models.ForeignKey( + TransmitterOriginalText, + on_delete=models.CASCADE, + related_name='references' + ) + book_reference = models.ForeignKey('hadis.BookReference', on_delete=models.SET_NULL, null=True, blank=True) + + # 👇 فیلدهای جدید برای هماهنگی با سیستم Edition و Volume + edition = models.ForeignKey('hadis.BookEdition', on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Edition')) + book_volume = models.ForeignKey('hadis.BookVolume', on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Volume Record')) + + hadith_number = models.CharField(max_length=100, null=True, blank=True, verbose_name=_('Hadith / Entry Number')) + # فیلدهای متنی قدیمی برای سادگی یا مواردی که هنوز ولوم ساخته نشده است + volume = models.CharField(max_length=100, null=True, blank=True, verbose_name=_('Volume Text')) + pages = models.CharField(max_length=100, null=True, blank=True) # 👈 تغییر page به pages + address = models.TextField(null=True, blank=True, verbose_name=_('Address')) + url = models.URLField(max_length=1000, null=True, blank=True) # 👈 مچ کردن طول با بقیه مدل‌ها (1000) +class OriginalTextReferenceImage(models.Model): + """Stores the screenshots for a specific Original Text Book Reference""" + reference = models.ForeignKey(OriginalTextReference, related_name='images', on_delete=models.CASCADE) + image = models.ImageField(upload_to='hadis/original_text_images/') + priority = models.IntegerField(default=0) + + def save(self, *args, **kwargs): + from utils.watermark import apply_watermark_to_field_file + apply_watermark_to_field_file(self.image, position="bottom_left") + super().save(*args, **kwargs) + + +RELATION_TYPE_MAP = { + 'stepfather': [{'language_code': 'ru', 'text': 'Отчим'}, {'language_code': 'ar', 'text': 'زوج الأم'}, {'language_code': 'en', 'text': 'Stepfather'}], + 'stepmother': [{'language_code': 'ru', 'text': 'Мачеха'}, {'language_code': 'ar', 'text': 'زوجة الأب'}, {'language_code': 'en', 'text': 'Stepmother'}], + 'uncles_and_aunts': [{'language_code': 'ru', 'text': 'Дяди и тёти'}, {'language_code': 'ar', 'text': 'الأعمام والعمات والأخوال والخالات'}, {'language_code': 'en', 'text': 'Uncles and aunts'}], + 'siblings': [{'language_code': 'ru', 'text': 'Сиблинги'}, {'language_code': 'ar', 'text': 'الإخوة'}, {'language_code': 'en', 'text': 'Siblings'}], + 'parents': [{'language_code': 'ru', 'text': 'Родители'}, {'language_code': 'ar', 'text': 'الوالدان'}, {'language_code': 'en', 'text': 'Parents'}], + 'nephews_and_nieces': [{'language_code': 'ru', 'text': 'Племянники'}, {'language_code': 'ar', 'text': 'أبناء الإخوة'}, {'language_code': 'en', 'text': 'Nephews and nieces'}], + 'children': [{'language_code': 'ru', 'text': 'Дети'}, {'language_code': 'ar', 'text': 'الأولاد'}, {'language_code': 'en', 'text': 'Children'}], + 'stepchildren': [{'language_code': 'ru', 'text': 'Пасынки и падчерицы'}, {'language_code': 'ar', 'text': 'الربائب'}, {'language_code': 'en', 'text': 'Stepchildren'}], + 'spouses': [{'language_code': 'ru', 'text': 'Супруги'}, {'language_code': 'ar', 'text': 'الأزواج'}, {'language_code': 'en', 'text': 'Spouses'}], + 'cousins': [{'language_code': 'ru', 'text': 'Двоюродные братья и сестры'}, {'language_code': 'ar', 'text': 'أبناء العمومة والخؤولة'}, {'language_code': 'en', 'text': 'Cousins'}], + 'granduncles_and_grandaunts': [{'language_code': 'ru', 'text': 'Великие дяди и тети'}, {'language_code': 'ar', 'text': 'إخوة الأجداد'}, {'language_code': 'en', 'text': 'Granduncles and grandaunts'}], + 'grandnephews_and_grandnieces': [{'language_code': 'ru', 'text': 'Внучатые племянники'}, {'language_code': 'ar', 'text': 'أحفاد الإخوة'}, {'language_code': 'en', 'text': 'Grandnephews and grandnieces'}], + 'grandchildren_and_great_grandchildren': [{'language_code': 'ru', 'text': 'Внуки и правнуки'}, {'language_code': 'ar', 'text': 'الأحفاد وأبناء الأحفاد'}, {'language_code': 'en', 'text': 'Grandchildren and great-grandchildren'}], + 'grandparents': [{'language_code': 'ru', 'text': 'Прародители'}, {'language_code': 'ar', 'text': 'الأجداد'}, {'language_code': 'en', 'text': 'Grandparents'}], + 'foster_mother': [{'language_code': 'ru', 'text': 'Молочная мать'}, {'language_code': 'ar', 'text': 'المرضعة'}, {'language_code': 'en', 'text': 'Foster mother'}], + 'tribe': [{'language_code': 'ru', 'text': 'Племя'}, {'language_code': 'ar', 'text': 'القبيلة'}, {'language_code': 'en', 'text': 'Tribe'}], + 'foster_children': [{'language_code': 'ru', 'text': 'Молочные дети'}, {'language_code': 'ar', 'text': 'الرضاء'}, {'language_code': 'en', 'text': 'Foster children'}], + 'in_laws': [{'language_code': 'ru', 'text': 'Свойственники'}, {'language_code': 'ar', 'text': 'الأصهار والأختان'}, {'language_code': 'en', 'text': 'In-laws'}], + 'students': [{'language_code': 'ru', 'text': 'Ученики'}, {'language_code': 'ar', 'text': 'التلاميذ'}, {'language_code': 'en', 'text': 'Students'}], + 'owners_and_masters': [{'language_code': 'ru', 'text': 'Хозяева и владельцы'}, {'language_code': 'ar', 'text': 'الملاك'}, {'language_code': 'en', 'text': 'Owners and masters'}], + 'other_relatives': [{'language_code': 'ru', 'text': 'Другие родственники'}, {'language_code': 'ar', 'text': 'الأقارب'}, {'language_code': 'en', 'text': 'Other relatives'}], + 'friends_and_companions': [{'language_code': 'ru', 'text': 'Друзья и сподвижники'}, {'language_code': 'ar', 'text': 'الأصحاب'}, {'language_code': 'en', 'text': 'Friends and companions'}], + 'allies': [{'language_code': 'ru', 'text': 'Союзники'}, {'language_code': 'ar', 'text': 'الحلفاء'}, {'language_code': 'en', 'text': 'Allies'}], + 'neighbors': [{'language_code': 'ru', 'text': 'Соседи'}, {'language_code': 'ar', 'text': 'الجيران'}, {'language_code': 'en', 'text': 'Neighbors'}], + 'wards_and_proteges': [{'language_code': 'ru', 'text': 'Подопечные и воспитанники'}, {'language_code': 'ar', 'text': 'المحضونون'}, {'language_code': 'en', 'text': 'Wards and proteges'}], + 'cousins_of_parents': [{'language_code': 'ru', 'text': 'Двоюродные дяди и тети'}, {'language_code': 'ar', 'text': 'أبناء عمومة الآباء'}, {'language_code': 'en', 'text': 'Cousins of parents'}], + 'cousins_of_grandparents': [{'language_code': 'ru', 'text': 'Двоюродные дедушки и бабушки'}, {'language_code': 'ar', 'text': 'أبناء عمومة الأجداد'}, {'language_code': 'en', 'text': 'Cousins of grandparents'}], + 'cousins_children': [{'language_code': 'ru', 'text': 'Двоюродные племянники'}, {'language_code': 'ar', 'text': 'أبناء أبناء العمومة'}, {'language_code': 'en', 'text': 'Cousins children'}], + 'cousins_grandchildren_and_great_grandchildren': [{'language_code': 'ru', 'text': 'Двоюродные внуки и правнуки'}, {'language_code': 'ar', 'text': 'أحفاد أبناء العمومة'}, {'language_code': 'en', 'text': 'Cousins grandchildren and great-grandchildren'}], +} + +class TransmitterRelative(models.Model): + class RelationTypeChoices(models.TextChoices): + STEPFATHER = 'stepfather', _('Stepfather') + STEPMOTHER = 'stepmother', _('Stepmother') + UNCLES_AND_AUNTS = 'uncles_and_aunts', _('Uncles and aunts') + SIBLINGS = 'siblings', _('Siblings') + PARENTS = 'parents', _('Parents') + NEPHEWS_AND_NIECES = 'nephews_and_nieces', _('Nephews and nieces') + CHILDREN = 'children', _('Children') + STEPCHILDREN = 'stepchildren', _('Stepchildren') + SPOUSES = 'spouses', _('Spouses') + COUSINS = 'cousins', _('Cousins') + GRANDUNCLES_AND_GRANDAUNTS = 'granduncles_and_grandaunts', _('Granduncles and grandaunts') + GRANDNEPHEWS_AND_GRANDNIECES = 'grandnephews_and_grandnieces', _('Grandnephews and grandnieces') + GRANDCHILDREN_AND_GREAT_GRANDCHILDREN = 'grandchildren_and_great_grandchildren', _('Grandchildren and great-grandchildren') + GRANDPARENTS = 'grandparents', _('Grandparents') + FOSTER_MOTHER = 'foster_mother', _('Foster mother') + TRIBE = 'tribe', _('Tribe') + FOSTER_CHILDREN = 'foster_children', _('Foster children') + IN_LAWS = 'in_laws', _('In-laws') + STUDENTS = 'students', _('Students') + OWNERS_AND_MASTERS = 'owners_and_masters', _('Owners and masters') + OTHER_RELATIVES = 'other_relatives', _('Other relatives') + FRIENDS_AND_COMPANIONS = 'friends_and_companions', _('Friends and companions') + ALLIES = 'allies', _('Allies') + NEIGHBORS = 'neighbors', _('Neighbors') + WARDS_AND_PROTEGES = 'wards_and_proteges', _('Wards and proteges') + COUSINS_OF_PARENTS = 'cousins_of_parents', _('Cousins of parents') + COUSINS_OF_GRANDPARENTS = 'cousins_of_grandparents', _('Cousins of grandparents') + COUSINS_CHILDREN = 'cousins_children', _('Cousins children') + COUSINS_GRANDCHILDREN_AND_GREAT_GRANDCHILDREN = 'cousins_grandchildren_and_great_grandchildren', _('Cousins grandchildren and great-grandchildren') + + related_to = models.ForeignKey( + Transmitters, + on_delete=models.CASCADE, + verbose_name=_('Related To'), + related_name='relatives' + ) + relation_type = models.CharField( + max_length=50, + choices=RelationTypeChoices.choices, + verbose_name=_('Relation Type') + ) + relation_value = models.JSONField(default=list, verbose_name=_('Relation Value')) + name = models.CharField(max_length=255, verbose_name=_('Name')) + narrator = models.ForeignKey( + Transmitters, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_('Narrator'), + related_name='relative_of' + ) + + 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 = _('Transmitter Relative') + verbose_name_plural = _('Transmitter Relatives') + ordering = ['relation_type', 'name'] + + def save(self, *args, **kwargs): + if self.relation_type and not self.relation_value: + self.relation_value = RELATION_TYPE_MAP.get(self.relation_type, []) + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.name} ({self.get_relation_type_display()} of {self.related_to})" + + +class YearValue(models.Model): + """ + Stores unique canonical Hijri dates (birth, death) and ages (age), + along with parsed numeric fields for filtering/sorting and localized translations (ru, en, ar). + Based on client specification: Коллекции дат (year_values). + """ + class CategoryChoices(models.TextChoices): + BIRTH = 'birth', _('Birth') + DEATH = 'death', _('Death') + AGE = 'age', _('Age') + + class ParseTypeChoices(models.TextChoices): + EXACT = 'exact', _('Exact') + RANGE = 'range', _('Range') + CIRCA = 'circa', _('Circa (~)') + GREATER_THAN = 'greater_than', _('Greater Than (>)') + LESS_THAN = 'less_than', _('Less Than (<)') + ALTERNATIVES = 'alternatives', _('Alternatives (2)') + ALTERNATIVES_MULTI = 'alternatives_multi', _('Alternatives (3+)') + ALTERNATIVES_RANGE = 'alternatives_range', _('Alternative Ranges (2)') + ALTERNATIVES_RANGE_MULTI = 'alternatives_range_multi', _('Alternative Ranges (3+)') + CONTEXT = 'context', _('Context (#tag)') + UNPARSED = 'unparsed', _('Unparsed') + + category = models.CharField( + max_length=20, + choices=CategoryChoices.choices, + db_index=True, + verbose_name=_('Category') + ) + raw_value = models.CharField( + max_length=100, + db_index=True, + verbose_name=_('Canonical Raw Value') + ) + parse_type = models.CharField( + max_length=40, + choices=ParseTypeChoices.choices, + default=ParseTypeChoices.EXACT, + db_index=True, + verbose_name=_('Parse Type') + ) + value = models.IntegerField(null=True, blank=True, db_index=True, verbose_name=_('Exact Value')) + value_min = models.IntegerField(null=True, blank=True, db_index=True, verbose_name=_('Min Value')) + value_max = models.IntegerField(null=True, blank=True, db_index=True, verbose_name=_('Max Value')) + values_list = models.JSONField(default=list, blank=True, verbose_name=_('Values List')) + translations = models.JSONField(default=list, blank=True, verbose_name=_('Translations (ru, en, ar)')) + + 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 = _('Year Value') + verbose_name_plural = _('Year Values') + unique_together = ('category', 'raw_value') + indexes = [ + models.Index(fields=['category', 'value']), + models.Index(fields=['category', 'value_min', 'value_max']), + models.Index(fields=['parse_type']), + ] + ordering = ('category', 'value_min', 'raw_value') + + def __str__(self): + return f"[{self.category}] {self.raw_value} ({self.parse_type})" + + def get_translation(self, lang: str = 'ru') -> Optional[str]: + if not self.translations or not isinstance(self.translations, list): + return None + target = (lang or 'ru').split('-')[0].lower() + for item in self.translations: + if isinstance(item, dict) and item.get('language_code') == target: + txt = item.get('text') or item.get('title') + if txt: + return str(txt) + for fallback in ('en', 'ru', 'ar'): + for item in self.translations: + if isinstance(item, dict) and item.get('language_code') == fallback: + txt = item.get('text') or item.get('title') + if txt: + return str(txt) + return None + + @classmethod + def get_or_create_from_canonical(cls, category: str, raw_value: str) -> "YearValue": + from ..utils.date_engine import parse_canonical_date, build_translations_for_parsed + + raw_clean = str(raw_value).strip() + parsed = parse_canonical_date(raw_clean, category=category) + canonical_str = parsed.raw_value if parsed.is_valid else raw_clean + + existing = cls.objects.filter(category=category, raw_value=canonical_str).first() + if existing: + # If it's a non-context value and translations are empty, populate them + if existing.parse_type != 'context' and not existing.translations and parsed.is_valid: + existing.translations = build_translations_for_parsed(parsed, category=category) + existing.save(update_fields=['translations', 'updated_at']) + return existing + + # If it is a context tag (#...), check if another category already has custom translations for this tag + custom_translations = None + custom_min = parsed.value_min + custom_max = parsed.value_max + if canonical_str.startswith('#'): + sibling_ctx = cls.objects.filter(raw_value=canonical_str, parse_type='context').first() + if sibling_ctx: + if sibling_ctx.translations: + custom_translations = sibling_ctx.translations + if sibling_ctx.value_min is not None: + custom_min = sibling_ctx.value_min + if sibling_ctx.value_max is not None: + custom_max = sibling_ctx.value_max + + if not custom_translations: + if parsed.is_valid: + custom_translations = build_translations_for_parsed(parsed, category=category) + else: + custom_translations = [ + {"language_code": "ru", "text": canonical_str}, + {"language_code": "en", "text": canonical_str}, + {"language_code": "ar", "text": canonical_str}, + ] + + obj = cls.objects.create( + category=category, + raw_value=canonical_str, + parse_type=parsed.parse_type if parsed.is_valid else 'unparsed', + value=parsed.value if parsed.is_valid else None, + value_min=custom_min if parsed.is_valid else None, + value_max=custom_max if parsed.is_valid else None, + values_list=parsed.values_list if parsed.is_valid else [], + translations=custom_translations, + ) + return obj + + @classmethod + def ensure_default_context_tags(cls): + """ + Ensures the standard #context tags (#hijra_habasha, #early_islam, #umayyad_era) + exist in YearValue so admins can see and select/edit them immediately. + """ + from ..utils.date_engine import DEFAULT_CONTEXT_TAGS + for tag, lang_map in DEFAULT_CONTEXT_TAGS.items(): + translations = [ + {"language_code": "ru", "text": lang_map.get("ru", tag)}, + {"language_code": "en", "text": lang_map.get("en", tag)}, + {"language_code": "ar", "text": lang_map.get("ar", tag)}, + ] + for cat in ('birth', 'death'): + cls.objects.get_or_create( + category=cat, + raw_value=tag, + defaults={ + 'parse_type': 'context', + 'translations': translations, + } + ) + + +class NarratorDate(models.Model): + """ + Links a Narrator (Transmitters) to a parsed YearValue for birth, death, or age. + Based on client specification: Коллекции дат (narrator_dates). + """ + class DateTypeChoices(models.TextChoices): + BIRTH = 'birth', _('Birth') + DEATH = 'death', _('Death') + AGE = 'age', _('Age') + + transmitter = models.ForeignKey( + Transmitters, + on_delete=models.CASCADE, + related_name='narrator_dates', + verbose_name=_('Transmitter') + ) + date_type = models.CharField( + max_length=20, + choices=DateTypeChoices.choices, + db_index=True, + verbose_name=_('Date Type') + ) + year_value = models.ForeignKey( + YearValue, + on_delete=models.CASCADE, + related_name='narrator_dates', + verbose_name=_('Year Value') + ) + raw_value = models.CharField( + max_length=100, + verbose_name=_('Canonical Raw Value') + ) + + 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 = _('Narrator Date') + verbose_name_plural = _('Narrator Dates') + unique_together = ('transmitter', 'date_type') + indexes = [ + models.Index(fields=['date_type', 'year_value']), + models.Index(fields=['transmitter', 'date_type']), + ] + ordering = ('transmitter_id', 'date_type') + + def __str__(self): + return f"{self.transmitter_id} - {self.date_type}: {self.raw_value}" diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index 4c14e02..0b745fb 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, 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 diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index fcccb79..3b0b4c0 100644 --- a/apps/hadis/urls.py +++ b/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') diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index 6fe1386..0684796 100644 --- a/apps/hadis/views_admin.py +++ b/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