Browse Source

feat(transmitters): introduce City and Kunya models, geographical relations, and admin viewsets

- Add City model and connect origin_city, lived_in_city, and died_in_city to Transmitters and BookAuthors
- Connect city_of_publication_city to BookEdition
- Add Kunya model and connect many-to-many kunyas relation to Transmitters
- Implement DovodiCityViewSet and DovodiKunyaViewSet and register routes in admin router
- Add migrations 0057, 0058, and 0059 to create tables and migrate existing string/JSON data
master
Mohsen Taba 1 week ago
parent
commit
18abe403df
  1. 213
      apps/hadis/migrations/0057_city_and_location_relations.py
  2. 48
      apps/hadis/migrations/0058_rename_hadis_booka_date_ty_9a1e3c_idx_hadis_booka_date_ty_99a484_idx_and_more.py
  3. 102
      apps/hadis/migrations/0059_kunya_and_relations.py
  4. 48
      apps/hadis/models/reference.py
  5. 122
      apps/hadis/models/transmitter.py
  6. 157
      apps/hadis/serializers/serializers_admin.py
  7. 167
      apps/hadis/urls.py
  8. 1490
      apps/hadis/views_admin.py

213
apps/hadis/migrations/0057_city_and_location_relations.py

@ -0,0 +1,213 @@
# Generated by Django 4.2.30 on 2026-10-20 10:35
from django.db import migrations, models
import django.db.models.deletion
from django.utils.text import slugify
import utils.mixins
def populate_cities_from_existing_records(apps, schema_editor):
City = apps.get_model('hadis', 'City')
Transmitters = apps.get_model('hadis', 'Transmitters')
BookEdition = apps.get_model('hadis', 'BookEdition')
# Cache cities by normalized text key -> City instance
city_by_text = {}
for c in City.objects.all():
if isinstance(c.title, list):
for entry in c.title:
if isinstance(entry, dict):
txt = (entry.get('text') or entry.get('title') or '').strip().lower()
if txt:
city_by_text[txt] = c
def get_or_create_city(json_list):
if not json_list or not isinstance(json_list, list):
return None
cleaned = []
lookup_keys = []
for entry in json_list:
if isinstance(entry, dict):
lang = (entry.get('language_code') or '').strip()
txt = (entry.get('text') or entry.get('title') or '').strip()
if lang and txt:
cleaned.append({'language_code': lang, 'text': txt, 'title': txt})
lookup_keys.append(txt.lower())
if not cleaned:
return None
for key in lookup_keys:
if key in city_by_text:
existing = city_by_text[key]
# Merge any missing language translations into existing city
existing_langs = {
item.get('language_code'): item
for item in (existing.title or [])
if isinstance(item, dict) and item.get('language_code')
}
updated = False
for item in cleaned:
if item['language_code'] not in existing_langs:
existing_langs[item['language_code']] = item
updated = True
if updated:
existing.title = list(existing_langs.values())
existing.save(update_fields=['title'])
for k in lookup_keys:
city_by_text[k] = existing
return existing
first_text = cleaned[0]['text']
slug_val = slugify(first_text, allow_unicode=True)[:240] or 'city'
city_obj = City.objects.create(title=cleaned, slug=slug_val)
for k in lookup_keys:
city_by_text[k] = city_obj
return city_obj
for tr in Transmitters.objects.all().iterator():
changed_fields = []
for json_attr, fk_attr in (
('origin', 'origin_city'),
('lived_in', 'lived_in_city'),
('died_in', 'died_in_city'),
):
if getattr(tr, f'{fk_attr}_id', None) is None:
city_obj = get_or_create_city(getattr(tr, json_attr, None))
if city_obj:
setattr(tr, fk_attr, city_obj)
changed_fields.append(fk_attr)
if changed_fields:
tr.save(update_fields=changed_fields)
for ed in BookEdition.objects.all().iterator():
if ed.city_of_publication_city_id is None:
city_obj = get_or_create_city(ed.city_of_publication)
if city_obj:
ed.city_of_publication_city = city_obj
ed.save(update_fields=['city_of_publication_city'])
class Migration(migrations.Migration):
dependencies = [
('hadis', '0056_bookauthor_date_fields_and_bookauthordate'),
]
operations = [
migrations.CreateModel(
name='City',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.JSONField(default=list, verbose_name='Title')),
('slug', models.SlugField(allow_unicode=True, blank=True, max_length=255, null=True, verbose_name='slug')),
('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': 'City',
'verbose_name_plural': 'Cities',
'ordering': ('-created_at', '-id'),
},
bases=(utils.mixins.LowercaseSlugMixin, models.Model),
),
migrations.AddField(
model_name='transmitters',
name='origin_city',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='origin_transmitters',
to='hadis.city',
verbose_name='Origin City',
),
),
migrations.AddField(
model_name='transmitters',
name='lived_in_city',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='lived_in_transmitters',
to='hadis.city',
verbose_name='Lived in City',
),
),
migrations.AddField(
model_name='transmitters',
name='died_in_city',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='died_in_transmitters',
to='hadis.city',
verbose_name='Died in City',
),
),
migrations.AddField(
model_name='bookauthor',
name='origin',
field=models.JSONField(blank=True, default=list, verbose_name='Origin'),
),
migrations.AddField(
model_name='bookauthor',
name='lived_in',
field=models.JSONField(blank=True, default=list, verbose_name='Lived in'),
),
migrations.AddField(
model_name='bookauthor',
name='died_in',
field=models.JSONField(blank=True, default=list, verbose_name='Died in'),
),
migrations.AddField(
model_name='bookauthor',
name='origin_city',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='origin_authors',
to='hadis.city',
verbose_name='Origin City',
),
),
migrations.AddField(
model_name='bookauthor',
name='lived_in_city',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='lived_in_authors',
to='hadis.city',
verbose_name='Lived in City',
),
),
migrations.AddField(
model_name='bookauthor',
name='died_in_city',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='died_in_authors',
to='hadis.city',
verbose_name='Died in City',
),
),
migrations.AddField(
model_name='bookedition',
name='city_of_publication_city',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='publication_editions',
to='hadis.city',
verbose_name='City of Publication (City)',
),
),
migrations.RunPython(populate_cities_from_existing_records, migrations.RunPython.noop),
]

