Browse Source

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
master
Mohsen Taba 2 weeks ago
parent
commit
9f07013336
  1. 64
      apps/hadis/admin/transmitter.py
  2. 156
      apps/hadis/migrations/0055_yearvalue_narratordate_and_transmitters_date_fields.py
  3. 333
      apps/hadis/models/transmitter.py
  4. 143
      apps/hadis/serializers/hadis.py
  5. 224
      apps/hadis/serializers/serializers_admin.py
  6. 6
      apps/hadis/urls.py
  7. 12
      apps/hadis/utils/__init__.py
  8. 680
      apps/hadis/utils/date_engine.py
  9. 36
      apps/hadis/views/transmitter.py
  10. 128
      apps/hadis/views_admin.py

64
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 # Apply JSON editor widgets
self.fields['title'].widget = JsonEditorWidget(attrs={ self.fields['title'].widget = JsonEditorWidget(attrs={
'schema': json.dumps(title_schema), 'schema': json.dumps(title_schema),
'title': 'Titles' 'title': 'Titles'
}) })
self.fields['description'].widget = JsonEditorWidget(attrs={
'schema': json.dumps(description_schema),
'title': 'Descriptions'
})
class OpinionStatusAdminForm(forms.ModelForm): 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 # Apply JSON editor widgets
self.fields['title'].widget = JsonEditorWidget(attrs={ self.fields['title'].widget = JsonEditorWidget(attrs={
'schema': json.dumps(title_schema), 'schema': json.dumps(title_schema),
'title': 'Titles' 'title': 'Titles'
}) })
self.fields['description'].widget = JsonEditorWidget(attrs={
'schema': json.dumps(description_schema),
'title': 'Descriptions'
})
class TransmitterOpinionAdminForm(forms.ModelForm): class TransmitterOpinionAdminForm(forms.ModelForm):
@ -535,12 +591,12 @@ class TransmitterReliabilityAdmin(ModelAdmin):
form = TransmitterReliabilityAdminForm form = TransmitterReliabilityAdminForm
list_display = ('get_title_display', 'slug', 'color') list_display = ('get_title_display', 'slug', 'color')
list_filter = ('color',) list_filter = ('color',)
search_fields = ('title', 'slug')
search_fields = ('title', 'slug', 'description')
readonly_fields = ('slug',) readonly_fields = ('slug',)
fieldsets = ( fieldsets = (
(None, { (None, {
'fields': ('title', 'slug', 'color')
'fields': ('title', 'slug', 'color', 'description')
}), }),
) )
@ -562,12 +618,12 @@ class OpinionStatusAdmin(ModelAdmin):
form = OpinionStatusAdminForm form = OpinionStatusAdminForm
list_display = ('get_title_display', 'slug', 'color') list_display = ('get_title_display', 'slug', 'color')
list_filter = ('color',) list_filter = ('color',)
search_fields = ('title', 'slug')
search_fields = ('title', 'slug', 'description')
readonly_fields = ('slug',) readonly_fields = ('slug',)
fieldsets = ( fieldsets = (
(None, { (None, {
'fields': ('title', 'slug', 'color')
'fields': ('title', 'slug', 'color', 'description')
}), }),
) )

156
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),
]

333
apps/hadis/models/transmitter.py

