Browse Source

feat(reference): support canonical dates and BookAuthorDate model for book authors

- Convert birth_year_hijri and death_year_hijri to CharField and add age_at_death
- Introduce BookAuthorDate model linking BookAuthor with parsed YearValue records
- Add sync_author_dates and date formatting methods to BookAuthor
- Support century filtering via YearValue min/max ranges and prefetch author dates
- Expose live preview fields and canonical date validation in AdminBookAuthorSerializer
- Add migration 0056 to convert fields and populate BookAuthorDate records
master
Mohsen Taba 2 weeks ago
parent
commit
c2615cb438
  1. 117
      apps/hadis/migrations/0056_bookauthor_date_fields_and_bookauthordate.py
  2. 144
      apps/hadis/models/reference.py
  3. 84
      apps/hadis/serializers/reference_v2.py
  4. 65
      apps/hadis/serializers/serializers_admin.py
  5. 46
      apps/hadis/views/reference_v2.py
  6. 7
      apps/hadis/views_admin.py

117
apps/hadis/migrations/0056_bookauthor_date_fields_and_bookauthordate.py

@ -0,0 +1,117 @@
# Generated by Django 4.2.30 on 2026-10-19 17:08
from django.db import migrations, models
import django.db.models.deletion
def populate_book_author_dates(apps, schema_editor):
BookAuthor = apps.get_model('hadis', 'BookAuthor')
YearValue = apps.get_model('hadis', 'YearValue')
BookAuthorDate = apps.get_model('hadis', 'BookAuthorDate')
from apps.hadis.utils.date_engine import (
parse_canonical_date,
build_translations_for_parsed,
)
yv_cache = {}
for author in BookAuthor.objects.all().iterator():
for date_type, raw_val in (
('birth', author.birth_year_hijri),
('death', author.death_year_hijri),
('age', getattr(author, 'age_at_death', None)),
):
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
BookAuthorDate.objects.update_or_create(
author_id=author.id,
date_type=date_type,
defaults={
'year_value_id': yv.id,
'raw_value': yv.raw_value,
}
)
class Migration(migrations.Migration):
dependencies = [
('hadis', '0055_yearvalue_narratordate_and_transmitters_date_fields'),
]
operations = [
migrations.AlterField(
model_name='bookauthor',
name='birth_year_hijri',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Birth Year (Hijri)'),
),
migrations.AlterField(
model_name='bookauthor',
name='death_year_hijri',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Death Year (Hijri)'),
),
migrations.AddField(
model_name='bookauthor',
name='age_at_death',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Age at Death'),
),
migrations.CreateModel(
name='BookAuthorDate',
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')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='author_dates', to='hadis.bookauthor', verbose_name='Book Author')),
('year_value', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='author_dates', to='hadis.yearvalue', verbose_name='Year Value')),
],
options={
'verbose_name': 'Book Author Date',
'verbose_name_plural': 'Book Author Dates',
'ordering': ('author_id', 'date_type'),
'unique_together': {('author', 'date_type')},
},
),
migrations.AddIndex(
model_name='bookauthordate',
index=models.Index(fields=['date_type', 'year_value'], name='hadis_booka_date_ty_9a1e3c_idx'),
),
migrations.AddIndex(
model_name='bookauthordate',
index=models.Index(fields=['author', 'date_type'], name='hadis_booka_author__6c2f1b_idx'),
),
migrations.RunPython(
populate_book_author_dates,
reverse_code=migrations.RunPython.noop,
),
]

144
apps/hadis/models/reference.py