48
apps/hadis/migrations/0058_rename_hadis_booka_date_ty_9a1e3c_idx_hadis_booka_date_ty_99a484_idx_and_more.py

@ -0,0 +1,48 @@
# Generated by Django 4.2.30 on 2026-10-20 11:52
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('hadis', '0057_city_and_location_relations'),
]
operations = [
migrations.RenameIndex(
model_name='bookauthordate',
new_name='hadis_booka_date_ty_99a484_idx',
old_name='hadis_booka_date_ty_9a1e3c_idx',
),
migrations.RenameIndex(
model_name='bookauthordate',
new_name='hadis_booka_author__8e6b59_idx',
old_name='hadis_booka_author__6c2f1b_idx',
),
migrations.RenameIndex(
model_name='narratordate',
new_name='hadis_narra_date_ty_3906be_idx',
old_name='hadis_narra_date_ty_idx',
),
migrations.RenameIndex(
model_name='narratordate',
new_name='hadis_narra_transmi_9111bf_idx',
old_name='hadis_narra_transmi_idx',
),
migrations.RenameIndex(
model_name='yearvalue',
new_name='hadis_yearv_categor_a95c95_idx',
old_name='hadis_yearv_categor_idx',
),
migrations.RenameIndex(
model_name='yearvalue',
new_name='hadis_yearv_categor_eebdd3_idx',
old_name='hadis_yearv_cat_min_max_idx',
),
migrations.RenameIndex(
model_name='yearvalue',
new_name='hadis_yearv_parse_t_1d6351_idx',
old_name='hadis_yearv_parse_t_idx',
),
]

102
apps/hadis/migrations/0059_kunya_and_relations.py

@ -0,0 +1,102 @@
# Generated by Django 4.2.30 on 2026-10-20 13:21
from django.db import migrations, models
from django.utils.text import slugify
def populate_kunyas_from_existing_records(apps, schema_editor):
Kunya = apps.get_model('hadis', 'Kunya')
Transmitters = apps.get_model('hadis', 'Transmitters')
# Cache kunyas by normalized text key -> Kunya instance
kunya_by_text = {}
for k in Kunya.objects.all():
if isinstance(k.title, list):
for entry in k.title:
if isinstance(entry, dict):
txt = (entry.get('text') or entry.get('title') or '').strip().lower()
if txt:
kunya_by_text[txt] = k
def get_or_create_kunya(json_list):
if not json_list or not isinstance(json_list, list):
return None
cleaned = []
lookup_keys = []
for entry in json_list:
if isinstance(entry, dict):
lang = (entry.get('language_code') or '').strip()
txt = (entry.get('text') or entry.get('title') or '').strip()
if lang and txt:
cleaned.append({'language_code': lang, 'text': txt, 'title': txt})
lookup_keys.append(txt.lower())
if not cleaned:
return None
for key in lookup_keys:
if key in kunya_by_text:
existing = kunya_by_text[key]
# Merge missing languages into existing kunya
existing_langs = {
item.get('language_code'): item
for item in (existing.title or [])
if isinstance(item, dict) and item.get('language_code')
}
updated = False
for item in cleaned:
if item['language_code'] not in existing_langs:
existing_langs[item['language_code']] = item
updated = True
if updated:
existing.title = list(existing_langs.values())
existing.save(update_fields=['title'])
for k in lookup_keys:
kunya_by_text[k] = existing
return existing
first_text = cleaned[0]['text']
slug_val = slugify(first_text, allow_unicode=True)[:240] or 'kunya'
kunya_obj = Kunya.objects.create(title=cleaned, slug=slug_val)
for k in lookup_keys:
kunya_by_text[k] = kunya_obj
return kunya_obj
for tr in Transmitters.objects.all().iterator():
if tr.kunya and isinstance(tr.kunya, list):
k_obj = get_or_create_kunya(tr.kunya)
if k_obj:
tr.kunyas.add(k_obj)
class Migration(migrations.Migration):
dependencies = [
('hadis', '0058_rename_hadis_booka_date_ty_9a1e3c_idx_hadis_booka_date_ty_99a484_idx_and_more'),
]
operations = [
migrations.CreateModel(
name='Kunya',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.JSONField(default=list, verbose_name='Title')),
('slug', models.SlugField(allow_unicode=True, blank=True, max_length=255, null=True, verbose_name='slug')),
('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': 'Kunya',
'verbose_name_plural': 'Kunyas',
'ordering': ('-created_at', '-id'),
},
),
migrations.AddField(
model_name='transmitters',
name='kunyas',
field=models.ManyToManyField(blank=True, related_name='transmitters', to='hadis.kunya', verbose_name='Kunyas'),
),
migrations.RunPython(
populate_kunyas_from_existing_records,
reverse_code=migrations.RunPython.noop,
),
]

48
apps/hadis/models/reference.py