@ -95,9 +95,7 @@ class TransmitterReliability(ColorPaletteMixin, LowercaseSlugMixin, models.Model
title = models.JSONField(default = list , verbose_name=_('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) 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): def __str__(self):
return self.title[0]['text'] if self.title else str(self.id) return self.title[0]['text'] if self.title else str(self.id)
@ -119,6 +117,19 @@ class TransmitterReliability(ColorPaletteMixin, LowercaseSlugMixin, models.Model
return tr.get('text', '') return tr.get('text', '')
return None 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: class Meta:
verbose_name = _('Transmitter Reliability') verbose_name = _('Transmitter Reliability')
verbose_name_plural = _('Transmitter Reliabilities') verbose_name_plural = _('Transmitter Reliabilities')
@ -174,9 +185,9 @@ class Transmitters(LowercaseSlugMixin, models.Model):
died_in = models.JSONField(default = list , verbose_name=_('Died in')) died_in = models.JSONField(default = list , verbose_name=_('Died in'))
# Date Information # 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)
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) generation = models.PositiveIntegerField(verbose_name=_('Generation'), blank=True, null=True)
# New fields # New fields
@ -234,6 +245,12 @@ class Transmitters(LowercaseSlugMixin, models.Model):
if not self.legacy_number: if not self.legacy_number:
self.legacy_number = None 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 from utils.text_normalizer import build_search_blob
self.normalized_text = build_search_blob( self.normalized_text = build_search_blob(
self.full_name, self.full_name,
@ -243,6 +260,86 @@ class Transmitters(LowercaseSlugMixin, models.Model):
) )
super().save(*args, **kwargs) 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 @property
def share_link(self): def share_link(self):
@ -392,8 +489,7 @@ class OpinionStatus(ColorPaletteMixin, LowercaseSlugMixin, models.Model):
title = models.JSONField(default = list , verbose_name=_('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) 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): def __str__(self):
return self.title[0]['text'] if self.title else str(self.id) return self.title[0]['text'] if self.title else str(self.id)
@ -415,6 +511,19 @@ class OpinionStatus(ColorPaletteMixin, LowercaseSlugMixin, models.Model):
return tr.get('text', '') return tr.get('text', '')
return None 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: class Meta:
verbose_name = _('Opinion Status') verbose_name = _('Opinion Status')
verbose_name_plural = _('Opinion Statuses') verbose_name_plural = _('Opinion Statuses')
@ -794,3 +903,211 @@ class TransmitterRelative(models.Model):
def __str__(self): def __str__(self):
return f"{self.name} ({self.get_relation_type_display()} of {self.related_to})" 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}"

143
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): def get_localized_correction_text(value, request=None):
""" """
Resolve HadisCorrection.text in the same multilingual style used across the hadis app. Resolve HadisCorrection.text in the same multilingual style used across the hadis app.
@ -234,8 +265,10 @@ class HadisSyncSerializer(serializers.ModelSerializer):
'gender': ut.gender, 'gender': ut.gender,
'known_as': get_localized_text(ut.known_as, request), 'known_as': get_localized_text(ut.known_as, request),
'nickname': get_localized_text(ut.nickname, 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, 'reliability': ut_rel_block,
}) })
@ -255,8 +288,10 @@ class HadisSyncSerializer(serializers.ModelSerializer):
'is_gap': transmitter_rel.is_gap, 'is_gap': transmitter_rel.is_gap,
'is_uncertain': transmitter_rel.is_uncertain, 'is_uncertain': transmitter_rel.is_uncertain,
'uncertain_transmitters': uncertain_list, '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, 'order': transmitter_rel.order,
}) })
@ -445,25 +480,45 @@ class TransmitterSerializer(serializers.ModelSerializer):
arabic_known_as = ArabicLocalizedField(source='known_as') arabic_known_as = ArabicLocalizedField(source='known_as')
nickname = LocalizedField() nickname = LocalizedField()
arabic_nickname = ArabicLocalizedField(source='nickname') 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() reliability = serializers.SerializerMethodField()
share_link = serializers.CharField(read_only=True) share_link = serializers.CharField(read_only=True)
class Meta: class Meta:
model = Transmitters model = Transmitters
fields = [ 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): def get_reliability(self, obj):
"""Serialize the reliability foreign key""" """Serialize the reliability foreign key"""
if obj.reliability: if obj.reliability:
request = self.context.get('request')
return { return {
'id': obj.reliability.id, '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, 'slug': obj.reliability.slug,
'color': obj.reliability.color, 'color': obj.reliability.color,
'main_color_code': obj.reliability.main_color_code, 'main_color_code': obj.reliability.main_color_code,
'description': get_localized_text(obj.reliability.description, request),
} }
return None return None
@ -475,15 +530,26 @@ class TransmitterShortSerializer(serializers.ModelSerializer):
arabic_known_as = ArabicLocalizedField(source='known_as') arabic_known_as = ArabicLocalizedField(source='known_as')
nickname = LocalizedField() nickname = LocalizedField()
arabic_nickname = ArabicLocalizedField(source='nickname') 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() reliability = serializers.SerializerMethodField()
class Meta: class Meta:
model = Transmitters model = Transmitters
fields = [ 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): def get_reliability(self, obj):
if obj.reliability: if obj.reliability:
request = self.context.get('request') request = self.context.get('request')
@ -493,6 +559,7 @@ class TransmitterShortSerializer(serializers.ModelSerializer):
'slug': obj.reliability.slug, 'slug': obj.reliability.slug,
'color': obj.reliability.color, 'color': obj.reliability.color,
'main_color_code': obj.reliability.main_color_code, 'main_color_code': obj.reliability.main_color_code,
'description': get_localized_text(obj.reliability.description, request),
} }
return None return None
class TransmitterOpinionSerializer(serializers.ModelSerializer): class TransmitterOpinionSerializer(serializers.ModelSerializer):
@ -537,6 +604,7 @@ class TransmitterOpinionSerializer(serializers.ModelSerializer):
'slug': status_obj.slug, 'slug': status_obj.slug,
'color': status_obj.color, 'color': status_obj.color,
'main_color_code': status_obj.main_color_code, 'main_color_code': status_obj.main_color_code,
'description': get_localized_text(status_obj.description, request),
} }
return None return None
@ -684,6 +752,12 @@ class TransmitterDetailSerializer(serializers.ModelSerializer):
description = LocalizedField() description = LocalizedField()
companion_type = LocalizedField() companion_type = LocalizedField()
arabic_companion_type = ArabicLocalizedField(source='companion_type') 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() reliability = serializers.SerializerMethodField()
relatives = serializers.SerializerMethodField() relatives = serializers.SerializerMethodField()
father = 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', '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', 'origin', 'arabic_origin', 'mawla_status', 'arabic_mawla_status', 'profession', 'arabic_profession', 'physical_traits', 'arabic_physical_traits',
'lived_in','died_in','birth_year_hijri', '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", 'madhhab',"in_sahih_muslim","in_sahih_bukhari",
"description",'generation','share_link', "description",'generation','share_link',
'tadlis', 'ikhtilat', 'companion_type', 'arabic_companion_type', 'relatives', 'tadlis', 'ikhtilat', 'companion_type', 'arabic_companion_type', 'relatives',
'father', 'sons', 'gender' '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): def get_father(self, obj):
"""Serialize the father foreign key or relative""" """Serialize the father foreign key or relative"""
father_obj = obj.father father_obj = obj.father
@ -765,12 +850,14 @@ class TransmitterDetailSerializer(serializers.ModelSerializer):
def get_reliability(self, obj): def get_reliability(self, obj):
"""Serialize the reliability foreign key""" """Serialize the reliability foreign key"""
if obj.reliability: if obj.reliability:
request = self.context.get('request')
return { return {
'id': obj.reliability.id, '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, 'slug': obj.reliability.slug,
'color': obj.reliability.color, 'color': obj.reliability.color,
'main_color_code': obj.reliability.main_color_code, 'main_color_code': obj.reliability.main_color_code,
'description': get_localized_text(obj.reliability.description, request),
} }
return None return None
@ -839,9 +926,10 @@ class TransmitterSyncSerializer(serializers.ModelSerializer):
if obj.reliability: if obj.reliability:
r= { r= {
'id': obj.reliability.id, '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, 'color': obj.reliability.color,
'main_color_code': obj.reliability.main_color_code, 'main_color_code': obj.reliability.main_color_code,
'description': get_localized_text(obj.reliability.description, request),
} }
else : else :
r= None r= None
@ -866,9 +954,12 @@ class TransmitterSyncSerializer(serializers.ModelSerializer):
'arabic_physical_traits': get_arabic_localized_text(obj.features), 'arabic_physical_traits': get_arabic_localized_text(obj.features),
'lived_in': get_localized_text(obj.lived_in, request), 'lived_in': get_localized_text(obj.lived_in, request),
'died_in': get_localized_text(obj.died_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, 'generation': obj.generation,
'reliability': r, 'reliability': r,
'madhhab': obj.madhhab, 'madhhab': obj.madhhab,
@ -946,6 +1037,7 @@ class TransmitterSyncSerializer(serializers.ModelSerializer):
'slug': status_obj.slug, 'slug': status_obj.slug,
'color': status_obj.color, 'color': status_obj.color,
'main_color_code': status_obj.main_color_code, 'main_color_code': status_obj.main_color_code,
'description': get_localized_text(status_obj.description, request),
} if status_obj else None, } if status_obj else None,
'created_at': opinion.created_at.isoformat() if opinion.created_at 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, 'updated_at': opinion.updated_at.isoformat() if opinion.updated_at else None,
@ -1010,6 +1102,7 @@ class HadisTransmitterSerializer(serializers.ModelSerializer):
'slug': obj.status.slug, 'slug': obj.status.slug,
'color': obj.status.color, 'color': obj.status.color,
'main_color_code': obj.status.main_color_code, 'main_color_code': obj.status.main_color_code,
'description': get_localized_text(obj.status.description, request),
} }
return None return None
@ -1562,20 +1655,36 @@ class NarratorRelationSerializer(serializers.ModelSerializer):
""" """
full_name = LocalizedField() full_name = LocalizedField()
known_as = 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() reliability = SerializerMethodField()
class Meta: class Meta:
model = Transmitters 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): def get_reliability(self, obj):
"""Serialize the reliability foreign key""" """Serialize the reliability foreign key"""
if obj.reliability: if obj.reliability:
request = self.context.get('request')
return { return {
'id': obj.reliability.id, '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, 'color': obj.reliability.color,
'main_color_code': obj.reliability.main_color_code, 'main_color_code': obj.reliability.main_color_code,
'description': get_localized_text(obj.reliability.description, request),
} }

