From 3c692f128ff79a94108f9adc54d4fe6cf249f67f Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Sun, 30 Aug 2026 23:14:23 +0330 Subject: [PATCH] feat(hadis): support source image upload, newest-first category ordering, and sync layer description --- apps/hadis/models/reference.py | 32 ++-- apps/hadis/serializers/hadis.py | 1 + apps/hadis/serializers/serializers_admin.py | 155 +++++++++++++++++++- apps/hadis/views_admin.py | 10 +- 4 files changed, 181 insertions(+), 17 deletions(-) diff --git a/apps/hadis/models/reference.py b/apps/hadis/models/reference.py index c42cbb0..a9c6960 100644 --- a/apps/hadis/models/reference.py +++ b/apps/hadis/models/reference.py @@ -9,9 +9,16 @@ class BookSubjectArea(models.Model): title = models.JSONField(default = list , verbose_name=_('Title')) 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 = _('Subject Area') + verbose_name_plural = _('Subject Areas') def __str__(self): - return self.title[0]['text'] if self.title else str(self.id) + 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): """ @@ -23,11 +30,11 @@ class BookSubjectArea(models.Model): for tr in self.title: if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') + 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', '') + return tr.get('text', '') or tr.get('title', '') return None @@ -44,10 +51,9 @@ class BookTag(models.Model): verbose_name_plural = _('Book Tags') def __str__(self): - try: - return self.title[0]['text'] - except: - return f"Tag {self.id}" + 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 f"Tag {self.id}" + return f"Tag {self.id}" class BookType(models.Model): class Sect(models.TextChoices): @@ -65,7 +71,9 @@ class BookType(models.Model): updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) def __str__(self): - return self.title[0]['text'] if self.title else str(self.id) + 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): """ @@ -77,11 +85,11 @@ class BookType(models.Model): for tr in self.title: if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') + 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', '') + return tr.get('text', '') or tr.get('title', '') return None class BookReference(LowercaseSlugMixin, models.Model): @@ -145,7 +153,9 @@ class BookReference(LowercaseSlugMixin, models.Model): ordering = ('-created_at',) def __str__(self): - return self.title[0]['text'] if self.title else "Untitled Reference" + 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 f"Reference {self.id}" + return f"Reference {self.id}" def _get_json_field(self, field_name: str, lang: Optional[str]=None , fallback: str = "en"): """ diff --git a/apps/hadis/serializers/hadis.py b/apps/hadis/serializers/hadis.py index e92b412..a4643e4 100644 --- a/apps/hadis/serializers/hadis.py +++ b/apps/hadis/serializers/hadis.py @@ -198,6 +198,7 @@ class HadisSyncSerializer(serializers.ModelSerializer): 'reliability': reliability_block, 'layer_level': layer.number if layer else None, 'layer_name': get_localized_text(layer.name, request) if layer else None, + 'layer_description': get_localized_text(layer.description, request) if layer else None, 'is_gap': transmitter_rel.is_gap, 'birth_year_hijri': t.birth_year_hijri, 'death_year_hijri': t.death_year_hijri, diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index a6c123d..749bb8d 100644 --- a/apps/hadis/serializers/serializers_admin.py +++ b/apps/hadis/serializers/serializers_admin.py @@ -20,7 +20,9 @@ def safe_copy_data(data): if hasattr(data, "lists"): new_data = {} for key, values in data.lists(): - if key in ["delete_image_ids", "uploaded_images"]: + if key in ["subject_area", "authors", "book_references", "delete_image_ids", "uploaded_images"] or key.endswith("[]"): + new_data[key] = values + elif len(values) > 1: new_data[key] = values else: new_data[key] = values[0] if values else None @@ -1220,6 +1222,8 @@ class AdminBookReferenceListSerializer(serializers.ModelSerializer): volume = serializers.SerializerMethodField() publisher = serializers.SerializerMethodField() year_of_publication = serializers.SerializerMethodField() + thumbnail = serializers.SerializerMethodField() + image = serializers.SerializerMethodField() class Meta: model = BookReference @@ -1236,9 +1240,29 @@ class AdminBookReferenceListSerializer(serializers.ModelSerializer): "publisher", "year_of_publication", "rate", + "thumbnail", + "image", "created_at", ] + def get_thumbnail(self, obj): + first_img = obj.images.filter(book_volume__isnull=True).order_by("order", "id").first() + if first_img and first_img.image: + request = self.context.get("request") + if request: + return absolute_https_url(first_img.image.url, request) + return absolute_https_url(first_img.image.url) + first_vol = obj.volumes.filter(image__isnull=False).exclude(image="").first() + if first_vol and first_vol.image: + request = self.context.get("request") + if request: + return absolute_https_url(first_vol.image.url, request) + return absolute_https_url(first_vol.image.url) + return None + + def get_image(self, obj): + return self.get_thumbnail(obj) + def get_volume(self, obj): vols = list(obj.volumes.all()) return vols[0].title if vols else "" @@ -1289,6 +1313,29 @@ class AdminBookReferenceDetailSerializer(serializers.ModelSerializer): country_of_publication = serializers.SerializerMethodField() edition_number = serializers.SerializerMethodField() + thumbnail = serializers.SerializerMethodField() + image = serializers.SerializerMethodField() + remove_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False) + remove_image = serializers.BooleanField(write_only=True, required=False, default=False) + + def get_thumbnail(self, obj): + first_img = obj.images.filter(book_volume__isnull=True).order_by("order", "id").first() + if first_img and first_img.image: + request = self.context.get("request") + if request: + return absolute_https_url(first_img.image.url, request) + return absolute_https_url(first_img.image.url) + first_vol = obj.volumes.filter(image__isnull=False).exclude(image="").first() + if first_vol and first_vol.image: + request = self.context.get("request") + if request: + return absolute_https_url(first_vol.image.url, request) + return absolute_https_url(first_vol.image.url) + return None + + def get_image(self, obj): + return self.get_thumbnail(obj) + def get_source_url(self, obj): ed = obj.editions.first() return ed.source_url if ed else "" @@ -1355,27 +1402,133 @@ class AdminBookReferenceDetailSerializer(serializers.ModelSerializer): "rate", "author", "author_detail", + "thumbnail", + "image", + "remove_thumbnail", + "remove_image", "created_at", "updated_at", "share_link", ] read_only_fields = ["id", "slug", "created_at", "updated_at", "share_link"] + def to_internal_value(self, data): + import json + mutable_data = safe_copy_data(data) + for json_field in ["title", "description", "language", "publisher", "city_of_publication", "country_of_publication"]: + if json_field in mutable_data: + val = mutable_data.get(json_field) + if isinstance(val, str): + try: + mutable_data[json_field] = json.loads(val) + except ValueError: + pass + + for list_field in ["subject_area", "authors"]: + if list_field in mutable_data: + val = mutable_data.get(list_field) + if isinstance(val, str): + try: + parsed = json.loads(val) + mutable_data[list_field] = parsed if isinstance(parsed, list) else [parsed] + except ValueError: + if val.isdigit(): + mutable_data[list_field] = [int(val)] + elif val.strip(): + mutable_data[list_field] = [int(v.strip()) for v in val.split(",") if v.strip().isdigit()] + else: + mutable_data[list_field] = [] + elif isinstance(val, int): + mutable_data[list_field] = [val] + elif isinstance(val, list): + cleaned_list = [] + for item in val: + if isinstance(item, str): + try: + item_parsed = json.loads(item) + if isinstance(item_parsed, list): + cleaned_list.extend(item_parsed) + else: + cleaned_list.append(item_parsed) + except ValueError: + cleaned_list.append(int(item) if item.isdigit() else item) + else: + cleaned_list.append(item) + mutable_data[list_field] = cleaned_list + + for int_field in ["number_page", "order", "type", "tag", "author"]: + if int_field in mutable_data: + val = mutable_data.get(int_field) + if val == "" or val == "null" or val == "undefined": + mutable_data[int_field] = None + + if "rate" in mutable_data: + val = mutable_data.get("rate") + if val == "" or val == "null" or val == "undefined": + mutable_data["rate"] = None + + if "has_editions" in mutable_data: + val = mutable_data.get("has_editions") + if isinstance(val, str): + mutable_data["has_editions"] = val.lower() in ["true", "1"] + + return super().to_internal_value(mutable_data) + def create(self, validated_data): subject_area = validated_data.pop("subject_area", []) + validated_data.pop("remove_thumbnail", False) + validated_data.pop("remove_image", False) + + request = self.context.get("request") + thumbnail = None + if request and hasattr(request, "FILES"): + thumbnail = request.FILES.get("thumbnail") or request.FILES.get("image") + book = super().create(validated_data) if subject_area: book.subject_area.set(subject_area) + + if thumbnail: + BookReferenceImage.objects.create( + book_reference=book, + book_volume=None, + image=thumbnail, + order=0, + ) return book def update(self, instance, validated_data): subject_area = validated_data.pop("subject_area", None) + remove_thumbnail = validated_data.pop("remove_thumbnail", False) or validated_data.pop("remove_image", False) + + request = self.context.get("request") + thumbnail = None + if request and hasattr(request, "FILES"): + thumbnail = request.FILES.get("thumbnail") or request.FILES.get("image") + book = super().update(instance, validated_data) if subject_area is not None: book.subject_area.set(subject_area) + + if remove_thumbnail: + instance.images.filter(book_volume__isnull=True, order=0).delete() + + if thumbnail: + main_img = instance.images.filter(book_volume__isnull=True, order=0).first() + if main_img: + main_img.image = thumbnail + main_img.save() + else: + BookReferenceImage.objects.create( + book_reference=instance, + book_volume=None, + image=thumbnail, + order=0, + ) return book + # Category Quran Verse Admin Serializers from ..models.category import CategoryQuranVerse, QuranSecondaryTranslation diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index 0d388cb..b5639ab 100644 --- a/apps/hadis/views_admin.py +++ b/apps/hadis/views_admin.py @@ -475,7 +475,7 @@ class AdminBookSubjectAreaViewSet(ReadOnlyModelViewSet): pagination_class = None def get_queryset(self): - return BookSubjectArea.objects.all().order_by("id") + return BookSubjectArea.objects.all().order_by("-created_at", "-id") class AdminBookTypeViewSet(ReadOnlyModelViewSet): @@ -485,7 +485,7 @@ class AdminBookTypeViewSet(ReadOnlyModelViewSet): pagination_class = None def get_queryset(self): - return BookType.objects.all().order_by("id") + return BookType.objects.all().order_by("-created_at", "-id") class DovodiBookSubjectAreaViewSet(ModelViewSet): @@ -500,7 +500,7 @@ class DovodiBookSubjectAreaViewSet(ModelViewSet): search_query = self.request.query_params.get("search") if search_query: queryset = queryset.filter(title__icontains=search_query) - return queryset.order_by("id") + return queryset.order_by("-created_at", "-id") class DovodiBookTypeViewSet(ModelViewSet): @@ -515,7 +515,7 @@ class DovodiBookTypeViewSet(ModelViewSet): search_query = self.request.query_params.get("search") if search_query: queryset = queryset.filter(title__icontains=search_query) - return queryset.order_by("id") + return queryset.order_by("-created_at", "-id") class AdminBookAuthorViewSet(ModelViewSet): @@ -530,7 +530,7 @@ class AdminBookAuthorViewSet(ModelViewSet): search_query = self.request.query_params.get("search") if search_query: queryset = queryset.filter(name__icontains=search_query) - return queryset.order_by("id") + return queryset.order_by("-created_at", "-id") @action(detail=True, methods=["post"], url_path="assign-references") def assign_references(self, request, pk=None):