From 9f07013336c430d3ede00cce87376356129b9dff Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Mon, 19 Oct 2026 15:07:17 +0330 Subject: [PATCH] feat(transmitters): implement canonical date system, YearValue models, and status descriptions - Add date_engine utility for parsing exact, range, circa, before, after, and #context dates - Introduce YearValue and NarratorDate models to index and translate narrator dates - Add description field to OpinionStatus and TransmitterReliability models - Register DovodiYearValueViewSet and DovodiBookAuthorRutbaViewSet admin endpoints - Add parse-preview action for validating and translating date inputs - Add migration 0055 to populate YearValue and NarratorDate from existing transmitters --- apps/hadis/admin/transmitter.py | 64 +- ...rratordate_and_transmitters_date_fields.py | 156 ++ apps/hadis/models/transmitter.py | 1909 ++++++++++------- apps/hadis/serializers/hadis.py | 143 +- apps/hadis/serializers/serializers_admin.py | 224 +- apps/hadis/urls.py | 6 +- apps/hadis/utils/__init__.py | 12 + apps/hadis/utils/date_engine.py | 680 ++++++ apps/hadis/views/transmitter.py | 36 +- apps/hadis/views_admin.py | 128 +- 10 files changed, 2521 insertions(+), 837 deletions(-) create mode 100644 apps/hadis/migrations/0055_yearvalue_narratordate_and_transmitters_date_fields.py create mode 100644 apps/hadis/utils/__init__.py create mode 100644 apps/hadis/utils/date_engine.py diff --git a/apps/hadis/admin/transmitter.py b/apps/hadis/admin/transmitter.py index f217178..cd95aed 100644 --- a/apps/hadis/admin/transmitter.py +++ b/apps/hadis/admin/transmitter.py @@ -223,11 +223,39 @@ class TransmitterReliabilityAdminForm(forms.ModelForm): } } + description_schema = { + "type": "array", + "title": "Descriptions", + "items": { + "type": "object", + "title": "Description", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Description Text" + } + }, + "required": ["language_code", "text"] + } + } + # Apply JSON editor widgets self.fields['title'].widget = JsonEditorWidget(attrs={ 'schema': json.dumps(title_schema), 'title': 'Titles' }) + self.fields['description'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(description_schema), + 'title': 'Descriptions' + }) class OpinionStatusAdminForm(forms.ModelForm): @@ -265,11 +293,39 @@ class OpinionStatusAdminForm(forms.ModelForm): } } + description_schema = { + "type": "array", + "title": "Descriptions", + "items": { + "type": "object", + "title": "Description", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Description Text" + } + }, + "required": ["language_code", "text"] + } + } + # Apply JSON editor widgets self.fields['title'].widget = JsonEditorWidget(attrs={ 'schema': json.dumps(title_schema), 'title': 'Titles' }) + self.fields['description'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(description_schema), + 'title': 'Descriptions' + }) class TransmitterOpinionAdminForm(forms.ModelForm): @@ -535,12 +591,12 @@ class TransmitterReliabilityAdmin(ModelAdmin): form = TransmitterReliabilityAdminForm list_display = ('get_title_display', 'slug', 'color') list_filter = ('color',) - search_fields = ('title', 'slug') + search_fields = ('title', 'slug', 'description') readonly_fields = ('slug',) fieldsets = ( (None, { - 'fields': ('title', 'slug', 'color') + 'fields': ('title', 'slug', 'color', 'description') }), ) @@ -562,12 +618,12 @@ class OpinionStatusAdmin(ModelAdmin): form = OpinionStatusAdminForm list_display = ('get_title_display', 'slug', 'color') list_filter = ('color',) - search_fields = ('title', 'slug') + search_fields = ('title', 'slug', 'description') readonly_fields = ('slug',) fieldsets = ( (None, { - 'fields': ('title', 'slug', 'color') + 'fields': ('title', 'slug', 'color', 'description') }), ) diff --git a/apps/hadis/migrations/0055_yearvalue_narratordate_and_transmitters_date_fields.py b/apps/hadis/migrations/0055_yearvalue_narratordate_and_transmitters_date_fields.py new file mode 100644 index 0000000..628c895 --- /dev/null +++ b/apps/hadis/migrations/0055_yearvalue_narratordate_and_transmitters_date_fields.py @@ -0,0 +1,156 @@ +# Generated by Django 4.2.30 on 2026-10-19 14:12 + +from django.db import migrations, models +import django.db.models.deletion + + +def populate_initial_year_values_and_narrator_dates(apps, schema_editor): + Transmitters = apps.get_model('hadis', 'Transmitters') + YearValue = apps.get_model('hadis', 'YearValue') + NarratorDate = apps.get_model('hadis', 'NarratorDate') + + from apps.hadis.utils.date_engine import ( + DEFAULT_CONTEXT_TAGS, + parse_canonical_date, + build_translations_for_parsed, + ) + + # 1. Seed 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'): + YearValue.objects.get_or_create( + category=cat, + raw_value=tag, + defaults={ + 'parse_type': 'context', + 'translations': translations, + } + ) + + # 2. Populate YearValue and NarratorDate for existing Transmitters + yv_cache = {} + for tx in Transmitters.objects.all().iterator(): + for date_type, raw_val in ( + ('birth', tx.birth_year_hijri), + ('death', tx.death_year_hijri), + ('age', tx.age_at_death), + ): + if raw_val is None or str(raw_val).strip() == '': + continue + raw_str = str(raw_val).strip() + parsed = parse_canonical_date(raw_str, category=date_type) + canonical_str = parsed.raw_value if parsed.is_valid else raw_str + + cache_key = (date_type, canonical_str) + yv = yv_cache.get(cache_key) + if not yv: + yv = YearValue.objects.filter(category=date_type, raw_value=canonical_str).first() + if not yv: + if parsed.is_valid: + translations = build_translations_for_parsed(parsed, category=date_type) + else: + translations = [ + {"language_code": "ru", "text": canonical_str}, + {"language_code": "en", "text": canonical_str}, + {"language_code": "ar", "text": canonical_str}, + ] + yv = YearValue.objects.create( + category=date_type, + 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=parsed.value_min if parsed.is_valid else None, + value_max=parsed.value_max if parsed.is_valid else None, + values_list=parsed.values_list if parsed.is_valid else [], + translations=translations, + ) + yv_cache[cache_key] = yv + + NarratorDate.objects.update_or_create( + transmitter_id=tx.id, + date_type=date_type, + defaults={ + 'year_value_id': yv.id, + 'raw_value': yv.raw_value, + } + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0054_bookauthorrutba_opinionstatus_description_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='transmitters', + name='birth_year_hijri', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Birth Year (Hijri)'), + ), + migrations.AlterField( + model_name='transmitters', + name='death_year_hijri', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Death Year (Hijri)'), + ), + migrations.AlterField( + model_name='transmitters', + name='age_at_death', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Age at Death'), + ), + migrations.CreateModel( + name='YearValue', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(choices=[('birth', 'Birth'), ('death', 'Death'), ('age', 'Age')], db_index=True, max_length=20, verbose_name='Category')), + ('raw_value', models.CharField(db_index=True, max_length=100, verbose_name='Canonical Raw Value')), + ('parse_type', models.CharField(choices=[('exact', 'Exact'), ('range', 'Range'), ('circa', 'Circa (~)'), ('greater_than', 'Greater Than (>)'), ('less_than', 'Less Than (<)'), ('alternatives', 'Alternatives (2)'), ('alternatives_multi', 'Alternatives (3+)'), ('alternatives_range', 'Alternative Ranges (2)'), ('alternatives_range_multi', 'Alternative Ranges (3+)'), ('context', 'Context (#tag)'), ('unparsed', 'Unparsed')], db_index=True, default='exact', max_length=40, verbose_name='Parse Type')), + ('value', models.IntegerField(blank=True, db_index=True, null=True, verbose_name='Exact Value')), + ('value_min', models.IntegerField(blank=True, db_index=True, null=True, verbose_name='Min Value')), + ('value_max', models.IntegerField(blank=True, db_index=True, null=True, verbose_name='Max Value')), + ('values_list', models.JSONField(blank=True, default=list, verbose_name='Values List')), + ('translations', models.JSONField(blank=True, default=list, 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')), + ], + options={ + 'verbose_name': 'Year Value', + 'verbose_name_plural': 'Year Values', + 'ordering': ('category', 'value_min', 'raw_value'), + 'unique_together': {('category', 'raw_value')}, + 'indexes': [ + models.Index(fields=['category', 'value'], name='hadis_yearv_categor_idx'), + models.Index(fields=['category', 'value_min', 'value_max'], name='hadis_yearv_cat_min_max_idx'), + models.Index(fields=['parse_type'], name='hadis_yearv_parse_t_idx'), + ], + }, + ), + migrations.CreateModel( + name='NarratorDate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_type', models.CharField(choices=[('birth', 'Birth'), ('death', 'Death'), ('age', 'Age')], db_index=True, max_length=20, verbose_name='Date Type')), + ('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')), + ('transmitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='narrator_dates', to='hadis.transmitters', verbose_name='Transmitter')), + ('year_value', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='narrator_dates', to='hadis.yearvalue', verbose_name='Year Value')), + ], + options={ + 'verbose_name': 'Narrator Date', + 'verbose_name_plural': 'Narrator Dates', + 'ordering': ('transmitter_id', 'date_type'), + 'unique_together': {('transmitter', 'date_type')}, + 'indexes': [ + models.Index(fields=['date_type', 'year_value'], name='hadis_narra_date_ty_idx'), + models.Index(fields=['transmitter', 'date_type'], name='hadis_narra_transmi_idx'), + ], + }, + ), + migrations.RunPython(populate_initial_year_values_and_narrator_dates, migrations.RunPython.noop), + ] diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index bc6d03f..4e4d553 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -1,796 +1,1113 @@ - - -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) - - - - - 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 - - class Meta: - verbose_name = _('Transmitter Reliability') - verbose_name_plural = _('Transmitter Reliabilities') - - - -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')) - 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')) - died_in = models.JSONField(default = list , verbose_name=_('Died in')) - - # Date Information - birth_year_hijri = models.CharField(max_length=50, verbose_name=_("Birth Year (Hijri)"), null=True, blank=True) - death_year_hijri = models.CharField(max_length=50, verbose_name=_("Death Year (Hijri)"), null=True, blank=True) - age_at_death = models.PositiveIntegerField(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 - - 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) - - @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) - - - - 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 - - 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})" \ No newline at end of file + + +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') + + + +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')) + 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')) + died_in = models.JSONField(default = list , verbose_name=_('Died in')) + + # 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) + + 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() + if target_lang not in ('ru', 'en', 'ar'): + target_lang = 'en' + + # 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 + return format_canonical_date(raw_str, category=date_type, lang=target_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 diff --git a/apps/hadis/serializers/hadis.py b/apps/hadis/serializers/hadis.py index c693977..9278c10 100644 --- a/apps/hadis/serializers/hadis.py +++ b/apps/hadis/serializers/hadis.py @@ -18,6 +18,37 @@ from ..models import ( ) +def get_request_language_code(request=None, fallback="ru") -> str: + lang = None + if request: + query_params = getattr(request, "query_params", None) or getattr(request, "GET", None) + if query_params: + lang = query_params.get("language_code") or query_params.get("lang") + if not lang: + lang = getattr(request, "LANGUAGE_CODE", None) + if not lang and hasattr(request, "headers"): + lang = request.headers.get("Accept-Language") + elif not lang and hasattr(request, "META"): + lang = request.META.get("HTTP_ACCEPT_LANGUAGE") + if lang: + lang = lang.split(",")[0].split(";")[0].split("-")[0].strip().lower() + if not lang: + from django.utils.translation import get_language + lang = (get_language() or fallback).split("-")[0].lower() + if lang not in ("ru", "en", "ar"): + return "en" + return lang + + +def format_transmitter_date(transmitter_obj, date_type: str, request=None): + if not transmitter_obj: + return None + lang = get_request_language_code(request) + if hasattr(transmitter_obj, "get_formatted_date"): + return transmitter_obj.get_formatted_date(date_type, lang=lang) + return None + + def get_localized_correction_text(value, request=None): """ Resolve HadisCorrection.text in the same multilingual style used across the hadis app. @@ -234,8 +265,10 @@ class HadisSyncSerializer(serializers.ModelSerializer): 'gender': ut.gender, 'known_as': get_localized_text(ut.known_as, request), 'nickname': get_localized_text(ut.nickname, request), - 'birth_year_hijri': ut.birth_year_hijri, - 'death_year_hijri': ut.death_year_hijri, + 'birth_year_hijri': format_transmitter_date(ut, 'birth', request), + 'death_year_hijri': format_transmitter_date(ut, 'death', request), + 'birth_year_raw': ut.birth_year_hijri, + 'death_year_raw': ut.death_year_hijri, 'reliability': ut_rel_block, }) @@ -255,8 +288,10 @@ class HadisSyncSerializer(serializers.ModelSerializer): 'is_gap': transmitter_rel.is_gap, 'is_uncertain': transmitter_rel.is_uncertain, 'uncertain_transmitters': uncertain_list, - 'birth_year_hijri': t.birth_year_hijri if t else None, - 'death_year_hijri': t.death_year_hijri if t else None, + 'birth_year_hijri': format_transmitter_date(t, 'birth', request) if t else None, + 'death_year_hijri': format_transmitter_date(t, 'death', request) if t else None, + 'birth_year_raw': t.birth_year_hijri if t else None, + 'death_year_raw': t.death_year_hijri if t else None, 'order': transmitter_rel.order, }) @@ -445,25 +480,45 @@ class TransmitterSerializer(serializers.ModelSerializer): arabic_known_as = ArabicLocalizedField(source='known_as') nickname = LocalizedField() arabic_nickname = ArabicLocalizedField(source='nickname') + birth_year_hijri = serializers.SerializerMethodField() + death_year_hijri = serializers.SerializerMethodField() + age_at_death = serializers.SerializerMethodField() + birth_year_raw = serializers.CharField(source='birth_year_hijri', read_only=True) + death_year_raw = serializers.CharField(source='death_year_hijri', read_only=True) + age_at_death_raw = serializers.CharField(source='age_at_death', read_only=True) reliability = serializers.SerializerMethodField() share_link = serializers.CharField(read_only=True) class Meta: model = Transmitters fields = [ - 'id', 'full_name', 'arabic_full_name', 'slug','birth_year_hijri', 'death_year_hijri', - "known_as", 'arabic_known_as', 'nickname', 'arabic_nickname', 'reliability','madhhab','generation','share_link', 'gender' + 'id', 'full_name', 'arabic_full_name', 'slug', + 'birth_year_hijri', 'death_year_hijri', 'age_at_death', + 'birth_year_raw', 'death_year_raw', 'age_at_death_raw', + 'known_as', 'arabic_known_as', 'nickname', 'arabic_nickname', + 'reliability', 'madhhab', 'generation', 'share_link', 'gender' ] + def get_birth_year_hijri(self, obj): + return format_transmitter_date(obj, 'birth', self.context.get('request')) + + def get_death_year_hijri(self, obj): + return format_transmitter_date(obj, 'death', self.context.get('request')) + + def get_age_at_death(self, obj): + return format_transmitter_date(obj, 'age', self.context.get('request')) + def get_reliability(self, obj): """Serialize the reliability foreign key""" if obj.reliability: + request = self.context.get('request') return { 'id': obj.reliability.id, - 'title': get_localized_text(obj.reliability.title, self.context.get('request')), + 'title': get_localized_text(obj.reliability.title, request), 'slug': obj.reliability.slug, 'color': obj.reliability.color, 'main_color_code': obj.reliability.main_color_code, + 'description': get_localized_text(obj.reliability.description, request), } return None @@ -475,15 +530,26 @@ class TransmitterShortSerializer(serializers.ModelSerializer): arabic_known_as = ArabicLocalizedField(source='known_as') nickname = LocalizedField() arabic_nickname = ArabicLocalizedField(source='nickname') + birth_year_hijri = serializers.SerializerMethodField() + death_year_hijri = serializers.SerializerMethodField() + birth_year_raw = serializers.CharField(source='birth_year_hijri', read_only=True) + death_year_raw = serializers.CharField(source='death_year_hijri', read_only=True) reliability = serializers.SerializerMethodField() class Meta: model = Transmitters fields = [ - 'id', 'full_name', 'arabic_full_name', 'slug','birth_year_hijri', 'death_year_hijri', - "known_as", 'arabic_known_as', 'nickname', 'arabic_nickname', 'reliability', 'gender' + 'id', 'full_name', 'arabic_full_name', 'slug', + 'birth_year_hijri', 'death_year_hijri', 'birth_year_raw', 'death_year_raw', + 'known_as', 'arabic_known_as', 'nickname', 'arabic_nickname', 'reliability', 'gender' ] + def get_birth_year_hijri(self, obj): + return format_transmitter_date(obj, 'birth', self.context.get('request')) + + def get_death_year_hijri(self, obj): + return format_transmitter_date(obj, 'death', self.context.get('request')) + def get_reliability(self, obj): if obj.reliability: request = self.context.get('request') @@ -493,6 +559,7 @@ class TransmitterShortSerializer(serializers.ModelSerializer): 'slug': obj.reliability.slug, 'color': obj.reliability.color, 'main_color_code': obj.reliability.main_color_code, + 'description': get_localized_text(obj.reliability.description, request), } return None class TransmitterOpinionSerializer(serializers.ModelSerializer): @@ -537,6 +604,7 @@ class TransmitterOpinionSerializer(serializers.ModelSerializer): 'slug': status_obj.slug, 'color': status_obj.color, 'main_color_code': status_obj.main_color_code, + 'description': get_localized_text(status_obj.description, request), } return None @@ -684,6 +752,12 @@ class TransmitterDetailSerializer(serializers.ModelSerializer): description = LocalizedField() companion_type = LocalizedField() arabic_companion_type = ArabicLocalizedField(source='companion_type') + birth_year_hijri = serializers.SerializerMethodField() + death_year_hijri = serializers.SerializerMethodField() + age_at_death = serializers.SerializerMethodField() + birth_year_raw = serializers.CharField(source='birth_year_hijri', read_only=True) + death_year_raw = serializers.CharField(source='death_year_hijri', read_only=True) + age_at_death_raw = serializers.CharField(source='age_at_death', read_only=True) reliability = serializers.SerializerMethodField() relatives = serializers.SerializerMethodField() father = serializers.SerializerMethodField() @@ -696,13 +770,24 @@ class TransmitterDetailSerializer(serializers.ModelSerializer): 'id','full_name', 'arabic_full_name', 'kunya', 'arabic_kunya', 'known_as', 'arabic_known_as', 'nickname', 'arabic_nickname', 'origin', 'arabic_origin', 'mawla_status', 'arabic_mawla_status', 'profession', 'arabic_profession', 'physical_traits', 'arabic_physical_traits', 'lived_in','died_in','birth_year_hijri', - 'death_year_hijri','age_at_death','reliability', + 'death_year_hijri','age_at_death', + 'birth_year_raw', 'death_year_raw', 'age_at_death_raw', + 'reliability', 'madhhab',"in_sahih_muslim","in_sahih_bukhari", "description",'generation','share_link', 'tadlis', 'ikhtilat', 'companion_type', 'arabic_companion_type', 'relatives', 'father', 'sons', 'gender' ] + def get_birth_year_hijri(self, obj): + return format_transmitter_date(obj, 'birth', self.context.get('request')) + + def get_death_year_hijri(self, obj): + return format_transmitter_date(obj, 'death', self.context.get('request')) + + def get_age_at_death(self, obj): + return format_transmitter_date(obj, 'age', self.context.get('request')) + def get_father(self, obj): """Serialize the father foreign key or relative""" father_obj = obj.father @@ -765,12 +850,14 @@ class TransmitterDetailSerializer(serializers.ModelSerializer): def get_reliability(self, obj): """Serialize the reliability foreign key""" if obj.reliability: + request = self.context.get('request') return { 'id': obj.reliability.id, - 'title': get_localized_text(obj.reliability.title, self.context.get('request')), + 'title': get_localized_text(obj.reliability.title, request), 'slug': obj.reliability.slug, 'color': obj.reliability.color, 'main_color_code': obj.reliability.main_color_code, + 'description': get_localized_text(obj.reliability.description, request), } return None @@ -839,9 +926,10 @@ class TransmitterSyncSerializer(serializers.ModelSerializer): if obj.reliability: r= { 'id': obj.reliability.id, - 'title': get_localized_text(obj.reliability.title, self.context.get('request')), + 'title': get_localized_text(obj.reliability.title, request), 'color': obj.reliability.color, 'main_color_code': obj.reliability.main_color_code, + 'description': get_localized_text(obj.reliability.description, request), } else : r= None @@ -866,9 +954,12 @@ class TransmitterSyncSerializer(serializers.ModelSerializer): 'arabic_physical_traits': get_arabic_localized_text(obj.features), 'lived_in': get_localized_text(obj.lived_in, request), 'died_in': get_localized_text(obj.died_in, request), - 'birth_year_hijri': obj.birth_year_hijri, - 'death_year_hijri': obj.death_year_hijri, - 'age_at_death': obj.age_at_death, + 'birth_year_hijri': format_transmitter_date(obj, 'birth', request), + 'death_year_hijri': format_transmitter_date(obj, 'death', request), + 'age_at_death': format_transmitter_date(obj, 'age', request), + 'birth_year_raw': obj.birth_year_hijri, + 'death_year_raw': obj.death_year_hijri, + 'age_at_death_raw': obj.age_at_death, 'generation': obj.generation, 'reliability': r, 'madhhab': obj.madhhab, @@ -946,6 +1037,7 @@ class TransmitterSyncSerializer(serializers.ModelSerializer): 'slug': status_obj.slug, 'color': status_obj.color, 'main_color_code': status_obj.main_color_code, + 'description': get_localized_text(status_obj.description, request), } if status_obj else None, 'created_at': opinion.created_at.isoformat() if opinion.created_at else None, 'updated_at': opinion.updated_at.isoformat() if opinion.updated_at else None, @@ -1010,6 +1102,7 @@ class HadisTransmitterSerializer(serializers.ModelSerializer): 'slug': obj.status.slug, 'color': obj.status.color, 'main_color_code': obj.status.main_color_code, + 'description': get_localized_text(obj.status.description, request), } return None @@ -1562,20 +1655,36 @@ class NarratorRelationSerializer(serializers.ModelSerializer): """ full_name = LocalizedField() known_as = LocalizedField() + birth_year_hijri = serializers.SerializerMethodField() + death_year_hijri = serializers.SerializerMethodField() + birth_year_raw = serializers.CharField(source='birth_year_hijri', read_only=True) + death_year_raw = serializers.CharField(source='death_year_hijri', read_only=True) reliability = SerializerMethodField() class Meta: model = Transmitters - fields = ['id', 'slug', 'full_name', 'known_as', 'birth_year_hijri', 'death_year_hijri','reliability', 'gender'] + fields = [ + 'id', 'slug', 'full_name', 'known_as', + 'birth_year_hijri', 'death_year_hijri', 'birth_year_raw', 'death_year_raw', + 'reliability', 'gender' + ] + + def get_birth_year_hijri(self, obj): + return format_transmitter_date(obj, 'birth', self.context.get('request')) + + def get_death_year_hijri(self, obj): + return format_transmitter_date(obj, 'death', self.context.get('request')) def get_reliability(self, obj): """Serialize the reliability foreign key""" if obj.reliability: + request = self.context.get('request') return { 'id': obj.reliability.id, - 'title': get_localized_text(obj.reliability.title, self.context.get('request')), + 'title': get_localized_text(obj.reliability.title, request), 'color': obj.reliability.color, 'main_color_code': obj.reliability.main_color_code, + 'description': get_localized_text(obj.reliability.description, request), } diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index f8faf9f..5e7a445 100644 --- a/apps/hadis/serializers/serializers_admin.py +++ b/apps/hadis/serializers/serializers_admin.py @@ -8,8 +8,9 @@ from utils.image_compression import maybe_compress_uploaded_file from utils import absolute_https_url from ..models import ( Hadis, HadisCategory, HadisStatus, HadisTag, HadisCorrection, HadisInterpretation, HadisCollection, HadisSect, HadisInCollection, HadisTransmitter, HadisReference, ReferenceImage, - BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, BookResearcher, BookEditor, + BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAuthorRutba, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, BookResearcher, BookEditor, Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, ScholarlyOpinion, TransmitterOpinion, TransmitterOriginalText, + YearValue, NarratorDate, CorrectionReference, CorrectionReferenceImage, InterpretationReference, InterpretationReferenceImage, OriginalTextReference, OriginalTextReferenceImage, ContentRelease @@ -1842,9 +1843,12 @@ class AdminHadisCategoryArchiveDetailSerializer(AdminHadisCategoryArchiveSeriali self._raise_drf_validation_error(exc) class AdminTransmitterReliabilitySerializer(serializers.ModelSerializer): + title = serializers.JSONField() + description = serializers.JSONField(required=False, default=list) + class Meta: model = TransmitterReliability - fields = ["id", "title", "slug", "color", "main_color_code"] + fields = ["id", "title", "slug", "color", "main_color_code", "description"] class AdminNarratorLayerSerializer(serializers.ModelSerializer): @@ -1884,10 +1888,143 @@ class AdminNarratorLayerSerializer(serializers.ModelSerializer): class AdminOpinionStatusSerializer(serializers.ModelSerializer): title = serializers.JSONField() + description = serializers.JSONField(required=False, default=list) class Meta: model = OpinionStatus - fields = ["id", "title", "slug", "color", "main_color_code"] + fields = ["id", "title", "slug", "color", "main_color_code", "description"] + + +class AdminYearValueSerializer(serializers.ModelSerializer): + translations = serializers.JSONField(required=False, default=list) + values_list = serializers.JSONField(required=False, default=list) + narrators_count = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = YearValue + fields = [ + "id", + "category", + "raw_value", + "parse_type", + "value", + "value_min", + "value_max", + "values_list", + "translations", + "narrators_count", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "narrators_count", "created_at", "updated_at"] + + def get_narrators_count(self, obj): + if hasattr(obj, "narrators_count_val"): + return obj.narrators_count_val + if obj.parse_type == "context" and obj.raw_value.startswith("#"): + return NarratorDate.objects.filter(raw_value=obj.raw_value).values("transmitter_id").distinct().count() + return obj.narrator_dates.count() + + def to_representation(self, instance): + rep = super().to_representation(instance) + rep["canonical"] = rep.get("raw_value") + rep["date_type"] = rep.get("category") + rep["date_mode"] = rep.get("parse_type") + return rep + + def to_internal_value(self, data): + import json + data = safe_copy_data(data) + if not data.get("raw_value") and data.get("canonical"): + data["raw_value"] = data["canonical"] + if not data.get("category") and data.get("date_type"): + data["category"] = data["date_type"] + if not data.get("parse_type") and data.get("date_mode"): + data["parse_type"] = data["date_mode"] + for jf in ["translations", "values_list"]: + val = data.get(jf) + if isinstance(val, str): + try: + data[jf] = json.loads(val) + except ValueError: + pass + for nf in ["value", "value_min", "value_max"]: + if nf in data and data[nf] in ("", "null", "undefined", None): + data[nf] = None + if not data.get("category"): + data["category"] = "death" + return super().to_internal_value(data) + + def validate(self, attrs): + from ..utils.date_engine import parse_canonical_date, build_translations_for_parsed + category = attrs.get("category", getattr(self.instance, "category", "death")) + raw_value = attrs.get("raw_value", getattr(self.instance, "raw_value", "")) + if raw_value is not None: + raw_str = str(raw_value).strip() + parsed = parse_canonical_date(raw_str, category=category) + if not parsed.is_valid: + raise serializers.ValidationError({"raw_value": parsed.error}) + attrs["raw_value"] = parsed.raw_value + attrs["parse_type"] = parsed.parse_type + if parsed.parse_type != "context": + attrs["value"] = parsed.value + attrs["value_min"] = parsed.value_min + attrs["value_max"] = parsed.value_max + attrs["values_list"] = parsed.values_list + if not attrs.get("translations"): + attrs["translations"] = build_translations_for_parsed(parsed, category=category) + return attrs + + def create(self, validated_data): + raw_value = validated_data.get("raw_value", "") + category = validated_data.get("category", "death") + existing = YearValue.objects.filter(category=category, raw_value=raw_value).first() + if existing: + for k, v in validated_data.items(): + setattr(existing, k, v) + existing.save() + instance = existing + else: + instance = super().create(validated_data) + + # Sync context tag across birth and death so #tag works seamlessly for both + if instance.parse_type == "context" and instance.raw_value.startswith("#"): + other_cat = "birth" if instance.category == "death" else "death" + YearValue.objects.update_or_create( + category=other_cat, + raw_value=instance.raw_value, + defaults={ + "parse_type": "context", + "value": instance.value, + "value_min": instance.value_min, + "value_max": instance.value_max, + "values_list": instance.values_list, + "translations": instance.translations, + } + ) + return instance + + def update(self, instance, validated_data): + instance = super().update(instance, validated_data) + if instance.parse_type == "context" and instance.raw_value.startswith("#"): + YearValue.objects.filter(raw_value=instance.raw_value, parse_type="context").exclude(id=instance.id).update( + value=instance.value, + value_min=instance.value_min, + value_max=instance.value_max, + values_list=instance.values_list, + translations=instance.translations, + ) + return instance + + +def _build_admin_date_preview(obj, date_type: str, raw_val: str): + if not raw_val or not str(raw_val).strip(): + return None + return { + "ru": obj.get_formatted_date(date_type, lang="ru"), + "en": obj.get_formatted_date(date_type, lang="en"), + "ar": obj.get_formatted_date(date_type, lang="ar"), + } class AdminTransmitterListSerializer(serializers.ModelSerializer): @@ -1897,6 +2034,9 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer): nickname = serializers.JSONField() thumbnail = AbsoluteImageField(required=False, allow_null=True) share_link = serializers.CharField(read_only=True) + birth_year_preview = serializers.SerializerMethodField(read_only=True) + death_year_preview = serializers.SerializerMethodField(read_only=True) + age_at_death_preview = serializers.SerializerMethodField(read_only=True) class Meta: model = Transmitters @@ -1907,6 +2047,10 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer): "gender", "birth_year_hijri", "death_year_hijri", + "age_at_death", + "birth_year_preview", + "death_year_preview", + "age_at_death_preview", "known_as", "nickname", "reliability", @@ -1920,6 +2064,15 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer): ] read_only_fields = ["id", "slug", "share_link", "created_at", "updated_at"] + def get_birth_year_preview(self, obj): + return _build_admin_date_preview(obj, "birth", obj.birth_year_hijri) + + def get_death_year_preview(self, obj): + return _build_admin_date_preview(obj, "death", obj.death_year_hijri) + + def get_age_at_death_preview(self, obj): + return _build_admin_date_preview(obj, "age", obj.age_at_death) + class AdminTransmitterDetailSerializer(serializers.ModelSerializer): reliability_detail = AdminTransmitterReliabilitySerializer(source="reliability", read_only=True) @@ -1950,6 +2103,9 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): write_only=True, ) share_link = serializers.CharField(read_only=True) + birth_year_preview = serializers.SerializerMethodField(read_only=True) + death_year_preview = serializers.SerializerMethodField(read_only=True) + age_at_death_preview = serializers.SerializerMethodField(read_only=True) class Meta: model = Transmitters @@ -1966,6 +2122,9 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): "birth_year_hijri", "death_year_hijri", "age_at_death", + "birth_year_preview", + "death_year_preview", + "age_at_death_preview", "generation", "reliability", "reliability_detail", @@ -1986,7 +2145,20 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): "created_at", "updated_at", ] - read_only_fields = ["id", "slug", "share_link", "created_at", "updated_at", "reliability_detail", "father_detail", "sons"] + read_only_fields = [ + "id", "slug", "share_link", "created_at", "updated_at", + "reliability_detail", "father_detail", "sons", + "birth_year_preview", "death_year_preview", "age_at_death_preview", + ] + + def get_birth_year_preview(self, obj): + return _build_admin_date_preview(obj, "birth", obj.birth_year_hijri) + + def get_death_year_preview(self, obj): + return _build_admin_date_preview(obj, "death", obj.death_year_hijri) + + def get_age_at_death_preview(self, obj): + return _build_admin_date_preview(obj, "age", obj.age_at_death) def get_father_detail(self, obj): if obj.father: @@ -2052,6 +2224,8 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): ]: if field in data and (data[field] == "" or data[field] is None or data[field] == "null" or data[field] == "undefined" or (isinstance(data[field], str) and not data[field].strip())): data[field] = None + elif field in ("birth_year_hijri", "death_year_hijri", "age_at_death") and field in data and data[field] is not None: + data[field] = str(data[field]).strip() if "sons_ids" in data: val = data["sons_ids"] @@ -2063,6 +2237,27 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): return super().to_internal_value(data) + def validate(self, attrs): + from ..utils.date_engine import parse_canonical_date + date_checks = [ + ("birth_year_hijri", "birth"), + ("death_year_hijri", "death"), + ("age_at_death", "age"), + ] + errors = {} + for field_name, category in date_checks: + if field_name in attrs and attrs[field_name] is not None: + raw_val = str(attrs[field_name]).strip() + if raw_val: + parsed = parse_canonical_date(raw_val, category=category) + if not parsed.is_valid: + errors[field_name] = parsed.error + else: + attrs[field_name] = parsed.raw_value + if errors: + raise serializers.ValidationError(errors) + return super().validate(attrs) + def create(self, validated_data): validated_data.pop("remove_thumbnail", False) sons_ids = validated_data.pop("sons_ids", None) @@ -2098,9 +2293,24 @@ class AdminBookTypeSerializer(serializers.ModelSerializer): read_only_fields = ["id", "created_at", "updated_at"] +class AdminBookAuthorRutbaSerializer(serializers.ModelSerializer): + title = serializers.JSONField() + description = serializers.JSONField(required=False, default=list) + + class Meta: + model = BookAuthorRutba + fields = ["id", "title", "slug", "color", "main_color_code", "description"] + + class AdminBookAuthorSerializer(serializers.ModelSerializer): image = AbsoluteImageField(required=False, allow_null=True) remove_image = serializers.BooleanField(write_only=True, required=False, default=False) + rutba_status = serializers.PrimaryKeyRelatedField( + queryset=BookAuthorRutba.objects.all(), + required=False, + allow_null=True, + ) + rutba_detail = AdminBookAuthorRutbaSerializer(source="rutba_status", read_only=True) book_references = serializers.PrimaryKeyRelatedField( many=True, required=False, @@ -2124,6 +2334,8 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer): "scholarity", "tabaqa", "rutba", + "rutba_status", + "rutba_detail", "features", "book_references", ] @@ -2140,10 +2352,10 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer): mutable_data[json_field] = json.loads(val) except ValueError: pass - for int_field in ["birth_year_hijri", "death_year_hijri", "birth_year_miladi", "death_year_miladi", "related_narrator"]: + for int_field in ["birth_year_hijri", "death_year_hijri", "birth_year_miladi", "death_year_miladi", "related_narrator", "rutba_status"]: if int_field in mutable_data: val = mutable_data.get(int_field) - if val == "" or val == "null" or val == "undefined": + if val == "" or val == "null" or val == "undefined" or val == "none": mutable_data[int_field] = None return super().to_internal_value(mutable_data) diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index 7610a90..41d33b5 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -15,9 +15,9 @@ from .views.version import ContentReleaseSyncView, HadisDatabaseVersionView from .views.info import HadisInfoView from .views_admin import ( AdminHadisCategoryViewSet, AdminHadisViewSet, AdminHadisCorrectionViewSet, AdminCorrectionReferenceViewSet, AdminHadisReferenceViewSet, AdminInterpretationReferenceViewSet, AdminHadisInterpretationViewSet, AdminHadisStatusViewSet, AdminHadisTagViewSet, AdminHadisCollectionViewSet, - AdminBookSubjectAreaViewSet, AdminBookTypeViewSet, AdminBookTagViewSet, AdminBookAuthorViewSet, AdminBookAttributeViewSet, AdminBookReferenceImageViewSet, AdminHadisReferenceImageViewSet, AdminBookReferenceDocumentViewSet, AdminBookReferenceViewSet, + AdminBookSubjectAreaViewSet, AdminBookTypeViewSet, AdminBookTagViewSet, AdminBookAuthorViewSet, DovodiBookAuthorRutbaViewSet, AdminBookAttributeViewSet, AdminBookReferenceImageViewSet, AdminHadisReferenceImageViewSet, AdminBookReferenceDocumentViewSet, AdminBookReferenceViewSet, AdminTransmitterViewSet, AdminTransmitterReliabilityViewSet, DovodiBookSubjectAreaViewSet, DovodiBookTypeViewSet, - DovodiNarratorLayerViewSet, DovodiTransmitterReliabilityViewSet, DovodiOpinionStatusViewSet, DovodiScholarlyOpinionViewSet, + DovodiNarratorLayerViewSet, DovodiTransmitterReliabilityViewSet, DovodiOpinionStatusViewSet, DovodiScholarlyOpinionViewSet, DovodiYearValueViewSet, DovodiHadisSectViewSet, DovodiHadisCategoryViewSet, AdminCategoryQuranVerseViewSet, AdminHadisTransmitterViewSet, AdminTransmitterOpinionViewSet, AdminTransmitterOriginalTextViewSet, AdminOriginalTextReferenceViewSet, AdminBookEditionViewSet, AdminBookVolumeViewSet, AdminContentReleaseViewSet @@ -49,6 +49,7 @@ admin_router.register(r'book-types', AdminBookTypeViewSet, basename='admin-book- admin_router.register(r'book-tags', AdminBookTagViewSet, basename='admin-book-tags') admin_router.register(r'dovodi-subject-areas', DovodiBookSubjectAreaViewSet, basename='dovodi-book-subject-areas') admin_router.register(r'dovodi-book-types', DovodiBookTypeViewSet, basename='dovodi-book-types') +admin_router.register(r'dovodi-author-rutbas', DovodiBookAuthorRutbaViewSet, basename='dovodi-author-rutbas') admin_router.register(r'book-authors', AdminBookAuthorViewSet, basename='admin-book-authors') admin_router.register(r'book-attributes', AdminBookAttributeViewSet, basename='admin-book-attributes') admin_router.register(r'book-images', AdminBookReferenceImageViewSet, basename='admin-book-images') @@ -60,6 +61,7 @@ admin_router.register(r'dovodi-narrator-layers', DovodiNarratorLayerViewSet, bas admin_router.register(r'dovodi-transmitter-reliabilities', DovodiTransmitterReliabilityViewSet, basename='dovodi-transmitter-reliabilities') admin_router.register(r'dovodi-opinion-statuses', DovodiOpinionStatusViewSet, basename='dovodi-opinion-statuses') admin_router.register(r'dovodi-opinions', DovodiScholarlyOpinionViewSet, basename='dovodi-opinions') +admin_router.register(r'dovodi-year-values', DovodiYearValueViewSet, basename='dovodi-year-values') admin_router.register(r'hadis-transmitters', AdminHadisTransmitterViewSet, basename='admin-hadis-transmitters') admin_router.register(r'transmitter-opinions', AdminTransmitterOpinionViewSet, basename='admin-transmitter-opinions') admin_router.register(r'transmitter-original-texts', AdminTransmitterOriginalTextViewSet, basename='admin-transmitter-original-texts') diff --git a/apps/hadis/utils/__init__.py b/apps/hadis/utils/__init__.py new file mode 100644 index 0000000..6f3002c --- /dev/null +++ b/apps/hadis/utils/__init__.py @@ -0,0 +1,12 @@ +from .date_engine import ( + SUPPORTED_LANGS, + DEFAULT_TEMPLATES, + DEFAULT_CONTEXT_TAGS, + CanonicalDateValidationError, + parse_canonical_date, + build_translations_for_parsed, + render_date_from_parsed, + format_canonical_for_all_langs, + suggest_canonical_fix, + normalize_lang_code, +) diff --git a/apps/hadis/utils/date_engine.py b/apps/hadis/utils/date_engine.py new file mode 100644 index 0000000..1997c15 --- /dev/null +++ b/apps/hadis/utils/date_engine.py @@ -0,0 +1,680 @@ +import re +from dataclasses import dataclass, field +from typing import Dict, Any, Optional, List, Tuple + +SUPPORTED_LANGS = ("ru", "en", "ar") + +# Default templates matching the specification in Коллекции дат.md +# Tuple structure: (display_template, display_template_negative, display_template_age) +DEFAULT_TEMPLATES: Dict[str, Dict[str, Tuple[Optional[str], Optional[str], Optional[str]]]] = { + "exact": { + "ar": ("{value} هـ", "{abs_value} ق.هـ", "{value} سنة"), + "ru": ("{value} г.", "{abs_value} г. до хиджры", "{value} г."), + "en": ("{value} AH", "{abs_value} BH", "{value} years"), + }, + "range": { + "ar": ("{min}–{max} هـ", "{abs_min} ق.هـ – {max} هـ", "{min}–{max} سنة"), + "ru": ("{min}–{max} гг.", "{abs_min} г. до хиджры – {max} г.", "{min}–{max} лет"), + "en": ("{min}–{max} AH", "{abs_min} BH – {max} AH", "{min}–{max} years"), + }, + "circa": { + "ar": ("حوالي {value} هـ", "حوالي {abs_value} ق.هـ", "حوالي {value} سنة"), + "ru": ("около {value} г.", "около {abs_value} г. до хиджры", "около {value} лет"), + "en": ("circa {value} AH", "circa {abs_value} BH", "circa {value} years"), + }, + "greater_than": { + "ar": ("بعد {min} هـ", "بعد {abs_min} ق.هـ", "أكثر من {min} سنة"), + "ru": ("после {min} г.", "после {abs_min} г. до хиджры", "более {min} лет"), + "en": ("after {min} AH", "after {abs_min} BH", "more than {min} years"), + }, + "less_than": { + "ar": ("قبل {max} هـ", "قبل {abs_max} ق.هـ", "أقل من {max} سنة"), + "ru": ("до {max} г.", "до {abs_max} г. до хиджры", "менее {max} лет"), + "en": ("before {max} AH", "before {abs_max} BH", "less than {max} years"), + }, + "alternatives": { + "ar": ("{min} أو {max}", None, "{min} أو {max} سنة"), + "ru": ("{min} или {max}", None, "{min} или {max} лет"), + "en": ("{min} or {max}", None, "{min} or {max} years"), + }, + "alternatives_range": { + "ar": ("{range1} أو {range2}", None, "{range1} أو {range2} سنة"), + "ru": ("{range1} или {range2}", None, "{range1} или {range2} лет"), + "en": ("{range1} or {range2}", None, "{range1} or {range2} years"), + }, + "context": { + "ar": ("", "", ""), + "ru": ("", "", ""), + "en": ("", "", ""), + }, +} + +DEFAULT_CONTEXT_TAGS: Dict[str, Dict[str, str]] = { + "#hijra_habasha": { + "ar": "وُلِدَ فِي الْحَبَشَةِ فِي فَتْرَةِ الْهِجْرَةِ الْأُولَى", + "ru": "Родился в Абиссинии во время первого переселения", + "en": "Born in Abyssinia during the first migration", + }, + "#early_islam": { + "ar": "صدر الإسلام", + "ru": "Ранний ислам", + "en": "Early Islam", + }, + "#umayyad_era": { + "ar": "العصر الأموي", + "ru": "Эпоха Омейядов", + "en": "Umayyad era", + }, +} + +# Regex patterns for strict validation +RE_EXACT = re.compile(r"^-?\d+$") +RE_RANGE = re.compile(r"^(-?\d+)\.\.(-?\d+)$") +RE_CIRCA = re.compile(r"^~(-?\d+)$") +RE_GREATER = re.compile(r"^>(-?\d+)$") +RE_LESS = re.compile(r"^<(-?\d+)$") +RE_ALT_EXACT = re.compile(r"^-?\d+(?:;-?\d+)+$") +RE_ALT_RANGE = re.compile(r"^-?\d+\.\.-?\d+(?:;-?\d+\.\.-?\d+)+$") +RE_CONTEXT = re.compile(r"^#([a-zA-Z0-9_]+)$") + + +class CanonicalDateValidationError(ValueError): + def __init__(self, message: str, suggestion: Optional[str] = None): + super().__init__(message) + self.message = message + self.suggestion = suggestion + + +@dataclass +class ParsedCanonicalDate: + raw_value: str + is_valid: bool = True + parse_type: str = "exact" + value: Optional[int] = None + value_min: Optional[int] = None + value_max: Optional[int] = None + values_list: List[int] = field(default_factory=list) + abs_value: Optional[int] = None + is_bh: bool = False + is_mixed: bool = False + context_key: Optional[str] = None + error: Optional[str] = None + suggestion: Optional[str] = None + translations: List[Dict[str, Any]] = field(default_factory=list) + _threshold: Optional[int] = None + _parts: List[int] = field(default_factory=list) + _ranges: List[Tuple[int, int]] = field(default_factory=list) + + @property + def canonical(self) -> str: + return self.raw_value + + @property + def type(self) -> str: + return self.parse_type + + def get(self, key: str, default: Any = None) -> Any: + return getattr(self, key, default) + + def __getitem__(self, key: str) -> Any: + return getattr(self, key) + + +def normalize_lang_code(lang: Optional[str]) -> str: + if not lang: + return "en" + code = lang.split(",")[0].split(";")[0].split("-")[0].split("_")[0].strip().lower() + if code in SUPPORTED_LANGS: + return code + return "en" + + +def suggest_canonical_fix(raw: str) -> Optional[str]: + """ + Attempts to auto-fix common operator mistakes described in the standard: + - '170-193' or '170 – 193' or '170...193' -> '170..193' + - '±100' or '≈100' or '~ 100' -> '~100' + - '> 80' -> '>80', '< 150' -> '<150' + - '131, 140' or '131; 140' or '131 или 140' or '131 or 140' -> '131;140' + """ + if not raw or not isinstance(raw, str): + return None + s = raw.strip() + if not s: + return None + + # Context tag cleanup + if s.startswith("#"): + cleaned_key = re.sub(r"[^a-zA-Z0-9_]", "_", s[1:].strip()).strip("_").lower() + return f"#{cleaned_key}" if cleaned_key else None + + # Replace approximate symbols + if s.startswith(("±", "≈")): + s = "~" + s[1:].strip() + elif s.startswith("~"): + s = "~" + s[1:].strip() + + # Replace greater/less spacing or words + s = re.sub(r"^(?:больше|more\s+than|after|بعد)\s*", ">", s, flags=re.IGNORECASE) + s = re.sub(r"^(?:меньше|до|less\s+than|before|قبل)\s*", "<", s, flags=re.IGNORECASE) + s = re.sub(r"([><~])\s+(?=-?\d)", r"\1", s) + + # Replace alternative separators ('или', 'or', 'أو', '∨', ',') with ';' + s = re.sub(r"\s*(?:или|or|أو|∨|,|،|;)\s*", ";", s, flags=re.IGNORECASE) + + # Replace range separators ('...', '–', '—', or '-' between two positive numbers) + s = re.sub(r"\s*\.\.\.+\s*", "..", s) + s = re.sub(r"\s*[–—]\s*", "..", s) + s = re.sub(r"(?<=\d)\s*-\s*(?=\d)", "..", s) + s = re.sub(r"\s*\.\.\s*", "..", s) + + return s if s != raw.strip() else None + + +def _join_multi_items(items: List[str], lang: str) -> str: + if not items: + return "" + if len(items) == 1: + return items[0] + if lang == "ar": + if len(items) == 2: + return f"{items[0]} أو {items[1]}" + return "، ".join(items[:-1]) + f" أو {items[-1]}" + elif lang == "ru": + if len(items) == 2: + return f"{items[0]} или {items[1]}" + return ", ".join(items[:-1]) + f" или {items[-1]}" + else: + if len(items) == 2: + return f"{items[0]} or {items[1]}" + return ", ".join(items[:-1]) + f" or {items[-1]}" + + +def _format_single_range(r_min: int, r_max: int, lang: str, for_age: bool = False, with_unit: bool = True) -> str: + if for_age: + base = f"{r_min}–{r_max}" + if not with_unit: + return base + unit = {"ar": "سنة", "ru": "лет", "en": "years"}.get(lang, "years") + return f"{base} {unit}" + + # Hijri year range + if r_min < 0 and r_max >= 0: + abs_min = abs(r_min) + if lang == "ar": + return f"{abs_min} ق.هـ – {r_max} هـ" + elif lang == "ru": + return f"{abs_min} г. до хиджры – {r_max} г." + else: + return f"{abs_min} BH – {r_max} AH" + elif r_min < 0 and r_max < 0: + abs_min = abs(r_min) + abs_max = abs(r_max) + if not with_unit: + return f"{abs_min}–{abs_max}" + if lang == "ar": + return f"{abs_min}–{abs_max} ق.هـ" + elif lang == "ru": + return f"{abs_min}–{abs_max} гг. до хиджры" + else: + return f"{abs_min}–{abs_max} BH" + else: + base = f"{r_min}–{r_max}" + if not with_unit: + return base + if lang == "ar": + return f"{base} هـ" + elif lang == "ru": + return f"{base} гг." + else: + return f"{base} AH" + + +def canonical_to_parts(canonical: str) -> List[int]: + return [int(p) for p in canonical.split(";") if p.strip()] + + +def canonical_to_ranges(canonical: str) -> List[Tuple[int, int]]: + res: List[Tuple[int, int]] = [] + for chunk in canonical.split(";"): + if ".." in chunk: + a, b = chunk.split("..", 1) + res.append((int(a), int(b))) + return res + + +def parse_canonical_date( + raw_value: Any, + category: Optional[str] = None, + date_type: Optional[str] = None, + raise_on_error: bool = False, +) -> ParsedCanonicalDate: + """ + Validates and parses a canonical date/age string according to the Russian specification. + Returns a ParsedCanonicalDate object with `is_valid`, `raw_value`, `parse_type`, `value`, + `value_min`, `value_max`, `values_list`, `error`, `suggestion`, and `translations`. + """ + cat = category or date_type or "birth" + is_age = (cat == "age") + + if raw_value is None: + return ParsedCanonicalDate(raw_value="", is_valid=False, parse_type="unparsed", error="Empty value") + + canonical = str(raw_value).strip() + if not canonical or canonical.lower() in ("null", "none", "undefined"): + return ParsedCanonicalDate(raw_value="", is_valid=False, parse_type="unparsed", error="Empty value") + + suggestion = suggest_canonical_fix(canonical) + + def _invalid(err_msg: str, custom_suggestion: Optional[str] = None) -> ParsedCanonicalDate: + sug = custom_suggestion if custom_suggestion is not None else suggestion + if seg_msg := (f"{err_msg} (Did you mean '{sug}'?)" if sug and sug != canonical else err_msg): + pass + if raise_on_error: + raise CanonicalDateValidationError(seg_msg, suggestion=sug) + return ParsedCanonicalDate( + raw_value=canonical, + is_valid=False, + parse_type="unparsed", + error=seg_msg, + suggestion=sug, + ) + + # 1. Exact value (e.g. "143", "-5", "0") + if RE_EXACT.match(canonical): + val = int(canonical) + if is_age and val < 0: + return _invalid("Negative values (Before Hijra) are not allowed for age.", str(abs(val))) + is_bh = val < 0 + parsed = ParsedCanonicalDate( + raw_value=canonical, + is_valid=True, + parse_type="exact", + value=val, + value_min=val, + value_max=val, + values_list=[val], + abs_value=abs(val), + is_bh=is_bh, + is_mixed=False, + context_key=None, + ) + parsed.translations = build_translations_for_parsed(parsed, category=cat) + return parsed + + # 2. Range (e.g. "170..193", "-3..5") + m_range = RE_RANGE.match(canonical) + if m_range: + v_min = int(m_range.group(1)) + v_max = int(m_range.group(2)) + if is_age and (v_min < 0 or v_max < 0): + return _invalid("Negative values are not allowed for age ranges.", f"{abs(v_min)}..{abs(v_max)}") + if v_min > v_max: + return _invalid( + f"Range minimum ({v_min}) cannot be greater than maximum ({v_max}).", + f"{v_max}..{v_min}", + ) + is_mixed = (v_min < 0 and v_max >= 0) + is_bh = (v_min < 0 and not is_mixed) + parsed = ParsedCanonicalDate( + raw_value=canonical, + is_valid=True, + parse_type="range", + value=None, + value_min=v_min, + value_max=v_max, + values_list=[v_min, v_max], + abs_value=None, + is_bh=is_bh, + is_mixed=is_mixed, + context_key=None, + ) + parsed.translations = build_translations_for_parsed(parsed, category=cat) + return parsed + + # 3. Circa / Approximate (e.g. "~100", "~-10") + m_circa = RE_CIRCA.match(canonical) + if m_circa: + val = int(m_circa.group(1)) + if is_age and val < 0: + return _invalid("Negative values are not allowed for age.", f"~{abs(val)}") + parsed = ParsedCanonicalDate( + raw_value=canonical, + is_valid=True, + parse_type="circa", + value=val, + value_min=val, + value_max=val, + values_list=[val], + abs_value=abs(val), + is_bh=val < 0, + is_mixed=False, + context_key=None, + ) + parsed.translations = build_translations_for_parsed(parsed, category=cat) + return parsed + + # 4. Greater than (e.g. ">80", ">-5") + m_gt = RE_GREATER.match(canonical) + if m_gt: + val = int(m_gt.group(1)) + if is_age and val < 0: + return _invalid("Negative values are not allowed for age.", f">{abs(val)}") + parsed = ParsedCanonicalDate( + raw_value=canonical, + is_valid=True, + parse_type="greater_than", + value=val, + value_min=val + 1, + value_max=None, + values_list=[val], + abs_value=abs(val), + is_bh=val < 0, + is_mixed=False, + context_key=None, + _threshold=val, + ) + parsed.translations = build_translations_for_parsed(parsed, category=cat) + return parsed + + # 5. Less than (e.g. "<150", "<-10") + m_lt = RE_LESS.match(canonical) + if m_lt: + val = int(m_lt.group(1)) + if is_age and val < 0: + return _invalid("Negative values are not allowed for age.", f"<{abs(val)}") + parsed = ParsedCanonicalDate( + raw_value=canonical, + is_valid=True, + parse_type="less_than", + value=val, + value_min=None, + value_max=val - 1, + values_list=[val], + abs_value=abs(val), + is_bh=val < 0, + is_mixed=False, + context_key=None, + _threshold=val, + ) + parsed.translations = build_translations_for_parsed(parsed, category=cat) + return parsed + + # 6. Alternatives (2 or 3+ exact numbers separated by ';', e.g. "131;140" or "131;140;142") + if RE_ALT_EXACT.match(canonical): + parts = [int(p) for p in canonical.split(";")] + if is_age and any(p < 0 for p in parts): + return _invalid( + "Negative values are not allowed for age alternatives.", + ";".join(str(abs(p)) for p in parts), + ) + v_type = "alternatives" if len(parts) == 2 else "alternatives_multi" + parsed = ParsedCanonicalDate( + raw_value=canonical, + is_valid=True, + parse_type=v_type, + value=None, + value_min=min(parts), + value_max=max(parts), + values_list=parts, + abs_value=None, + is_bh=min(parts) < 0, + is_mixed=False, + context_key=None, + _parts=parts, + ) + parsed.translations = build_translations_for_parsed(parsed, category=cat) + return parsed + + # 7. Alternatives Range (2 or 3+ ranges separated by ';', e.g. "75..76;68..69") + if RE_ALT_RANGE.match(canonical): + raw_ranges = canonical.split(";") + ranges: List[Tuple[int, int]] = [] + all_nums: List[int] = [] + for r_str in raw_ranges: + a_str, b_str = r_str.split("..") + a, b = int(a_str), int(b_str) + if is_age and (a < 0 or b < 0): + return _invalid("Negative values are not allowed for age ranges.") + if a > b: + return _invalid(f"Range minimum ({a}) cannot be greater than maximum ({b}) in '{r_str}'.") + ranges.append((a, b)) + all_nums.extend([a, b]) + + v_type = "alternatives_range" if len(ranges) == 2 else "alternatives_range_multi" + parsed = ParsedCanonicalDate( + raw_value=canonical, + is_valid=True, + parse_type=v_type, + value=None, + value_min=min(all_nums), + value_max=max(all_nums), + values_list=all_nums, + abs_value=None, + is_bh=min(all_nums) < 0, + is_mixed=False, + context_key=None, + _ranges=ranges, + ) + parsed.translations = build_translations_for_parsed(parsed, category=cat) + return parsed + + # 8. Context tag (e.g. "#hijra_habasha") + m_ctx = RE_CONTEXT.match(canonical) + if m_ctx: + if is_age: + return _invalid("Context tags (#key) are only allowed for birth/death years, not for age.") + ctx_key = m_ctx.group(1).lower() + normalized_tag = f"#{ctx_key}" + parsed = ParsedCanonicalDate( + raw_value=normalized_tag, + is_valid=True, + parse_type="context", + value=None, + value_min=None, + value_max=None, + values_list=[], + abs_value=None, + is_bh=False, + is_mixed=False, + context_key=ctx_key, + ) + parsed.translations = build_translations_for_parsed(parsed, category=cat) + return parsed + + msg = ( + f"Invalid canonical format '{canonical}'. " + "Allowed formats: exact (143, -5), range (170..193), circa (~100), " + "greater/less (>80, <150), alternatives (131;140), range alternatives (75..76;68..69), " + "or context (#hijra_habasha)." + ) + return _invalid(msg) + + +def render_date_from_parsed( + parsed: Any, + date_type: str = "birth", + lang: str = "en", + translations_override: Optional[List[Dict[str, Any]]] = None, +) -> str: + """ + Renders the human-readable localized date/age string for a given parsed canonical object, + `date_type` ('birth', 'death', or 'age'), and language ('ru', 'en', 'ar'). + """ + lang_code = normalize_lang_code(lang) + v_type = parsed["parse_type"] if isinstance(parsed, dict) and "parse_type" in parsed else parsed.get("type") + canonical_str = parsed.get("raw_value") or parsed.get("canonical") or "" + + if translations_override: + for t in translations_override: + if isinstance(t, dict) and t.get("language_code") == lang_code: + txt = t.get("text") or t.get("title") or t.get("context_translation") + if txt: + return str(txt) + + # 1. Context tag + if v_type == "context": + tag_key = canonical_str if canonical_str.startswith("#") else f"#{parsed.get('context_key', '')}" + default_ctx = DEFAULT_CONTEXT_TAGS.get(tag_key, {}) + return default_ctx.get(lang_code, tag_key) + + is_age = (date_type == "age") + + # 2. Multi-item ready strings (3+ alternatives) + if v_type == "alternatives_multi": + parts = [str(p) for p in (parsed.get("_parts") or canonical_to_parts(canonical_str))] + joined = _join_multi_items(parts, lang_code) + if is_age: + age_unit = {"ar": "سنة", "ru": "лет", "en": "years"}[lang_code] + return f"{joined} {age_unit}" + return joined + + if v_type == "alternatives_range_multi": + ranges = parsed.get("_ranges") or canonical_to_ranges(canonical_str) + if is_age: + age_items = [_format_single_range(a, b, lang_code, for_age=True, with_unit=False) for a, b in ranges] + joined_age = _join_multi_items(age_items, lang_code) + age_unit = {"ar": "سنة", "ru": "лет", "en": "years"}[lang_code] + return f"{joined_age} {age_unit}" + hijri_items = [_format_single_range(a, b, lang_code, for_age=False, with_unit=False) for a, b in ranges] + return _join_multi_items(hijri_items, lang_code) + + # 3. Standard template rendering + tpl_tuple = DEFAULT_TEMPLATES.get(v_type, DEFAULT_TEMPLATES["exact"]).get(lang_code) + disp, disp_neg, disp_age = tpl_tuple if tpl_tuple else ("{value}", None, "{value}") + + is_neg_or_mixed = bool(parsed.get("is_bh") or parsed.get("is_mixed")) + if is_age: + template = disp_age + elif is_neg_or_mixed: + template = disp_neg or disp + else: + template = disp + + if not template: + return canonical_str + + # Special case: range where both min and max are negative (e.g. -10..-5) + v_min = parsed.get("value_min") + v_max = parsed.get("value_max") + if v_type == "range" and not is_age and v_min is not None and v_max is not None and v_min < 0 and v_max < 0: + return _format_single_range(v_min, v_max, lang_code, for_age=False, with_unit=True) + + abs_val = parsed.get("abs_value") + if v_type == "greater_than": + threshold = parsed.get("_threshold") + if threshold is None: + threshold = (v_min - 1) if v_min is not None else 0 + sub_min = threshold + sub_abs_min = abs(threshold) + sub_max = v_max + sub_abs_max = abs(v_max) if v_max is not None else None + sub_val = threshold + elif v_type == "less_than": + threshold = parsed.get("_threshold") + if threshold is None: + threshold = (v_max + 1) if v_max is not None else 0 + sub_max = threshold + sub_abs_max = abs(threshold) + sub_min = v_min + sub_abs_min = abs(v_min) if v_min is not None else None + sub_val = threshold + elif v_type == "alternatives": + parts = parsed.get("_parts") or canonical_to_parts(canonical_str) + sub_min = parts[0] if len(parts) > 0 else v_min + sub_max = parts[1] if len(parts) > 1 else v_max + sub_abs_min = abs(sub_min) if sub_min is not None else None + sub_abs_max = abs(sub_max) if sub_max is not None else None + sub_val = sub_min + else: + sub_min = v_min + sub_max = v_max + sub_abs_min = abs(v_min) if v_min is not None else None + sub_abs_max = abs(v_max) if v_max is not None else None + sub_val = v_min if v_min is not None else v_max + + range1_str = "" + range2_str = "" + if v_type == "alternatives_range": + ranges = parsed.get("_ranges") or canonical_to_ranges(canonical_str) + if len(ranges) >= 2: + if is_age: + range1_str = _format_single_range(ranges[0][0], ranges[0][1], lang_code, for_age=True, with_unit=False) + range2_str = _format_single_range(ranges[1][0], ranges[1][1], lang_code, for_age=True, with_unit=False) + else: + range1_str = _format_single_range(ranges[0][0], ranges[0][1], lang_code, for_age=False, with_unit=True) + range2_str = _format_single_range(ranges[1][0], ranges[1][1], lang_code, for_age=False, with_unit=True) + + return template.format( + value=sub_val if sub_val is not None else "", + abs_value=abs_val if abs_val is not None else (abs(sub_val) if sub_val is not None else ""), + min=sub_min if sub_min is not None else "", + max=sub_max if sub_max is not None else "", + abs_min=sub_abs_min if sub_abs_min is not None else "", + abs_max=sub_abs_max if sub_abs_max is not None else "", + range1=range1_str, + range2=range2_str, + ) + + +def build_translations_for_parsed( + parsed: Any, + category: str = "birth", + custom_context_translations: Optional[Dict[str, str]] = None, +) -> List[Dict[str, str]]: + """ + Generates the 3 language translation objects (ru, en, ar) in standard + `[{"language_code": "ru", "text": "..."}, ...]` format stored on `YearValue.translations`. + """ + translations: List[Dict[str, str]] = [] + v_type = parsed.get("parse_type") or parsed.get("type") + for lang in SUPPORTED_LANGS: + if v_type == "context" and custom_context_translations and custom_context_translations.get(lang): + txt = custom_context_translations[lang] + else: + txt = render_date_from_parsed(parsed, date_type=category, lang=lang) + translations.append({ + "language_code": lang, + "text": txt, + }) + return translations + + +def format_canonical_date( + raw_value: Any, + category: str = "birth", + lang: str = "ru", + date_type: Optional[str] = None, +) -> Optional[str]: + """ + Formats a canonical date/age string into the requested language ('ru', 'en', 'ar'). + Returns raw string as fallback if unparseable. + """ + if raw_value is None or str(raw_value).strip() == "": + return None + cat = date_type or category or "birth" + raw_str = str(raw_value).strip() + parsed = parse_canonical_date(raw_str, category=cat) + if not parsed.is_valid: + return raw_str + return render_date_from_parsed(parsed, date_type=cat, lang=lang) + + +def format_canonical_for_all_langs( + raw_value: Any, + date_type: str = "birth", + translations_override: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, str]: + """ + Returns a dictionary of formatted strings for all supported languages: {'ru': ..., 'en': ..., 'ar': ...}. + """ + if raw_value is None or str(raw_value).strip() == "": + return {} + raw_str = str(raw_value).strip() + parsed = parse_canonical_date(raw_str, category=date_type) + if not parsed.is_valid: + return {lang: raw_str for lang in SUPPORTED_LANGS} + return { + lang: render_date_from_parsed(parsed, date_type=date_type, lang=lang, translations_override=translations_override) + for lang in SUPPORTED_LANGS + } diff --git a/apps/hadis/views/transmitter.py b/apps/hadis/views/transmitter.py index 9f80905..4455d13 100644 --- a/apps/hadis/views/transmitter.py +++ b/apps/hadis/views/transmitter.py @@ -32,7 +32,7 @@ class TransmitterView(ListAPIView): return super().get(request, *args, **kwargs) def get_queryset(self): - queryset = Transmitters.objects.all() + queryset = Transmitters.objects.select_related('reliability').prefetch_related('narrator_dates__year_value') # Get query parameters search_query = self.request.query_params.get('search', None) @@ -59,6 +59,33 @@ class TransmitterView(ListAPIView): if generation_filter: queryset = queryset.filter(generation=generation_filter) + # 3. Optional chronological / age range filters via NarratorDate -> YearValue + for date_type, min_param, max_param in ( + ('birth', 'birth_year_min', 'birth_year_max'), + ('death', 'death_year_min', 'death_year_max'), + ('age', 'age_min', 'age_max'), + ): + min_val = self.request.query_params.get(min_param) + max_val = self.request.query_params.get(max_param) + try: + min_int = int(min_val) if min_val not in (None, '') else None + except (ValueError, TypeError): + min_int = None + try: + max_int = int(max_val) if max_val not in (None, '') else None + except (ValueError, TypeError): + max_int = None + + if min_int is not None or max_int is not None: + from ..models import NarratorDate + nd_q = Q(date_type=date_type) + if min_int is not None: + nd_q &= (Q(year_value__value_max__gte=min_int) | Q(year_value__value__gte=min_int)) + if max_int is not None: + nd_q &= (Q(year_value__value_min__lte=max_int) | Q(year_value__value__lte=max_int)) + matching_tx_ids = NarratorDate.objects.filter(nd_q).values_list('transmitter_id', flat=True) + queryset = queryset.filter(id__in=matching_tx_ids) + return queryset class TransmitterDetailView(RetrieveAPIView): @@ -72,7 +99,11 @@ class TransmitterDetailView(RetrieveAPIView): def get_queryset(self): input = self.kwargs.get('narrator_slug') - return Transmitters.objects.filter(slug=input) + return Transmitters.objects.filter(slug=input).select_related('reliability', 'father').prefetch_related( + 'narrator_dates__year_value', + 'children_as_father', + 'relatives__narrator', + ) class TransmitterOpinionView(ListAPIView): serializer_class = TransmitterOpinionSerializer @@ -152,6 +183,7 @@ class TransmitterSyncView(ListAPIView): # 👇 ۱. واکشی اطلاعات reliability راوی در همان کوئری اصلی .select_related('reliability') .prefetch_related( + 'narrator_dates__year_value', # 👇 ۲. اضافه کردن 'scholar' برای جلوگیری از کوئری اضافه در نظرات دانشمندان Prefetch('opinions', queryset=TransmitterOpinion.objects.select_related('status', 'scholar', 'opinion', 'opinion__status')), diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index 6687ce8..5af478b 100644 --- a/apps/hadis/views_admin.py +++ b/apps/hadis/views_admin.py @@ -11,8 +11,9 @@ from utils.pagination import StandardResultsSetPagination, OptionalPagination from .models import ( Hadis, HadisCategory, HadisCollection, HadisInCollection, HadisCorrection, HadisInterpretation, HadisStatus, HadisTag, HadisSect, HadisTransmitter, HadisReference, ReferenceImage, - BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, + BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAuthorRutba, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, ScholarlyOpinion, TransmitterOpinion, TransmitterOriginalText, + YearValue, NarratorDate, CorrectionReference, InterpretationReference, OriginalTextReference, ContentRelease, CategoryQuranVerse, @@ -38,6 +39,7 @@ from .serializers.serializers_admin import ( AdminBookTypeSerializer, AdminBookTagSerializer, AdminBookAuthorSerializer, + AdminBookAuthorRutbaSerializer, AdminBookAttributeSerializer, AdminBookReferenceImageSerializer, AdminHadisReferenceImageSerializer, @@ -47,6 +49,7 @@ from .serializers.serializers_admin import ( AdminTransmitterListSerializer, AdminTransmitterDetailSerializer, AdminTransmitterReliabilitySerializer, + AdminYearValueSerializer, AdminNarratorLayerSerializer, AdminOpinionStatusSerializer, AdminScholarlyOpinionSerializer, @@ -282,17 +285,25 @@ class AdminTransmitterReliabilityViewSet(ReadOnlyModelViewSet): serializer_class = AdminTransmitterReliabilitySerializer permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] - pagination_class = None + pagination_class = OptionalPagination def get_queryset(self): - return TransmitterReliability.objects.all().order_by("-id") + queryset = TransmitterReliability.objects.all() + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter( + Q(title__icontains=search_query) + | Q(slug__icontains=search_query) + | Q(description__icontains=search_query) + ) + return queryset.order_by("-id") class DovodiNarratorLayerViewSet(ModelViewSet): serializer_class = AdminNarratorLayerSerializer permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] - pagination_class = None + pagination_class = OptionalPagination parser_classes = (JSONParser, MultiPartParser, FormParser) def get_queryset(self): @@ -314,7 +325,7 @@ class DovodiTransmitterReliabilityViewSet(ModelViewSet): serializer_class = AdminTransmitterReliabilitySerializer permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] - pagination_class = None + pagination_class = OptionalPagination parser_classes = (JSONParser, MultiPartParser, FormParser) def get_queryset(self): @@ -324,6 +335,7 @@ class DovodiTransmitterReliabilityViewSet(ModelViewSet): queryset = queryset.filter( Q(title__icontains=search_query) | Q(slug__icontains=search_query) + | Q(description__icontains=search_query) ) return queryset.order_by("-id") @@ -332,7 +344,7 @@ class DovodiOpinionStatusViewSet(ModelViewSet): serializer_class = AdminOpinionStatusSerializer permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] - pagination_class = None + pagination_class = OptionalPagination parser_classes = (JSONParser, MultiPartParser, FormParser) def get_queryset(self): @@ -342,6 +354,7 @@ class DovodiOpinionStatusViewSet(ModelViewSet): queryset = queryset.filter( Q(title__icontains=search_query) | Q(slug__icontains=search_query) + | Q(description__icontains=search_query) ) return queryset.order_by("-id") @@ -350,7 +363,7 @@ class DovodiScholarlyOpinionViewSet(ModelViewSet): serializer_class = AdminScholarlyOpinionSerializer permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] - pagination_class = None + pagination_class = OptionalPagination parser_classes = (JSONParser, MultiPartParser, FormParser) def get_queryset(self): @@ -374,6 +387,79 @@ class DovodiScholarlyOpinionViewSet(ModelViewSet): return queryset.order_by("-id") +class DovodiYearValueViewSet(ModelViewSet): + serializer_class = AdminYearValueSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + try: + YearValue.ensure_default_context_tags() + except Exception: + pass + + queryset = YearValue.objects.all() + + category = self.request.query_params.get("category") + if category and category != "all": + queryset = queryset.filter(category=category) + + parse_type = self.request.query_params.get("parse_type") + if parse_type and parse_type != "all": + queryset = queryset.filter(parse_type=parse_type) + + # If context_only=true, deduplicate #tags by returning one representative record per raw_value + context_only = self.request.query_params.get("context_only") + if context_only in ("true", "1", "True"): + queryset = queryset.filter(parse_type="context") + if not category: + # Prefer 'death' record so each #tag appears once in the context manager list + death_tags = set( + YearValue.objects.filter(parse_type="context", category="death").values_list("raw_value", flat=True) + ) + queryset = queryset.filter( + Q(category="death") | (Q(category="birth") & ~Q(raw_value__in=death_tags)) + ) + + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter( + Q(raw_value__icontains=search_query) + | Q(translations__icontains=search_query) + ) + + return queryset.order_by("parse_type", "raw_value", "id") + + @action(detail=False, methods=["post"], url_path="parse-preview") + def parse_preview(self, request): + from .utils.date_engine import parse_canonical_date, build_translations_for_parsed + raw_value = str(request.data.get("raw_value") or "").strip() + category = str(request.data.get("category") or "death").strip() + parsed = parse_canonical_date(raw_value, category=category) + translations = [] + if parsed.is_valid: + if parsed.parse_type == "context": + yv = YearValue.objects.filter(raw_value=parsed.raw_value).first() + if yv and yv.translations: + translations = yv.translations + if not translations: + translations = build_translations_for_parsed(parsed, category=category) + return Response({ + "raw_value": parsed.raw_value, + "is_valid": parsed.is_valid, + "parse_type": parsed.parse_type, + "value": parsed.value, + "value_min": parsed.value_min, + "value_max": parsed.value_max, + "values_list": parsed.values_list, + "error": parsed.error, + "suggestion": parsed.suggestion, + "translations": translations, + }) + + class AdminTransmitterViewSet(ModelViewSet): permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] @@ -386,7 +472,7 @@ class AdminTransmitterViewSet(ModelViewSet): return AdminTransmitterDetailSerializer def get_queryset(self): - queryset = Transmitters.objects.select_related("reliability") + queryset = Transmitters.objects.select_related("reliability").prefetch_related("narrator_dates__year_value") search_query = self.request.query_params.get("search") if search_query: @@ -400,6 +486,9 @@ class AdminTransmitterViewSet(ModelViewSet): | Q(died_in__icontains=search_query) | Q(slug__icontains=search_query) | Q(legacy_id__icontains=search_query) + | Q(birth_year_hijri__icontains=search_query) + | Q(death_year_hijri__icontains=search_query) + | Q(age_at_death__icontains=search_query) ) if search_query.isdigit(): q_filter |= Q(legacy_number=int(search_query)) | Q(generation=int(search_query)) @@ -792,15 +881,34 @@ class DovodiBookTypeViewSet(ModelViewSet): return queryset.order_by("-created_at", "-id") +class DovodiBookAuthorRutbaViewSet(ModelViewSet): + serializer_class = AdminBookAuthorRutbaSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = BookAuthorRutba.objects.all() + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter( + Q(title__icontains=search_query) + | Q(slug__icontains=search_query) + | Q(description__icontains=search_query) + ) + return queryset.order_by("-id") + + class AdminBookAuthorViewSet(ModelViewSet): serializer_class = AdminBookAuthorSerializer permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] - pagination_class = None + pagination_class = OptionalPagination parser_classes = (MultiPartParser, FormParser, JSONParser) def get_queryset(self): - queryset = BookAuthor.objects.all() + queryset = BookAuthor.objects.select_related("rutba_status").all() search_query = self.request.query_params.get("search") if search_query: queryset = queryset.filter(name__icontains=search_query)