diff --git a/apps/podcast/serializers_admin.py b/apps/podcast/serializers_admin.py index f10ea46..e0fc3f8 100644 --- a/apps/podcast/serializers_admin.py +++ b/apps/podcast/serializers_admin.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .models import Podcast , PodcastCategory , PodcastCollection +from .models import PlaylistItem, Podcast, PodcastCategory, PodcastCollection, PodcastPlaylist class AbsoluteImageField(serializers.ImageField): @@ -128,3 +128,129 @@ class AdminPodcastDetailSerializer(serializers.ModelSerializer): if collections is not None: podcast.collections.set(collections) return podcast + + +class AdminPodcastPlaylistItemPodcastSerializer(serializers.ModelSerializer): + thumbnail = AbsoluteImageField(required=False, allow_null=True) + + class Meta: + model = Podcast + fields = ["id", "title", "slug", "thumbnail", "audio_time", "status"] + + +class AdminPodcastPlaylistItemSerializer(serializers.ModelSerializer): + podcast_detail = AdminPodcastPlaylistItemPodcastSerializer(source="podcast", read_only=True) + + class Meta: + model = PlaylistItem + fields = ["id", "playlist", "podcast", "priority", "created_at", "updated_at", "podcast_detail"] + read_only_fields = ["id", "created_at", "updated_at", "podcast_detail"] + + def validate(self, attrs): + playlist = attrs.get("playlist", getattr(self.instance, "playlist", None)) + podcast = attrs.get("podcast", getattr(self.instance, "podcast", None)) + if playlist and podcast: + query = PlaylistItem.objects.filter(podcast=podcast).exclude(playlist=playlist) + if self.instance: + query = query.exclude(pk=self.instance.pk) + existing = query.select_related("playlist").first() + if existing: + raise serializers.ValidationError({ + "podcast": f'This podcast is already used in playlist "{existing.playlist.title}".', + }) + return attrs + + +class AdminPodcastPlaylistListSerializer(serializers.ModelSerializer): + thumbnail = AbsoluteImageField(required=False, allow_null=True) + categories_detail = AdminPodcastCategorySerializer(source="categories", many=True, read_only=True) + collections_detail = AdminPodcastCollectionSerializer(source="collections", many=True, read_only=True) + items_count = serializers.SerializerMethodField() + + class Meta: + model = PodcastPlaylist + fields = [ + "id", + "title", + "slug", + "thumbnail", + "slogan", + "status", + "order", + "view_count", + "total_time", + "created_at", + "updated_at", + "categories_detail", + "collections_detail", + "items_count", + ] + read_only_fields = ["id", "view_count", "total_time", "created_at", "updated_at", "items_count"] + + def get_items_count(self, obj): + return obj.playlist_items.count() + + +class AdminPodcastPlaylistDetailSerializer(serializers.ModelSerializer): + thumbnail = AbsoluteImageField(required=False, allow_null=True) + categories = serializers.PrimaryKeyRelatedField(queryset=PodcastCategory.objects.all(), many=True, required=False) + collections = serializers.PrimaryKeyRelatedField(queryset=PodcastCollection.objects.all(), many=True, required=False) + categories_detail = AdminPodcastCategorySerializer(source="categories", many=True, read_only=True) + collections_detail = AdminPodcastCollectionSerializer(source="collections", many=True, read_only=True) + playlist_items_detail = AdminPodcastPlaylistItemSerializer(source="playlist_items", many=True, read_only=True) + remove_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False) + + class Meta: + model = PodcastPlaylist + fields = [ + "id", + "title", + "slug", + "slogan", + "description", + "thumbnail", + "order", + "status", + "view_count", + "total_time", + "created_at", + "updated_at", + "categories", + "collections", + "categories_detail", + "collections_detail", + "playlist_items_detail", + "remove_thumbnail", + ] + read_only_fields = ["id", "view_count", "total_time", "created_at", "updated_at", "playlist_items_detail"] + + def create(self, validated_data): + validated_data.pop("remove_thumbnail", False) + categories = validated_data.pop("categories", []) + collections = validated_data.pop("collections", []) + playlist = super().create(validated_data) + if categories: + playlist.categories.set(categories) + if collections: + playlist.collections.set(collections) + playlist.total_time = playlist.calculate_total_time() + playlist.save(update_fields=["total_time"]) + return playlist + + def update(self, instance, validated_data): + remove_thumbnail = validated_data.pop("remove_thumbnail", False) + categories = validated_data.pop("categories", None) + collections = validated_data.pop("collections", None) + + if remove_thumbnail and instance.thumbnail: + instance.thumbnail.delete(save=False) + instance.thumbnail = None + + playlist = super().update(instance, validated_data) + if categories is not None: + playlist.categories.set(categories) + if collections is not None: + playlist.collections.set(collections) + playlist.total_time = playlist.calculate_total_time() + playlist.save(update_fields=["total_time"]) + return playlist diff --git a/apps/podcast/urls.py b/apps/podcast/urls.py index 358ae72..864c0c1 100755 --- a/apps/podcast/urls.py +++ b/apps/podcast/urls.py @@ -5,6 +5,7 @@ from .views_admin import * admin_router = SimpleRouter() admin_router.register(r'podcasts', AdminPodcastViewSet, basename='admin-podcast') +admin_router.register(r'playlists', AdminPodcastPlaylistViewSet, basename='admin-podcast-playlist') admin_router.register(r'categories', AdminPodcastCategoryViewSet, basename='admin-podcast-category') admin_router.register(r'collections', AdminPodcastCollectionViewSet, basename='admin-podcast-collection') @@ -24,4 +25,4 @@ urlpatterns = [ path('user-playlist/list/', UserPlaylistListAPIView.as_view(), name='user-playlist-list'), -] \ No newline at end of file +] diff --git a/apps/podcast/views_admin.py b/apps/podcast/views_admin.py index 7955435..3f9be84 100644 --- a/apps/podcast/views_admin.py +++ b/apps/podcast/views_admin.py @@ -1,18 +1,25 @@ +from django.db import transaction from django.db.models import Q +from rest_framework import status 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 -from .models import Podcast, PodcastCollection, PodcastCategory +from .models import PlaylistItem, Podcast, PodcastCollection, PodcastCategory, PodcastPlaylist from .serializers_admin import ( AdminPodcastCategorySerializer, AdminPodcastCollectionSerializer, AdminPodcastDetailSerializer, AdminPodcastListSerializer, + AdminPodcastPlaylistDetailSerializer, + AdminPodcastPlaylistItemSerializer, + AdminPodcastPlaylistListSerializer, ) @@ -71,3 +78,113 @@ class AdminPodcastViewSet(ModelViewSet): queryset = queryset.filter(collections__id=collection_id) return queryset.distinct().order_by("-created_at") + + +class AdminPodcastPlaylistViewSet(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 AdminPodcastPlaylistListSerializer + return AdminPodcastPlaylistDetailSerializer + + def get_queryset(self): + queryset = PodcastPlaylist.objects.all().prefetch_related("categories", "collections", "playlist_items__podcast") + + 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(slogan__icontains=search_query) + | Q(description__icontains=search_query) + ) + + status_filter = self.request.query_params.get("status") + if status_filter in {"true", "false"}: + queryset = queryset.filter(status=status_filter == "true") + + category_id = self.request.query_params.get("category") + if category_id and category_id != "all": + queryset = queryset.filter(categories__id=category_id) + + collection_id = self.request.query_params.get("collection") + if collection_id and collection_id != "all": + queryset = queryset.filter(collections__id=collection_id) + + return queryset.distinct().order_by("order", "-created_at") + + @action(detail=True, methods=["get", "post"], url_path="items") + def items(self, request, pk=None): + playlist = self.get_object() + + if request.method.lower() == "get": + items = playlist.playlist_items.select_related("podcast").order_by("priority", "id") + serializer = AdminPodcastPlaylistItemSerializer(items, many=True, context={"request": request}) + return Response(serializer.data) + + serializer = AdminPodcastPlaylistItemSerializer( + data={ + **request.data, + "playlist": playlist.id, + "priority": request.data.get("priority", playlist.playlist_items.count() + 1), + }, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + playlist.total_time = playlist.calculate_total_time() + playlist.save(update_fields=["total_time"]) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=["patch", "delete"], url_path=r"items/(?P\d+)") + def item_detail(self, request, pk=None, item_id=None): + playlist = self.get_object() + item = playlist.playlist_items.select_related("podcast").get(pk=item_id) + + if request.method.lower() == "delete": + item.delete() + playlist.total_time = playlist.calculate_total_time() + playlist.save(update_fields=["total_time"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + serializer = AdminPodcastPlaylistItemSerializer( + item, + data={**request.data, "playlist": playlist.id}, + partial=True, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + playlist.total_time = playlist.calculate_total_time() + playlist.save(update_fields=["total_time"]) + return Response(serializer.data) + + @action(detail=True, methods=["post"], url_path="items/reorder") + def reorder_items(self, request, pk=None): + playlist = self.get_object() + item_ids = request.data.get("item_ids", []) + if not isinstance(item_ids, list): + return Response({"detail": "item_ids must be a list."}, status=status.HTTP_400_BAD_REQUEST) + + items = list(playlist.playlist_items.filter(id__in=item_ids)) + if len(items) != len(item_ids): + return Response({"detail": "Some items were not found."}, status=status.HTTP_400_BAD_REQUEST) + + item_map = {item.id: item for item in items} + with transaction.atomic(): + for index, item_id in enumerate(item_ids, start=1): + item = item_map[item_id] + if item.priority != index: + item.priority = index + item.save(update_fields=["priority"]) + + serializer = AdminPodcastPlaylistItemSerializer( + playlist.playlist_items.select_related("podcast").order_by("priority", "id"), + many=True, + context={"request": request}, + ) + return Response(serializer.data) diff --git a/apps/video/serializers_admin.py b/apps/video/serializers_admin.py index ec912d9..53f6967 100644 --- a/apps/video/serializers_admin.py +++ b/apps/video/serializers_admin.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .models import Video +from .models import PlaylistItem, Video, VideoCategory, VideoCollection, VideoPlaylist class AbsoluteImageField(serializers.ImageField): @@ -25,6 +25,18 @@ class AbsoluteFileField(serializers.FileField): return url +class AdminVideoCategorySerializer(serializers.ModelSerializer): + class Meta: + model = VideoCategory + fields = ["id", "title", "slug", "status", "order"] + + +class AdminVideoCollectionSerializer(serializers.ModelSerializer): + class Meta: + model = VideoCollection + fields = ["id", "title", "slug", "summary", "display_position", "status", "order", "pin_top"] + + class AdminVideoListSerializer(serializers.ModelSerializer): thumbnail = AbsoluteImageField(required=False, allow_null=True) video_file = AbsoluteFileField(required=False, allow_null=True) @@ -117,3 +129,129 @@ class AdminVideoDetailSerializer(serializers.ModelSerializer): instance.video_file = None return super().update(instance, validated_data) + + +class AdminVideoPlaylistItemVideoSerializer(serializers.ModelSerializer): + thumbnail = AbsoluteImageField(required=False, allow_null=True) + + class Meta: + model = Video + fields = ["id", "title", "slug", "thumbnail", "video_type", "video_time", "status"] + + +class AdminVideoPlaylistItemSerializer(serializers.ModelSerializer): + video_detail = AdminVideoPlaylistItemVideoSerializer(source="video", read_only=True) + + class Meta: + model = PlaylistItem + fields = ["id", "playlist", "video", "priority", "created_at", "updated_at", "video_detail"] + read_only_fields = ["id", "created_at", "updated_at", "video_detail"] + + def validate(self, attrs): + playlist = attrs.get("playlist", getattr(self.instance, "playlist", None)) + video = attrs.get("video", getattr(self.instance, "video", None)) + if playlist and video: + query = PlaylistItem.objects.filter(video=video).exclude(playlist=playlist) + if self.instance: + query = query.exclude(pk=self.instance.pk) + existing = query.select_related("playlist").first() + if existing: + raise serializers.ValidationError({ + "video": f'This video is already used in playlist "{existing.playlist.title}".', + }) + return attrs + + +class AdminVideoPlaylistListSerializer(serializers.ModelSerializer): + thumbnail = AbsoluteImageField(required=False, allow_null=True) + categories_detail = AdminVideoCategorySerializer(source="categories", many=True, read_only=True) + collections_detail = AdminVideoCollectionSerializer(source="collections", many=True, read_only=True) + items_count = serializers.SerializerMethodField() + + class Meta: + model = VideoPlaylist + fields = [ + "id", + "title", + "slug", + "thumbnail", + "slogan", + "status", + "order", + "view_count", + "total_time", + "created_at", + "updated_at", + "categories_detail", + "collections_detail", + "items_count", + ] + read_only_fields = ["id", "view_count", "total_time", "created_at", "updated_at", "items_count"] + + def get_items_count(self, obj): + return obj.playlist_items.count() + + +class AdminVideoPlaylistDetailSerializer(serializers.ModelSerializer): + thumbnail = AbsoluteImageField(required=False, allow_null=True) + categories = serializers.PrimaryKeyRelatedField(queryset=VideoCategory.objects.all(), many=True, required=False) + collections = serializers.PrimaryKeyRelatedField(queryset=VideoCollection.objects.all(), many=True, required=False) + categories_detail = AdminVideoCategorySerializer(source="categories", many=True, read_only=True) + collections_detail = AdminVideoCollectionSerializer(source="collections", many=True, read_only=True) + playlist_items_detail = AdminVideoPlaylistItemSerializer(source="playlist_items", many=True, read_only=True) + remove_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False) + + class Meta: + model = VideoPlaylist + fields = [ + "id", + "title", + "slug", + "slogan", + "description", + "thumbnail", + "order", + "status", + "view_count", + "total_time", + "created_at", + "updated_at", + "categories", + "collections", + "categories_detail", + "collections_detail", + "playlist_items_detail", + "remove_thumbnail", + ] + read_only_fields = ["id", "view_count", "total_time", "created_at", "updated_at", "playlist_items_detail"] + + def create(self, validated_data): + validated_data.pop("remove_thumbnail", False) + categories = validated_data.pop("categories", []) + collections = validated_data.pop("collections", []) + playlist = super().create(validated_data) + if categories: + playlist.categories.set(categories) + if collections: + playlist.collections.set(collections) + playlist.total_time = playlist.calculate_total_time() + playlist.save(update_fields=["total_time"]) + return playlist + + def update(self, instance, validated_data): + remove_thumbnail = validated_data.pop("remove_thumbnail", False) + categories = validated_data.pop("categories", None) + collections = validated_data.pop("collections", None) + + if remove_thumbnail and instance.thumbnail: + instance.thumbnail.delete(save=False) + instance.thumbnail = None + + playlist = super().update(instance, validated_data) + if categories is not None: + playlist.categories.set(categories) + if collections is not None: + playlist.collections.set(collections) + playlist.total_time = playlist.calculate_total_time() + playlist.save(update_fields=["total_time"]) + return playlist diff --git a/apps/video/urls.py b/apps/video/urls.py index 87680ab..48dca16 100644 --- a/apps/video/urls.py +++ b/apps/video/urls.py @@ -7,6 +7,9 @@ app_name = 'video' admin_router = SimpleRouter() admin_router.register(r'videos', AdminVideoViewSet, basename='admin-video') +admin_router.register(r'playlists', AdminVideoPlaylistViewSet, basename='admin-video-playlist') +admin_router.register(r'categories', AdminVideoCategoryViewSet, basename='admin-video-category') +admin_router.register(r'collections', AdminVideoCollectionViewSet, basename='admin-video-collection') urlpatterns = [ path('admin/', include(admin_router.urls)), diff --git a/apps/video/views_admin.py b/apps/video/views_admin.py index b65493c..c1c7ab7 100644 --- a/apps/video/views_admin.py +++ b/apps/video/views_admin.py @@ -1,14 +1,46 @@ +from django.db import transaction from django.db.models import Q +from rest_framework import status 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.viewsets import ModelViewSet +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from apps.account.permissions import IsSuperAdmin from utils.pagination import StandardResultsSetPagination -from .models import Video -from .serializers_admin import AdminVideoDetailSerializer, AdminVideoListSerializer +from .models import PlaylistItem, Video, VideoCategory, VideoCollection, VideoPlaylist +from .serializers_admin import ( + AdminVideoCategorySerializer, + AdminVideoCollectionSerializer, + AdminVideoDetailSerializer, + AdminVideoListSerializer, + AdminVideoPlaylistDetailSerializer, + AdminVideoPlaylistItemSerializer, + AdminVideoPlaylistListSerializer, +) + + +class AdminVideoCategoryViewSet(ReadOnlyModelViewSet): + serializer_class = AdminVideoCategorySerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + + def get_queryset(self): + return VideoCategory.objects.all().order_by("title") + + +class AdminVideoCollectionViewSet(ReadOnlyModelViewSet): + serializer_class = AdminVideoCollectionSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = None + + def get_queryset(self): + return VideoCollection.objects.all().order_by("display_position", "order", "title") class AdminVideoViewSet(ModelViewSet): @@ -43,3 +75,113 @@ class AdminVideoViewSet(ModelViewSet): queryset = queryset.filter(video_type=video_type) return queryset.order_by("-created_at") + + +class AdminVideoPlaylistViewSet(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 AdminVideoPlaylistListSerializer + return AdminVideoPlaylistDetailSerializer + + def get_queryset(self): + queryset = VideoPlaylist.objects.all().prefetch_related("categories", "collections", "playlist_items__video") + + 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(slogan__icontains=search_query) + | Q(description__icontains=search_query) + ) + + status_filter = self.request.query_params.get("status") + if status_filter in {"true", "false"}: + queryset = queryset.filter(status=status_filter == "true") + + category_id = self.request.query_params.get("category") + if category_id and category_id != "all": + queryset = queryset.filter(categories__id=category_id) + + collection_id = self.request.query_params.get("collection") + if collection_id and collection_id != "all": + queryset = queryset.filter(collections__id=collection_id) + + return queryset.distinct().order_by("order", "-created_at") + + @action(detail=True, methods=["get", "post"], url_path="items") + def items(self, request, pk=None): + playlist = self.get_object() + + if request.method.lower() == "get": + items = playlist.playlist_items.select_related("video").order_by("priority", "id") + serializer = AdminVideoPlaylistItemSerializer(items, many=True, context={"request": request}) + return Response(serializer.data) + + serializer = AdminVideoPlaylistItemSerializer( + data={ + **request.data, + "playlist": playlist.id, + "priority": request.data.get("priority", playlist.playlist_items.count() + 1), + }, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + playlist.total_time = playlist.calculate_total_time() + playlist.save(update_fields=["total_time"]) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=["patch", "delete"], url_path=r"items/(?P\d+)") + def item_detail(self, request, pk=None, item_id=None): + playlist = self.get_object() + item = playlist.playlist_items.select_related("video").get(pk=item_id) + + if request.method.lower() == "delete": + item.delete() + playlist.total_time = playlist.calculate_total_time() + playlist.save(update_fields=["total_time"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + serializer = AdminVideoPlaylistItemSerializer( + item, + data={**request.data, "playlist": playlist.id}, + partial=True, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + playlist.total_time = playlist.calculate_total_time() + playlist.save(update_fields=["total_time"]) + return Response(serializer.data) + + @action(detail=True, methods=["post"], url_path="items/reorder") + def reorder_items(self, request, pk=None): + playlist = self.get_object() + item_ids = request.data.get("item_ids", []) + if not isinstance(item_ids, list): + return Response({"detail": "item_ids must be a list."}, status=status.HTTP_400_BAD_REQUEST) + + items = list(playlist.playlist_items.filter(id__in=item_ids)) + if len(items) != len(item_ids): + return Response({"detail": "Some items were not found."}, status=status.HTTP_400_BAD_REQUEST) + + item_map = {item.id: item for item in items} + with transaction.atomic(): + for index, item_id in enumerate(item_ids, start=1): + item = item_map[item_id] + if item.priority != index: + item.priority = index + item.save(update_fields=["priority"]) + + serializer = AdminVideoPlaylistItemSerializer( + playlist.playlist_items.select_related("video").order_by("priority", "id"), + many=True, + context={"request": request}, + ) + return Response(serializer.data)