From af2f3d61b41a6c0910582cd2beed9691db1ffd8a Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Sat, 29 Aug 2026 18:24:00 +0330 Subject: [PATCH] feat(sync): add content version management endpoints and fix author serializer --- apps/hadis/serializers/serializers_admin.py | 63 ++++++++++++--- apps/hadis/urls.py | 4 +- apps/hadis/views_admin.py | 90 ++++++++++++++++++++- 3 files changed, 143 insertions(+), 14 deletions(-) diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index 70c243e..a6c123d 100644 --- a/apps/hadis/serializers/serializers_admin.py +++ b/apps/hadis/serializers/serializers_admin.py @@ -10,7 +10,8 @@ from ..models import ( BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, BookResearcher, BookEditor, Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, TransmitterOpinion, TransmitterOriginalText, CorrectionReference, CorrectionReferenceImage, InterpretationReference, InterpretationReferenceImage, - OriginalTextReference, OriginalTextReferenceImage + OriginalTextReference, OriginalTextReferenceImage, + ContentRelease ) from .category import LocalizedField @@ -1098,6 +1099,11 @@ class AdminBookTypeSerializer(serializers.ModelSerializer): class AdminBookAuthorSerializer(serializers.ModelSerializer): image = AbsoluteImageField(required=False, allow_null=True) remove_image = serializers.BooleanField(write_only=True, required=False, default=False) + book_references = serializers.PrimaryKeyRelatedField( + many=True, + required=False, + queryset=BookReference.objects.all(), + ) class Meta: model = BookAuthor @@ -1125,28 +1131,47 @@ class AdminBookAuthorSerializer(serializers.ModelSerializer): import json mutable_data = safe_copy_data(data) for json_field in ["name", "known_as", "scholarity", "tabaqa", "rutba", "features", "book_references"]: - val = mutable_data.get(json_field) - if isinstance(val, str): - try: - mutable_data[json_field] = json.loads(val) - except ValueError: - pass + 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 int_field in ["birth_year_hijri", "death_year_hijri", "birth_year_miladi", "death_year_miladi", "related_narrator"]: - val = mutable_data.get(int_field) - if val == "" or val == "null" or val == "undefined": - mutable_data[int_field] = None + 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 return super().to_internal_value(mutable_data) def create(self, validated_data): validated_data.pop("remove_image", False) - return super().create(validated_data) + book_references = validated_data.pop("book_references", None) + author = super().create(validated_data) + if book_references: + for book in book_references: + book.author = author + book.save(update_fields=["author"]) + return author def update(self, instance, validated_data): remove_image = validated_data.pop("remove_image", False) + book_references = validated_data.pop("book_references", None) if remove_image and instance.image: instance.image.delete(save=False) instance.image = None - return super().update(instance, validated_data) + author = super().update(instance, validated_data) + if book_references is not None: + current_ids = set(author.book_references.values_list('id', flat=True)) + new_ids = {b.id for b in book_references} + to_remove = current_ids - new_ids + if to_remove: + BookReference.objects.filter(id__in=to_remove, author=author).update(author=None) + to_add = new_ids - current_ids + if to_add: + BookReference.objects.filter(id__in=to_add).update(author=author) + return author class AdminBookAttributeSerializer(serializers.ModelSerializer): @@ -1775,3 +1800,17 @@ class AdminBookEditionSerializer(serializers.ModelSerializer): author_links_dict["editor_author_id"] = ed_author_id self._handle_author_links(edition, author_links_dict) return edition + + +class AdminContentReleaseSerializer(serializers.ModelSerializer): + class Meta: + model = ContentRelease + fields = [ + "id", + "version_name", + "published_at", + "description", + "is_active", + ] + read_only_fields = ["id", "published_at"] + diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index 2291ff4..2c06a48 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -19,7 +19,8 @@ from .views_admin import ( AdminTransmitterViewSet, AdminTransmitterReliabilityViewSet, DovodiBookSubjectAreaViewSet, DovodiBookTypeViewSet, DovodiNarratorLayerViewSet, DovodiTransmitterReliabilityViewSet, DovodiOpinionStatusViewSet, DovodiHadisSectViewSet, DovodiHadisCategoryViewSet, AdminCategoryQuranVerseViewSet, AdminHadisTransmitterViewSet, - AdminTransmitterOpinionViewSet, AdminTransmitterOriginalTextViewSet, AdminBookEditionViewSet, AdminBookVolumeViewSet + AdminTransmitterOpinionViewSet, AdminTransmitterOriginalTextViewSet, AdminBookEditionViewSet, AdminBookVolumeViewSet, + AdminContentReleaseViewSet ) from .views.reference_v2 import BookAuthorListView, BookAuthorDetailView, AuthorReferencesListView, BookAuthorSyncView from django.views.decorators.cache import cache_page @@ -60,6 +61,7 @@ admin_router.register(r'transmitter-opinions', AdminTransmitterOpinionViewSet, b 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') # Hide admin viewsets from swagger for prefix, viewset, basename in admin_router.registry: diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index a36df9c..0d388cb 100644 --- a/apps/hadis/views_admin.py +++ b/apps/hadis/views_admin.py @@ -12,7 +12,8 @@ from utils.pagination import StandardResultsSetPagination, OptionalPagination from .models import ( Hadis, HadisCategory, HadisCollection, HadisInCollection, HadisCorrection, HadisInterpretation, HadisStatus, HadisTag, HadisSect, HadisTransmitter, BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, - Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, TransmitterOpinion, TransmitterOriginalText + Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, TransmitterOpinion, TransmitterOriginalText, + ContentRelease ) from .serializers.serializers_admin import ( AdminHadisCategorySerializer, @@ -46,6 +47,7 @@ from .serializers.serializers_admin import ( AdminTransmitterOriginalTextSerializer, AdminBookEditionSerializer, AdminBookVolumeSerializer, + AdminContentReleaseSerializer, ) @@ -805,3 +807,89 @@ class AdminBookVolumeViewSet(ModelViewSet): 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 + ) + + 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) + }) +