diff --git a/apps/hadis/migrations/0057_city_and_location_relations.py b/apps/hadis/migrations/0057_city_and_location_relations.py new file mode 100644 index 0000000..fe55fa5 --- /dev/null +++ b/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), + ] diff --git a/apps/hadis/migrations/0058_rename_hadis_booka_date_ty_9a1e3c_idx_hadis_booka_date_ty_99a484_idx_and_more.py b/apps/hadis/migrations/0058_rename_hadis_booka_date_ty_9a1e3c_idx_hadis_booka_date_ty_99a484_idx_and_more.py new file mode 100644 index 0000000..86d902d --- /dev/null +++ b/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', + ), + ] diff --git a/apps/hadis/migrations/0059_kunya_and_relations.py b/apps/hadis/migrations/0059_kunya_and_relations.py new file mode 100644 index 0000000..1ebc445 --- /dev/null +++ b/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, + ), + ] diff --git a/apps/hadis/models/reference.py b/apps/hadis/models/reference.py index cedebaf..f53b0de 100644 --- a/apps/hadis/models/reference.py +++ b/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) diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index b0a010d..4b886f9 100644 --- a/apps/hadis/models/transmitter.py +++ b/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, diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index 7cfdeb3..4c14e02 100644 --- a/apps/hadis/serializers/serializers_admin.py +++ b/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", diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index 41d33b5..fcccb79 100644 --- a/apps/hadis/urls.py +++ b/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//references/', cached_view(AuthorReferencesListView.as_view()), name='author-references'), + path('authors//', 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////', HadisCategorySelectBySectSourceView.as_view(), name='categories-tree-by-sect-source'), + path('categories///', HadisCategorySelectBySectView.as_view(), name='categories-tree-by-sect'), + path('categories//', CategoriesBySectView.as_view(), name='categories-by-sect'), + path('categories/', CategoriesView.as_view(), name='categories'), # ← Least specific LAST + + # Hadis paths + path('category//xmind/', HadisCategoryXMindView.as_view(), name='hadis-category-xmind'), # ← Must be before other category paths + path('category//corrections/', CategoryHadisCorrectionsView.as_view(), name='category-hadis-corrections'), + path('category//', 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//teachers/', cached_view(NarratorTeachersView.as_view()), name='narrator-teachers'), + path('narrators//students/', cached_view(NarratorStudentsView.as_view()), name='narrator-students'), + path('narrators//arguments/', cached_view(NarratorArgumentsListView.as_view()), name='narrator-arguments'), + path('narrators//opinions/', cached_view(TransmitterOpinionView.as_view()), name='narrator-opinions'), + path('narrators//original_texts/', TransmitterOriginalTextView.as_view(), name='narrator-original-texts'), + path('narrators//', cached_view(TransmitterDetailView.as_view()), name='narrator-detail'), + path('narrators/', cached_view(TransmitterView.as_view()), name='narrators'), + + # Reference paths + path('v2/references//excerpts/', cached_view(ReferenceExcerptsListView.as_view()), name='reference-excerpts-v2'), + path('v2/references//', cached_view(BookReferenceV2DetailView.as_view()), name='reference-detail-v2'), + path('v2/references/', cached_view(BookReferenceV2ListView.as_view()), name='reference-list-v2'), + path('references//excerpts/', cached_view(ReferenceExcerptsListView.as_view()), name='reference-excerpts'), + path('references//', cached_view(BookDetailView.as_view()), name='reference-detail'), + path('references/', 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('/source-details/', cached_view(HadisSourceDetailsView.as_view()), name='hadis-source-details'), + path('/sources/', cached_view(HadisSourceDetailsView.as_view()), name='hadis-sources'), + path('/detail/', HadisDetailView.as_view(), name='hadis-detail'), + path('/transmitters/', cached_view(HadisTransmittersView.as_view()), name='hadis-transmitters'), + path('/transmitters/layers/', cached_view(HadisLayersView.as_view()), name='hadis-layers'), + path('/corrections/', HadisCorrectionsView.as_view(), name='hadis-corrections'), + path('/interprets/', HadisInterpretsListView.as_view(), name='hadis-interprets'), + path('/', 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//', cached_view(HadisInterpretationDetailView.as_view()), name='interpretation-detail'), + # Correction Detail Endpoint (تصحیح بر اساس اسلاگ) + path('corrections//', HadisCorrectionDetailView.as_view(), name='correction-detail'), + + # Original Text Detail Endpoint (متن اصلی راوی بر اساس اسلاگ) + path('original-texts//', cached_view(TransmitterOriginalTextDetailView.as_view()), name='original-text-detail'), + + ] + diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index ab9123c..6fe1386 100644 --- a/apps/hadis/views_admin.py +++ b/apps/hadis/views_admin.py @@ -1,1429 +1,2919 @@ from django.db.models import Q, Exists, OuterRef, Count + from rest_framework.authentication import TokenAuthentication + from rest_framework.decorators import action + from rest_framework.parsers import FormParser, JSONParser, MultiPartParser + from rest_framework.permissions import IsAuthenticated + from rest_framework.response import Response + from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet + + from apps.account.permissions import IsSuperAdmin + from utils.pagination import StandardResultsSetPagination, OptionalPagination + + from .models import ( + Hadis, HadisCategory, HadisCollection, HadisInCollection, HadisCorrection, HadisInterpretation, HadisStatus, HadisTag, HadisSect, HadisTransmitter, HadisReference, ReferenceImage, + BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAuthorRutba, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, + Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, ScholarlyOpinion, TransmitterOpinion, TransmitterOriginalText, + YearValue, NarratorDate, + CorrectionReference, InterpretationReference, OriginalTextReference, + ContentRelease, + CategoryQuranVerse, + + City, + + Kunya, + ) + from .serializers.serializers_admin import ( + AdminHadisCategorySerializer, + AdminHadisSectArchiveSerializer, + AdminHadisCategoryArchiveSerializer, + AdminHadisCategoryArchiveDetailSerializer, + AdminHadisCorrectionSerializer, + AdminCorrectionReferenceSerializer, + AdminInterpretationReferenceSerializer, + AdminOriginalTextReferenceSerializer, + AdminHadisReferenceSerializer, + AdminHadisDetailSerializer, + AdminHadisInterpretationSerializer, + AdminHadisCollectionSerializer, + AdminHadisListSerializer, + AdminHadisBriefSerializer, + AdminHadisStatusSerializer, + AdminHadisTagSerializer, + AdminBookSubjectAreaSerializer, + AdminBookTypeSerializer, + AdminBookTagSerializer, + AdminBookAuthorSerializer, + AdminBookAuthorRutbaSerializer, + AdminBookAttributeSerializer, + AdminBookReferenceImageSerializer, + AdminHadisReferenceImageSerializer, + AdminBookReferenceDocumentSerializer, + AdminBookReferenceListSerializer, + AdminBookReferenceDetailSerializer, + AdminTransmitterListSerializer, + AdminTransmitterDetailSerializer, + AdminTransmitterReliabilitySerializer, + AdminYearValueSerializer, + AdminNarratorLayerSerializer, + AdminOpinionStatusSerializer, + AdminScholarlyOpinionSerializer, + AdminHadisTransmitterSerializer, + AdminTransmitterOpinionSerializer, + AdminTransmitterOriginalTextSerializer, + AdminBookEditionSerializer, + AdminBookVolumeSerializer, + AdminContentReleaseSerializer, + + AdminCitySerializer, + + AdminKunyaSerializer, + ) + + + class AdminHadisCategoryViewSet(ReadOnlyModelViewSet): + serializer_class = AdminHadisCategorySerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + + def get_queryset(self): + hadis_subquery = Hadis.objects.filter(Q(category=OuterRef("pk")) | Q(categories=OuterRef("pk"))) + children_subquery = HadisCategory.objects.filter(parent=OuterRef("pk")) + quran_verse_subquery = CategoryQuranVerse.objects.filter(category=OuterRef("pk")) + queryset = HadisCategory.objects.select_related("parent", "sect").annotate( + annotated_has_hadith=Exists(hadis_subquery), + annotated_has_children=Exists(children_subquery), + annotated_has_quran_verse=Exists(quran_verse_subquery), + ) + + has_quran_verse = self.request.query_params.get("has_quran_verse") + exclude_verse_id = self.request.query_params.get("exclude_verse_id") + + if has_quran_verse in ["false", "False", "0"]: + if exclude_verse_id: + queryset = queryset.filter( + Q(source_type="quran", quran_verse__isnull=True) + | Q(quran_verse__id=exclude_verse_id) + ) + else: + queryset = queryset.filter(source_type="quran", quran_verse__isnull=True) + elif has_quran_verse in ["true", "True", "1"]: + queryset = queryset.filter(quran_verse__isnull=False) + source_type = self.request.query_params.get("source_type") + if source_type: + queryset = queryset.filter(source_type=source_type) + else: + source_type = self.request.query_params.get("source_type") + if source_type: + queryset = queryset.filter(source_type=source_type) + + search = self.request.query_params.get("search") + if search: + queryset = queryset.filter( + Q(title__icontains=search) | Q(slug__icontains=search) + ) + has_children = self.request.query_params.get("has_children") + if has_children is not None and has_children != "all": + if str(has_children).lower() in ["false", "0"]: + queryset = queryset.filter(annotated_has_children=False) + elif str(has_children).lower() in ["true", "1"]: + queryset = queryset.filter(annotated_has_children=True) + is_leaf = self.request.query_params.get("is_leaf") + if is_leaf is not None and is_leaf != "all": + if str(is_leaf).lower() in ["true", "1"]: + queryset = queryset.filter(annotated_has_children=False) + elif str(is_leaf).lower() in ["false", "0"]: + queryset = queryset.filter(annotated_has_children=True) + is_topic = self.request.query_params.get("is_topic") + if is_topic is not None and is_topic != "all": + if str(is_topic).lower() in ["true", "1"]: + queryset = queryset.filter(is_topic=True) + elif str(is_topic).lower() in ["false", "0"]: + queryset = queryset.filter(is_topic=False) + return queryset.order_by("order", "id") + + + class DovodiHadisSectViewSet(ModelViewSet): + serializer_class = AdminHadisSectArchiveSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = HadisSect.objects.all() + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter( + Q(title__icontains=search_query) + | Q(description__icontains=search_query) + | Q(sect_type__icontains=search_query) + ) + return queryset.order_by("order", "id") + + + class DovodiHadisCategoryViewSet(ModelViewSet): + serializer_class = AdminHadisCategoryArchiveSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_serializer_class(self): + if self.action in ["retrieve"]: + return AdminHadisCategoryArchiveDetailSerializer + return AdminHadisCategoryArchiveSerializer + + def get_queryset(self): + hadis_subquery = Hadis.objects.filter(Q(category=OuterRef("pk")) | Q(categories=OuterRef("pk"))) + children_subquery = HadisCategory.objects.filter(parent=OuterRef("pk")) + queryset = HadisCategory.objects.select_related("parent", "sect").annotate( + annotated_has_hadith=Exists(hadis_subquery), + annotated_has_children=Exists(children_subquery), + ) + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter( + Q(title__icontains=search_query) + | Q(description__icontains=search_query) + | Q(slug__icontains=search_query) + | Q(source_type__icontains=search_query) + ) + source_type = self.request.query_params.get("source_type") + if source_type and source_type != "all": + queryset = queryset.filter(source_type=source_type) + sect_type = self.request.query_params.get("sect_type") + if sect_type and sect_type != "all": + queryset = queryset.filter(sect__sect_type=sect_type) + sect_id = self.request.query_params.get("sect") + if sect_id and sect_id != "all": + queryset = queryset.filter(sect_id=sect_id) + has_hadith = self.request.query_params.get("has_hadith") + if has_hadith is not None and has_hadith != "all": + if str(has_hadith).lower() in ["false", "0"]: + queryset = queryset.filter(annotated_has_hadith=False) + elif str(has_hadith).lower() in ["true", "1"]: + queryset = queryset.filter(annotated_has_hadith=True) + has_children = self.request.query_params.get("has_children") + if has_children is not None and has_children != "all": + if str(has_children).lower() in ["false", "0"]: + queryset = queryset.filter(annotated_has_children=False) + elif str(has_children).lower() in ["true", "1"]: + queryset = queryset.filter(annotated_has_children=True) + is_leaf = self.request.query_params.get("is_leaf") + if is_leaf is not None and is_leaf != "all": + if str(is_leaf).lower() in ["true", "1"]: + queryset = queryset.filter(annotated_has_children=False) + elif str(is_leaf).lower() in ["false", "0"]: + queryset = queryset.filter(annotated_has_children=True) + is_topic = self.request.query_params.get("is_topic") + if is_topic is not None and is_topic != "all": + if str(is_topic).lower() in ["true", "1"]: + queryset = queryset.filter(is_topic=True) + elif str(is_topic).lower() in ["false", "0"]: + queryset = queryset.filter(is_topic=False) + return queryset.order_by("-id") + + + class AdminHadisStatusViewSet(ModelViewSet): + serializer_class = AdminHadisStatusSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = HadisStatus.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("order", "id") + + + class AdminHadisTagViewSet(ModelViewSet): + serializer_class = AdminHadisTagSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = HadisTag.objects.all() + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter(title__icontains=search_query) + return queryset.order_by("id") + + + class AdminHadisCollectionViewSet(ModelViewSet): + serializer_class = AdminHadisCollectionSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + parser_classes = (MultiPartParser, FormParser, JSONParser) + + def get_queryset(self): + queryset = HadisCollection.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(summary__icontains=search_query) + ) + status_filter = self.request.query_params.get("status") + if status_filter in {"true", "false"}: + queryset = queryset.filter(status=status_filter == "true") + return queryset.order_by("-created_at", "-id") + + @action(detail=True, methods=["post"], url_path="assign-items") + def assign_items(self, request, pk=None): + collection = self.get_object() + add_ids = request.data.get("add_ids", []) or request.data.get("item_ids", []) + remove_ids = request.data.get("remove_ids", []) + + if add_ids: + new_items = [ + HadisInCollection(hadis_id=hid, collection_id=collection.id) + for hid in add_ids + ] + HadisInCollection.objects.bulk_create(new_items, ignore_conflicts=True) + + if remove_ids: + HadisInCollection.objects.filter(collection_id=collection.id, hadis_id__in=remove_ids).delete() + + return Response({"status": "success", "attached_count": collection.hadis_items.count()}) + + + class AdminTransmitterReliabilityViewSet(ReadOnlyModelViewSet): + serializer_class = AdminTransmitterReliabilitySerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + + def get_queryset(self): + 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): + serializer_class = AdminNarratorLayerSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = NarratorLayer.objects.select_related("hadis").all() + hadis_id = self.request.query_params.get("hadis") or self.request.query_params.get("hadis_id") + if hadis_id: + queryset = queryset.filter(hadis_id=hadis_id) + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter( + Q(name__icontains=search_query) + | Q(description__icontains=search_query) + | Q(slug__icontains=search_query) + ) + return queryset.order_by("number", "id") + + + class DovodiTransmitterReliabilityViewSet(ModelViewSet): + serializer_class = AdminTransmitterReliabilitySerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + 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 DovodiOpinionStatusViewSet(ModelViewSet): + serializer_class = AdminOpinionStatusSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = OpinionStatus.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 DovodiScholarlyOpinionViewSet(ModelViewSet): + serializer_class = AdminScholarlyOpinionSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = ScholarlyOpinion.objects.select_related("status").annotate( + transmitters_count_val=Count("transmitter_opinions__transmitter", distinct=True) + ) + search_query = self.request.query_params.get("search") + if search_query: + q_filter = ( + Q(opinion_text__icontains=search_query) + | Q(status__title__icontains=search_query) + ) + if search_query.isdigit(): + q_filter |= Q(id=int(search_query)) + queryset = queryset.filter(q_filter) + + status_id = self.request.query_params.get("status") + if status_id and status_id != "all": + queryset = queryset.filter(status_id=status_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): + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = StandardResultsSetPagination + parser_classes = (MultiPartParser, FormParser, JSONParser) + + def get_serializer_class(self): + if self.action == "list": + return AdminTransmitterListSerializer + return AdminTransmitterDetailSerializer + + def get_queryset(self): + queryset = Transmitters.objects.select_related("reliability").prefetch_related("narrator_dates__year_value") + + search_query = self.request.query_params.get("search") + if search_query: + q_filter = ( + Q(full_name__icontains=search_query) + | Q(kunya__icontains=search_query) + | Q(known_as__icontains=search_query) + | Q(nickname__icontains=search_query) + | Q(origin__icontains=search_query) + | Q(lived_in__icontains=search_query) + | Q(died_in__icontains=search_query) + | Q(slug__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(): + q_filter |= Q(legacy_number=int(search_query)) | Q(generation=int(search_query)) + queryset = queryset.filter(q_filter) + + status_filter = self.request.query_params.get("status") + if status_filter and status_filter != "all": + queryset = queryset.filter(reliability__slug=status_filter) + + madhhab_filter = self.request.query_params.get("madhhab") + if madhhab_filter and madhhab_filter != "all": + queryset = queryset.filter(madhhab=madhhab_filter) + + generation_filter = self.request.query_params.get("generation") + if generation_filter and generation_filter != "all": + queryset = queryset.filter(generation=generation_filter) + + return queryset.order_by("-created_at", "-id") + + + + class AdminHadisViewSet(ModelViewSet): + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = StandardResultsSetPagination + parser_classes = (MultiPartParser, FormParser, JSONParser) + + def get_serializer_class(self): + if self.action == "list": + if self.request.query_params.get("brief") == "true": + return AdminHadisBriefSerializer + return AdminHadisListSerializer + return AdminHadisDetailSerializer + + def get_queryset(self): + queryset = ( + Hadis.objects.all() + .select_related("category", "hadis_status") + .prefetch_related("categories", "collection_items__collection") + ) + + search_query = self.request.query_params.get("search") + if search_query: + q_filter = ( + Q(title__icontains=search_query) + | Q(title_narrator__icontains=search_query) + | Q(text__icontains=search_query) + | Q(translation__icontains=search_query) + | Q(slug__icontains=search_query) + ) + if search_query.isdigit(): + q_filter |= Q(number=int(search_query)) + queryset = queryset.filter(q_filter) + + category_id = self.request.query_params.get("category") + if category_id and category_id != "all": + queryset = queryset.filter(Q(category_id=category_id) | Q(categories__id=category_id)).distinct() + + collection_id = self.request.query_params.get("collection") or self.request.query_params.get("collection_id") + if collection_id and collection_id != "all": + queryset = queryset.filter(collection_items__collection_id=collection_id).distinct() + + source_type = self.request.query_params.get("source_type") + if source_type: + queryset = queryset.filter(Q(category__source_type=source_type) | Q(categories__source_type=source_type)).distinct() + + status_filter = self.request.query_params.get("status") + if status_filter in {"true", "false"}: + queryset = queryset.filter(status=status_filter == "true") + + return queryset.order_by("-id") + + @action(detail=False, methods=["post"], url_path="bulk-assign-topics") + def bulk_assign_topics(self, request): + from django.db import transaction + from rest_framework import status as drf_status + from .signals import invalidate_hadis_cache + + hadis_ids = request.data.get("hadis_ids") or [] + category_ids = request.data.get("category_ids") or [] + mode = request.data.get("mode", "add") + + if not isinstance(hadis_ids, list) or not hadis_ids: + return Response( + {"detail": "hadis_ids must be a non-empty list."}, + status=drf_status.HTTP_400_BAD_REQUEST, + ) + if not isinstance(category_ids, list) or not category_ids: + return Response( + {"detail": "category_ids must be a non-empty list."}, + status=drf_status.HTTP_400_BAD_REQUEST, + ) + + try: + hadis_ids = [int(hid) for hid in hadis_ids] + category_ids = list(dict.fromkeys(int(cid) for cid in category_ids)) + except (ValueError, TypeError): + return Response( + {"detail": "Invalid ID format."}, + status=drf_status.HTTP_400_BAD_REQUEST, + ) + + categories = list(HadisCategory.objects.filter(id__in=category_ids)) + if len(categories) != len(category_ids): + return Response( + {"detail": "One or more selected topics do not exist."}, + status=drf_status.HTTP_400_BAD_REQUEST, + ) + + for cat in categories: + if cat.children.exists(): + return Response( + {"detail": "Cannot assign items to a parent category that has subcategories. Please select leaf topics only."}, + status=drf_status.HTTP_400_BAD_REQUEST, + ) + + hadises_qs = Hadis.objects.filter(id__in=hadis_ids) + existing_hadis_ids = list(hadises_qs.values_list("id", flat=True)) + if not existing_hadis_ids: + return Response( + {"detail": "No matching items found."}, + status=drf_status.HTTP_404_NOT_FOUND, + ) + + through_model = Hadis.categories.through + first_category_id = category_ids[0] + + with transaction.atomic(): + if mode == "replace": + through_model.objects.filter(hadis_id__in=existing_hadis_ids).delete() + relations = [ + through_model(hadis_id=hid, hadiscategory_id=cid) + for hid in existing_hadis_ids + for cid in category_ids + ] + through_model.objects.bulk_create(relations, ignore_conflicts=True) + Hadis.objects.filter(id__in=existing_hadis_ids).update(category_id=first_category_id) + else: + relations = [ + through_model(hadis_id=hid, hadiscategory_id=cid) + for hid in existing_hadis_ids + for cid in category_ids + ] + through_model.objects.bulk_create(relations, ignore_conflicts=True) + Hadis.objects.filter(id__in=existing_hadis_ids, category__isnull=True).update( + category_id=first_category_id + ) + + invalidate_hadis_cache() + + cat_map = {c.id: c for c in categories} + ordered_cats = [cat_map[cid] for cid in category_ids if cid in cat_map] + categories_detail = [ + { + "id": c.id, + "title": c.title, + "slug": c.slug, + "source_type": c.source_type, + "is_topic": True, + } + for c in ordered_cats + ] + + return Response( + { + "updated_count": len(existing_hadis_ids), + "mode": mode, + "categories_detail": categories_detail, + }, + status=drf_status.HTTP_200_OK, + ) + + + + class AdminHadisCorrectionViewSet(ModelViewSet): + serializer_class = AdminHadisCorrectionSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + parser_classes = (MultiPartParser, FormParser, JSONParser) + pagination_class = OptionalPagination + + def get_queryset(self): + from django.db.models import Count, OuterRef, Subquery, IntegerField + from django.db.models.functions import Coalesce + from apps.bookmark.models.bookmark import Bookmark + + bookmarks_subquery = Bookmark.objects.filter( + service=Bookmark.ServiceChoices.HADITH_CORRECTION, + content_id=OuterRef("id"), + status=True + ).values("content_id").annotate(count=Count("id")).values("count") + + queryset = HadisCorrection.objects.all().select_related("hadis").prefetch_related("references", "references__images").annotate( + annotated_bookmark_count=Coalesce(Subquery(bookmarks_subquery, output_field=IntegerField()), 0) + ) + hadis_id = self.request.query_params.get("hadis_id") or self.request.query_params.get("hadis") + if hadis_id: + queryset = queryset.filter(hadis_id=hadis_id) + search = self.request.query_params.get("search") + if search: + from django.db.models import Q + queryset = queryset.filter( + Q(title__icontains=search) | Q(text__icontains=search) + ) + return queryset.order_by("-created_at", "-id") + + def perform_create(self, serializer): + priority = self.request.data.get("priority", 1) + try: + priority = int(priority) + except (ValueError, TypeError): + priority = 1 + hadis_id = self.request.data.get("hadis") + if hadis_id: + from django.db.models import F + HadisCorrection.objects.filter(hadis_id=hadis_id, priority__gte=priority).update(priority=F("priority") + 1) + serializer.save() + + @action(detail=False, methods=["post"]) + def reorder(self, request): + correction_ids = request.data.get("correction_ids", []) + if not correction_ids: + return Response({"error": "correction_ids list is required"}, status=400) + + for index, correction_id in enumerate(correction_ids, start=1): + HadisCorrection.objects.filter(id=correction_id).update(priority=index) + + return Response({"status": "success"}) + + + class AdminCorrectionReferenceViewSet(ModelViewSet): + serializer_class = AdminCorrectionReferenceSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + parser_classes = (MultiPartParser, FormParser, JSONParser) + pagination_class = OptionalPagination + + def get_queryset(self): + qs = CorrectionReference.objects.all().select_related( + "book_reference__author", "edition", "book_volume" + ).prefetch_related("images") + correction_id = self.request.query_params.get("correction_id") + if correction_id: + qs = qs.filter(correction_id=correction_id) + return qs.order_by("id") + + + class AdminHadisReferenceViewSet(ModelViewSet): + serializer_class = AdminHadisReferenceSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + parser_classes = (MultiPartParser, FormParser, JSONParser) + pagination_class = OptionalPagination + + def get_queryset(self): + qs = HadisReference.objects.all().select_related( + "book_reference__author", "edition", "book_volume" + ).prefetch_related("images") + hadis_id = self.request.query_params.get("hadis_id") + if hadis_id: + qs = qs.filter(hadis_id=hadis_id) + return qs.order_by("id") + + + class AdminInterpretationReferenceViewSet(ModelViewSet): + serializer_class = AdminInterpretationReferenceSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + parser_classes = (MultiPartParser, FormParser, JSONParser) + pagination_class = OptionalPagination + + def get_queryset(self): + qs = InterpretationReference.objects.all().select_related( + "book_reference__author", "edition", "book_volume" + ).prefetch_related("images") + interpretation_id = self.request.query_params.get("interpretation_id") or self.request.query_params.get("interpretation") + if interpretation_id: + qs = qs.filter(interpretation_id=interpretation_id) + return qs.order_by("id") + + + class AdminOriginalTextReferenceViewSet(ModelViewSet): + serializer_class = AdminOriginalTextReferenceSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + parser_classes = (MultiPartParser, FormParser, JSONParser) + pagination_class = OptionalPagination + + def get_queryset(self): + qs = OriginalTextReference.objects.all().select_related( + "book_reference__author", "edition", "book_volume" + ).prefetch_related("images") + original_text_id = self.request.query_params.get("original_text_id") or self.request.query_params.get("original_text") + if original_text_id: + qs = qs.filter(original_text_id=original_text_id) + return qs.order_by("id") + + + class AdminHadisInterpretationViewSet(ModelViewSet): + serializer_class = AdminHadisInterpretationSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + parser_classes = (MultiPartParser, FormParser, JSONParser) + pagination_class = OptionalPagination + + def get_queryset(self): + queryset = HadisInterpretation.objects.all().select_related("category").prefetch_related("references", "references__images") + category_id = self.request.query_params.get("category_id") or self.request.query_params.get("category") + if category_id: + queryset = queryset.filter(category_id=category_id) + search = self.request.query_params.get("search") + if search: + from django.db.models import Q + queryset = queryset.filter( + Q(title__icontains=search) | Q(text__icontains=search) + ) + return queryset.order_by("-created_at", "-id") + + def perform_create(self, serializer): + priority = self.request.data.get("priority", 1) + try: + priority = int(priority) + except (ValueError, TypeError): + priority = 1 + category_id = self.request.data.get("category") + if category_id: + from django.db.models import F + HadisInterpretation.objects.filter(category_id=category_id, priority__gte=priority).update(priority=F("priority") + 1) + serializer.save() + + @action(detail=False, methods=["post"]) + def reorder(self, request): + interpretation_ids = request.data.get("interpretation_ids", []) + if not interpretation_ids: + return Response({"error": "interpretation_ids list is required"}, status=400) + + for index, interpretation_id in enumerate(interpretation_ids, start=1): + HadisInterpretation.objects.filter(id=interpretation_id).update(priority=index) + + return Response({"status": "success"}) + + + class AdminBookSubjectAreaViewSet(ReadOnlyModelViewSet): + serializer_class = AdminBookSubjectAreaSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + + def get_queryset(self): + return BookSubjectArea.objects.all().order_by("-created_at", "-id") + + + class AdminBookTypeViewSet(ReadOnlyModelViewSet): + serializer_class = AdminBookTypeSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + + def get_queryset(self): + return BookType.objects.all().order_by("-created_at", "-id") + + + class DovodiBookSubjectAreaViewSet(ModelViewSet): + serializer_class = AdminBookSubjectAreaSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = BookSubjectArea.objects.all() + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter(title__icontains=search_query) + return queryset.order_by("-created_at", "-id") + + + class DovodiBookTypeViewSet(ModelViewSet): + serializer_class = AdminBookTypeSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = BookType.objects.all() + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter(title__icontains=search_query) + 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): + serializer_class = AdminBookAuthorSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + parser_classes = (MultiPartParser, FormParser, JSONParser) + + def get_queryset(self): + 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) + return queryset.order_by("-created_at", "-id") + + @action(detail=True, methods=["post"], url_path="assign-references") + def assign_references(self, request, pk=None): + author = self.get_object() + add_ids = request.data.get("add_ids", []) or request.data.get("book_ids", []) or request.data.get("item_ids", []) + remove_ids = request.data.get("remove_ids", []) + + if add_ids: + BookReference.objects.filter(id__in=add_ids).update(author=author) + + if remove_ids: + BookReference.objects.filter(id__in=remove_ids, author=author).update(author=None) + + return Response({"status": "success", "references_count": author.book_references.count()}) + + + class AdminBookAttributeViewSet(ModelViewSet): + serializer_class = AdminBookAttributeSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + + def get_queryset(self): + queryset = BookAttribute.objects.all() + book_reference_id = self.request.query_params.get("book_reference_id") + if book_reference_id: + queryset = queryset.filter(book_reference_id=book_reference_id) + return queryset.order_by("id") + + + class AdminBookReferenceImageViewSet(ModelViewSet): + serializer_class = AdminBookReferenceImageSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + parser_classes = (MultiPartParser, FormParser, JSONParser) + pagination_class = None + + def get_queryset(self): + queryset = BookReferenceImage.objects.all() + book_reference_id = self.request.query_params.get("book_reference_id") + if book_reference_id: + queryset = queryset.filter(book_reference_id=book_reference_id) + return queryset.order_by("order", "id") + + @action(detail=False, methods=["post"]) + def reorder(self, request): + image_ids = request.data.get("image_ids", []) or request.data.get("ids", []) + if not image_ids: + return Response({"error": "image_ids list is required"}, status=400) + + for index, img_id in enumerate(image_ids, start=1): + BookReferenceImage.objects.filter(id=img_id).update(order=index) + + return Response({"status": "success"}) + + + class AdminHadisReferenceImageViewSet(ModelViewSet): + serializer_class = AdminHadisReferenceImageSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + parser_classes = (MultiPartParser, FormParser, JSONParser) + pagination_class = None + + def get_queryset(self): + queryset = ReferenceImage.objects.all().select_related("reference") + hadis_id = self.request.query_params.get("hadis_id") + if hadis_id: + queryset = queryset.filter(reference__hadis_id=hadis_id) + reference_id = self.request.query_params.get("reference_id") + if reference_id: + queryset = queryset.filter(reference_id=reference_id) + return queryset.order_by("priority", "id") + + def create(self, request, *args, **kwargs): + hadis_id = request.data.get("hadis_id") or request.data.get("hadis") + reference_id = request.data.get("reference_id") or request.data.get("reference") + thumbnail = request.FILES.get("thumbnail") or request.FILES.get("image") + priority = request.data.get("priority", 0) + + if not thumbnail: + return Response({"error": "thumbnail image is required"}, status=400) + + if hadis_id: + try: + hadis = Hadis.objects.get(id=hadis_id) + except Hadis.DoesNotExist: + return Response({"error": "Hadis not found"}, status=404) + reference = hadis.references.first() + if not reference: + reference = HadisReference.objects.create(hadis=hadis) + elif reference_id: + try: + reference = HadisReference.objects.get(id=reference_id) + except HadisReference.DoesNotExist: + return Response({"error": "HadisReference not found"}, status=404) + else: + return Response({"error": "hadis_id or reference_id is required"}, status=400) + + if not priority: + last_img = reference.images.order_by("-priority").first() + priority = (last_img.priority + 1) if last_img else 1 + else: + try: + priority = int(priority) + except (ValueError, TypeError): + priority = 1 + + ref_image = ReferenceImage.objects.create( + reference=reference, + thumbnail=thumbnail, + priority=priority + ) + serializer = self.get_serializer(ref_image) + return Response(serializer.data, status=201) + + @action(detail=False, methods=["post"]) + def reorder(self, request): + image_ids = request.data.get("image_ids", []) or request.data.get("ids", []) + if not image_ids: + return Response({"error": "image_ids list is required"}, status=400) + + for index, img_id in enumerate(image_ids, start=1): + ReferenceImage.objects.filter(id=img_id).update(priority=index) + + return Response({"status": "success"}) + + @action(detail=False, methods=["post"]) + def bulk_delete(self, request): + image_ids = request.data.get("ids", []) or request.data.get("image_ids", []) + if not image_ids: + return Response({"error": "ids list is required"}, status=400) + + deleted_count, _ = ReferenceImage.objects.filter(id__in=image_ids).delete() + return Response({"status": "success", "deleted_count": deleted_count}) + + + class AdminBookReferenceDocumentViewSet(ModelViewSet): + serializer_class = AdminBookReferenceDocumentSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + parser_classes = (MultiPartParser, FormParser, JSONParser) + pagination_class = None + + def get_queryset(self): + queryset = BookReferenceDocument.objects.all() + book_reference_id = self.request.query_params.get("book_reference_id") + if book_reference_id: + queryset = queryset.filter(book_reference_id=book_reference_id) + return queryset.order_by("order", "id") + + + class AdminBookReferenceViewSet(ModelViewSet): + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = StandardResultsSetPagination + parser_classes = (MultiPartParser, FormParser, JSONParser) + + def get_serializer_class(self): + if self.action == "list": + return AdminBookReferenceListSerializer + return AdminBookReferenceDetailSerializer + + def get_queryset(self): + queryset = BookReference.objects.all().annotate(annotated_hadis_count=Count("hadis_references__hadis", distinct=True)).prefetch_related("subject_area", "volumes", "editions").select_related("type", "author") + + 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(editions__isbn__icontains=search_query) + | Q(editions__publisher__icontains=search_query) + | Q(description__icontains=search_query) + | Q(author__name__icontains=search_query) + | Q(tag__title__icontains=search_query) + | Q(editions__tag__title__icontains=search_query) + ) + + type_id = self.request.query_params.get("type") + if type_id and type_id != "all": + queryset = queryset.filter(type_id=type_id) + + madhab = self.request.query_params.get("madhab") + if madhab and madhab != "all": + queryset = queryset.filter(type__sect=madhab) + + subject_area_id = self.request.query_params.get("subject_area") + if subject_area_id and subject_area_id != "all": + queryset = queryset.filter(subject_area__id=subject_area_id) + + author_id = self.request.query_params.get("author") + if author_id and author_id != "all": + queryset = queryset.filter(author_id=author_id) + + return queryset.distinct().order_by("-created_at") + + + class AdminCategoryQuranVerseViewSet(ModelViewSet): + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + parser_classes = (JSONParser, MultiPartParser, FormParser) + pagination_class = OptionalPagination + + def get_serializer_class(self): + from .serializers.serializers_admin import AdminCategoryQuranVerseSerializer + return AdminCategoryQuranVerseSerializer + + def get_queryset(self): + from .models.category import CategoryQuranVerse + queryset = ( + CategoryQuranVerse.objects.all() + .select_related("category", "category__sect") + .prefetch_related("secondary_translations") + ) + + search_query = self.request.query_params.get("search") + if search_query: + q_filter = ( + Q(surah_name__icontains=search_query) + | Q(ayah_number__icontains=search_query) + | Q(arabic_text__icontains=search_query) + | Q(translation__icontains=search_query) + | Q(translator__icontains=search_query) + | Q(category__title__icontains=search_query) + | Q(category__slug__icontains=search_query) + ) + queryset = queryset.filter(q_filter) + + category_id = self.request.query_params.get("category") + if category_id and category_id != "all": + queryset = queryset.filter(category_id=category_id) + + source_type = self.request.query_params.get("source_type") + if source_type: + queryset = queryset.filter(category__source_type=source_type) + + return queryset.order_by("-id") + + + class AdminHadisTransmitterViewSet(ModelViewSet): + serializer_class = AdminHadisTransmitterSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = HadisTransmitter.objects.all().select_related("transmitter", "narrator_layer", "status").prefetch_related("uncertain_transmitters") + hadis_id = self.request.query_params.get("hadis_id") + if hadis_id: + queryset = queryset.filter(hadis_id=hadis_id) + return queryset.order_by("order") + + @action(detail=False, methods=["post"]) + def reorder(self, request): + from rest_framework.decorators import action + from rest_framework.response import Response + from django.db import transaction + + hadis_transmitter_ids = request.data.get("hadis_transmitter_ids", []) + if not hadis_transmitter_ids: + return Response({"error": "hadis_transmitter_ids list is required"}, status=400) + + with transaction.atomic(): + # Pass 1: Set temporary positive offset order indices to avoid duplicate key violations (>= 0) + for index, ht_id in enumerate(hadis_transmitter_ids): + HadisTransmitter.objects.filter(id=ht_id).update(order=10000 + index) + + # Pass 2: Set final sequential order indices starting from 0 + for index, ht_id in enumerate(hadis_transmitter_ids): + HadisTransmitter.objects.filter(id=ht_id).update(order=index) + + return Response({"status": "success"}) + + + class AdminTransmitterOpinionViewSet(ModelViewSet): + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + serializer_class = AdminTransmitterOpinionSerializer + pagination_class = None + + def get_queryset(self): + queryset = TransmitterOpinion.objects.all().select_related("status", "scholar", "opinion", "opinion__status") + transmitter_id = self.request.query_params.get("transmitter_id") + if transmitter_id: + queryset = queryset.filter(transmitter_id=transmitter_id) + return queryset.order_by("order", "id") + + @action(detail=False, methods=["post"]) + def reorder(self, request): + opinion_ids = request.data.get("opinion_ids", []) + if not opinion_ids: + return Response({"error": "opinion_ids list is required"}, status=400) + + from django.db import transaction + with transaction.atomic(): + for index, opinion_id in enumerate(opinion_ids): + TransmitterOpinion.objects.filter(id=opinion_id).update(order=10000 + index) + for index, opinion_id in enumerate(opinion_ids): + TransmitterOpinion.objects.filter(id=opinion_id).update(order=index) + + return Response({"status": "success"}) + + + class AdminTransmitterOriginalTextViewSet(ModelViewSet): + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + serializer_class = AdminTransmitterOriginalTextSerializer + parser_classes = (JSONParser, MultiPartParser, FormParser) + pagination_class = None + + def get_queryset(self): + queryset = TransmitterOriginalText.objects.all().prefetch_related("references", "references__images") + transmitter_id = self.request.query_params.get("transmitter_id") + if transmitter_id: + queryset = queryset.filter(transmitter_id=transmitter_id) + return queryset.order_by("order", "id") + + @action(detail=False, methods=["post"]) + def reorder(self, request): + original_text_ids = request.data.get("original_text_ids", []) + if not original_text_ids: + return Response({"error": "original_text_ids list is required"}, status=400) + + from django.db import transaction + with transaction.atomic(): + for index, ot_id in enumerate(original_text_ids): + TransmitterOriginalText.objects.filter(id=ot_id).update(order=10000 + index) + for index, ot_id in enumerate(original_text_ids): + TransmitterOriginalText.objects.filter(id=ot_id).update(order=index) + + return Response({"status": "success"}) + + + class AdminBookEditionViewSet(ModelViewSet): + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + serializer_class = AdminBookEditionSerializer + pagination_class = None + + def get_queryset(self): + queryset = BookEdition.objects.all() + book_reference_id = self.request.query_params.get("book_reference_id") + if book_reference_id: + queryset = queryset.filter(book_reference_id=book_reference_id) + return queryset.order_by("-created_at") + + + class AdminBookTagViewSet(ModelViewSet): + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + serializer_class = AdminBookTagSerializer + pagination_class = None + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = BookTag.objects.all() + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter(title__icontains=search_query) + return queryset.order_by("-id") + + + class AdminBookVolumeViewSet(ModelViewSet): + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + serializer_class = AdminBookVolumeSerializer + pagination_class = None + parser_classes = (MultiPartParser, FormParser, JSONParser) + + def get_queryset(self): + queryset = BookVolume.objects.all() + book_reference_id = self.request.query_params.get("book_reference_id") + edition_id = self.request.query_params.get("edition_id") + if book_reference_id: + queryset = queryset.filter(book_reference_id=book_reference_id) + if edition_id: + if edition_id in ["none", "null"]: + queryset = queryset.filter(edition__isnull=True) + else: + queryset = queryset.filter(edition_id=edition_id) + return queryset.order_by("title", "id") + + + import re + + def compute_next_version(current_version_str: str) -> str: + """ + Increments the last integer part of a version string. + Examples: + 'V 0.51' -> 'V 0.52' + 'V0.5' -> 'V0.6' + '1.0.0' -> '1.0.1' + '1' -> '2' + 'v1.9' -> 'v1.10' + """ + if not current_version_str or not str(current_version_str).strip(): + return "V 1.0" + + matches = list(re.finditer(r'\d+', str(current_version_str))) + if not matches: + return f"{str(current_version_str).strip()} 2" + + last_match = matches[-1] + start, end = last_match.span() + num_str = last_match.group() + next_num = int(num_str) + 1 + + if len(num_str) > 1 and num_str.startswith('0'): + formatted_num = str(next_num).zfill(len(num_str)) + else: + formatted_num = str(next_num) + + return str(current_version_str)[:start] + formatted_num + str(current_version_str)[end:] + + + class AdminContentReleaseViewSet(ModelViewSet): + serializer_class = AdminContentReleaseSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + + def get_queryset(self): + return ContentRelease.objects.all().order_by("-published_at", "-id") + + def list(self, request, *args, **kwargs): + queryset = self.get_queryset() + current_release = queryset.filter(is_active=True).first() + current_data = self.get_serializer(current_release).data if current_release else None + + current_ver_name = current_release.version_name if current_release else "V 0.5" + next_suggested = compute_next_version(current_ver_name) + + history_data = self.get_serializer(queryset[:50], many=True).data + + return Response({ + "current_version": current_data, + "next_suggested_version": next_suggested, + "history": history_data + }) + + @action(detail=False, methods=["post"], url_path="increment") + def increment(self, request): + current_release = self.get_queryset().filter(is_active=True).first() + current_ver_name = current_release.version_name if current_release else "V 0.5" + + version_name = request.data.get("version_name") + if not version_name or not str(version_name).strip(): + version_name = compute_next_version(current_ver_name) + else: + version_name = str(version_name).strip() + + description = request.data.get("description", "") or "" + + # Create new content release + new_release = ContentRelease.objects.create( + version_name=version_name, + description=description, + is_active=True + ) + + from .signals import invalidate_hadis_cache + invalidate_hadis_cache() + + # تریگر خودکار ساخت و فشرده‌سازی دیتابیس SQLite در پس‌زمینه + self._trigger_sqlite_export(notes=f"Auto-generated for version {version_name}") + + return Response({ + "status": "success", + "message": f"Version incremented to {version_name}", + "release": self.get_serializer(new_release).data, + "next_suggested_version": compute_next_version(version_name) + }) + + def _trigger_sqlite_export(self, notes=""): + """اجرای پس‌زمینه دستور تولید دیتابیس SQLite بدون متوقف کردن پاسخ HTTP""" + import threading + import logging + from django.core.management import call_command + + logger = logging.getLogger(__name__) + + def _task(): + try: + logger.info("Starting background SQLite export...") + call_command('export_hadis_sqlite', notes=notes) + logger.info("Background SQLite export completed successfully.") + except Exception as e: + logger.error(f"Failed to export SQLite in background: {e}", exc_info=True) + + thread = threading.Thread(target=_task, daemon=True) + thread.start() + + def perform_create(self, serializer): + instance = serializer.save() + from .signals import invalidate_hadis_cache + invalidate_hadis_cache() + self._trigger_sqlite_export(notes=f"Auto-generated for created version {instance.version_name}") + + def perform_update(self, serializer): + super().perform_update(serializer) + from .signals import invalidate_hadis_cache + invalidate_hadis_cache() + + def perform_destroy(self, instance): + super().perform_destroy(instance) + from .signals import invalidate_hadis_cache + invalidate_hadis_cache() + + + + + + + +class DovodiCityViewSet(ModelViewSet): + + serializer_class = AdminCitySerializer + + permission_classes = [IsAuthenticated, IsSuperAdmin] + + authentication_classes = [TokenAuthentication] + + pagination_class = OptionalPagination + + parser_classes = (JSONParser, MultiPartParser, FormParser) + + + + def get_queryset(self): + + queryset = City.objects.all().order_by("slug") + + search_query = self.request.query_params.get("search") + + if search_query: + + queryset = queryset.filter( + + Q(title__icontains=search_query) + + | Q(slug__icontains=search_query) + + ) + + return queryset + + +class DovodiKunyaViewSet(ModelViewSet): + serializer_class = AdminKunyaSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = Kunya.objects.all().order_by("slug") + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter( + Q(title__icontains=search_query) + | Q(slug__icontains=search_query) + ) + return queryset