224
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 utils import absolute_https_url
from ..models import ( from ..models import (
Hadis, HadisCategory, HadisStatus, HadisTag, HadisCorrection, HadisInterpretation, HadisCollection, HadisSect, HadisInCollection, HadisTransmitter, HadisReference, ReferenceImage, 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, Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, ScholarlyOpinion, TransmitterOpinion, TransmitterOriginalText,
YearValue, NarratorDate,
CorrectionReference, CorrectionReferenceImage, InterpretationReference, InterpretationReferenceImage, CorrectionReference, CorrectionReferenceImage, InterpretationReference, InterpretationReferenceImage,
OriginalTextReference, OriginalTextReferenceImage, OriginalTextReference, OriginalTextReferenceImage,
ContentRelease ContentRelease
@ -1842,9 +1843,12 @@ class AdminHadisCategoryArchiveDetailSerializer(AdminHadisCategoryArchiveSeriali
self._raise_drf_validation_error(exc) self._raise_drf_validation_error(exc)
class AdminTransmitterReliabilitySerializer(serializers.ModelSerializer): class AdminTransmitterReliabilitySerializer(serializers.ModelSerializer):
title = serializers.JSONField()
description = serializers.JSONField(required=False, default=list)
class Meta: class Meta:
model = TransmitterReliability model = TransmitterReliability
fields = ["id", "title", "slug", "color", "main_color_code"]
fields = ["id", "title", "slug", "color", "main_color_code", "description"]
class AdminNarratorLayerSerializer(serializers.ModelSerializer): class AdminNarratorLayerSerializer(serializers.ModelSerializer):
@ -1884,10 +1888,143 @@ class AdminNarratorLayerSerializer(serializers.ModelSerializer):
class AdminOpinionStatusSerializer(serializers.ModelSerializer): class AdminOpinionStatusSerializer(serializers.ModelSerializer):
title = serializers.JSONField() title = serializers.JSONField()
description = serializers.JSONField(required=False, default=list)
class Meta: class Meta:
model = OpinionStatus 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): class AdminTransmitterListSerializer(serializers.ModelSerializer):
@ -1897,6 +2034,9 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer):
nickname = serializers.JSONField() nickname = serializers.JSONField()
thumbnail = AbsoluteImageField(required=False, allow_null=True) thumbnail = AbsoluteImageField(required=False, allow_null=True)
share_link = serializers.CharField(read_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: class Meta:
model = Transmitters model = Transmitters
@ -1907,6 +2047,10 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer):
"gender", "gender",
"birth_year_hijri", "birth_year_hijri",
"death_year_hijri", "death_year_hijri",
"age_at_death",
"birth_year_preview",
"death_year_preview",
"age_at_death_preview",
"known_as", "known_as",
"nickname", "nickname",
"reliability", "reliability",
@ -1920,6 +2064,15 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer):
] ]
read_only_fields = ["id", "slug", "share_link", "created_at", "updated_at"] 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): class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
reliability_detail = AdminTransmitterReliabilitySerializer(source="reliability", read_only=True) reliability_detail = AdminTransmitterReliabilitySerializer(source="reliability", read_only=True)
@ -1950,6 +2103,9 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
write_only=True, write_only=True,
) )
share_link = serializers.CharField(read_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: class Meta:
model = Transmitters model = Transmitters
@ -1966,6 +2122,9 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
"birth_year_hijri", "birth_year_hijri",
"death_year_hijri", "death_year_hijri",
"age_at_death", "age_at_death",
"birth_year_preview",
"death_year_preview",
"age_at_death_preview",
"generation", "generation",
"reliability", "reliability",
"reliability_detail", "reliability_detail",
@ -1986,7 +2145,20 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
"created_at", "created_at",
"updated_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): def get_father_detail(self, obj):
if obj.father: 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())): 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 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: if "sons_ids" in data:
val = data["sons_ids"] val = data["sons_ids"]
@ -2063,6 +2237,27 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
return super().to_internal_value(data) 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): def create(self, validated_data):
validated_data.pop("remove_thumbnail", False) validated_data.pop("remove_thumbnail", False)
sons_ids = validated_data.pop("sons_ids", None) sons_ids = validated_data.pop("sons_ids", None)
@ -2098,9 +2293,24 @@ class AdminBookTypeSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "created_at", "updated_at"] 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): class AdminBookAuthorSerializer(serializers.ModelSerializer):
image = AbsoluteImageField(required=False, allow_null=True) image = AbsoluteImageField(required=False, allow_null=True)
remove_image = serializers.BooleanField(write_only=True, required=False, default=False) 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( book_references = serializers.PrimaryKeyRelatedField(
many=True, many=True,
required=False, required=False,
@ -2124,6 +2334,8 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
"scholarity", "scholarity",
"tabaqa", "tabaqa",
"rutba", "rutba",
"rutba_status",
"rutba_detail",
"features", "features",
"book_references", "book_references",
] ]
@ -2140,10 +2352,10 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
mutable_data[json_field] = json.loads(val) mutable_data[json_field] = json.loads(val)
except ValueError: except ValueError:
pass 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: if int_field in mutable_data:
val = mutable_data.get(int_field) 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 mutable_data[int_field] = None
return super().to_internal_value(mutable_data) return super().to_internal_value(mutable_data)

