You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
758 lines
32 KiB
758 lines
32 KiB
import logging
|
|
from django.db.models import Q, F
|
|
from django.shortcuts import get_object_or_404
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
from rest_framework import status, serializers
|
|
from rest_framework.generics import GenericAPIView
|
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
|
from rest_framework.permissions import AllowAny, IsAuthenticated, IsAdminUser
|
|
from rest_framework.response import Response
|
|
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponse, inline_serializer
|
|
|
|
from apps.cms.models.post import (
|
|
PostCategory,
|
|
PostTag,
|
|
Post,
|
|
PostRevision,
|
|
PostComment,
|
|
PostLike,
|
|
MediaAsset,
|
|
)
|
|
from apps.cms.permissions import (
|
|
IsAuthorOrEditorOrReadOnly,
|
|
CanPublishPost,
|
|
can_publish_or_moderate_post,
|
|
)
|
|
from apps.cms.serializers import (
|
|
PostCategorySerializer,
|
|
PostTagSerializer,
|
|
PostRevisionSerializer,
|
|
PostRevisionCreateSerializer,
|
|
PostCommentSerializer,
|
|
PostCommentCreateSerializer,
|
|
PostListSerializer,
|
|
PostDetailSerializer,
|
|
PostCreateUpdateSerializer,
|
|
PostLikeResponseSerializer,
|
|
MediaAssetSerializer,
|
|
)
|
|
from utils.pagination import StandardResultsSetPagination
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_post_by_id_or_slug(lookup_val, user=None):
|
|
qs = Post.objects.all().select_related('author', 'category', 'institution')
|
|
if not user or not user.is_authenticated:
|
|
qs = qs.filter(status=Post.Status.PUBLISHED)
|
|
elif not (user.is_superuser or getattr(user, 'is_super_admin', False)):
|
|
accessible_inst_ids = list(user.institution_memberships.values_list('institution_id', flat=True))
|
|
region_ids = list(user.managed_regions.values_list('id', flat=True)) if hasattr(user, 'managed_regions') else []
|
|
|
|
qs = qs.filter(
|
|
Q(status=Post.Status.PUBLISHED) |
|
|
Q(author=user) |
|
|
Q(institution_id__in=accessible_inst_ids) |
|
|
Q(institution__region_id__in=region_ids)
|
|
).distinct()
|
|
|
|
if str(lookup_val).isdigit():
|
|
return get_object_or_404(qs, id=int(lookup_val))
|
|
return get_object_or_404(qs, slug=lookup_val)
|
|
|
|
|
|
class PostListCreateView(GenericAPIView):
|
|
serializer_class = PostListSerializer
|
|
pagination_class = StandardResultsSetPagination
|
|
|
|
def get_permissions(self):
|
|
if self.request.method == 'POST':
|
|
return [IsAuthenticated(), CanPublishPost()]
|
|
return [AllowAny()]
|
|
|
|
def get_queryset(self):
|
|
if getattr(self, 'swagger_fake_view', False):
|
|
return Post.objects.none()
|
|
|
|
user = self.request.user
|
|
qs = Post.objects.all().select_related('author', 'category', 'institution')
|
|
|
|
status_param = self.request.query_params.get('status')
|
|
my_posts = self.request.query_params.get('my_posts')
|
|
|
|
is_super = user.is_authenticated and (user.is_superuser or getattr(user, 'is_super_admin', False))
|
|
|
|
if is_super:
|
|
if status_param:
|
|
qs = qs.filter(status=status_param)
|
|
elif user.is_authenticated and my_posts:
|
|
accessible_inst_ids = list(user.institution_memberships.filter(
|
|
role__in=['admin', 'editor']
|
|
).values_list('institution_id', flat=True))
|
|
qs = qs.filter(
|
|
Q(author=user) |
|
|
Q(author_type=Post.AuthorType.INSTITUTION, institution_id__in=accessible_inst_ids)
|
|
).distinct()
|
|
if status_param:
|
|
qs = qs.filter(status=status_param)
|
|
elif user.is_authenticated and user.has_perm_flag('can_publish_posts_directly') and status_param:
|
|
accessible_inst_ids = list(user.institution_memberships.values_list('institution_id', flat=True))
|
|
region_ids = list(user.managed_regions.values_list('id', flat=True)) if hasattr(user, 'managed_regions') else []
|
|
|
|
qs = qs.filter(status=status_param).filter(
|
|
Q(author=user) |
|
|
Q(institution_id__in=accessible_inst_ids) |
|
|
Q(institution__region_id__in=region_ids)
|
|
).distinct()
|
|
else:
|
|
qs = qs.filter(status=Post.Status.PUBLISHED)
|
|
|
|
# Filters
|
|
post_type = self.request.query_params.get('post_type')
|
|
if post_type:
|
|
qs = qs.filter(post_type=post_type)
|
|
|
|
author_type = self.request.query_params.get('author_type')
|
|
if author_type:
|
|
qs = qs.filter(author_type=author_type)
|
|
|
|
language = self.request.query_params.get('language')
|
|
if language:
|
|
qs = qs.filter(language=language)
|
|
|
|
category = self.request.query_params.get('category')
|
|
if category:
|
|
if str(category).isdigit():
|
|
qs = qs.filter(category_id=int(category))
|
|
else:
|
|
qs = qs.filter(category__slug=category)
|
|
|
|
tag = self.request.query_params.get('tag')
|
|
if tag:
|
|
qs = qs.filter(tags__icontains=tag)
|
|
|
|
institution_id = self.request.query_params.get('institution_id')
|
|
if institution_id:
|
|
qs = qs.filter(institution_id=institution_id)
|
|
|
|
author_id = self.request.query_params.get('author_id')
|
|
if author_id:
|
|
qs = qs.filter(author_id=author_id)
|
|
|
|
is_featured = self.request.query_params.get('is_featured')
|
|
if is_featured is not None:
|
|
qs = qs.filter(is_featured=is_featured.lower() in ['true', '1'])
|
|
|
|
search = self.request.query_params.get('search')
|
|
if search:
|
|
qs = qs.filter(
|
|
Q(title__icontains=search) |
|
|
Q(excerpt__icontains=search) |
|
|
Q(content__icontains=search)
|
|
)
|
|
|
|
return qs.order_by('-is_featured', '-publish_date')
|
|
|
|
@extend_schema(
|
|
summary="List multilingual posts & articles",
|
|
description="Returns a paginated list of published news, articles, and interviews with multilingual filtering.",
|
|
parameters=[
|
|
OpenApiParameter('post_type', str, description='Filter by type (news, article, report, interview)'),
|
|
OpenApiParameter('author_type', str, description='Filter by author type (user, institution)'),
|
|
OpenApiParameter('language', str, description='Filter by language code (fa, ar, en, ur, fr)'),
|
|
OpenApiParameter('category', str, description='Filter by category ID or slug'),
|
|
OpenApiParameter('tag', str, description='Filter by tag string'),
|
|
OpenApiParameter('institution_id', int, description='Filter by associated institution ID'),
|
|
OpenApiParameter('author_id', int, description='Filter by author user ID'),
|
|
OpenApiParameter('is_featured', bool, description='Filter by featured status'),
|
|
OpenApiParameter('search', str, description='Search text in title, excerpt, and content'),
|
|
OpenApiParameter('status', str, description='Filter by status (staff/admin only)'),
|
|
OpenApiParameter('my_posts', bool, description='Filter by current authenticated user posts'),
|
|
],
|
|
responses={200: PostListSerializer(many=True)},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def get(self, request, *args, **kwargs):
|
|
queryset = self.filter_queryset(self.get_queryset())
|
|
page = self.paginate_queryset(queryset)
|
|
if page is not None:
|
|
serializer = self.get_serializer(page, many=True, context={'request': request})
|
|
return self.get_paginated_response(serializer.data)
|
|
|
|
serializer = self.get_serializer(queryset, many=True, context={'request': request})
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
@extend_schema(
|
|
summary="Publish or draft new post",
|
|
description="Creates a new article, news announcement, or report.",
|
|
request=PostCreateUpdateSerializer,
|
|
responses={
|
|
201: PostDetailSerializer,
|
|
400: OpenApiResponse(description="Validation error"),
|
|
401: OpenApiResponse(description="Authentication required"),
|
|
},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = PostCreateUpdateSerializer(data=request.data, context={'request': request})
|
|
serializer.is_valid(raise_exception=True)
|
|
post_obj = serializer.save()
|
|
|
|
response_serializer = PostDetailSerializer(post_obj, context={'request': request})
|
|
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
class PostDetailView(GenericAPIView):
|
|
serializer_class = PostDetailSerializer
|
|
queryset = Post.objects.all()
|
|
|
|
def get_permissions(self):
|
|
if self.request.method in ['PATCH', 'PUT', 'DELETE']:
|
|
return [IsAuthenticated(), IsAuthorOrEditorOrReadOnly()]
|
|
return [AllowAny()]
|
|
|
|
def get_object(self):
|
|
lookup = self.kwargs.get('pk_or_slug')
|
|
post_obj = get_post_by_id_or_slug(lookup, self.request.user)
|
|
self.check_object_permissions(self.request, post_obj)
|
|
return post_obj
|
|
|
|
@extend_schema(
|
|
summary="Get full post details",
|
|
description="Retrieves full article content and increments the view counter.",
|
|
responses={
|
|
200: PostDetailSerializer,
|
|
404: OpenApiResponse(description="Post not found"),
|
|
},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def get(self, request, pk_or_slug, *args, **kwargs):
|
|
post_obj = self.get_object()
|
|
|
|
# Increment views counter
|
|
Post.objects.filter(id=post_obj.id).update(views_count=F('views_count') + 1)
|
|
post_obj.refresh_from_db()
|
|
|
|
serializer = PostDetailSerializer(post_obj, context={'request': request})
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
@extend_schema(
|
|
summary="Update post",
|
|
description="Updates an existing post. If post is published and editor rights are lacking, submits a zero-downtime staged revision.",
|
|
request=PostCreateUpdateSerializer,
|
|
responses={
|
|
200: PostDetailSerializer,
|
|
202: OpenApiResponse(description="Revision staged for editorial review"),
|
|
403: OpenApiResponse(description="Permission denied"),
|
|
},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def patch(self, request, pk_or_slug, *args, **kwargs):
|
|
post_obj = self.get_object()
|
|
user = request.user
|
|
has_mod_cap = can_publish_or_moderate_post(user, post_obj.institution)
|
|
|
|
target_status = request.data.get('status')
|
|
is_unpublishing_to_draft = (target_status == Post.Status.DRAFT)
|
|
|
|
# Option C: If post is PUBLISHED and editor capability is lacking,
|
|
# create or update a staged PostRevision (Zero-Downtime, live post remains online).
|
|
# EXCEPT when the author or owner is deliberately unpublishing the post back to DRAFT!
|
|
if post_obj.status == Post.Status.PUBLISHED and not has_mod_cap and not is_unpublishing_to_draft:
|
|
if not user.has_perm_flag('can_edit_own_posts'):
|
|
return Response(
|
|
{"detail": _("You do not have permission to edit posts.")},
|
|
status=status.HTTP_403_FORBIDDEN
|
|
)
|
|
|
|
rev_serializer = PostRevisionCreateSerializer(data=request.data, partial=True)
|
|
rev_serializer.is_valid(raise_exception=True)
|
|
validated_data = rev_serializer.validated_data
|
|
|
|
revision = post_obj.revisions.filter(status=PostRevision.Status.PENDING).first()
|
|
if revision:
|
|
for field, val in validated_data.items():
|
|
setattr(revision, field, val)
|
|
revision.updated_at = timezone.now()
|
|
revision.save()
|
|
else:
|
|
revision = PostRevision.objects.create(
|
|
post=post_obj,
|
|
author=user,
|
|
title=validated_data.get('title', post_obj.title),
|
|
excerpt=validated_data.get('excerpt', post_obj.excerpt or ''),
|
|
content=validated_data.get('content', post_obj.content),
|
|
tags=validated_data.get('tags', post_obj.tags),
|
|
featured_image=validated_data.get('featured_image', post_obj.featured_image),
|
|
category=validated_data.get('category', post_obj.category),
|
|
status=PostRevision.Status.PENDING,
|
|
)
|
|
|
|
return Response({
|
|
"detail": _("Post is currently published. Your changes have been submitted as a pending revision for review without taking down the live article."),
|
|
"has_pending_revision": True,
|
|
"revision": PostRevisionSerializer(revision, context={'request': request}).data,
|
|
"live_post": PostDetailSerializer(post_obj, context={'request': request}).data,
|
|
}, status=status.HTTP_202_ACCEPTED)
|
|
|
|
serializer = PostCreateUpdateSerializer(
|
|
post_obj,
|
|
data=request.data,
|
|
partial=True,
|
|
context={'request': request}
|
|
)
|
|
serializer.is_valid(raise_exception=True)
|
|
serializer.save()
|
|
|
|
# If post was successfully unpublished to DRAFT, clear any open pending revision
|
|
if post_obj.status == Post.Status.DRAFT:
|
|
post_obj.revisions.filter(status=PostRevision.Status.PENDING).delete()
|
|
|
|
response_serializer = PostDetailSerializer(post_obj, context={'request': request})
|
|
return Response(response_serializer.data, status=status.HTTP_200_OK)
|
|
|
|
def put(self, request, pk_or_slug, *args, **kwargs):
|
|
return self.patch(request, pk_or_slug, *args, **kwargs)
|
|
|
|
@extend_schema(
|
|
summary="Delete post",
|
|
description="Removes a post from the platform.",
|
|
responses={
|
|
204: OpenApiResponse(description="Post deleted successfully"),
|
|
403: OpenApiResponse(description="Permission denied"),
|
|
},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def delete(self, request, pk_or_slug, *args, **kwargs):
|
|
post_obj = self.get_object()
|
|
post_obj.delete()
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
class PostLikeToggleView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
serializer_class = PostLikeResponseSerializer
|
|
queryset = PostLike.objects.all()
|
|
|
|
@extend_schema(
|
|
summary="Toggle like on post",
|
|
description="Likes or unlikes an article for the authenticated user and updates the like counter.",
|
|
responses={200: PostLikeResponseSerializer},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def post(self, request, pk_or_slug, *args, **kwargs):
|
|
post_obj = get_post_by_id_or_slug(pk_or_slug, request.user)
|
|
|
|
like_obj, created = PostLike.objects.get_or_create(
|
|
post=post_obj,
|
|
user=request.user
|
|
)
|
|
|
|
if created:
|
|
Post.objects.filter(id=post_obj.id).update(likes_count=F('likes_count') + 1)
|
|
is_liked = True
|
|
msg = _("Post liked.")
|
|
else:
|
|
like_obj.delete()
|
|
Post.objects.filter(id=post_obj.id, likes_count__gt=0).update(likes_count=F('likes_count') - 1)
|
|
is_liked = False
|
|
msg = _("Post unliked.")
|
|
|
|
post_obj.refresh_from_db()
|
|
return Response({
|
|
'is_liked': is_liked,
|
|
'likes_count': post_obj.likes_count,
|
|
'message': msg
|
|
}, status=status.HTTP_200_OK)
|
|
|
|
|
|
class PostCommentsView(GenericAPIView):
|
|
serializer_class = PostCommentSerializer
|
|
queryset = PostComment.objects.all()
|
|
|
|
def get_permissions(self):
|
|
return [AllowAny()]
|
|
|
|
@extend_schema(
|
|
summary="List post comments",
|
|
description="Retrieves approved reader comments for a post.",
|
|
responses={200: PostCommentSerializer(many=True)},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def get(self, request, pk_or_slug, *args, **kwargs):
|
|
post_obj = get_post_by_id_or_slug(pk_or_slug, request.user)
|
|
comments = post_obj.comments.filter(status=PostComment.Status.APPROVED).order_by('-created_at')
|
|
serializer = PostCommentSerializer(comments, many=True)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
@extend_schema(
|
|
summary="Submit post comment",
|
|
description="Submits a comment on an article.",
|
|
request=PostCommentCreateSerializer,
|
|
responses={
|
|
201: PostCommentSerializer,
|
|
400: OpenApiResponse(description="Validation error"),
|
|
},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def post(self, request, pk_or_slug, *args, **kwargs):
|
|
post_obj = get_post_by_id_or_slug(pk_or_slug, request.user)
|
|
serializer = PostCommentCreateSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
user = request.user if request.user.is_authenticated else None
|
|
author_name = serializer.validated_data.get('author_name') or (user.fullname if user else "Visitor")
|
|
author_email = serializer.validated_data.get('author_email') or (user.email if user else "")
|
|
|
|
comment = PostComment.objects.create(
|
|
post=post_obj,
|
|
user=user,
|
|
author_name=author_name,
|
|
author_email=author_email,
|
|
content=serializer.validated_data['content'],
|
|
status=PostComment.Status.APPROVED
|
|
)
|
|
|
|
return Response(PostCommentSerializer(comment).data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
class PostCategoryListView(GenericAPIView):
|
|
permission_classes = [AllowAny]
|
|
serializer_class = PostCategorySerializer
|
|
queryset = PostCategory.objects.all()
|
|
|
|
@extend_schema(
|
|
summary="List post categories",
|
|
description="Returns content categories with optional language filtering.",
|
|
parameters=[
|
|
OpenApiParameter('language', str, description='Filter categories by language code (fa, ar, en, ur, fr)')
|
|
],
|
|
responses={200: PostCategorySerializer(many=True)},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def get(self, request, *args, **kwargs):
|
|
qs = PostCategory.objects.all()
|
|
lang = request.query_params.get('language')
|
|
if lang:
|
|
qs = qs.filter(language=lang)
|
|
serializer = PostCategorySerializer(qs, many=True)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class PostTagListView(GenericAPIView):
|
|
permission_classes = [AllowAny]
|
|
serializer_class = PostTagSerializer
|
|
queryset = PostTag.objects.all()
|
|
|
|
@extend_schema(
|
|
summary="List CMS tags",
|
|
description="Returns distinct content tags used across posts.",
|
|
responses={200: PostTagSerializer(many=True)},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def get(self, request, *args, **kwargs):
|
|
tags = PostTag.objects.all()
|
|
serializer = PostTagSerializer(tags, many=True)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class MediaAssetListCreateView(GenericAPIView):
|
|
serializer_class = MediaAssetSerializer
|
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
|
queryset = MediaAsset.objects.all()
|
|
|
|
def get_permissions(self):
|
|
if self.request.method == 'POST':
|
|
return [IsAuthenticated()]
|
|
return [AllowAny()]
|
|
|
|
@extend_schema(
|
|
summary="List media library assets",
|
|
description="Retrieves uploaded photos, videos, and documents.",
|
|
parameters=[
|
|
OpenApiParameter('media_type', str, description='Filter by media type (image, video, audio, document)')
|
|
],
|
|
responses={200: MediaAssetSerializer(many=True)},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def get(self, request, *args, **kwargs):
|
|
qs = MediaAsset.objects.all().order_by('-created_at')
|
|
m_type = request.query_params.get('media_type')
|
|
if m_type:
|
|
qs = qs.filter(media_type=m_type)
|
|
serializer = MediaAssetSerializer(qs, many=True)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
@extend_schema(
|
|
summary="Upload media asset",
|
|
description="Uploads a new photo, document, or audio file to the media library.",
|
|
request=MediaAssetSerializer,
|
|
responses={201: MediaAssetSerializer},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
serializer = MediaAssetSerializer(data=request.data, context={'request': request})
|
|
serializer.is_valid(raise_exception=True)
|
|
asset = serializer.save()
|
|
return Response(MediaAssetSerializer(asset).data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
class PostApproveView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
serializer_class = PostDetailSerializer
|
|
queryset = Post.objects.all()
|
|
|
|
@extend_schema(
|
|
summary="Approve and publish post",
|
|
description="Approves a pending post and immediately publishes it. Requires direct publishing capability for the associated institution.",
|
|
responses={
|
|
200: PostDetailSerializer,
|
|
400: OpenApiResponse(description="Post cannot be approved"),
|
|
403: OpenApiResponse(description="Permission denied"),
|
|
404: OpenApiResponse(description="Post not found"),
|
|
},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def post(self, request, pk_or_slug, *args, **kwargs):
|
|
if str(pk_or_slug).isdigit():
|
|
post_obj = get_object_or_404(Post, id=int(pk_or_slug))
|
|
else:
|
|
post_obj = get_object_or_404(Post, slug=pk_or_slug)
|
|
|
|
if not can_publish_or_moderate_post(request.user, post_obj.institution):
|
|
return Response(
|
|
{"detail": _("You do not have permission to publish or approve posts for this institution.")},
|
|
status=status.HTTP_403_FORBIDDEN
|
|
)
|
|
post_obj.status = Post.Status.PUBLISHED
|
|
post_obj.reviewed_by = request.user
|
|
post_obj.reviewed_at = timezone.now()
|
|
post_obj.rejection_reason = ''
|
|
post_obj.save(update_fields=['status', 'reviewed_by', 'reviewed_at', 'rejection_reason', 'updated_at'])
|
|
|
|
serializer = PostDetailSerializer(post_obj, context={'request': request})
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class PostRejectView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
serializer_class = PostDetailSerializer
|
|
queryset = Post.objects.all()
|
|
|
|
@extend_schema(
|
|
summary="Reject post with reason",
|
|
description="Rejects a pending post. Requires direct publishing capability for the associated institution and a mandatory explanation reason.",
|
|
request=inline_serializer(
|
|
name='PostRejectRequest',
|
|
fields={'reason': serializers.CharField(required=True, help_text="Explanation for rejection")}
|
|
),
|
|
responses={
|
|
200: PostDetailSerializer,
|
|
400: OpenApiResponse(description="Rejection reason is missing"),
|
|
403: OpenApiResponse(description="Permission denied"),
|
|
404: OpenApiResponse(description="Post not found"),
|
|
},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def post(self, request, pk_or_slug, *args, **kwargs):
|
|
if str(pk_or_slug).isdigit():
|
|
post_obj = get_object_or_404(Post, id=int(pk_or_slug))
|
|
else:
|
|
post_obj = get_object_or_404(Post, slug=pk_or_slug)
|
|
|
|
if not can_publish_or_moderate_post(request.user, post_obj.institution):
|
|
return Response(
|
|
{"detail": _("You do not have permission to moderate posts for this institution.")},
|
|
status=status.HTTP_403_FORBIDDEN
|
|
)
|
|
reason = request.data.get('reason', '').strip()
|
|
if not reason:
|
|
return Response(
|
|
{"reason": [_("A reason is required when rejecting a post.")]},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
post_obj.status = Post.Status.REJECTED
|
|
post_obj.rejection_reason = reason
|
|
post_obj.reviewed_by = request.user
|
|
post_obj.reviewed_at = timezone.now()
|
|
post_obj.save(update_fields=['status', 'rejection_reason', 'reviewed_by', 'reviewed_at', 'updated_at'])
|
|
|
|
serializer = PostDetailSerializer(post_obj, context={'request': request})
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class PostRevisionListView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
serializer_class = PostRevisionSerializer
|
|
queryset = PostRevision.objects.all()
|
|
|
|
@extend_schema(
|
|
summary="List revisions for a post",
|
|
description="Lists all historical and pending revisions for this post. Permitted for the author or institution editors.",
|
|
responses={
|
|
200: PostRevisionSerializer(many=True),
|
|
403: OpenApiResponse(description="Permission denied"),
|
|
404: OpenApiResponse(description="Post not found"),
|
|
},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def get(self, request, pk_or_slug, *args, **kwargs):
|
|
post_obj = get_post_by_id_or_slug(pk_or_slug, request.user)
|
|
is_author = (post_obj.author_id == request.user.id)
|
|
is_editor = can_publish_or_moderate_post(request.user, post_obj.institution)
|
|
if not (is_author or is_editor or request.user.is_superuser):
|
|
return Response(
|
|
{"detail": _("You do not have permission to view revisions for this post.")},
|
|
status=status.HTTP_403_FORBIDDEN
|
|
)
|
|
revisions = post_obj.revisions.all().select_related('author', 'reviewed_by', 'category').order_by('-created_at')
|
|
serializer = PostRevisionSerializer(revisions, many=True, context={'request': request})
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
@extend_schema(
|
|
summary="Discard pending revision",
|
|
description="Allows the post author to withdraw or delete their pending revision before editorial review.",
|
|
responses={
|
|
204: OpenApiResponse(description="Pending revision discarded"),
|
|
400: OpenApiResponse(description="No pending revision to discard"),
|
|
403: OpenApiResponse(description="Permission denied"),
|
|
},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def delete(self, request, pk_or_slug, *args, **kwargs):
|
|
post_obj = get_post_by_id_or_slug(pk_or_slug, request.user)
|
|
is_author = (post_obj.author_id == request.user.id)
|
|
is_editor = can_publish_or_moderate_post(request.user, post_obj.institution)
|
|
if not (is_author or is_editor or request.user.is_superuser):
|
|
return Response(
|
|
{"detail": _("You do not have permission to discard revisions for this post.")},
|
|
status=status.HTTP_403_FORBIDDEN
|
|
)
|
|
pending = post_obj.revisions.filter(status=PostRevision.Status.PENDING).first()
|
|
if not pending:
|
|
return Response(
|
|
{"detail": _("No pending revision found to discard.")},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
pending.delete()
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
class PostRevisionApproveView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
serializer_class = PostDetailSerializer
|
|
queryset = PostRevision.objects.all()
|
|
|
|
@extend_schema(
|
|
summary="Approve and merge staged post revision",
|
|
description="Approves a pending post revision and merges it live into the published post with zero downtime. Requires direct publishing capability for the institution.",
|
|
responses={
|
|
200: inline_serializer(
|
|
name='PostRevisionApproveResponse',
|
|
fields={
|
|
'detail': serializers.CharField(),
|
|
'post': PostDetailSerializer(),
|
|
'revision': PostRevisionSerializer(),
|
|
}
|
|
),
|
|
400: OpenApiResponse(description="No pending revision found"),
|
|
403: OpenApiResponse(description="Permission denied"),
|
|
404: OpenApiResponse(description="Post not found"),
|
|
},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def post(self, request, pk_or_slug, *args, **kwargs):
|
|
if str(pk_or_slug).isdigit():
|
|
post_obj = get_object_or_404(Post, id=int(pk_or_slug))
|
|
else:
|
|
post_obj = get_object_or_404(Post, slug=pk_or_slug)
|
|
|
|
if not can_publish_or_moderate_post(request.user, post_obj.institution):
|
|
return Response(
|
|
{"detail": _("You do not have permission to approve revisions for this institution.")},
|
|
status=status.HTTP_403_FORBIDDEN
|
|
)
|
|
|
|
revision = post_obj.revisions.filter(status=PostRevision.Status.PENDING).first()
|
|
if not revision:
|
|
return Response(
|
|
{"detail": _("No pending revision found for this post.")},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
revision.apply_to_post(editor=request.user)
|
|
post_obj.refresh_from_db()
|
|
|
|
return Response({
|
|
"detail": _("Revision approved and merged successfully. Live post updated with zero downtime."),
|
|
"post": PostDetailSerializer(post_obj, context={'request': request}).data,
|
|
"revision": PostRevisionSerializer(revision, context={'request': request}).data,
|
|
}, status=status.HTTP_200_OK)
|
|
|
|
|
|
class PostRevisionRejectView(GenericAPIView):
|
|
permission_classes = [IsAuthenticated]
|
|
serializer_class = PostRevisionSerializer
|
|
queryset = PostRevision.objects.all()
|
|
|
|
@extend_schema(
|
|
summary="Reject staged post revision with explanation",
|
|
description="Rejects a pending post revision without affecting the live published article. Requires direct publishing capability and an explanation reason.",
|
|
request=inline_serializer(
|
|
name='PostRevisionRejectRequest',
|
|
fields={'reason': serializers.CharField(required=True, help_text="Explanation for rejection")}
|
|
),
|
|
responses={
|
|
200: inline_serializer(
|
|
name='PostRevisionRejectResponse',
|
|
fields={
|
|
'detail': serializers.CharField(),
|
|
'post': PostDetailSerializer(),
|
|
'revision': PostRevisionSerializer(),
|
|
}
|
|
),
|
|
400: OpenApiResponse(description="Rejection reason missing or no pending revision"),
|
|
403: OpenApiResponse(description="Permission denied"),
|
|
404: OpenApiResponse(description="Post not found"),
|
|
},
|
|
tags=["Content Management System (CMS)"],
|
|
)
|
|
def post(self, request, pk_or_slug, *args, **kwargs):
|
|
if str(pk_or_slug).isdigit():
|
|
post_obj = get_object_or_404(Post, id=int(pk_or_slug))
|
|
else:
|
|
post_obj = get_object_or_404(Post, slug=pk_or_slug)
|
|
|
|
if not can_publish_or_moderate_post(request.user, post_obj.institution):
|
|
return Response(
|
|
{"detail": _("You do not have permission to moderate revisions for this institution.")},
|
|
status=status.HTTP_403_FORBIDDEN
|
|
)
|
|
|
|
revision = post_obj.revisions.filter(status=PostRevision.Status.PENDING).first()
|
|
if not revision:
|
|
return Response(
|
|
{"detail": _("No pending revision found for this post.")},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
reason = request.data.get('reason', '').strip()
|
|
if not reason:
|
|
return Response(
|
|
{"reason": [_("A reason is required when rejecting a revision.")]},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
revision.status = PostRevision.Status.REJECTED
|
|
revision.rejection_reason = reason
|
|
revision.reviewed_by = request.user
|
|
revision.reviewed_at = timezone.now()
|
|
revision.save(update_fields=['status', 'rejection_reason', 'reviewed_by', 'reviewed_at', 'updated_at'])
|
|
|
|
return Response({
|
|
"detail": _("Revision rejected. Live post remains published and unchanged."),
|
|
"post": PostDetailSerializer(post_obj, context={'request': request}).data,
|
|
"revision": PostRevisionSerializer(revision, context={'request': request}).data,
|
|
}, status=status.HTTP_200_OK)
|
|
|