from django.db.models import Count, Q from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.parsers import MultiPartParser from rest_framework.response import Response from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter from rest_framework.permissions import IsAuthenticated from social.hubs.models import Tags from vontor_cz.pagination import CreatedCursorPagination from .models import Post, PostContent, PostVote, PostSave from .permissions import CanDeletePost, IsPostAuthorOnly from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer # --------------------------------------------------------------------------- # Post ViewSet # --------------------------------------------------------------------------- @extend_schema_view( list=extend_schema( tags=["posts"], summary="List posts", description="Returns posts. Filter by `hub` query param to scope to a hub.", ), retrieve=extend_schema( tags=["posts"], summary="Retrieve a post", ), create=extend_schema( tags=["posts"], summary="Create a post", description="Creates a post. The requesting user is set as the author automatically.", ), update=extend_schema( tags=["posts"], summary="Replace a post", description="Full update. Author only.", ), partial_update=extend_schema( tags=["posts"], summary="Update a post", description="Partial update. Author only.", ), destroy=extend_schema( tags=["posts"], summary="Delete a post", description="Soft-deletes the post. Author, superuser, hub owner, or hub moderator with `managing_posts`.", ), ) class PostViewSet(viewsets.ModelViewSet): serializer_class = PostSerializer filterset_fields = ['hub', 'author', 'reply_to'] search_fields = ['content'] ordering_fields = ['created_at'] ordering = ['-created_at'] def get_permissions(self): if self.action == 'destroy': return [CanDeletePost()] if self.action in ('update', 'partial_update'): return [IsPostAuthorOnly()] return [IsAuthenticated()] def get_queryset(self): qs = ( Post.objects .select_related('author', 'hub') .prefetch_related('tags', 'contents', 'votes', 'saves') .annotate(reply_count=Count('replies', distinct=True)) ) hub_id = self.request.query_params.get('hub') if hub_id: qs = qs.filter(hub_id=hub_id) return qs def perform_create(self, serializer): serializer.save(author=self.request.user) # ------------------------------------------------------------------ # Media upload action # ------------------------------------------------------------------ @extend_schema( tags=["posts"], summary="Upload media to a post", description="Attach an image or video file to a post. Only the post author can upload.", request={'multipart/form-data': { 'type': 'object', 'properties': {'file': {'type': 'string', 'format': 'binary'}}, 'required': ['file'], }}, responses={201: PostContentSerializer}, ) @action(detail=True, methods=['post'], url_path='media', parser_classes=[MultiPartParser]) def upload_media(self, request, pk=None): post = self.get_object() if post.author != request.user: raise PermissionDenied('Only the post author can upload media.') file = request.FILES.get('file') if not file: raise ValidationError({'file': 'No file provided.'}) content = PostContent(post=post, file=file) content.save() return Response(PostContentSerializer(content).data, status=status.HTTP_201_CREATED) # ------------------------------------------------------------------ # Tag attachment actions # ------------------------------------------------------------------ @extend_schema( tags=["posts"], summary="Attach a tag to a post", description=( "Attaches an existing hub tag to the post. " "The tag must belong to the same hub as the post. " "Any authenticated hub member can attach tags." ), request=TagAttachSerializer, responses={200: PostSerializer}, ) @action(detail=True, methods=['post'], url_path='tags/attach') def attach_tag(self, request, pk=None): post = self.get_object() ser = TagAttachSerializer(data=request.data) ser.is_valid(raise_exception=True) try: tag = Tags.objects.get(pk=ser.validated_data['tag_id'], hub=post.hub) except Tags.DoesNotExist: raise ValidationError({'tag_id': 'Tag not found or does not belong to this post\'s hub.'}) post.tags.add(tag) return Response(PostSerializer(post, context={'request': request}).data) @extend_schema( tags=["posts"], summary="Detach a tag from a post", description=( "Removes a tag from the post. " "Post author, hub owner, site admin, or moderator with `managing_posts` can detach." ), request=TagAttachSerializer, responses={200: PostSerializer}, ) @action(detail=True, methods=['post'], url_path='tags/detach') def detach_tag(self, request, pk=None): post = self.get_object() ser = TagAttachSerializer(data=request.data) ser.is_valid(raise_exception=True) user = request.user hub = post.hub is_author = post.author == user is_hub_owner = hub and hub.owner == user is_moderator = hub and hub.moderators.filter(user=user, managing_posts=True).exists() if not (is_author or user.is_superuser or is_hub_owner or is_moderator): raise PermissionDenied('Only the post author, hub owner, admin, or moderator with managing_posts can detach tags.') try: tag = Tags.objects.get(pk=ser.validated_data['tag_id'], hub=post.hub) except Tags.DoesNotExist: raise ValidationError({'tag_id': 'Tag not found or does not belong to this post\'s hub.'}) post.tags.remove(tag) return Response(PostSerializer(post, context={'request': request}).data) # ------------------------------------------------------------------ # Feed action (cursor-paginated aggregated feed for the user) # ------------------------------------------------------------------ @extend_schema( tags=["posts"], summary="Get the user's post feed", description=( "Returns a cursor-paginated stream of top-level posts (excluding replies) " "aggregated from the user's joined hubs, public hubs, and hub-less posts. " "Pass `feed_strategy` to switch between ranking algorithms (currently only " "`recent` is implemented; reserved for future custom algorithms)." ), parameters=[ OpenApiParameter(name='feed_strategy', required=False, type=str, description="Algorithm key, default `recent`."), OpenApiParameter(name='cursor', required=False, type=str, description="Opaque pagination cursor."), ], responses={200: PostSerializer(many=True)}, ) @action(detail=False, methods=['get'], url_path='feed') def feed(self, request): user = request.user strategy = request.query_params.get('feed_strategy', 'recent') base_qs = ( Post.objects .select_related('author', 'hub') .prefetch_related('tags', 'contents', 'votes', 'saves') .annotate(reply_count=Count('replies', distinct=True)) .filter(reply_to__isnull=True) ) joined_hub_ids = list(user.hubs.values_list('id', flat=True)) if user.is_authenticated else [] visibility_filter = ( Q(hub__isnull=True) | Q(hub__is_public=True) | Q(hub_id__in=joined_hub_ids) ) qs = base_qs.filter(visibility_filter).distinct() if strategy == 'recent': qs = qs.order_by('-created_at') else: qs = qs.order_by('-created_at') paginator = CreatedCursorPagination() page = paginator.paginate_queryset(qs, request, view=self) ser = PostSerializer(page, many=True, context={'request': request}) return paginator.get_paginated_response(ser.data) # ------------------------------------------------------------------ # Vote action # ------------------------------------------------------------------ @extend_schema( tags=["posts"], summary="Vote on a post", description="Cast or update a vote (`1` = upvote, `-1` = downvote) on a post.", request=PostVoteSerializer, responses={200: PostVoteSerializer}, ) @action(detail=True, methods=['post']) def vote(self, request, pk=None): post = self.get_object() ser = PostVoteSerializer(data={**request.data, 'post': post.pk, 'user': request.user.pk}) ser.is_valid(raise_exception=True) vote_obj, _ = PostVote.objects.update_or_create( post=post, user=request.user, defaults={'vote': ser.validated_data['vote']}, ) return Response(PostVoteSerializer(vote_obj).data) # ------------------------------------------------------------------ # Save / unsave action # ------------------------------------------------------------------ @extend_schema( tags=["posts"], summary="Toggle save on a post", description="Saves the post for the current user, or unsaves it if already saved. Returns `{saved: true/false}`.", responses={200: {'type': 'object', 'properties': {'saved': {'type': 'boolean'}}}}, ) @action(detail=True, methods=['post'], url_path='save') def save_post(self, request, pk=None): post = self.get_object() obj, created = PostSave.objects.get_or_create(post=post, user=request.user) if not created: obj.delete() return Response({'saved': created}) # ------------------------------------------------------------------ # Saved feed # ------------------------------------------------------------------ @extend_schema( tags=["posts"], summary="List posts saved by the current user", responses={200: PostSerializer(many=True)}, ) @action(detail=False, methods=['get'], url_path='saved') def saved(self, request): qs = ( Post.objects .select_related('author', 'hub') .prefetch_related('tags', 'contents', 'votes', 'saves') .annotate(reply_count=Count('replies', distinct=True)) .filter(saves__user=request.user) .order_by('-saves__created_at') ) paginator = CreatedCursorPagination() page = paginator.paginate_queryset(qs, request, view=self) ser = PostSerializer(page, many=True, context={'request': request}) return paginator.get_paginated_response(ser.data)