6
apps/hadis/urls.py

@ -15,9 +15,9 @@ from .views.version import ContentReleaseSyncView, HadisDatabaseVersionView
from .views.info import HadisInfoView from .views.info import HadisInfoView
from .views_admin import ( from .views_admin import (
AdminHadisCategoryViewSet, AdminHadisViewSet, AdminHadisCorrectionViewSet, AdminCorrectionReferenceViewSet, AdminHadisReferenceViewSet, AdminInterpretationReferenceViewSet, AdminHadisInterpretationViewSet, AdminHadisStatusViewSet, AdminHadisTagViewSet, AdminHadisCollectionViewSet, 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, AdminTransmitterViewSet, AdminTransmitterReliabilityViewSet, DovodiBookSubjectAreaViewSet, DovodiBookTypeViewSet,
DovodiNarratorLayerViewSet, DovodiTransmitterReliabilityViewSet, DovodiOpinionStatusViewSet, DovodiScholarlyOpinionViewSet,
DovodiNarratorLayerViewSet, DovodiTransmitterReliabilityViewSet, DovodiOpinionStatusViewSet, DovodiScholarlyOpinionViewSet, DovodiYearValueViewSet,
DovodiHadisSectViewSet, DovodiHadisCategoryViewSet, AdminCategoryQuranVerseViewSet, AdminHadisTransmitterViewSet, DovodiHadisSectViewSet, DovodiHadisCategoryViewSet, AdminCategoryQuranVerseViewSet, AdminHadisTransmitterViewSet,
AdminTransmitterOpinionViewSet, AdminTransmitterOriginalTextViewSet, AdminOriginalTextReferenceViewSet, AdminBookEditionViewSet, AdminBookVolumeViewSet, AdminTransmitterOpinionViewSet, AdminTransmitterOriginalTextViewSet, AdminOriginalTextReferenceViewSet, AdminBookEditionViewSet, AdminBookVolumeViewSet,
AdminContentReleaseViewSet 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'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-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-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-authors', AdminBookAuthorViewSet, basename='admin-book-authors')
admin_router.register(r'book-attributes', AdminBookAttributeViewSet, basename='admin-book-attributes') admin_router.register(r'book-attributes', AdminBookAttributeViewSet, basename='admin-book-attributes')
admin_router.register(r'book-images', AdminBookReferenceImageViewSet, basename='admin-book-images') 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-transmitter-reliabilities', DovodiTransmitterReliabilityViewSet, basename='dovodi-transmitter-reliabilities')
admin_router.register(r'dovodi-opinion-statuses', DovodiOpinionStatusViewSet, basename='dovodi-opinion-statuses') 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-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'hadis-transmitters', AdminHadisTransmitterViewSet, basename='admin-hadis-transmitters')
admin_router.register(r'transmitter-opinions', AdminTransmitterOpinionViewSet, basename='admin-transmitter-opinions') admin_router.register(r'transmitter-opinions', AdminTransmitterOpinionViewSet, basename='admin-transmitter-opinions')
admin_router.register(r'transmitter-original-texts', AdminTransmitterOriginalTextViewSet, basename='admin-transmitter-original-texts') admin_router.register(r'transmitter-original-texts', AdminTransmitterOriginalTextViewSet, basename='admin-transmitter-original-texts')