@ -246,6 +246,14 @@ class BookEdition(models.Model):
book_reference = models.ForeignKey(BookReference, on_delete=models.CASCADE, related_name='editions', verbose_name=_('book reference'))
publisher = models.JSONField(default=list, blank=True, verbose_name=_('Publisher'))
city_of_publication = models.JSONField(default=list, blank=True, verbose_name=_('City of Publication'))
city_of_publication_city = models.ForeignKey(
'hadis.City',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='publication_editions',
verbose_name=_('City of Publication (City)'),
)
country_of_publication = models.JSONField(default=list, blank=True, verbose_name=_('Country of Publication'))
edition_number = models.CharField(max_length=100, blank=True, null=True, verbose_name=_('Edition Number'))
year_of_publication = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('year of publication'))
@ -273,6 +281,11 @@ class BookEdition(models.Model):
def __str__(self):
return f"{self.book_reference} - {self.edition_number or 'Edition ' + str(self.id)}"
def save(self, *args, **kwargs):
if self.city_of_publication_city_id and self.city_of_publication_city:
self.city_of_publication = self.city_of_publication_city.title or []
super().save(*args, **kwargs)
class BookVolume(models.Model):
"""
Model representing a single volume, which can belong to an edition or directly to the book.
@ -469,6 +482,35 @@ class BookAuthor(LowercaseSlugMixin, models.Model):
)
features = models.JSONField(default=list, blank=True, verbose_name=_('Features'))
# Geographic Information
origin = models.JSONField(default=list, blank=True, verbose_name=_('Origin'))
lived_in = models.JSONField(default=list, blank=True, verbose_name=_('Lived in'))
died_in = models.JSONField(default=list, blank=True, verbose_name=_('Died in'))
origin_city = models.ForeignKey(
'hadis.City',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='origin_authors',
verbose_name=_('Origin City'),
)
lived_in_city = models.ForeignKey(
'hadis.City',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='lived_in_authors',
verbose_name=_('Lived in City'),
)
died_in_city = models.ForeignKey(
'hadis.City',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='died_in_authors',
verbose_name=_('Died in City'),
)
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)
@ -514,6 +556,12 @@ 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 []
if self.origin_city_id and self.origin_city:
self.origin = self.origin_city.title or []
if self.lived_in_city_id and self.lived_in_city:
self.lived_in = self.lived_in_city.title or []
if self.died_in_city_id and self.died_in_city:
self.died_in = self.died_in_city.title or []
for date_field in ('birth_year_hijri', 'death_year_hijri', 'age_at_death'):
val = getattr(self, date_field, None)

122
apps/hadis/models/transmitter.py

@ -132,7 +132,86 @@ class TransmitterReliability(ColorPaletteMixin, LowercaseSlugMixin, models.Model
class Meta:
verbose_name = _('Transmitter Reliability')
verbose_name_plural = _('Transmitter Reliabilities')
verbose_name_plural = _('Transmitter Reliabilities')
class City(LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
title = models.JSONField(default=list, verbose_name=_('Title'))
slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True, null=True, allow_unicode=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
class Meta:
ordering = ('-created_at', '-id')
verbose_name = _('City')
verbose_name_plural = _('Cities')
def __str__(self):
if self.title and isinstance(self.title, list) and len(self.title) > 0 and isinstance(self.title[0], dict):
return self.title[0].get('text') or self.title[0].get('title') or str(self.id)
return str(self.id)
def get_title(self, lang: str = 'en') -> Optional[str]:
if not self.title or not isinstance(self.title, list):
return None
for tr in self.title:
if isinstance(tr, dict) and tr.get('language_code') == lang:
return tr.get('text', '') or tr.get('title', '')
for tr in self.title:
if isinstance(tr, dict) and tr.get('language_code') == 'en':
return tr.get('text', '') or tr.get('title', '')
if isinstance(self.title[0], dict):
return self.title[0].get('text', '') or self.title[0].get('title', '')
return None
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
try:
city_title = self.title or []
self.origin_transmitters.all().update(origin=city_title)
self.lived_in_transmitters.all().update(lived_in=city_title)
self.died_in_transmitters.all().update(died_in=city_title)
self.origin_authors.all().update(origin=city_title)
self.lived_in_authors.all().update(lived_in=city_title)
self.died_in_authors.all().update(died_in=city_title)
self.publication_editions.all().update(city_of_publication=city_title)
except Exception:
pass
class Kunya(LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
title = models.JSONField(default=list, verbose_name=_('Title'))
slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True, null=True, allow_unicode=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
class Meta:
ordering = ('-created_at', '-id')
verbose_name = _('Kunya')
verbose_name_plural = _('Kunyas')
def __str__(self):
if self.title and isinstance(self.title, list) and len(self.title) > 0 and isinstance(self.title[0], dict):
return self.title[0].get('text') or self.title[0].get('title') or str(self.id)
return str(self.id)
def get_title(self, lang: str = 'en') -> Optional[str]:
if not self.title or not isinstance(self.title, list):
return None
for tr in self.title:
if isinstance(tr, dict) and tr.get('language_code') == lang:
return tr.get('text', '') or tr.get('title', '')
for tr in self.title:
if isinstance(tr, dict) and tr.get('language_code') == 'en':
return tr.get('text', '') or tr.get('title', '')
if isinstance(self.title[0], dict):
return self.title[0].get('text', '') or self.title[0].get('title', '')
return None
@ -156,7 +235,13 @@ class Transmitters(LowercaseSlugMixin, models.Model):
# Basic Information
full_name = models.JSONField(default = list , verbose_name=_('Full Name'))
kunya = models.JSONField(default = list , verbose_name=_('Kunya'))
kunya = models.JSONField(default = list , verbose_name=_('Kunya'))
kunyas = models.ManyToManyField(
'hadis.Kunya',
blank=True,
related_name='transmitters',
verbose_name=_('Kunyas')
)
known_as = models.JSONField(default = list , verbose_name=_('Known as'))
nickname = models.JSONField(default = list , verbose_name=_('Nick Name'))
gender = models.CharField(
@ -182,7 +267,31 @@ class Transmitters(LowercaseSlugMixin, models.Model):
# Geographic Information
origin = models.JSONField(default = list , verbose_name=_('Origin'))
lived_in = models.JSONField(default = list , verbose_name=_('Lived in'))
died_in = models.JSONField(default = list , verbose_name=_('Died in'))
died_in = models.JSONField(default = list , verbose_name=_('Died in'))
origin_city = models.ForeignKey(
'hadis.City',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='origin_transmitters',
verbose_name=_('Origin City'),
)
lived_in_city = models.ForeignKey(
'hadis.City',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='lived_in_transmitters',
verbose_name=_('Lived in City'),
)
died_in_city = models.ForeignKey(
'hadis.City',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='died_in_transmitters',
verbose_name=_('Died in City'),
)
# Date Information
birth_year_hijri = models.CharField(max_length=100, verbose_name=_("Birth Year (Hijri)"), null=True, blank=True)
@ -251,6 +360,13 @@ class Transmitters(LowercaseSlugMixin, models.Model):
val_str = str(val).strip()
setattr(self, date_field, val_str if val_str else None)
if self.origin_city_id and self.origin_city:
self.origin = self.origin_city.title or []
if self.lived_in_city_id and self.lived_in_city:
self.lived_in = self.lived_in_city.title or []
if self.died_in_city_id and self.died_in_city:
self.died_in = self.died_in_city.title or []
from utils.text_normalizer import build_search_blob
self.normalized_text = build_search_blob(
self.full_name,

157
apps/hadis/serializers/serializers_admin.py

@ -10,7 +10,7 @@ from ..models import (
Hadis, HadisCategory, HadisStatus, HadisTag, HadisCorrection, HadisInterpretation, HadisCollection, HadisSect, HadisInCollection, HadisTransmitter, HadisReference, ReferenceImage,
BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAuthorRutba, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, BookResearcher, BookEditor,
Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, ScholarlyOpinion, TransmitterOpinion, TransmitterOriginalText,
YearValue, NarratorDate,
City, Kunya, YearValue, NarratorDate,
CorrectionReference, CorrectionReferenceImage, InterpretationReference, InterpretationReferenceImage,
OriginalTextReference, OriginalTextReferenceImage,
ContentRelease
@ -2017,6 +2017,24 @@ class AdminYearValueSerializer(serializers.ModelSerializer):
return instance
class AdminCitySerializer(serializers.ModelSerializer):
title = serializers.JSONField()
class Meta:
model = City
fields = ["id", "title", "slug", "created_at", "updated_at"]
read_only_fields = ["id", "slug", "created_at", "updated_at"]
class AdminKunyaSerializer(serializers.ModelSerializer):
title = serializers.JSONField()
class Meta:
model = Kunya
fields = ["id", "title", "slug", "created_at", "updated_at"]
read_only_fields = ["id", "slug", "created_at", "updated_at"]
def _build_admin_date_preview(obj, date_type: str, raw_val: str):
if not raw_val or not str(raw_val).strip():
return None
@ -2029,6 +2047,7 @@ def _build_admin_date_preview(obj, date_type: str, raw_val: str):
class AdminTransmitterListSerializer(serializers.ModelSerializer):
reliability_detail = AdminTransmitterReliabilitySerializer(source="reliability", read_only=True)
kunyas_detail = AdminKunyaSerializer(source="kunyas", many=True, read_only=True)
full_name = serializers.JSONField()
known_as = serializers.JSONField()
nickname = serializers.JSONField()
@ -2053,6 +2072,7 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer):
"age_at_death_preview",
"known_as",
"nickname",
"kunyas_detail",
"reliability",
"reliability_detail",
"madhhab",
@ -2077,12 +2097,36 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer):
class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
reliability_detail = AdminTransmitterReliabilitySerializer(source="reliability", read_only=True)
full_name = serializers.JSONField()
kunya = serializers.JSONField()
kunya = serializers.JSONField(required=False, default=list)
kunyas = serializers.PrimaryKeyRelatedField(
queryset=Kunya.objects.all(),
many=True,
required=False,
)
kunyas_detail = AdminKunyaSerializer(source="kunyas", many=True, read_only=True)
known_as = serializers.JSONField()
nickname = serializers.JSONField()
origin = serializers.JSONField()
lived_in = serializers.JSONField()
died_in = serializers.JSONField()
origin = serializers.JSONField(required=False, allow_null=True)
lived_in = serializers.JSONField(required=False, allow_null=True)
died_in = serializers.JSONField(required=False, allow_null=True)
origin_city = serializers.PrimaryKeyRelatedField(
queryset=City.objects.all(),
required=False,
allow_null=True,
)
origin_city_detail = AdminCitySerializer(source="origin_city", read_only=True)
lived_in_city = serializers.PrimaryKeyRelatedField(
queryset=City.objects.all(),
required=False,
allow_null=True,
)
lived_in_city_detail = AdminCitySerializer(source="lived_in_city", read_only=True)
died_in_city = serializers.PrimaryKeyRelatedField(
queryset=City.objects.all(),
required=False,
allow_null=True,
)
died_in_city_detail = AdminCitySerializer(source="died_in_city", read_only=True)
description = serializers.JSONField()
thumbnail = AbsoluteImageField(required=False, allow_null=True)
remove_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False)
@ -2113,6 +2157,8 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
"id",
"full_name",
"kunya",
"kunyas",
"kunyas_detail",
"known_as",
"nickname",
"gender",
@ -2132,8 +2178,14 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
"in_sahih_muslim",
"in_sahih_bukhari",
"origin",
"origin_city",
"origin_city_detail",
"lived_in",
"lived_in_city",
"lived_in_city_detail",
"died_in",
"died_in_city",
"died_in_city_detail",
"description",
"thumbnail",
"remove_thumbnail",
@ -2149,6 +2201,8 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
"id", "slug", "share_link", "created_at", "updated_at",
"reliability_detail", "father_detail", "sons",
"birth_year_preview", "death_year_preview", "age_at_death_preview",
"origin_city_detail", "lived_in_city_detail", "died_in_city_detail",
"kunyas_detail",
]
def get_birth_year_preview(self, obj):
@ -2221,6 +2275,9 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
"death_year_hijri",
"companion_type",
"father",
"origin_city",
"lived_in_city",
"died_in_city",
]:
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
@ -2235,6 +2292,14 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
except ValueError:
pass
if "kunyas" in data:
val = data["kunyas"]
if isinstance(val, str):
try:
data["kunyas"] = json.loads(val)
except ValueError:
pass
return super().to_internal_value(data)
def validate(self, attrs):
@ -2261,14 +2326,25 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
def create(self, validated_data):
validated_data.pop("remove_thumbnail", False)
sons_ids = validated_data.pop("sons_ids", None)
kunyas_objs = validated_data.pop("kunyas", None)
instance = super().create(validated_data)
if sons_ids is not None:
Transmitters.objects.filter(id__in=sons_ids).update(father=instance)
if kunyas_objs is not None:
instance.kunyas.set(kunyas_objs)
if kunyas_objs:
first_kunya = kunyas_objs[0]
instance.kunya = first_kunya.title or []
instance.save(update_fields=['kunya'])
else:
instance.kunya = []
instance.save(update_fields=['kunya'])
return instance
def update(self, instance, validated_data):
remove_thumbnail = validated_data.pop("remove_thumbnail", False)
sons_ids = validated_data.pop("sons_ids", None)
kunyas_objs = validated_data.pop("kunyas", None)
if remove_thumbnail and instance.thumbnail:
instance.thumbnail.delete(save=False)
instance.thumbnail = None
@ -2276,6 +2352,15 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer):
if sons_ids is not None:
instance.children_as_father.exclude(id__in=sons_ids).update(father=None)
Transmitters.objects.filter(id__in=sons_ids).update(father=instance)
if kunyas_objs is not None:
instance.kunyas.set(kunyas_objs)
if kunyas_objs:
first_kunya = kunyas_objs[0]
instance.kunya = first_kunya.title or []
instance.save(update_fields=['kunya'])
else:
instance.kunya = []
instance.save(update_fields=['kunya'])
return instance
@ -2319,6 +2404,27 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
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)
origin = serializers.JSONField(required=False, allow_null=True)
lived_in = serializers.JSONField(required=False, allow_null=True)
died_in = serializers.JSONField(required=False, allow_null=True)
origin_city = serializers.PrimaryKeyRelatedField(
queryset=City.objects.all(),
required=False,
allow_null=True,
)
origin_city_detail = AdminCitySerializer(source="origin_city", read_only=True)
lived_in_city = serializers.PrimaryKeyRelatedField(
queryset=City.objects.all(),
required=False,
allow_null=True,
)
lived_in_city_detail = AdminCitySerializer(source="lived_in_city", read_only=True)
died_in_city = serializers.PrimaryKeyRelatedField(
queryset=City.objects.all(),
required=False,
allow_null=True,
)
died_in_city_detail = AdminCitySerializer(source="died_in_city", read_only=True)
class Meta:
model = BookAuthor
@ -2344,6 +2450,15 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
"rutba_status",
"rutba_detail",
"features",
"origin",
"origin_city",
"origin_city_detail",
"lived_in",
"lived_in_city",
"lived_in_city_detail",
"died_in",
"died_in_city",
"died_in_city_detail",
"book_references",
]
read_only_fields = [
@ -2352,6 +2467,9 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
"birth_year_preview",
"death_year_preview",
"age_at_death_preview",
"origin_city_detail",
"lived_in_city_detail",
"died_in_city_detail",
]
def get_birth_year_preview(self, obj):
@ -2366,7 +2484,7 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
def to_internal_value(self, data):
import json
mutable_data = safe_copy_data(data)
for json_field in ["name", "known_as", "scholarity", "tabaqa", "rutba", "features", "book_references"]:
for json_field in ["name", "known_as", "scholarity", "tabaqa", "rutba", "features", "origin", "lived_in", "died_in", "book_references"]:
if json_field in mutable_data:
val = mutable_data.get(json_field)
if isinstance(val, str):
@ -2382,6 +2500,9 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer):
"death_year_miladi",
"related_narrator",
"rutba_status",
"origin_city",
"lived_in_city",
"died_in_city",
]:
if field in mutable_data:
val = mutable_data.get(field)
@ -2593,6 +2714,8 @@ class AdminBookReferenceDetailSerializer(serializers.ModelSerializer):
year_of_publication = serializers.SerializerMethodField()
publisher = serializers.SerializerMethodField()
city_of_publication = serializers.SerializerMethodField()
city_of_publication_city = serializers.SerializerMethodField()
city_of_publication_city_detail = serializers.SerializerMethodField()
country_of_publication = serializers.SerializerMethodField()
edition_number = serializers.SerializerMethodField()
hadis_count = serializers.SerializerMethodField()
@ -2649,6 +2772,16 @@ class AdminBookReferenceDetailSerializer(serializers.ModelSerializer):
ed = obj.editions.first()
return ed.city_of_publication if ed else []
def get_city_of_publication_city(self, obj):
ed = obj.editions.first()
return ed.city_of_publication_city_id if ed else None
def get_city_of_publication_city_detail(self, obj):
ed = obj.editions.first()
if ed and ed.city_of_publication_city:
return AdminCitySerializer(ed.city_of_publication_city).data
return None
def get_country_of_publication(self, obj):
ed = obj.editions.first()
return ed.country_of_publication if ed else []
@ -2717,6 +2850,8 @@ class AdminBookReferenceDetailSerializer(serializers.ModelSerializer):
"tag_detail",
"order",
"city_of_publication",
"city_of_publication_city",
"city_of_publication_city_detail",
"country_of_publication",
"edition_number",
"rate",
@ -2732,7 +2867,7 @@ class AdminBookReferenceDetailSerializer(serializers.ModelSerializer):
"updated_at",
"share_link",
]
read_only_fields = ["id", "slug", "hadis_count", "child_arguments", "created_at", "updated_at", "share_link"]
read_only_fields = ["id", "slug", "hadis_count", "child_arguments", "created_at", "updated_at", "share_link", "city_of_publication_city", "city_of_publication_city_detail"]
def to_internal_value(self, data):
import json
@ -3273,6 +3408,12 @@ class AdminBookEditorSerializer(serializers.ModelSerializer):
class AdminBookEditionSerializer(serializers.ModelSerializer):
publisher = serializers.JSONField(required=False, default=list)
city_of_publication = serializers.JSONField(required=False, default=list)
city_of_publication_city = serializers.PrimaryKeyRelatedField(
queryset=City.objects.all(),
required=False,
allow_null=True,
)
city_of_publication_city_detail = AdminCitySerializer(source="city_of_publication_city", read_only=True)
country_of_publication = serializers.JSONField(required=False, default=list)
notes = serializers.JSONField(required=False, default=list)
tag = serializers.PrimaryKeyRelatedField(
@ -3295,6 +3436,8 @@ class AdminBookEditionSerializer(serializers.ModelSerializer):
"book_reference",
"publisher",
"city_of_publication",
"city_of_publication_city",
"city_of_publication_city_detail",
"country_of_publication",
"edition_number",
"year_of_publication",

167
apps/hadis/urls.py

@ -1,156 +1,321 @@
from django.urls import path, include
from rest_framework.routers import SimpleRouter
from .views.category import HadisCategorySectListView, HadisCategoryTreeView, CategoriesView, CategoriesBySectView, HadisCategorySelectBySectView, HadisCategorySelectBySectSourceView , HadisCategoryTreeNormalView ,test_deploy,debug_headers,HadisCategoryXMindView
from .views.hadis import (
HadisCollectionListView, HadisListView, HadisBasicView, HadisDetailView, HadisSyncView, HadisTransmittersView, HadisCorrectionsView, CategoryHadisCorrectionsView, HadisMainListView, HadisFiltersView, HadisLayersView,PinnedHadisCollectionListView, HadisFiltersSyncAPIView,
HadisCorrectionDetailView, HadisInterpretationDetailView, HadisInterpretsListView, HadisSourceDetailsView, HadisTagListView
)
from .views.transmitter import (
TransmitterView ,TransmitterDetailView, TransmitterSyncView,TransmitterOpinionView, TransmitterOriginalTextView, TransmitterFiltersView,
NarratorArgumentsListView , NarratorTeachersView, NarratorStudentsView, TransmitterOriginalTextDetailView
)
from .views.reference import BookDetailView, BookReferencesView, BookReferenceSyncView, BookAttributeView, ReferenceExcerptsListView
from .views.reference_v2 import BookReferenceV2DetailView, BookReferenceV2SyncView, BookReferenceV2ListView
from .views.version import ContentReleaseSyncView, HadisDatabaseVersionView
from .views.info import HadisInfoView
from .views_admin import (
AdminHadisCategoryViewSet, AdminHadisViewSet, AdminHadisCorrectionViewSet, AdminCorrectionReferenceViewSet, AdminHadisReferenceViewSet, AdminInterpretationReferenceViewSet, AdminHadisInterpretationViewSet, AdminHadisStatusViewSet, AdminHadisTagViewSet, AdminHadisCollectionViewSet,
AdminBookSubjectAreaViewSet, AdminBookTypeViewSet, AdminBookTagViewSet, AdminBookAuthorViewSet, DovodiBookAuthorRutbaViewSet, AdminBookAttributeViewSet, AdminBookReferenceImageViewSet, AdminHadisReferenceImageViewSet, AdminBookReferenceDocumentViewSet, AdminBookReferenceViewSet,
AdminTransmitterViewSet, AdminTransmitterReliabilityViewSet, DovodiBookSubjectAreaViewSet, DovodiBookTypeViewSet,
DovodiNarratorLayerViewSet, DovodiTransmitterReliabilityViewSet, DovodiOpinionStatusViewSet, DovodiScholarlyOpinionViewSet, DovodiYearValueViewSet,
DovodiHadisSectViewSet, DovodiHadisCategoryViewSet, AdminCategoryQuranVerseViewSet, AdminHadisTransmitterViewSet,
AdminTransmitterOpinionViewSet, AdminTransmitterOriginalTextViewSet, AdminOriginalTextReferenceViewSet, AdminBookEditionViewSet, AdminBookVolumeViewSet,
AdminContentReleaseViewSet
AdminContentReleaseViewSet,
DovodiCityViewSet,
DovodiKunyaViewSet
)
from .views.reference_v2 import BookAuthorListView, BookAuthorDetailView, AuthorReferencesListView, BookAuthorSyncView
from utils.cache import smart_cached_view
# Helper function to cache public views with smart invalidation and force-refresh support
cached_view = smart_cached_view(60 * 60 * 2, key_prefix='hadis_api')
admin_router = SimpleRouter()
admin_router.register(r'hadises', AdminHadisViewSet, basename='admin-hadises')
admin_router.register(r'categories', AdminHadisCategoryViewSet, basename='admin-hadis-categories')
admin_router.register(r'quran-verses', AdminCategoryQuranVerseViewSet, basename='admin-category-quran-verses')
admin_router.register(r'dovodi-sects', DovodiHadisSectViewSet, basename='dovodi-hadis-sects')
admin_router.register(r'dovodi-categories', DovodiHadisCategoryViewSet, basename='dovodi-hadis-categories')
admin_router.register(r'corrections', AdminHadisCorrectionViewSet, basename='admin-hadis-corrections')
admin_router.register(r'correction-references', AdminCorrectionReferenceViewSet, basename='admin-correction-references')
admin_router.register(r'hadis-references', AdminHadisReferenceViewSet, basename='admin-hadis-references')
admin_router.register(r'interpretations', AdminHadisInterpretationViewSet, basename='admin-hadis-interpretations')
admin_router.register(r'interpretation-references', AdminInterpretationReferenceViewSet, basename='admin-interpretation-references')
admin_router.register(r'original-text-references', AdminOriginalTextReferenceViewSet, basename='admin-original-text-references')
admin_router.register(r'statuses', AdminHadisStatusViewSet, basename='admin-hadis-statuses')
admin_router.register(r'tags', AdminHadisTagViewSet, basename='admin-hadis-tags')
admin_router.register(r'collections', AdminHadisCollectionViewSet, basename='admin-hadis-collections')
admin_router.register(r'references', AdminBookReferenceViewSet, basename='admin-book-references')
admin_router.register(r'subject-areas', AdminBookSubjectAreaViewSet, basename='admin-book-subject-areas')
admin_router.register(r'book-types', AdminBookTypeViewSet, basename='admin-book-types')
admin_router.register(r'book-tags', AdminBookTagViewSet, basename='admin-book-tags')
admin_router.register(r'dovodi-subject-areas', DovodiBookSubjectAreaViewSet, basename='dovodi-book-subject-areas')
admin_router.register(r'dovodi-book-types', DovodiBookTypeViewSet, basename='dovodi-book-types')
admin_router.register(r'dovodi-author-rutbas', DovodiBookAuthorRutbaViewSet, basename='dovodi-author-rutbas')
admin_router.register(r'book-authors', AdminBookAuthorViewSet, basename='admin-book-authors')
admin_router.register(r'book-attributes', AdminBookAttributeViewSet, basename='admin-book-attributes')
admin_router.register(r'book-images', AdminBookReferenceImageViewSet, basename='admin-book-images')
admin_router.register(r'hadis-reference-images', AdminHadisReferenceImageViewSet, basename='admin-hadis-reference-images')
admin_router.register(r'book-documents', AdminBookReferenceDocumentViewSet, basename='admin-book-documents')
admin_router.register(r'transmitters', AdminTransmitterViewSet, basename='admin-transmitters')
admin_router.register(r'transmitter-reliabilities', AdminTransmitterReliabilityViewSet, basename='admin-transmitter-reliabilities')
admin_router.register(r'dovodi-narrator-layers', DovodiNarratorLayerViewSet, basename='dovodi-narrator-layers')
admin_router.register(r'dovodi-transmitter-reliabilities', DovodiTransmitterReliabilityViewSet, basename='dovodi-transmitter-reliabilities')
admin_router.register(r'dovodi-opinion-statuses', DovodiOpinionStatusViewSet, basename='dovodi-opinion-statuses')
admin_router.register(r'dovodi-opinions', DovodiScholarlyOpinionViewSet, basename='dovodi-opinions')
admin_router.register(r'dovodi-year-values', DovodiYearValueViewSet, basename='dovodi-year-values')
admin_router.register(r'hadis-transmitters', AdminHadisTransmitterViewSet, basename='admin-hadis-transmitters')
admin_router.register(r'transmitter-opinions', AdminTransmitterOpinionViewSet, basename='admin-transmitter-opinions')
admin_router.register(r'transmitter-original-texts', AdminTransmitterOriginalTextViewSet, basename='admin-transmitter-original-texts')
admin_router.register(r'book-editions', AdminBookEditionViewSet, basename='admin-book-editions')
admin_router.register(r'book-volumes', AdminBookVolumeViewSet, basename='admin-book-volumes')
admin_router.register(r'content-version', AdminContentReleaseViewSet, basename='admin-content-version')
admin_router.register(r'dovodi-cities', DovodiCityViewSet, basename='dovodi-cities')
admin_router.register(r'cities', DovodiCityViewSet, basename='admin-cities')
admin_router.register(r'dovodi-kunyas', DovodiKunyaViewSet, basename='dovodi-kunyas')
admin_router.register(r'kunyas', DovodiKunyaViewSet, basename='admin-kunyas')
# Hide admin viewsets from swagger
for prefix, viewset, basename in admin_router.registry:
viewset.swagger_schema = None
urlpatterns = [
path('sync/authors/', cached_view(BookAuthorSyncView.as_view()), name='sync-author-list'),
path('authors/<str:author_slug>/references/', cached_view(AuthorReferencesListView.as_view()), name='author-references'),
path('authors/<str:author_slug>/', cached_view(BookAuthorDetailView.as_view()), name='author-detail'),
path('authors/', cached_view(BookAuthorListView.as_view()), name='author-list'),
# Admin endpoints
path('admin/', include(admin_router.urls)),
# Most specific first (with parameters)
path('pinned-collections/', cached_view(PinnedHadisCollectionListView.as_view()), name='pinned-hadis-collection-list'),
path('collections/', cached_view(HadisCollectionListView.as_view()), name='hadis-collection-list'),
path('sync/sects/', cached_view(HadisCategorySectListView.as_view()), name='hadis-sect-list'),
path('sync/categories/tree/', HadisCategoryTreeView.as_view(), name='hadis-category-tree'),
path('sync/hadis/', cached_view(HadisSyncView.as_view()), name='hadis-sync'),
path('sync/narrators/', cached_view(TransmitterSyncView.as_view()), name='transmitter-sync'),
path('sync/references/', cached_view(BookReferenceSyncView.as_view()), name='reference-sync'),
path('v2/sync/references/', cached_view(BookReferenceV2SyncView.as_view()), name='reference-sync-v2'),
path('sync/version/', ContentReleaseSyncView.as_view(), name='content-release-sync'),
path('sync/database/', HadisDatabaseVersionView.as_view(), name='hadis-database-sync'),
path('sync/filters/', cached_view(HadisFiltersSyncAPIView.as_view()), name='hadis-filters-sync'),
path('info/', cached_view(HadisInfoView.as_view()), name='hadis-info'),
path('tags/', cached_view(HadisTagListView.as_view()), name='hadis-tag-list'),
# Category paths (more specific first)
path('categories/tree/', HadisCategoryTreeNormalView.as_view(), name='hadis-category-tree-normal'),
path('categories/<str:sect_type>/<str:slug>/<str:source_type>/', HadisCategorySelectBySectSourceView.as_view(), name='categories-tree-by-sect-source'),
path('categories/<str:sect_type>/<str:slug>/', HadisCategorySelectBySectView.as_view(), name='categories-tree-by-sect'),
path('categories/<str:sect_type>/', CategoriesBySectView.as_view(), name='categories-by-sect'),
path('categories/', CategoriesView.as_view(), name='categories'), # ← Least specific LAST
# Hadis paths
path('category/<str:category_slug>/xmind/', HadisCategoryXMindView.as_view(), name='hadis-category-xmind'), # ← Must be before other category paths
path('category/<str:category_slug>/corrections/', CategoryHadisCorrectionsView.as_view(), name='category-hadis-corrections'),
path('category/<str:category_slug>/', HadisListView.as_view(), name='hadis-list'),
path('arguments/filters/', cached_view(HadisFiltersView.as_view()), name='hadis-filters'),
path('arguments/', HadisMainListView.as_view(), name='hadis-main-list'),
# Narrator paths
path('narrators/filters/', cached_view(TransmitterFiltersView.as_view()), name='narrator-filters'),
path('narrators/<str:narrator_slug>/teachers/', cached_view(NarratorTeachersView.as_view()), name='narrator-teachers'),
path('narrators/<str:narrator_slug>/students/', cached_view(NarratorStudentsView.as_view()), name='narrator-students'),
path('narrators/<str:narrator_slug>/arguments/', cached_view(NarratorArgumentsListView.as_view()), name='narrator-arguments'),
path('narrators/<str:narrator_slug>/opinions/', cached_view(TransmitterOpinionView.as_view()), name='narrator-opinions'),
path('narrators/<str:narrator_slug>/original_texts/', TransmitterOriginalTextView.as_view(), name='narrator-original-texts'),
path('narrators/<str:narrator_slug>/', cached_view(TransmitterDetailView.as_view()), name='narrator-detail'),
path('narrators/', cached_view(TransmitterView.as_view()), name='narrators'),
# Reference paths
path('v2/references/<str:reference_slug>/excerpts/', cached_view(ReferenceExcerptsListView.as_view()), name='reference-excerpts-v2'),
path('v2/references/<str:slug>/', cached_view(BookReferenceV2DetailView.as_view()), name='reference-detail-v2'),
path('v2/references/', cached_view(BookReferenceV2ListView.as_view()), name='reference-list-v2'),
path('references/<str:reference_slug>/excerpts/', cached_view(ReferenceExcerptsListView.as_view()), name='reference-excerpts'),
path('references/<str:reference_slug>/', cached_view(BookDetailView.as_view()), name='reference-detail'),
path('references/<str:reference_slug>', cached_view(BookDetailView.as_view()), name='reference-detail-no-slash'),
path('references/', cached_view(BookReferencesView.as_view()), name='references'),
# Hadis detail paths (with slug, more specific)
path('<str:hadis_slug>/source-details/', cached_view(HadisSourceDetailsView.as_view()), name='hadis-source-details'),
path('<str:hadis_slug>/sources/', cached_view(HadisSourceDetailsView.as_view()), name='hadis-sources'),
path('<str:hadis_slug>/detail/', HadisDetailView.as_view(), name='hadis-detail'),
path('<str:hadis_slug>/transmitters/', cached_view(HadisTransmittersView.as_view()), name='hadis-transmitters'),
path('<str:hadis_slug>/transmitters/layers/', cached_view(HadisLayersView.as_view()), name='hadis-layers'),
path('<str:hadis_slug>/corrections/', HadisCorrectionsView.as_view(), name='hadis-corrections'),
path('<str:hadis_slug>/interprets/', HadisInterpretsListView.as_view(), name='hadis-interprets'),
path('<str:hadis_slug>/', HadisBasicView.as_view(), name='hadis-basic'), # ← Least specific LAST
# path('test/test-deploy',test_deploy , name='test'),
# path('debug-headers',debug_headers , name='headers'),
# Interpretation Detail Endpoint (تفسیر بر اساس آی‌دی)
path('interpretations/<str:interpretation_slug>/', cached_view(HadisInterpretationDetailView.as_view()), name='interpretation-detail'),
# Correction Detail Endpoint (تصحیح بر اساس اسلاگ)
path('corrections/<str:correction_slug>/', HadisCorrectionDetailView.as_view(), name='correction-detail'),
# Original Text Detail Endpoint (متن اصلی راوی بر اساس اسلاگ)
path('original-texts/<str:original_text_slug>/', cached_view(TransmitterOriginalTextDetailView.as_view()), name='original-text-detail'),
]

1490
apps/hadis/views_admin.py
File diff suppressed because it is too large
View File

Loading…
Cancel
Save