@ -469,8 +469,9 @@ class BookAuthor(LowercaseSlugMixin, models.Model):
)
features = models.JSONField(default=list, blank=True, verbose_name=_('Features'))
birth_year_hijri = models.IntegerField(verbose_name=_("Birth Year (Hijri)"), null=True, blank=True)
death_year_hijri = models.IntegerField(verbose_name=_("Death Year (Hijri)"), null=True, blank=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"), null=True, blank=True)
birth_year_miladi = models.IntegerField(verbose_name=_("Birth Year (Miladi)"), null=True, blank=True)
death_year_miladi = models.IntegerField(verbose_name=_("Death Year (Miladi)"), null=True, blank=True)
@ -513,8 +514,146 @@ class BookAuthor(LowercaseSlugMixin, models.Model):
def save(self, *args, **kwargs):
if self.rutba_status_id and self.rutba_status:
self.rutba = self.rutba_status.title or []
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)
super().save(*args, **kwargs)
self.researcher_profiles.all().update(name=self.name, slug=self.slug)
try:
self.sync_author_dates()
except Exception:
pass
def sync_author_dates(self):
"""
Synchronizes birth_year_hijri, death_year_hijri, and age_at_death
with YearValue and BookAuthorDate tables.
"""
if not self.pk:
return
from .transmitter import YearValue
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():
BookAuthorDate.objects.filter(author=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)
BookAuthorDate.objects.update_or_create(
author=self,
date_type=date_type,
defaults={
'year_value': yv,
'raw_value': yv.raw_value,
}
)
def get_formatted_date(self, date_type: str, lang: str = 'ru') -> Optional[str]:
field_map = {
'birth': 'birth_year_hijri',
'death': 'death_year_hijri',
'age': 'age_at_death',
}
field_name = field_map.get(date_type)
if not field_name:
return None
raw_val = getattr(self, field_name, None)
if raw_val is None or str(raw_val).strip() == '':
return None
raw_str = str(raw_val).strip()
target_lang = (lang or 'ru').split('-')[0].lower()
# Check prefetched author_dates first if available
if hasattr(self, '_prefetched_objects_cache') and 'author_dates' in self._prefetched_objects_cache:
for ad in self._prefetched_objects_cache['author_dates']:
if ad.date_type == date_type and ad.year_value_id and ad.year_value:
txt = ad.year_value.get_translation(target_lang)
if txt:
return txt
# If context tag (#...), check YearValue in DB for custom translations
if raw_str.startswith('#'):
from .transmitter import YearValue
yv = YearValue.objects.filter(raw_value=raw_str).first()
if yv:
txt = yv.get_translation(target_lang)
if txt:
return txt
from ..utils.date_engine import format_canonical_date
engine_lang = target_lang if target_lang in ('ru', 'en', 'ar') else 'en'
return format_canonical_date(raw_str, category=date_type, lang=engine_lang)
def get_formatted_birth_year(self, lang: str = 'ru') -> Optional[str]:
return self.get_formatted_date('birth', lang=lang)
def get_formatted_death_year(self, lang: str = 'ru') -> Optional[str]:
return self.get_formatted_date('death', lang=lang)
def get_formatted_age(self, lang: str = 'ru') -> Optional[str]:
return self.get_formatted_date('age', lang=lang)
class BookAuthorDate(models.Model):
"""
Links a BookAuthor to a parsed YearValue for birth, death, or age.
"""
class DateTypeChoices(models.TextChoices):
BIRTH = 'birth', _('Birth')
DEATH = 'death', _('Death')
AGE = 'age', _('Age')
author = models.ForeignKey(
BookAuthor,
on_delete=models.CASCADE,
related_name='author_dates',
verbose_name=_('Book Author')
)
date_type = models.CharField(
max_length=20,
choices=DateTypeChoices.choices,
db_index=True,
verbose_name=_('Date Type')
)
year_value = models.ForeignKey(
'hadis.YearValue',
on_delete=models.CASCADE,
related_name='author_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 = _('Book Author Date')
verbose_name_plural = _('Book Author Dates')
unique_together = ('author', 'date_type')
indexes = [
models.Index(fields=['date_type', 'year_value']),
models.Index(fields=['author', 'date_type']),
]
ordering = ('author_id', 'date_type')
def __str__(self):
return f"{self.author_id} - {self.date_type}: {self.raw_value}"
class BookAttribute(models.Model):
@ -649,4 +788,3 @@ class BookEditor(LowercaseSlugMixin, models.Model):
return self.name[0]['text']
except:
return self.slug or f"Editor {self.id}"

84
apps/hadis/serializers/reference_v2.py

@ -293,13 +293,58 @@ class BookReferenceV2SyncSerializer(serializers.ModelSerializer):
from apps.hadis.models.reference import BookAuthor, BookReference
from .category import LocalizedField
def _get_request_lang_v2(serializer_instance) -> str:
from django.utils.translation import get_language
request = serializer_instance.context.get("request") if hasattr(serializer_instance, "context") and serializer_instance.context else None
lang = None
if request:
lang = getattr(request, "LANGUAGE_CODE", None) or request.headers.get("Accept-Language") or request.query_params.get("lang")
if not lang:
lang = get_language() or "ru"
return str(lang).split(",")[0].split("-")[0].strip().lower() or "ru"
class BookAuthorListSerializer(serializers.ModelSerializer):
"""سریالایزر برای لیست نویسنده‌ها"""
name = LocalizedField()
known_as = LocalizedField()
scholarity = LocalizedField()
tabaqa = LocalizedField()
rutba = serializers.SerializerMethodField()
features = LocalizedField()
related_narrator_slug = serializers.CharField(source='related_narrator.slug', read_only=True, allow_null=True)
birth_year_hijri = serializers.SerializerMethodField()
death_year_hijri = serializers.SerializerMethodField()
age_at_death = serializers.SerializerMethodField()
birth_year_canonical = serializers.CharField(source='birth_year_hijri', read_only=True)
death_year_canonical = serializers.CharField(source='death_year_hijri', read_only=True)
age_at_death_canonical = serializers.CharField(source='age_at_death', read_only=True)
class Meta:
model = BookAuthor
fields = ['id', 'slug', 'name', 'image', 'birth_year_hijri', 'birth_year_miladi']
fields = [
'id', 'slug', 'name', 'image',
'known_as', 'scholarity', 'tabaqa', 'rutba', 'features',
'birth_year_hijri', 'death_year_hijri', 'age_at_death',
'birth_year_canonical', 'death_year_canonical', 'age_at_death_canonical',
'birth_year_miladi', 'death_year_miladi',
'related_narrator_slug',
]
def get_rutba(self, obj):
request = self.context.get('request')
if obj.rutba_status:
return get_localized_text(obj.rutba_status.title, request)
return get_localized_text(obj.rutba, request) if obj.rutba else None
def get_birth_year_hijri(self, obj):
return obj.get_formatted_birth_year(lang=_get_request_lang_v2(self))
def get_death_year_hijri(self, obj):
return obj.get_formatted_death_year(lang=_get_request_lang_v2(self))
def get_age_at_death(self, obj):
return obj.get_formatted_age(lang=_get_request_lang_v2(self))
class BookAuthorDetailSerializer(serializers.ModelSerializer):
@ -312,17 +357,33 @@ class BookAuthorDetailSerializer(serializers.ModelSerializer):
rutba_detail = serializers.SerializerMethodField()
features = LocalizedField()
related_narrator_slug = serializers.CharField(source='related_narrator.slug', read_only=True, allow_null=True)
birth_year_hijri = serializers.SerializerMethodField()
death_year_hijri = serializers.SerializerMethodField()
age_at_death = serializers.SerializerMethodField()
birth_year_canonical = serializers.CharField(source='birth_year_hijri', read_only=True)
death_year_canonical = serializers.CharField(source='death_year_hijri', read_only=True)
age_at_death_canonical = serializers.CharField(source='age_at_death', read_only=True)
class Meta:
model = BookAuthor
fields = [
'id', 'slug', 'name', 'image', 'known_as', 'scholarity',
'tabaqa', 'rutba', 'rutba_detail', 'features',
'birth_year_hijri', 'death_year_hijri',
'birth_year_hijri', 'death_year_hijri', 'age_at_death',
'birth_year_canonical', 'death_year_canonical', 'age_at_death_canonical',
'birth_year_miladi', 'death_year_miladi',
'related_narrator_slug'
]
def get_birth_year_hijri(self, obj):
return obj.get_formatted_birth_year(lang=_get_request_lang_v2(self))
def get_death_year_hijri(self, obj):
return obj.get_formatted_death_year(lang=_get_request_lang_v2(self))
def get_age_at_death(self, obj):
return obj.get_formatted_age(lang=_get_request_lang_v2(self))
def get_rutba(self, obj):
request = self.context.get('request')
if obj.rutba_status:
@ -389,6 +450,12 @@ class BookAuthorSyncSerializer(serializers.ModelSerializer):
rutba_detail = serializers.SerializerMethodField()
features = LocalizedField()
related_narrator_slug = serializers.CharField(source='related_narrator.slug', read_only=True, allow_null=True)
birth_year_hijri = serializers.SerializerMethodField()
death_year_hijri = serializers.SerializerMethodField()
age_at_death = serializers.SerializerMethodField()
birth_year_canonical = serializers.CharField(source='birth_year_hijri', read_only=True)
death_year_canonical = serializers.CharField(source='death_year_hijri', read_only=True)
age_at_death_canonical = serializers.CharField(source='age_at_death', read_only=True)
# اضافه کردن لیست کتاب‌ها
books = BookAuthorSyncReferenceSerializer(source='book_references', many=True, read_only=True)
@ -407,12 +474,25 @@ class BookAuthorSyncSerializer(serializers.ModelSerializer):
'features',
'birth_year_hijri',
'death_year_hijri',
'age_at_death',
'birth_year_canonical',
'death_year_canonical',
'age_at_death_canonical',
'birth_year_miladi',
'death_year_miladi',
'books', # 👈 لیست کتاب‌های هر نویسنده
'related_narrator_slug'
]
def get_birth_year_hijri(self, obj):
return obj.get_formatted_birth_year(lang=_get_request_lang_v2(self))
def get_death_year_hijri(self, obj):
return obj.get_formatted_death_year(lang=_get_request_lang_v2(self))
def get_age_at_death(self, obj):
return obj.get_formatted_age(lang=_get_request_lang_v2(self))
def get_rutba(self, obj):
request = self.context.get('request')
if obj.rutba_status:

