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