244 lines
9.4 KiB
Python
244 lines
9.4 KiB
Python
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
|
|
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')
|
|
.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')
|
|
.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)
|