You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1112 lines
49 KiB

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()
# 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}"