12
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,
)

680
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
}

36
apps/hadis/views/transmitter.py

@ -32,7 +32,7 @@ class TransmitterView(ListAPIView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
queryset = Transmitters.objects.all()
queryset = Transmitters.objects.select_related('reliability').prefetch_related('narrator_dates__year_value')
# Get query parameters # Get query parameters
search_query = self.request.query_params.get('search', None) search_query = self.request.query_params.get('search', None)
@ -59,6 +59,33 @@ class TransmitterView(ListAPIView):
if generation_filter: if generation_filter:
queryset = queryset.filter(generation=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 return queryset
class TransmitterDetailView(RetrieveAPIView): class TransmitterDetailView(RetrieveAPIView):
@ -72,7 +99,11 @@ class TransmitterDetailView(RetrieveAPIView):
def get_queryset(self): def get_queryset(self):
input = self.kwargs.get('narrator_slug') 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): class TransmitterOpinionView(ListAPIView):
serializer_class = TransmitterOpinionSerializer serializer_class = TransmitterOpinionSerializer
@ -152,6 +183,7 @@ class TransmitterSyncView(ListAPIView):
# 👇 ۱. واکشی اطلاعات reliability راوی در همان کوئری اصلی # 👇 ۱. واکشی اطلاعات reliability راوی در همان کوئری اصلی
.select_related('reliability') .select_related('reliability')
.prefetch_related( .prefetch_related(
'narrator_dates__year_value',
# 👇 ۲. اضافه کردن 'scholar' برای جلوگیری از کوئری اضافه در نظرات دانشمندان # 👇 ۲. اضافه کردن 'scholar' برای جلوگیری از کوئری اضافه در نظرات دانشمندان
Prefetch('opinions', queryset=TransmitterOpinion.objects.select_related('status', 'scholar', 'opinion', 'opinion__status')), Prefetch('opinions', queryset=TransmitterOpinion.objects.select_related('status', 'scholar', 'opinion', 'opinion__status')),

128
apps/hadis/views_admin.py

@ -11,8 +11,9 @@ from utils.pagination import StandardResultsSetPagination, OptionalPagination
from .models import ( from .models import (
Hadis, HadisCategory, HadisCollection, HadisInCollection, HadisCorrection, HadisInterpretation, HadisStatus, HadisTag, HadisSect, HadisTransmitter, HadisReference, ReferenceImage, 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, Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, ScholarlyOpinion, TransmitterOpinion, TransmitterOriginalText,
YearValue, NarratorDate,
CorrectionReference, InterpretationReference, OriginalTextReference, CorrectionReference, InterpretationReference, OriginalTextReference,
ContentRelease, ContentRelease,
CategoryQuranVerse, CategoryQuranVerse,
@ -38,6 +39,7 @@ from .serializers.serializers_admin import (
AdminBookTypeSerializer, AdminBookTypeSerializer,
AdminBookTagSerializer, AdminBookTagSerializer,
AdminBookAuthorSerializer, AdminBookAuthorSerializer,
AdminBookAuthorRutbaSerializer,
AdminBookAttributeSerializer, AdminBookAttributeSerializer,
AdminBookReferenceImageSerializer, AdminBookReferenceImageSerializer,
AdminHadisReferenceImageSerializer, AdminHadisReferenceImageSerializer,
@ -47,6 +49,7 @@ from .serializers.serializers_admin import (
AdminTransmitterListSerializer, AdminTransmitterListSerializer,
AdminTransmitterDetailSerializer, AdminTransmitterDetailSerializer,
AdminTransmitterReliabilitySerializer, AdminTransmitterReliabilitySerializer,
AdminYearValueSerializer,
AdminNarratorLayerSerializer, AdminNarratorLayerSerializer,
AdminOpinionStatusSerializer, AdminOpinionStatusSerializer,
AdminScholarlyOpinionSerializer, AdminScholarlyOpinionSerializer,
@ -282,17 +285,25 @@ class AdminTransmitterReliabilityViewSet(ReadOnlyModelViewSet):
serializer_class = AdminTransmitterReliabilitySerializer serializer_class = AdminTransmitterReliabilitySerializer
permission_classes = [IsAuthenticated, IsSuperAdmin] permission_classes = [IsAuthenticated, IsSuperAdmin]
authentication_classes = [TokenAuthentication] authentication_classes = [TokenAuthentication]
pagination_class = None
pagination_class = OptionalPagination
def get_queryset(self): 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): class DovodiNarratorLayerViewSet(ModelViewSet):
serializer_class = AdminNarratorLayerSerializer serializer_class = AdminNarratorLayerSerializer
permission_classes = [IsAuthenticated, IsSuperAdmin] permission_classes = [IsAuthenticated, IsSuperAdmin]
authentication_classes = [TokenAuthentication] authentication_classes = [TokenAuthentication]
pagination_class = None
pagination_class = OptionalPagination
parser_classes = (JSONParser, MultiPartParser, FormParser) parser_classes = (JSONParser, MultiPartParser, FormParser)
def get_queryset(self): def get_queryset(self):
@ -314,7 +325,7 @@ class DovodiTransmitterReliabilityViewSet(ModelViewSet):
serializer_class = AdminTransmitterReliabilitySerializer serializer_class = AdminTransmitterReliabilitySerializer
permission_classes = [IsAuthenticated, IsSuperAdmin] permission_classes = [IsAuthenticated, IsSuperAdmin]
authentication_classes = [TokenAuthentication] authentication_classes = [TokenAuthentication]
pagination_class = None
pagination_class = OptionalPagination
parser_classes = (JSONParser, MultiPartParser, FormParser) parser_classes = (JSONParser, MultiPartParser, FormParser)
def get_queryset(self): def get_queryset(self):
@ -324,6 +335,7 @@ class DovodiTransmitterReliabilityViewSet(ModelViewSet):
queryset = queryset.filter( queryset = queryset.filter(
Q(title__icontains=search_query) Q(title__icontains=search_query)
| Q(slug__icontains=search_query) | Q(slug__icontains=search_query)
| Q(description__icontains=search_query)
) )
return queryset.order_by("-id") return queryset.order_by("-id")
@ -332,7 +344,7 @@ class DovodiOpinionStatusViewSet(ModelViewSet):
serializer_class = AdminOpinionStatusSerializer serializer_class = AdminOpinionStatusSerializer
permission_classes = [IsAuthenticated, IsSuperAdmin] permission_classes = [IsAuthenticated, IsSuperAdmin]
authentication_classes = [TokenAuthentication] authentication_classes = [TokenAuthentication]
pagination_class = None
pagination_class = OptionalPagination
parser_classes = (JSONParser, MultiPartParser, FormParser) parser_classes = (JSONParser, MultiPartParser, FormParser)
def get_queryset(self): def get_queryset(self):
@ -342,6 +354,7 @@ class DovodiOpinionStatusViewSet(ModelViewSet):
queryset = queryset.filter( queryset = queryset.filter(
Q(title__icontains=search_query) Q(title__icontains=search_query)
| Q(slug__icontains=search_query) | Q(slug__icontains=search_query)
| Q(description__icontains=search_query)
) )
return queryset.order_by("-id") return queryset.order_by("-id")
@ -350,7 +363,7 @@ class DovodiScholarlyOpinionViewSet(ModelViewSet):
serializer_class = AdminScholarlyOpinionSerializer serializer_class = AdminScholarlyOpinionSerializer
permission_classes = [IsAuthenticated, IsSuperAdmin] permission_classes = [IsAuthenticated, IsSuperAdmin]
authentication_classes = [TokenAuthentication] authentication_classes = [TokenAuthentication]
pagination_class = None
pagination_class = OptionalPagination
parser_classes = (JSONParser, MultiPartParser, FormParser) parser_classes = (JSONParser, MultiPartParser, FormParser)
def get_queryset(self): def get_queryset(self):
@ -374,6 +387,79 @@ class DovodiScholarlyOpinionViewSet(ModelViewSet):
return queryset.order_by("-id") 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): class AdminTransmitterViewSet(ModelViewSet):
permission_classes = [IsAuthenticated, IsSuperAdmin] permission_classes = [IsAuthenticated, IsSuperAdmin]
authentication_classes = [TokenAuthentication] authentication_classes = [TokenAuthentication]
@ -386,7 +472,7 @@ class AdminTransmitterViewSet(ModelViewSet):
return AdminTransmitterDetailSerializer return AdminTransmitterDetailSerializer
def get_queryset(self): 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") search_query = self.request.query_params.get("search")
if search_query: if search_query:
@ -400,6 +486,9 @@ class AdminTransmitterViewSet(ModelViewSet):
| Q(died_in__icontains=search_query) | Q(died_in__icontains=search_query)
| Q(slug__icontains=search_query) | Q(slug__icontains=search_query)
| Q(legacy_id__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(): if search_query.isdigit():
q_filter |= Q(legacy_number=int(search_query)) | Q(generation=int(search_query)) 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") 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): class AdminBookAuthorViewSet(ModelViewSet):
serializer_class = AdminBookAuthorSerializer serializer_class = AdminBookAuthorSerializer
permission_classes = [IsAuthenticated, IsSuperAdmin] permission_classes = [IsAuthenticated, IsSuperAdmin]
authentication_classes = [TokenAuthentication] authentication_classes = [TokenAuthentication]
pagination_class = None
pagination_class = OptionalPagination
parser_classes = (MultiPartParser, FormParser, JSONParser) parser_classes = (MultiPartParser, FormParser, JSONParser)
def get_queryset(self): def get_queryset(self):
queryset = BookAuthor.objects.all()
queryset = BookAuthor.objects.select_related("rutba_status").all()
search_query = self.request.query_params.get("search") search_query = self.request.query_params.get("search")
if search_query: if search_query:
queryset = queryset.filter(name__icontains=search_query) queryset = queryset.filter(name__icontains=search_query)

Loading…
Cancel
Save