65
apps/hadis/serializers/serializers_admin.py

@ -2316,6 +2316,9 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
required=False,
queryset=BookReference.objects.all(),
)
birth_year_preview = serializers.SerializerMethodField(read_only=True)
death_year_preview = serializers.SerializerMethodField(read_only=True)
age_at_death_preview = serializers.SerializerMethodField(read_only=True)
class Meta:
model = BookAuthor
@ -2327,6 +2330,10 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
"remove_image",
"birth_year_hijri",
"death_year_hijri",
"age_at_death",
"birth_year_preview",
"death_year_preview",
"age_at_death_preview",
"birth_year_miladi",
"death_year_miladi",
"related_narrator",
@ -2339,7 +2346,22 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
"features",
"book_references",
]
read_only_fields = ["id", "slug"]
read_only_fields = [
"id",
"slug",
"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", getattr(obj, "age_at_death", None))
def to_internal_value(self, data):
import json
@ -2352,13 +2374,44 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
mutable_data[json_field] = json.loads(val)
except ValueError:
pass
for int_field in ["birth_year_hijri", "death_year_hijri", "birth_year_miladi", "death_year_miladi", "related_narrator", "rutba_status"]:
if int_field in mutable_data:
val = mutable_data.get(int_field)
if val == "" or val == "null" or val == "undefined" or val == "none":
mutable_data[int_field] = None
for field in [
"birth_year_hijri",
"death_year_hijri",
"age_at_death",
"birth_year_miladi",
"death_year_miladi",
"related_narrator",
"rutba_status",
]:
if field in mutable_data:
val = mutable_data.get(field)
if val == "" or val is None or val == "null" or val == "undefined" or val == "none" or (isinstance(val, str) and not val.strip()):
mutable_data[field] = None
elif field in ("birth_year_hijri", "death_year_hijri", "age_at_death") and val is not None:
mutable_data[field] = str(val).strip()
return super().to_internal_value(mutable_data)
def validate(self, attrs):
from ..utils.date_engine import parse_canonical_date
date_checks = [
("birth_year_hijri", "birth"),
("death_year_hijri", "death"),
("age_at_death", "age"),
]
errors = {}
for field_name, category in date_checks:
if field_name in attrs and attrs[field_name] is not None:
raw_val = str(attrs[field_name]).strip()
if raw_val:
parsed = parse_canonical_date(raw_val, category=category)
if not parsed.is_valid:
errors[field_name] = parsed.error
else:
attrs[field_name] = parsed.raw_value
if errors:
raise serializers.ValidationError(errors)
return super().validate(attrs)
def create(self, validated_data):
validated_data.pop("remove_image", False)
book_references = validated_data.pop("book_references", None)

