From d4e9f1dcfbe072b5560ddabe4c9ba0da6da3dc7f Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Mon, 19 Oct 2026 11:35:00 +0330 Subject: [PATCH] feat(transmitters): introduce reusable ScholarlyOpinion model and link to TransmitterOpinion - Create ScholarlyOpinion model for standalone reusable scholarly opinions with status and multilingual text - Add foreign key link from TransmitterOpinion to ScholarlyOpinion with automatic backward sync - Create data migration 0053 to populate ScholarlyOpinion records from existing transmitter opinions - Expose DovodiScholarlyOpinionViewSet and register route /dovodi-opinions/ in admin router - Update TransmitterOpinion serializers and views to prioritize linked ScholarlyOpinion data --- ...yopinion_and_transmitteropinion_opinion.py | 79 +++++++++++++++++++ apps/hadis/models/transmitter.py | 73 ++++++++++++++++- apps/hadis/serializers/hadis.py | 59 ++++++++------ apps/hadis/serializers/serializers_admin.py | 37 ++++++++- apps/hadis/urls.py | 3 +- apps/hadis/views/transmitter.py | 7 +- apps/hadis/views_admin.py | 33 +++++++- 7 files changed, 258 insertions(+), 33 deletions(-) create mode 100644 apps/hadis/migrations/0053_scholarlyopinion_and_transmitteropinion_opinion.py diff --git a/apps/hadis/migrations/0053_scholarlyopinion_and_transmitteropinion_opinion.py b/apps/hadis/migrations/0053_scholarlyopinion_and_transmitteropinion_opinion.py new file mode 100644 index 0000000..ec30cbc --- /dev/null +++ b/apps/hadis/migrations/0053_scholarlyopinion_and_transmitteropinion_opinion.py @@ -0,0 +1,79 @@ +# Generated by Django 4.2.30 on 2026-10-19 06:30 + +import json +from django.db import migrations, models +import django.db.models.deletion + + +def migrate_existing_opinions_forward(apps, schema_editor): + ScholarlyOpinion = apps.get_model("hadis", "ScholarlyOpinion") + TransmitterOpinion = apps.get_model("hadis", "TransmitterOpinion") + + cache = {} + for to in TransmitterOpinion.objects.all().order_by("id"): + if to.opinion_id: + continue + raw_text = to.opinion_text if isinstance(to.opinion_text, list) else [] + try: + key = (to.status_id, json.dumps(raw_text, sort_keys=True, ensure_ascii=False)) + except Exception: + key = (to.status_id, str(raw_text)) + + scholarly_op = cache.get(key) + if scholarly_op is None: + scholarly_op = ScholarlyOpinion.objects.create( + opinion_text=raw_text, + status_id=to.status_id, + ) + cache[key] = scholarly_op + + to.opinion_id = scholarly_op.id + to.save(update_fields=["opinion"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("hadis", "0052_transmitters_gender"), + ] + + operations = [ + migrations.CreateModel( + name="ScholarlyOpinion", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("opinion_text", models.JSONField(default=list, verbose_name="Opinion Text")), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="created at")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="updated at")), + ( + "status", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="scholarly_opinions", + to="hadis.opinionstatus", + verbose_name="opinion status", + ), + ), + ], + options={ + "verbose_name": "Scholarly Opinion", + "verbose_name_plural": "Scholarly Opinions", + "ordering": ("-id",), + }, + ), + migrations.AddField( + model_name="transmitteropinion", + name="opinion", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="transmitter_opinions", + to="hadis.scholarlyopinion", + verbose_name="Scholarly Opinion", + ), + ), + migrations.RunPython(migrate_existing_opinions_forward, migrations.RunPython.noop), + ] diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index 954e16a..bc6d03f 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -420,6 +420,54 @@ class OpinionStatus(ColorPaletteMixin, LowercaseSlugMixin, models.Model): verbose_name_plural = _('Opinion Statuses') +class ScholarlyOpinion(models.Model): + """ + Standalone reusable scholarly opinion object containing status, Arabic text, and translations. + Can be connected to multiple transmitters via TransmitterOpinion. + """ + opinion_text = models.JSONField(default=list, verbose_name=_('Opinion Text')) + status = models.ForeignKey( + OpinionStatus, + on_delete=models.CASCADE, + verbose_name=_('opinion status'), + related_name='scholarly_opinions', + blank=True, + null=True + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + verbose_name = _('Scholarly Opinion') + verbose_name_plural = _('Scholarly Opinions') + ordering = ('-id',) + + def __str__(self): + if self.opinion_text and isinstance(self.opinion_text, list) and len(self.opinion_text) > 0: + first_text = self.opinion_text[0].get('text', '') if isinstance(self.opinion_text[0], dict) else '' + return f"{first_text[:50]} ({self.status})" if first_text else f"Opinion #{self.id}" + return f"Opinion #{self.id}" + + def get_opinion_tex(self, lang): + if not self.opinion_text or not isinstance(self.opinion_text, list): + return None + for tr in self.opinion_text: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + for tr in self.opinion_text: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + # Keep all connected TransmitterOpinion records synced for 100% backward compatibility + TransmitterOpinion.objects.filter(opinion_id=self.id).update( + opinion_text=self.opinion_text, + status_id=self.status_id, + ) + + class TransmitterOpinion(models.Model): """ Model for scholarly opinions about transmitters @@ -443,6 +491,14 @@ class TransmitterOpinion(models.Model): null=True, blank=True ) + opinion = models.ForeignKey( + ScholarlyOpinion, + on_delete=models.CASCADE, + verbose_name=_('Scholarly Opinion'), + related_name='transmitter_opinions', + null=True, + blank=True + ) scholar_name = models.JSONField(default = list , verbose_name=_('Scholar Name')) opinion_text = models.JSONField(default = list , verbose_name=_('Opinion Text')) status = models.ForeignKey( @@ -467,6 +523,15 @@ class TransmitterOpinion(models.Model): verbose_name_plural = _('Transmitter Opinions') ordering = ('order', 'id') + def save(self, *args, **kwargs): + if self.opinion_id and self.opinion: + self.opinion_text = self.opinion.opinion_text + self.status_id = self.opinion.status_id + if self.scholar_id and self.scholar and not self.scholar_name: + if isinstance(self.scholar.name, list): + self.scholar_name = self.scholar.name + super().save(*args, **kwargs) + def __str__(self): scholar = self.scholar_name[0]['text'] if self.scholar_name else "Unknown Scholar" return f"{scholar}'s opinion on {self.transmitter} ({self.status})" @@ -492,15 +557,15 @@ class TransmitterOpinion(models.Model): """ Get title for a specific language """ - - if not self.opinion_text or not isinstance(self.opinion_text, list): + source_text = self.opinion.opinion_text if (self.opinion_id and self.opinion) else self.opinion_text + if not source_text or not isinstance(source_text, list): return None - for tr in self.opinion_text: + for tr in source_text: if isinstance(tr, dict) and tr.get('language_code') == lang: return tr.get('text', '') - for tr in self.opinion_text: + for tr in source_text: if isinstance(tr, dict) and tr.get('language_code') == 'en': return tr.get('text', '') return None diff --git a/apps/hadis/serializers/hadis.py b/apps/hadis/serializers/hadis.py index fd3544c..c693977 100644 --- a/apps/hadis/serializers/hadis.py +++ b/apps/hadis/serializers/hadis.py @@ -498,8 +498,8 @@ class TransmitterShortSerializer(serializers.ModelSerializer): class TransmitterOpinionSerializer(serializers.ModelSerializer): """ Serializer for TransmitterOpinions """ scholar_name = LocalizedField() - opinion_text = LocalizedField() - arabic_opinion_text = ArabicLocalizedField(source='opinion_text') + opinion_text = serializers.SerializerMethodField() + arabic_opinion_text = serializers.SerializerMethodField() status = serializers.SerializerMethodField() scholar = serializers.SerializerMethodField() @@ -507,6 +507,15 @@ class TransmitterOpinionSerializer(serializers.ModelSerializer): model = TransmitterOpinion fields = ['id','transmitter','scholar_name','scholar','opinion_text','arabic_opinion_text','status'] + def get_opinion_text(self, obj): + request = self.context.get('request') + source_text = obj.opinion.opinion_text if (obj.opinion_id and obj.opinion) else obj.opinion_text + return get_localized_text(source_text, request) + + def get_arabic_opinion_text(self, obj): + source_text = obj.opinion.opinion_text if (obj.opinion_id and obj.opinion) else obj.opinion_text + return get_arabic_localized_text(source_text) + def get_scholar(self, obj): request = self.context.get('request') if obj.scholar: @@ -519,14 +528,15 @@ class TransmitterOpinionSerializer(serializers.ModelSerializer): def get_status(self, obj): """Serialize the opinion status foreign key""" - if obj.status: + status_obj = (obj.opinion.status if (obj.opinion_id and obj.opinion and obj.opinion.status) else obj.status) + if status_obj: request = self.context.get('request') return { - 'id': obj.status.id, - 'title': get_localized_text(obj.status.title, request), - 'slug': obj.status.slug, - 'color': obj.status.color, - 'main_color_code': obj.status.main_color_code, + 'id': status_obj.id, + 'title': get_localized_text(status_obj.title, request), + 'slug': status_obj.slug, + 'color': status_obj.color, + 'main_color_code': status_obj.main_color_code, } return None @@ -914,30 +924,33 @@ class TransmitterSyncSerializer(serializers.ModelSerializer): """Get all scholarly opinions about this transmitter""" request = self.context.get('request') # ← FIX: Define request - return [ - { + results = [] + for opinion in obj.opinions.all(): + source_text = opinion.opinion.opinion_text if (opinion.opinion_id and opinion.opinion) else opinion.opinion_text + status_obj = (opinion.opinion.status if (opinion.opinion_id and opinion.opinion and opinion.opinion.status) else opinion.status) + scholar_name_src = opinion.scholar.name if (opinion.scholar and opinion.scholar.name) else opinion.scholar_name + results.append({ 'id': opinion.id, - 'scholar_name': get_localized_text(opinion.scholar_name, request), - 'arabic_scholar_name': get_arabic_localized_text(opinion.scholar_name), + 'scholar_name': get_localized_text(scholar_name_src, request), + 'arabic_scholar_name': get_arabic_localized_text(scholar_name_src), 'scholar': { 'id': opinion.scholar.id, 'name': get_localized_text(opinion.scholar.name, request), 'slug': opinion.scholar.slug } if opinion.scholar else None, - 'opinion_text': get_localized_text(opinion.opinion_text, request), - 'arabic_opinion_text': get_arabic_localized_text(opinion.opinion_text), + 'opinion_text': get_localized_text(source_text, request), + 'arabic_opinion_text': get_arabic_localized_text(source_text), 'status': { - 'id': opinion.status.id, - 'title': get_localized_text(opinion.status.title, request), - 'slug': opinion.status.slug, - 'color': opinion.status.color, - 'main_color_code': opinion.status.main_color_code, - } if opinion.status else None, + 'id': status_obj.id, + 'title': get_localized_text(status_obj.title, request), + 'slug': status_obj.slug, + 'color': status_obj.color, + 'main_color_code': status_obj.main_color_code, + } if status_obj else None, 'created_at': opinion.created_at.isoformat() if opinion.created_at else None, 'updated_at': opinion.updated_at.isoformat() if opinion.updated_at else None, - } - for opinion in obj.opinions.all() # Already prefetched - ] + }) + return results def get_original_texts(self, obj): """گرفتن متون اصلی راوی با جزئیات کامل و رفرنس‌ها""" diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index 3a02e76..f8faf9f 100644 --- a/apps/hadis/serializers/serializers_admin.py +++ b/apps/hadis/serializers/serializers_admin.py @@ -9,7 +9,7 @@ from utils import absolute_https_url from ..models import ( Hadis, HadisCategory, HadisStatus, HadisTag, HadisCorrection, HadisInterpretation, HadisCollection, HadisSect, HadisInCollection, HadisTransmitter, HadisReference, ReferenceImage, BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, BookResearcher, BookEditor, - Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, TransmitterOpinion, TransmitterOriginalText, + Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, ScholarlyOpinion, TransmitterOpinion, TransmitterOriginalText, CorrectionReference, CorrectionReferenceImage, InterpretationReference, InterpretationReferenceImage, OriginalTextReference, OriginalTextReferenceImage, ContentRelease @@ -2733,9 +2733,33 @@ class AdminHadisTransmitterSerializer(serializers.ModelSerializer): return instance +class AdminScholarlyOpinionSerializer(serializers.ModelSerializer): + opinion_text = serializers.JSONField(required=False, default=list) + status_detail = AdminOpinionStatusSerializer(source="status", read_only=True) + transmitters_count = serializers.SerializerMethodField() + + class Meta: + model = ScholarlyOpinion + fields = [ + "id", + "opinion_text", + "status", + "status_detail", + "transmitters_count", + "created_at", + "updated_at", + ] + + def get_transmitters_count(self, obj): + if hasattr(obj, "transmitters_count_val"): + return obj.transmitters_count_val + return obj.transmitter_opinions.values("transmitter_id").distinct().count() + + class AdminTransmitterOpinionSerializer(serializers.ModelSerializer): scholar_name = serializers.JSONField(required=False, default=list) opinion_text = serializers.JSONField(required=False, default=list) + opinion_detail = AdminScholarlyOpinionSerializer(source="opinion", read_only=True) status_detail = AdminTransmitterReliabilitySerializer(source="status", read_only=True) scholar_detail = AdminBookAuthorSerializer(source="scholar", read_only=True) @@ -2746,6 +2770,8 @@ class AdminTransmitterOpinionSerializer(serializers.ModelSerializer): "transmitter", "scholar", "scholar_detail", + "opinion", + "opinion_detail", "scholar_name", "opinion_text", "status", @@ -2755,6 +2781,15 @@ class AdminTransmitterOpinionSerializer(serializers.ModelSerializer): "updated_at", ] + def to_representation(self, instance): + data = super().to_representation(instance) + if instance.opinion_id and instance.opinion: + data["opinion_text"] = instance.opinion.opinion_text + data["status"] = instance.opinion.status_id + if instance.opinion.status: + data["status_detail"] = AdminOpinionStatusSerializer(instance.opinion.status, context=self.context).data + return data + class AdminTransmitterOriginalTextSerializer(serializers.ModelSerializer): title = serializers.JSONField(required=False, default=list) diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index 85ea445..7610a90 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -17,7 +17,7 @@ from .views_admin import ( AdminHadisCategoryViewSet, AdminHadisViewSet, AdminHadisCorrectionViewSet, AdminCorrectionReferenceViewSet, AdminHadisReferenceViewSet, AdminInterpretationReferenceViewSet, AdminHadisInterpretationViewSet, AdminHadisStatusViewSet, AdminHadisTagViewSet, AdminHadisCollectionViewSet, AdminBookSubjectAreaViewSet, AdminBookTypeViewSet, AdminBookTagViewSet, AdminBookAuthorViewSet, AdminBookAttributeViewSet, AdminBookReferenceImageViewSet, AdminHadisReferenceImageViewSet, AdminBookReferenceDocumentViewSet, AdminBookReferenceViewSet, AdminTransmitterViewSet, AdminTransmitterReliabilityViewSet, DovodiBookSubjectAreaViewSet, DovodiBookTypeViewSet, - DovodiNarratorLayerViewSet, DovodiTransmitterReliabilityViewSet, DovodiOpinionStatusViewSet, + DovodiNarratorLayerViewSet, DovodiTransmitterReliabilityViewSet, DovodiOpinionStatusViewSet, DovodiScholarlyOpinionViewSet, DovodiHadisSectViewSet, DovodiHadisCategoryViewSet, AdminCategoryQuranVerseViewSet, AdminHadisTransmitterViewSet, AdminTransmitterOpinionViewSet, AdminTransmitterOriginalTextViewSet, AdminOriginalTextReferenceViewSet, AdminBookEditionViewSet, AdminBookVolumeViewSet, AdminContentReleaseViewSet @@ -59,6 +59,7 @@ admin_router.register(r'transmitter-reliabilities', AdminTransmitterReliabilityV 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'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') diff --git a/apps/hadis/views/transmitter.py b/apps/hadis/views/transmitter.py index 7510e15..9f80905 100644 --- a/apps/hadis/views/transmitter.py +++ b/apps/hadis/views/transmitter.py @@ -88,7 +88,10 @@ class TransmitterOpinionView(ListAPIView): transmitter__slug=narrator_slug ).select_related( 'transmitter', # Essential if serializer includes transmitter data - 'status' # Added for the new OpinionStatus ForeignKey + 'status', # Added for the new OpinionStatus ForeignKey + 'scholar', + 'opinion', + 'opinion__status', ).prefetch_related( # Add any nested relations from TransmitterOriginalTextSerializer # 'translations', @@ -150,7 +153,7 @@ class TransmitterSyncView(ListAPIView): .select_related('reliability') .prefetch_related( # 👇 ۲. اضافه کردن 'scholar' برای جلوگیری از کوئری اضافه در نظرات دانشمندان - Prefetch('opinions', queryset=TransmitterOpinion.objects.select_related('status', 'scholar')), + Prefetch('opinions', queryset=TransmitterOpinion.objects.select_related('status', 'scholar', 'opinion', 'opinion__status')), Prefetch( 'originaltexts', diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index 11e80cd..6687ce8 100644 --- a/apps/hadis/views_admin.py +++ b/apps/hadis/views_admin.py @@ -12,7 +12,7 @@ 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, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, - Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, TransmitterOpinion, TransmitterOriginalText, + Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, ScholarlyOpinion, TransmitterOpinion, TransmitterOriginalText, CorrectionReference, InterpretationReference, OriginalTextReference, ContentRelease, CategoryQuranVerse, @@ -49,6 +49,7 @@ from .serializers.serializers_admin import ( AdminTransmitterReliabilitySerializer, AdminNarratorLayerSerializer, AdminOpinionStatusSerializer, + AdminScholarlyOpinionSerializer, AdminHadisTransmitterSerializer, AdminTransmitterOpinionSerializer, AdminTransmitterOriginalTextSerializer, @@ -345,6 +346,34 @@ class DovodiOpinionStatusViewSet(ModelViewSet): return queryset.order_by("-id") +class DovodiScholarlyOpinionViewSet(ModelViewSet): + serializer_class = AdminScholarlyOpinionSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + 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 AdminTransmitterViewSet(ModelViewSet): permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] @@ -1057,7 +1086,7 @@ class AdminTransmitterOpinionViewSet(ModelViewSet): pagination_class = None def get_queryset(self): - queryset = TransmitterOpinion.objects.all().select_related("status", "scholar") + 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)