46
apps/hadis/views/reference_v2.py

@ -85,12 +85,19 @@ class BookReferenceV2SyncView(generics.ListAPIView):
century = self.request.query_params.get('century')
if century:
if century == 'before_islam':
qs = qs.filter(author__death_year_hijri__lt=0)
qs = qs.filter(
author__author_dates__date_type='death',
author__author_dates__year_value__value_min__lt=0,
).distinct()
elif century.isdigit():
century_num = int(century)
start_year = (century_num - 1) * 100 + 1
end_year = century_num * 100
qs = qs.filter(author__death_year_hijri__gte=start_year, author__death_year_hijri__lte=end_year)
qs = qs.filter(
author__author_dates__date_type='death',
author__author_dates__year_value__value_min__lte=end_year,
author__author_dates__year_value__value_max__gte=start_year,
).distinct()
return qs
@ -209,14 +216,21 @@ class BookReferenceV2ListView(generics.ListAPIView):
century_q = Q()
for c in centuries:
if c == 'before_islam':
century_q |= Q(author__death_year_hijri__lt=0)
century_q |= Q(
author__author_dates__date_type='death',
author__author_dates__year_value__value_min__lt=0,
)
elif c.isdigit():
century_num = int(c)
start_year = (century_num - 1) * 100 + 1
end_year = century_num * 100
century_q |= Q(author__death_year_hijri__gte=start_year, author__death_year_hijri__lte=end_year)
century_q |= Q(
author__author_dates__date_type='death',
author__author_dates__year_value__value_min__lte=end_year,
author__author_dates__year_value__value_max__gte=start_year,
)
if century_q:
qs = qs.filter(century_q)
qs = qs.filter(century_q).distinct()
return qs
@ -249,6 +263,7 @@ class BookReferenceV2ListView(generics.ListAPIView):
})
from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.permissions import AllowAny
from rest_framework.filters import SearchFilter
@ -265,7 +280,12 @@ class BookAuthorListView(ListAPIView):
"""
لیست تمام نویسندگان رفرنس
"""
queryset = BookAuthor.objects.all().order_by('-created_at')
queryset = (
BookAuthor.objects
.select_related('related_narrator', 'rutba_status')
.prefetch_related('author_dates__year_value')
.order_by('-created_at')
)
serializer_class = BookAuthorListSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [AllowAny]
@ -285,7 +305,12 @@ class BookAuthorDetailView(RetrieveAPIView):
"""
دریافت اطلاعات دقیق یک نویسنده بر اساس slug
"""
queryset = BookAuthor.objects.select_related('related_narrator', 'rutba_status').all()
queryset = (
BookAuthor.objects
.select_related('related_narrator', 'rutba_status')
.prefetch_related('author_dates__year_value')
.all()
)
serializer_class = BookAuthorDetailSerializer
lookup_field = 'slug'
lookup_url_kwarg = 'author_slug'
@ -330,7 +355,12 @@ class BookAuthorSyncView(generics.ListAPIView):
Returns complete author data and their associated books without pagination.
"""
# 👇 استفاده از prefetch_related برای جلوگیری از کرش کردن دیتابیس به خاطر N+1 Query
queryset = BookAuthor.objects.select_related('related_narrator', 'rutba_status').prefetch_related('book_references').order_by('id')
queryset = (
BookAuthor.objects
.select_related('related_narrator', 'rutba_status')
.prefetch_related('book_references', 'author_dates__year_value')
.order_by('id')
)
serializer_class = BookAuthorSyncSerializer
pagination_class = NoPagination
permission_classes = [AllowAny]

7
apps/hadis/views_admin.py

@ -908,7 +908,12 @@ class AdminBookAuthorViewSet(ModelViewSet):
parser_classes = (MultiPartParser, FormParser, JSONParser)
def get_queryset(self):
queryset = BookAuthor.objects.select_related("rutba_status").all()
queryset = (
BookAuthor.objects
.select_related("rutba_status")
.prefetch_related("author_dates__year_value")
.all()
)
search_query = self.request.query_params.get("search")
if search_query:
queryset = queryset.filter(name__icontains=search_query)

Loading…
Cancel
Save