Browse Source

feat: implement CMS application with models, admin, serializers, and views for editorial workflow and post management

main
sina_sajjadi 2 hours ago
parent
commit
6977643114
  1. 10
      apps/account/models/user.py
  2. 61
      apps/account/serializers/auth_serializers.py
  3. 62
      apps/cms/admin.py
  4. 36
      apps/cms/migrations/0002_post_rejection_reason_post_reviewed_at_and_more.py
  5. 41
      apps/cms/migrations/0003_postrevision.py
  6. 32
      apps/cms/migrations/0004_post_author_type_alter_post_author_and_more.py
  7. 3
      apps/cms/models/__init__.py
  8. 188
      apps/cms/models/post.py
  9. 82
      apps/cms/permissions.py
  10. 6
      apps/cms/serializers/__init__.py
  11. 229
      apps/cms/serializers/post_serializers.py
  12. 620
      apps/cms/tests/test_editorial_workflow.py
  13. 10
      apps/cms/tests/test_phase4_cms.py
  14. 11
      apps/cms/urls.py
  15. 11
      apps/cms/views/__init__.py
  16. 392
      apps/cms/views/post_views.py

10
apps/account/models/user.py

@ -245,7 +245,10 @@ class User(AbstractUser):
1. Superuser always returns True.
2. Custom permission override on the user takes precedence if set.
3. Falls back to assigned Role value if user has a role.
4. Otherwise returns False.
4. Fallback when user has no explicit Role attached:
- Basic community actions ('can_create_posts', 'can_edit_own_posts', 'can_comment') default to True.
- 'can_publish_posts_directly' defaults to True if user is_editor.
5. Otherwise returns False.
"""
if not self.is_active:
return False
@ -257,6 +260,11 @@ class User(AbstractUser):
return bool(val)
if self.role and hasattr(self.role, perm_name):
return getattr(self.role, perm_name)
# Graceful fallback matching Role model defaults when no Role object is linked
if perm_name in ['can_create_posts', 'can_edit_own_posts', 'can_delete_own_posts', 'can_comment']:
return True
if perm_name == 'can_publish_posts_directly':
return bool(getattr(self, 'is_editor', False))
return False
@property

61
apps/account/serializers/auth_serializers.py

@ -92,8 +92,10 @@ class RegisterRequestSerializer(serializers.Serializer):
class UserMeSerializer(serializers.ModelSerializer):
bio = serializers.CharField(source='info', allow_blank=True, required=False, allow_null=True)
role = serializers.SerializerMethodField()
roles = serializers.SerializerMethodField()
permissions = serializers.SerializerMethodField()
permission_flags = serializers.SerializerMethodField()
managed_regions = serializers.SerializerMethodField()
regional_permissions = serializers.ReadOnlyField()
verification_status = serializers.SerializerMethodField()
@ -143,8 +145,10 @@ class UserMeSerializer(serializers.ModelSerializer):
'date_joined',
'is_active',
'is_staff',
'role',
'roles',
'permissions',
'permission_flags',
'managed_regions',
'regional_permissions',
'verification_status',
@ -248,25 +252,58 @@ class UserMeSerializer(serializers.ModelSerializer):
return membership.role
return ""
def get_role(self, obj):
if obj.role:
return {
'id': obj.role.id,
'name': obj.role.name,
'slug': obj.role.slug,
'description': obj.role.description,
}
return None
def get_roles(self, obj) -> list:
roles = [obj.user_type] if obj.user_type else []
if obj.role and obj.role.slug:
roles.append(obj.role.slug)
for group in obj.groups.all():
roles.append(group.name)
return list(set(roles))
def get_permission_flags(self, obj) -> dict:
"""
Returns full boolean flags dictionary dynamically resolved from Role and custom_permissions.
"""
if not obj or not getattr(obj, 'is_active', False):
return {}
if getattr(obj, 'is_superuser', False):
from apps.account.models.role import ALL_PERMISSION_FIELDS
return {perm: True for perm in ALL_PERMISSION_FIELDS}
flags = obj.role.get_permissions_dict() if obj.role else {}
if obj.custom_permissions and isinstance(obj.custom_permissions, dict):
flags.update(obj.custom_permissions)
return flags
def get_permissions(self, obj) -> list:
perms = []
if obj.is_super_admin:
perms.extend(['super_admin', 'manage_all', 'manage_institutions', 'manage_users', 'manage_content', 'approve_verifications'])
elif obj.is_regional_admin:
perms.extend(['regional_admin', 'manage_regional_institutions', 'manage_content', 'approve_verifications'])
elif obj.is_institution_admin:
perms.extend(['institution_admin', 'manage_institution_profile', 'manage_team', 'manage_events', 'manage_projects'])
elif obj.is_editor:
perms.extend(['editor', 'create_posts', 'manage_events', 'edit_projects'])
else:
perms.extend(['client_access', 'view_courses', 'submit_tickets', 'attend_events'])
return perms
"""
Returns active permission capability flags where boolean value is True,
dynamically derived from the user's assigned database Role and custom overrides.
"""
if not obj or not getattr(obj, 'is_active', False):
return []
if getattr(obj, 'is_superuser', False):
from apps.account.models.role import ALL_PERMISSION_FIELDS
return list(ALL_PERMISSION_FIELDS)
flags = obj.role.get_permissions_dict() if obj.role else {}
if obj.custom_permissions and isinstance(obj.custom_permissions, dict):
flags.update(obj.custom_permissions)
granted = [k for k, v in flags.items() if v]
if obj.is_super_admin and 'super_admin' not in granted:
granted.append('super_admin')
return granted
def get_verification_status(self, obj) -> str:
return obj.verification_status

62
apps/cms/admin.py

@ -7,6 +7,7 @@ from apps.cms.models.post import (
PostCategory,
PostTag,
Post,
PostRevision,
PostComment,
PostLike,
MediaAsset,
@ -14,6 +15,14 @@ from apps.cms.models.post import (
from utils.admin import project_admin_site
class PostRevisionInline(TabularInline):
model = PostRevision
extra = 0
fields = ('id', 'author', 'title', 'status', 'reviewed_by', 'reviewed_at', 'created_at')
readonly_fields = ('id', 'author', 'title', 'status', 'reviewed_by', 'reviewed_at', 'created_at')
show_change_link = True
class PostCommentInline(TabularInline):
model = PostComment
extra = 0
@ -29,26 +38,31 @@ class PostAdmin(ModelAdmin):
'language',
'category',
'status_badge',
'author_type',
'author',
'institution',
'views_count',
'likes_count',
'is_featured',
'publish_date',
)
list_display_links = ('title', 'post_type_badge', 'category', 'author')
list_filter = ('post_type', 'language', 'status', 'is_featured', 'category')
search_fields = ('title', 'excerpt', 'content', 'author__email', 'author__fullname')
list_filter = ('post_type', 'author_type', 'language', 'status', 'is_featured', 'category', 'institution')
search_fields = ('title', 'excerpt', 'content', 'author__email', 'author__fullname', 'institution__name')
prepopulated_fields = {'slug': ('title',)}
readonly_fields = ('reading_time_minutes', 'views_count', 'likes_count', 'created_at', 'updated_at')
inlines = [PostCommentInline]
actions = ['publish_posts', 'archive_posts', 'toggle_featured']
readonly_fields = ('reading_time_minutes', 'views_count', 'likes_count', 'reviewed_at', 'created_at', 'updated_at')
inlines = [PostRevisionInline, PostCommentInline]
actions = ['publish_posts', 'mark_pending_review', 'archive_posts', 'toggle_featured']
fieldsets = (
(_('Article Content'), {
'fields': ('title', 'slug', 'post_type', 'category', 'language', 'status', 'is_featured')
}),
(_('Editorial & Moderation'), {
'fields': ('rejection_reason', 'reviewed_by', 'reviewed_at')
}),
(_('Author & Affiliation'), {
'fields': ('author', 'institution', 'author_role_label', 'author_custom_avatar')
'fields': ('author_type', 'author', 'institution', 'author_role_label', 'author_custom_avatar')
}),
(_('Media & Excerpt'), {
'fields': ('featured_image', 'excerpt', 'content', 'tags')
@ -64,9 +78,10 @@ class PostAdmin(ModelAdmin):
@display(description=_('Status'), label={
Post.Status.PUBLISHED: "success",
Post.Status.REVIEW: "warning",
Post.Status.PENDING_REVIEW: "warning",
Post.Status.DRAFT: "info",
Post.Status.ARCHIVED: "danger",
Post.Status.REJECTED: "danger",
Post.Status.ARCHIVED: "secondary",
})
def status_badge(self, obj):
return obj.get_status_display()
@ -76,6 +91,11 @@ class PostAdmin(ModelAdmin):
count = queryset.update(status=Post.Status.PUBLISHED)
self.message_user(request, _(f"{count} post(s) published successfully."))
@action(description=_('Mark selected posts as Pending Review'))
def mark_pending_review(self, request, queryset):
count = queryset.update(status=Post.Status.PENDING_REVIEW)
self.message_user(request, _(f"{count} post(s) marked as pending review."))
@action(description=_('Archive selected posts'))
def archive_posts(self, request, queryset):
count = queryset.update(status=Post.Status.ARCHIVED)
@ -152,3 +172,29 @@ class MediaAssetAdmin(ModelAdmin):
list_filter = ('media_type', 'created_at')
search_fields = ('title', 'uploaded_by__email')
readonly_fields = ('created_at',)
@admin.register(PostRevision, site=project_admin_site)
class PostRevisionAdmin(ModelAdmin):
list_display = ('id', 'post', 'author', 'status_badge', 'created_at', 'reviewed_by', 'reviewed_at')
list_display_links = ('id', 'post')
list_filter = ('status', 'created_at')
search_fields = ('title', 'content', 'author__email', 'post__title')
readonly_fields = ('created_at', 'updated_at', 'reviewed_at')
actions = ['approve_revisions']
@display(description=_('Status'), label={
PostRevision.Status.APPROVED: "success",
PostRevision.Status.PENDING: "warning",
PostRevision.Status.REJECTED: "danger",
})
def status_badge(self, obj):
return obj.get_status_display()
@action(description=_('Approve and merge selected revisions'))
def approve_revisions(self, request, queryset):
count = 0
for rev in queryset.filter(status=PostRevision.Status.PENDING):
rev.apply_to_post(editor=request.user)
count += 1
self.message_user(request, _(f"{count} revision(s) approved and merged into live posts."))

36
apps/cms/migrations/0002_post_rejection_reason_post_reviewed_at_and_more.py

@ -0,0 +1,36 @@
# Generated by Django 4.2.30 on 2026-10-10 08:42
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cms', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='post',
name='rejection_reason',
field=models.TextField(blank=True, default='', help_text='Reason provided by editor when rejecting a post.', verbose_name='Rejection Reason'),
),
migrations.AddField(
model_name='post',
name='reviewed_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Reviewed At'),
),
migrations.AddField(
model_name='post',
name='reviewed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_cms_posts', to=settings.AUTH_USER_MODEL, verbose_name='Reviewed By'),
),
migrations.AlterField(
model_name='post',
name='status',
field=models.CharField(choices=[('draft', 'Draft'), ('pending_review', 'Pending Review'), ('published', 'Published'), ('rejected', 'Rejected'), ('archived', 'Archived')], default='draft', max_length=20, verbose_name='Publication Status'),
),
]

41
apps/cms/migrations/0003_postrevision.py

@ -0,0 +1,41 @@
# Generated by Django 4.2.30 on 2026-10-10 09:19
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cms', '0002_post_rejection_reason_post_reviewed_at_and_more'),
]
operations = [
migrations.CreateModel(
name='PostRevision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='Revised Title')),
('excerpt', models.TextField(blank=True, default='', verbose_name='Revised Excerpt')),
('content', models.TextField(verbose_name='Revised Content')),
('tags', models.JSONField(blank=True, default=list, verbose_name='Revised Tags')),
('featured_image', models.ImageField(blank=True, null=True, upload_to='cms/revisions/%Y/%m/', verbose_name='Revised Featured Image')),
('status', models.CharField(choices=[('pending', 'Pending Review'), ('approved', 'Approved & Merged'), ('rejected', 'Rejected')], default='pending', max_length=20, verbose_name='Revision Status')),
('rejection_reason', models.TextField(blank=True, default='', verbose_name='Rejection Reason')),
('reviewed_at', models.DateTimeField(blank=True, null=True, verbose_name='Reviewed At')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Submitted At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cms_post_revisions', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revisions', to='cms.postcategory', verbose_name='Revised Category')),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='cms.post', verbose_name='Original Post')),
('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_cms_revisions', to=settings.AUTH_USER_MODEL, verbose_name='Reviewed By')),
],
options={
'verbose_name': 'Post Revision',
'verbose_name_plural': 'Post Revisions',
'ordering': ('-created_at',),
},
),
]

32
apps/cms/migrations/0004_post_author_type_alter_post_author_and_more.py

@ -0,0 +1,32 @@
# Generated by Django 4.2.30 on 2026-10-10 11:00
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('profiles', '0002_region_and_institution_region'),
('cms', '0003_postrevision'),
]
operations = [
migrations.AddField(
model_name='post',
name='author_type',
field=models.CharField(choices=[('user', 'Individual User'), ('institution', 'Institution')], default='user', help_text='Indicates whether the post is authored as an individual user or on behalf of an institution.', max_length=20, verbose_name='Author Type'),
),
migrations.AlterField(
model_name='post',
name='author',
field=models.ForeignKey(help_text='User account that created or submitted the post.', on_delete=django.db.models.deletion.CASCADE, related_name='cms_posts', to=settings.AUTH_USER_MODEL, verbose_name='Author'),
),
migrations.AlterField(
model_name='post',
name='institution',
field=models.ForeignKey(blank=True, help_text='Institution on whose behalf the post is published if author_type is institution.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cms_posts', to='profiles.institution', verbose_name='Associated Institution'),
),
]

3
apps/cms/models/__init__.py

@ -2,6 +2,7 @@ from .post import (
PostCategory,
PostTag,
Post,
PostRevision,
PostComment,
PostLike,
MediaAsset,
@ -11,7 +12,9 @@ __all__ = [
'PostCategory',
'PostTag',
'Post',
'PostRevision',
'PostComment',
'PostLike',
'MediaAsset',
]

188
apps/cms/models/post.py

@ -1,6 +1,7 @@
import math
import random
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from django.utils.text import slugify
@ -89,12 +90,19 @@ class Post(models.Model):
REPORT = 'report', _('Field & Diplomatic Report')
INTERVIEW = 'interview', _('Expert Interview')
class AuthorType(models.TextChoices):
USER = 'user', _('Individual User')
INSTITUTION = 'institution', _('Institution')
class Status(models.TextChoices):
DRAFT = 'draft', _('Draft')
REVIEW = 'review', _('Under Review')
PENDING_REVIEW = 'pending_review', _('Pending Review')
PUBLISHED = 'published', _('Published')
REJECTED = 'rejected', _('Rejected')
ARCHIVED = 'archived', _('Archived')
Status.REVIEW = Status.PENDING_REVIEW
class LanguageChoices(models.TextChoices):
FA = 'fa', _('Persian')
AR = 'ar', _('Arabic')
@ -120,6 +128,13 @@ class Post(models.Model):
default=PostType.ARTICLE,
verbose_name=_('Post Type')
)
author_type = models.CharField(
max_length=20,
choices=AuthorType.choices,
default=AuthorType.USER,
verbose_name=_('Author Type'),
help_text=_('Indicates whether the post is authored as an individual user or on behalf of an institution.')
)
category = models.ForeignKey(
PostCategory,
on_delete=models.SET_NULL,
@ -137,14 +152,34 @@ class Post(models.Model):
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PUBLISHED,
default=Status.DRAFT,
verbose_name=_('Publication Status')
)
rejection_reason = models.TextField(
blank=True,
default='',
verbose_name=_('Rejection Reason'),
help_text=_('Reason provided by editor when rejecting a post.')
)
reviewed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='reviewed_cms_posts',
verbose_name=_('Reviewed By')
)
reviewed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_('Reviewed At')
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='cms_posts',
verbose_name=_('Author')
verbose_name=_('Author'),
help_text=_('User account that created or submitted the post.')
)
institution = models.ForeignKey(
Institution,
@ -152,7 +187,8 @@ class Post(models.Model):
null=True,
blank=True,
related_name='cms_posts',
verbose_name=_('Associated Institution')
verbose_name=_('Associated Institution'),
help_text=_('Institution on whose behalf the post is published if author_type is institution.')
)
author_role_label = models.CharField(
max_length=150,
@ -241,11 +277,155 @@ class Post(models.Model):
super().save(*args, **kwargs)
def clean(self):
super().clean()
if self.author_type == self.AuthorType.INSTITUTION and not self.institution_id:
raise ValidationError({
'institution': _('An institution is required when author type is Institution.')
})
@property
def is_institution_authored(self) -> bool:
return self.author_type == self.AuthorType.INSTITUTION and self.institution_id is not None
@property
def author_display_name(self) -> str:
if self.is_institution_authored and self.institution:
return self.institution.name
if self.author:
return getattr(self.author, 'fullname', None) or self.author.get_full_name() or self.author.email
return ""
def is_liked_by(self, user):
if not user or not user.is_authenticated:
return False
return self.likes.filter(user=user).exists()
@property
def has_pending_revision(self) -> bool:
return self.revisions.filter(status=PostRevision.Status.PENDING).exists()
def get_pending_revision(self):
return self.revisions.filter(status=PostRevision.Status.PENDING).first()
class PostRevision(models.Model):
class Status(models.TextChoices):
PENDING = 'pending', _('Pending Review')
APPROVED = 'approved', _('Approved & Merged')
REJECTED = 'rejected', _('Rejected')
post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name='revisions',
verbose_name=_('Original Post')
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='cms_post_revisions',
verbose_name=_('Author')
)
title = models.CharField(
max_length=255,
verbose_name=_('Revised Title')
)
excerpt = models.TextField(
blank=True,
default='',
verbose_name=_('Revised Excerpt')
)
content = models.TextField(
verbose_name=_('Revised Content')
)
tags = models.JSONField(
default=list,
blank=True,
verbose_name=_('Revised Tags')
)
featured_image = models.ImageField(
upload_to='cms/revisions/%Y/%m/',
blank=True,
null=True,
verbose_name=_('Revised Featured Image')
)
category = models.ForeignKey(
PostCategory,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='revisions',
verbose_name=_('Revised Category')
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING,
verbose_name=_('Revision Status')
)
rejection_reason = models.TextField(
blank=True,
default='',
verbose_name=_('Rejection Reason')
)
reviewed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='reviewed_cms_revisions',
verbose_name=_('Reviewed By')
)
reviewed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_('Reviewed At')
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Submitted At')
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name=_('Updated At')
)
class Meta:
ordering = ('-created_at',)
verbose_name = _('Post Revision')
verbose_name_plural = _('Post Revisions')
def __str__(self):
return f"Revision #{self.id} for Post #{self.post_id} ({self.get_status_display()})"
def apply_to_post(self, editor=None):
"""
Merges this revision into the live Post seamlessly with zero downtime.
"""
post = self.post
post.title = self.title
post.content = self.content
if self.excerpt:
post.excerpt = self.excerpt
if self.tags:
post.tags = self.tags
if self.category:
post.category = self.category
if self.featured_image:
post.featured_image = self.featured_image
post.reviewed_by = editor
post.reviewed_at = timezone.now()
post.rejection_reason = ''
post.save()
self.status = PostRevision.Status.APPROVED
self.reviewed_by = editor
self.reviewed_at = timezone.now()
self.rejection_reason = ''
self.save(update_fields=['status', 'reviewed_by', 'reviewed_at', 'rejection_reason', 'updated_at'])
class PostComment(models.Model):
class Status(models.TextChoices):

82
apps/cms/permissions.py

@ -1,11 +1,46 @@
from rest_framework.permissions import BasePermission, SAFE_METHODS
def can_publish_or_moderate_post(user, institution=None) -> bool:
"""
Evaluates dynamic permission capability without hardcoding any role names or slugs:
1. User must be active and authenticated.
2. Superuser / Super Admin has global authority.
3. User must hold the capability flag: user.has_perm_flag('can_publish_posts_directly').
4. If an institution is associated with the post:
- User must be affiliated with this institution (as a member in institution.members), OR
- User's managed_regions covers the institution's geographical region.
5. Standalone post (no institution):
- User holds 'can_publish_posts_directly'.
"""
if not user or not user.is_authenticated or not user.is_active:
return False
if user.is_superuser or getattr(user, 'is_super_admin', False):
return True
# Dynamic capability check (configurable in Django Admin on Role / custom_permissions)
if not user.has_perm_flag('can_publish_posts_directly'):
return False
if institution:
# Check direct institution membership affiliation
if institution.members.filter(user=user).exists():
return True
# Check managed_regions (if user has managed regions covering this institution's region)
if hasattr(user, 'managed_regions') and getattr(institution, 'region_id', None):
if user.managed_regions.filter(id=institution.region_id).exists():
return True
return False
return True
class IsAuthorOrEditorOrReadOnly(BasePermission):
"""
Read access is public.
Editing / deleting is restricted to the original author, an institution editor/admin,
or a platform administrator.
Read access is public (handled at view queryset level for status).
Object write/delete access:
- Author can edit their own post (draft, pending_review, rejected).
- Editors satisfying can_publish_or_moderate_post() can edit, approve, or reject.
"""
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
@ -16,29 +51,36 @@ class IsAuthorOrEditorOrReadOnly(BasePermission):
if request.method in SAFE_METHODS:
return True
if not request.user or not request.user.is_authenticated:
if not request.user or not request.user.is_authenticated or not request.user.is_active:
return False
# Platform Super Admin & Regional Admin
if getattr(request.user, 'is_super_admin', False) or getattr(request.user, 'is_regional_admin', False):
if request.user.is_superuser or getattr(request.user, 'is_super_admin', False):
return True
# Post author
if hasattr(obj, 'author') and obj.author == request.user:
# Editor / Moderator with capability over post institution
if can_publish_or_moderate_post(request.user, getattr(obj, 'institution', None)):
return True
# Institution Editor / Admin
if hasattr(obj, 'institution') and obj.institution:
if obj.institution.is_editor(request.user):
return True
# Post author or owning institution member can edit or delete their own post depending on HTTP method
is_owner = False
if hasattr(obj, 'author') and obj.author == request.user:
is_owner = True
elif getattr(obj, 'institution', None) and hasattr(obj.institution, 'members'):
if obj.institution.members.filter(user=request.user, role__in=['admin', 'editor']).exists():
is_owner = True
if is_owner:
if request.method == 'DELETE':
return request.user.has_perm_flag('can_delete_own_posts')
return request.user.has_perm_flag('can_edit_own_posts')
return False
class CanPublishPost(BasePermission):
"""
Allows creating / publishing content for authenticated users who are staff,
platform administrators, or institutional editors.
Validates that authenticated user has capability to create posts (can_create_posts).
Actual status transitions (draft vs pending_review vs published) are enforced by serializer.
"""
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
@ -47,15 +89,7 @@ class CanPublishPost(BasePermission):
if not request.user or not request.user.is_authenticated or not request.user.is_active:
return False
if (
request.user.is_staff or
getattr(request.user, 'is_super_admin', False) or
getattr(request.user, 'is_regional_admin', False) or
getattr(request.user, 'is_institution_admin', False) or
getattr(request.user, 'is_editor', False) or
request.user.institution_memberships.filter(role__in=['admin', 'editor']).exists()
):
if request.user.is_superuser or getattr(request.user, 'is_super_admin', False):
return True
# By default, any authenticated registered user can draft/submit posts
return True
return request.user.has_perm_flag('can_create_posts')

6
apps/cms/serializers/__init__.py

@ -1,6 +1,8 @@
from .post_serializers import (
PostCategorySerializer,
PostTagSerializer,
PostRevisionSerializer,
PostRevisionCreateSerializer,
PostCommentSerializer,
PostCommentCreateSerializer,
PostListSerializer,
@ -8,11 +10,14 @@ from .post_serializers import (
PostCreateUpdateSerializer,
PostLikeResponseSerializer,
MediaAssetSerializer,
PostAuthorDisplaySerializer,
)
__all__ = [
'PostCategorySerializer',
'PostTagSerializer',
'PostRevisionSerializer',
'PostRevisionCreateSerializer',
'PostCommentSerializer',
'PostCommentCreateSerializer',
'PostListSerializer',
@ -20,4 +25,5 @@ __all__ = [
'PostCreateUpdateSerializer',
'PostLikeResponseSerializer',
'MediaAssetSerializer',
'PostAuthorDisplaySerializer',
]

229
apps/cms/serializers/post_serializers.py

@ -1,4 +1,6 @@
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
@ -6,11 +8,13 @@ from apps.cms.models.post import (
PostCategory,
PostTag,
Post,
PostRevision,
PostComment,
PostLike,
MediaAsset,
)
from apps.profiles.models.institution import Institution
from apps.cms.permissions import can_publish_or_moderate_post
User = get_user_model()
@ -59,6 +63,104 @@ class PostInstitutionMiniSerializer(serializers.ModelSerializer):
read_only_fields = fields
class PostAuthorDisplaySerializer(serializers.Serializer):
type = serializers.CharField(help_text="Author type: 'user' or 'institution'")
id = serializers.IntegerField(help_text="ID of the user or institution", allow_null=True)
name = serializers.CharField(help_text="Name of the author or institution")
slug = serializers.CharField(allow_null=True, required=False)
avatar = serializers.CharField(allow_null=True, required=False)
role_label = serializers.CharField(allow_null=True, required=False)
submitted_by = serializers.DictField(allow_null=True, required=False)
def resolve_post_author_display(obj, request=None) -> dict:
custom_avatar = obj.author_custom_avatar.url if getattr(obj, 'author_custom_avatar', None) else None
if custom_avatar and request and not custom_avatar.startswith(('http://', 'https://')):
custom_avatar = request.build_absolute_uri(custom_avatar)
if obj.author_type == Post.AuthorType.INSTITUTION and obj.institution:
inst = obj.institution
avatar_url = custom_avatar or (inst.avatar.url if getattr(inst, 'avatar', None) else None)
if avatar_url and request and not avatar_url.startswith(('http://', 'https://')):
avatar_url = request.build_absolute_uri(avatar_url)
user = obj.author
submitted_by = None
if user:
submitted_by = {
'id': user.id,
'name': getattr(user, 'fullname', None) or user.get_full_name() or user.email,
}
return {
'type': 'institution',
'id': inst.id,
'name': inst.name,
'slug': inst.slug,
'avatar': avatar_url,
'role_label': obj.author_role_label,
'submitted_by': submitted_by,
}
user = obj.author
avatar_url = custom_avatar or (user.avatar.url if (user and getattr(user, 'avatar', None)) else None)
if avatar_url and request and not avatar_url.startswith(('http://', 'https://')):
avatar_url = request.build_absolute_uri(avatar_url)
return {
'type': 'user',
'id': user.id if user else None,
'name': (getattr(user, 'fullname', None) or user.get_full_name() or user.email) if user else '',
'slug': getattr(user, 'username', None),
'avatar': avatar_url,
'role_label': obj.author_role_label,
'submitted_by': None,
}
class PostRevisionSerializer(serializers.ModelSerializer):
author = PostAuthorMiniSerializer(read_only=True)
reviewed_by = PostAuthorMiniSerializer(read_only=True)
status_display = serializers.CharField(source='get_status_display', read_only=True)
category_name = serializers.CharField(source='category.name', read_only=True, allow_null=True)
class Meta:
model = PostRevision
fields = [
'id',
'post',
'author',
'title',
'excerpt',
'content',
'tags',
'featured_image',
'category',
'category_name',
'status',
'status_display',
'rejection_reason',
'reviewed_by',
'reviewed_at',
'created_at',
'updated_at',
]
read_only_fields = fields
class PostRevisionCreateSerializer(serializers.ModelSerializer):
class Meta:
model = PostRevision
fields = [
'title',
'excerpt',
'content',
'tags',
'featured_image',
'category',
]
class PostCommentSerializer(serializers.ModelSerializer):
user = PostAuthorMiniSerializer(read_only=True)
status_display = serializers.CharField(source='get_status_display', read_only=True)
@ -87,11 +189,14 @@ class PostCommentCreateSerializer(serializers.Serializer):
class PostListSerializer(serializers.ModelSerializer):
post_type_display = serializers.CharField(source='get_post_type_display', read_only=True)
author_type_display = serializers.CharField(source='get_author_type_display', read_only=True)
author_display = serializers.SerializerMethodField()
category_name = serializers.CharField(source='category.name', read_only=True, allow_null=True)
author = PostAuthorMiniSerializer(read_only=True)
institution = PostInstitutionMiniSerializer(read_only=True)
comments_count = serializers.SerializerMethodField()
is_liked = serializers.SerializerMethodField()
has_pending_revision = serializers.BooleanField(read_only=True)
class Meta:
model = Post
@ -101,10 +206,14 @@ class PostListSerializer(serializers.ModelSerializer):
'slug',
'post_type',
'post_type_display',
'author_type',
'author_type_display',
'author_display',
'category',
'category_name',
'language',
'status',
'rejection_reason',
'author',
'institution',
'author_role_label',
@ -119,10 +228,16 @@ class PostListSerializer(serializers.ModelSerializer):
'is_featured',
'publish_date',
'is_liked',
'has_pending_revision',
'reviewed_at',
'created_at',
]
read_only_fields = fields
@extend_schema_field(PostAuthorDisplaySerializer)
def get_author_display(self, obj) -> dict:
return resolve_post_author_display(obj, self.context.get('request'))
@extend_schema_field(serializers.IntegerField)
def get_comments_count(self, obj) -> int:
return obj.comments.filter(status=PostComment.Status.APPROVED).count()
@ -137,12 +252,17 @@ class PostListSerializer(serializers.ModelSerializer):
class PostDetailSerializer(serializers.ModelSerializer):
post_type_display = serializers.CharField(source='get_post_type_display', read_only=True)
author_type_display = serializers.CharField(source='get_author_type_display', read_only=True)
author_display = serializers.SerializerMethodField()
category = PostCategorySerializer(read_only=True)
author = PostAuthorMiniSerializer(read_only=True)
institution = PostInstitutionMiniSerializer(read_only=True)
reviewed_by = PostAuthorMiniSerializer(read_only=True)
comments = serializers.SerializerMethodField()
comments_count = serializers.SerializerMethodField()
is_liked = serializers.SerializerMethodField()
has_pending_revision = serializers.BooleanField(read_only=True)
pending_revision = serializers.SerializerMethodField()
class Meta:
model = Post
@ -152,9 +272,13 @@ class PostDetailSerializer(serializers.ModelSerializer):
'slug',
'post_type',
'post_type_display',
'author_type',
'author_type_display',
'author_display',
'category',
'language',
'status',
'rejection_reason',
'author',
'institution',
'author_role_label',
@ -170,7 +294,11 @@ class PostDetailSerializer(serializers.ModelSerializer):
'is_featured',
'publish_date',
'is_liked',
'has_pending_revision',
'pending_revision',
'comments',
'reviewed_by',
'reviewed_at',
'created_at',
'updated_at',
]
@ -182,11 +310,33 @@ class PostDetailSerializer(serializers.ModelSerializer):
'likes_count',
'comments_count',
'is_liked',
'has_pending_revision',
'pending_revision',
'comments',
'reviewed_by',
'reviewed_at',
'created_at',
'updated_at',
]
@extend_schema_field(PostAuthorDisplaySerializer)
def get_author_display(self, obj) -> dict:
return resolve_post_author_display(obj, self.context.get('request'))
@extend_schema_field(PostRevisionSerializer)
def get_pending_revision(self, obj):
request = self.context.get('request')
if not request or not request.user.is_authenticated:
return None
user = request.user
is_author = (obj.author_id == user.id)
is_editor = can_publish_or_moderate_post(user, obj.institution)
if is_author or is_editor:
pending = obj.get_pending_revision()
if pending:
return PostRevisionSerializer(pending, context=self.context).data
return None
@extend_schema_field(PostCommentSerializer(many=True))
def get_comments(self, obj):
approved_comments = obj.comments.filter(status=PostComment.Status.APPROVED).order_by('-created_at')
@ -210,9 +360,11 @@ class PostCreateUpdateSerializer(serializers.ModelSerializer):
fields = [
'title',
'post_type',
'author_type',
'category',
'language',
'status',
'rejection_reason',
'institution',
'author_role_label',
'author_custom_avatar',
@ -224,12 +376,89 @@ class PostCreateUpdateSerializer(serializers.ModelSerializer):
'publish_date',
]
def validate(self, attrs):
request = self.context.get('request')
user = request.user if request else None
instance = getattr(self, 'instance', None)
target_author_type = attrs.get('author_type') or (instance.author_type if instance else Post.AuthorType.USER)
target_institution = attrs.get('institution') or (instance.institution if instance else None)
if target_author_type == Post.AuthorType.INSTITUTION:
if not target_institution:
raise serializers.ValidationError({
"institution": _("An institution is required when authoring on behalf of an institution.")
})
if user and not (user.is_superuser or getattr(user, 'is_super_admin', False)):
is_inst_editor = target_institution.is_editor(user)
has_reg_perm = False
if getattr(user, 'is_regional_admin', False) and hasattr(user, 'can_manage_region'):
has_reg_perm = user.can_manage_region(target_institution.region)
if not (is_inst_editor or has_reg_perm):
raise serializers.ValidationError({
"institution": _("You are not authorized to author content on behalf of this institution.")
})
target_status = attrs.get('status')
if target_status:
has_mod_cap = can_publish_or_moderate_post(user, target_institution)
if target_status == Post.Status.PUBLISHED:
if not has_mod_cap:
raise serializers.ValidationError({
"status": _("You do not have direct publishing capability for this institution. Please save as draft or submit for review.")
})
elif target_status == Post.Status.REJECTED:
if not has_mod_cap:
raise serializers.ValidationError({
"status": _("You do not have authority to reject posts for this institution.")
})
reason = attrs.get('rejection_reason', '') or (instance.rejection_reason if instance else '')
if not reason or not str(reason).strip():
raise serializers.ValidationError({
"rejection_reason": _("A rejection reason is required when rejecting a post.")
})
elif target_status == Post.Status.ARCHIVED:
if not has_mod_cap:
raise serializers.ValidationError({
"status": _("You do not have authority to archive posts for this institution.")
})
elif target_status in [Post.Status.PENDING_REVIEW, Post.Status.DRAFT]:
# If resubmitting from rejected to draft/pending_review, clear old rejection reason
if instance and instance.status == Post.Status.REJECTED and 'rejection_reason' not in attrs:
attrs['rejection_reason'] = ''
else:
if not instance:
attrs['status'] = Post.Status.DRAFT
return attrs
def create(self, validated_data):
request = self.context.get('request')
if request and request.user.is_authenticated:
validated_data['author'] = request.user
target_status = validated_data.get('status')
if target_status in [Post.Status.PUBLISHED, Post.Status.REJECTED]:
validated_data['reviewed_by'] = request.user
validated_data['reviewed_at'] = timezone.now()
return super().create(validated_data)
def update(self, instance, validated_data):
request = self.context.get('request')
target_status = validated_data.get('status')
if target_status and target_status != instance.status:
if target_status in [Post.Status.PUBLISHED, Post.Status.REJECTED]:
if request and request.user.is_authenticated:
validated_data['reviewed_by'] = request.user
validated_data['reviewed_at'] = timezone.now()
return super().update(instance, validated_data)
class PostLikeResponseSerializer(serializers.Serializer):
is_liked = serializers.BooleanField()

620
apps/cms/tests/test_editorial_workflow.py

@ -0,0 +1,620 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework import status
from apps.cms.models.post import Post, PostCategory, PostRevision
from apps.profiles.models.institution import Institution, InstitutionMember
from apps.account.models.role import Role
User = get_user_model()
class DynamicEditorialWorkflowTests(TestCase):
"""
Automated test suite verifying 100% dynamic, capability-based editorial workflow:
- Zero hardcoded role names or slugs
- Permission checks driven by dynamic Role capabilities (can_publish_posts_directly)
- Default status is 'draft'
- Direct publishing by non-publishers blocked (400 ValidationError)
- Authors can submit to 'pending_review'
- Scoped moderation: only editors affiliated with the specific institution can approve/reject
- Non-affiliated editors blocked with 403 Forbidden
- Rejection requires explanation reason
- Dynamic permission toggle in DB instantly reflects without code changes
- UserMeSerializer exposes dynamic role and permission_flags
"""
def setUp(self):
self.client = APIClient()
# Dynamic Roles created in DB (simulating admin panel configuration)
self.writer_role = Role.objects.create(
name="Community Contributor",
slug="community_contributor",
can_create_posts=True,
can_publish_posts_directly=False,
can_edit_own_posts=True,
)
self.editor_role = Role.objects.create(
name="Cultural Affairs Editor",
slug="cultural_affairs_editor",
can_create_posts=True,
can_publish_posts_directly=True,
can_edit_own_posts=True,
)
# Users
self.writer = User.objects.create_user(
email="[email protected]",
password="Password123!",
fullname="Sadiq Writer",
role=self.writer_role
)
self.editor_inst_a = User.objects.create_user(
email="[email protected]",
password="Password123!",
fullname="Fatima Editor A",
role=self.editor_role
)
self.editor_inst_b = User.objects.create_user(
email="[email protected]",
password="Password123!",
fullname="Zahra Editor B",
role=self.editor_role
)
# Institutions
self.inst_a = Institution.objects.create(
name="Islamic Center London",
country="United Kingdom",
city="London"
)
self.inst_b = Institution.objects.create(
name="Imam Ali Foundation Berlin",
country="Germany",
city="Berlin"
)
# Affiliations: writer and editor_a belong to Inst A, editor_b belongs to Inst B
InstitutionMember.objects.create(
institution=self.inst_a,
user=self.writer,
role=InstitutionMember.MemberRole.VIEWER
)
InstitutionMember.objects.create(
institution=self.inst_a,
user=self.editor_inst_a,
role=InstitutionMember.MemberRole.EDITOR
)
InstitutionMember.objects.create(
institution=self.inst_b,
user=self.editor_inst_b,
role=InstitutionMember.MemberRole.EDITOR
)
# Category
self.category = PostCategory.objects.create(
name="Cultural Dialogues",
slug="cultural-dialogues",
language="en"
)
def test_post_creation_defaults_to_draft(self):
"""Creating a post without status must default to draft."""
self.client.force_authenticate(user=self.writer)
payload = {
"title": "Dialogue in London",
"content": "Deep analytical exploration of community dialogues.",
"post_type": "article",
"language": "en",
"category": self.category.id,
"institution": self.inst_a.id,
}
res = self.client.post("/api/v1/cms/posts/", payload, format="json")
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
self.assertEqual(res.data['status'], Post.Status.DRAFT)
def test_non_publisher_cannot_publish_directly(self):
"""User without can_publish_posts_directly gets 400 when attempting direct published status."""
self.client.force_authenticate(user=self.writer)
payload = {
"title": "Direct Publishing Attempt",
"content": "Trying to bypass review.",
"status": "published",
"post_type": "article",
"language": "en",
"category": self.category.id,
"institution": self.inst_a.id,
}
res = self.client.post("/api/v1/cms/posts/", payload, format="json")
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("status", res.data)
def test_writer_can_submit_for_review(self):
"""Writer can transition their draft to pending_review."""
post = Post.objects.create(
title="Awaiting Review Article",
content="Detailed text.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.DRAFT
)
self.client.force_authenticate(user=self.writer)
res = self.client.patch(f"/api/v1/cms/posts/{post.id}/", {"status": "pending_review"}, format="json")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(res.data['status'], Post.Status.PENDING_REVIEW)
def test_affiliated_editor_can_approve_post(self):
"""Editor affiliated with institution A can approve pending post for institution A."""
post = Post.objects.create(
title="Pending Article for London",
content="Ready to be approved.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.PENDING_REVIEW
)
self.client.force_authenticate(user=self.editor_inst_a)
res = self.client.post(f"/api/v1/cms/posts/{post.id}/approve/")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(res.data['status'], Post.Status.PUBLISHED)
self.assertIsNotNone(res.data['reviewed_at'])
self.assertEqual(res.data['reviewed_by']['email'], self.editor_inst_a.email)
def test_non_affiliated_editor_cannot_approve_post(self):
"""Editor of institution B cannot approve post for institution A (403 Forbidden)."""
post = Post.objects.create(
title="Pending Article for London",
content="Ready to be approved.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.PENDING_REVIEW
)
self.client.force_authenticate(user=self.editor_inst_b)
res = self.client.post(f"/api/v1/cms/posts/{post.id}/approve/")
self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN)
def test_rejection_requires_reason(self):
"""Rejecting a post without reason returns 400 Bad Request."""
post = Post.objects.create(
title="Needs Revisions",
content="Draft content.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.PENDING_REVIEW
)
self.client.force_authenticate(user=self.editor_inst_a)
res = self.client.post(f"/api/v1/cms/posts/{post.id}/reject/", {"reason": ""}, format="json")
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("reason", res.data)
def test_rejection_with_reason_succeeds(self):
"""Rejecting with reason transitions status to rejected and stores explanation."""
post = Post.objects.create(
title="Needs Revisions",
content="Draft content.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.PENDING_REVIEW
)
self.client.force_authenticate(user=self.editor_inst_a)
reason_text = "Please cite primary academic sources for section 2."
res = self.client.post(f"/api/v1/cms/posts/{post.id}/reject/", {"reason": reason_text}, format="json")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(res.data['status'], Post.Status.REJECTED)
self.assertEqual(res.data['rejection_reason'], reason_text)
self.assertEqual(res.data['reviewed_by']['email'], self.editor_inst_a.email)
def test_author_can_resubmit_rejected_post(self):
"""Author can read rejection reason, update content, and resubmit to pending_review."""
post = Post.objects.create(
title="Article With Corrections",
content="Initial text.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.REJECTED,
rejection_reason="Fix spelling and citations."
)
self.client.force_authenticate(user=self.writer)
res = self.client.patch(
f"/api/v1/cms/posts/{post.id}/",
{"content": "Revised text with citations.", "status": "pending_review"},
format="json"
)
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(res.data['status'], Post.Status.PENDING_REVIEW)
self.assertEqual(res.data['rejection_reason'], "")
def test_dynamic_role_permission_revocation(self):
"""Changing role permissions dynamically in DB immediately revokes publishing capability."""
post = Post.objects.create(
title="Institution A Post",
content="Content.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.PENDING_REVIEW
)
# Admin revokes can_publish_posts_directly on the editor role
self.editor_role.can_publish_posts_directly = False
self.editor_role.save()
# Editor A attempts to approve now
self.client.force_authenticate(user=self.editor_inst_a)
res = self.client.post(f"/api/v1/cms/posts/{post.id}/approve/")
self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN)
def test_auth_me_returns_dynamic_role_and_permission_flags(self):
"""UserMeSerializer dynamically reflects assigned role and permission flags."""
self.client.force_authenticate(user=self.editor_inst_a)
res = self.client.get("/api/v1/auth/me/")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(res.data['role']['slug'], "cultural_affairs_editor")
self.assertTrue(res.data['permission_flags']['can_publish_posts_directly'])
self.assertIn("can_publish_posts_directly", res.data['permissions'])
def test_published_post_author_edit_stages_revision_zero_downtime(self):
"""Option C: Editing a published post as a non-editor creates a staged revision with zero downtime."""
post = Post.objects.create(
title="Live Islamic Architecture Review",
content="Original live article content viewed by readers.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.PUBLISHED
)
self.client.force_authenticate(user=self.writer)
payload = {
"title": "Updated Architecture Analysis (v2)",
"content": "Expanded architectural breakdown with new discoveries."
}
res = self.client.patch(f"/api/v1/cms/posts/{post.id}/", payload, format="json")
self.assertEqual(res.status_code, status.HTTP_202_ACCEPTED)
self.assertTrue(res.data['has_pending_revision'])
self.assertEqual(res.data['revision']['title'], "Updated Architecture Analysis (v2)")
# Verify ZERO DOWNTIME: Live post in DB is still PUBLISHED with original content
post.refresh_from_db()
self.assertEqual(post.status, Post.Status.PUBLISHED)
self.assertEqual(post.title, "Live Islamic Architecture Review")
self.assertEqual(post.content, "Original live article content viewed by readers.")
# Staged revision exists in pending status
self.assertTrue(post.has_pending_revision)
revision = post.get_pending_revision()
self.assertIsNotNone(revision)
self.assertEqual(revision.status, PostRevision.Status.PENDING)
self.assertEqual(revision.title, "Updated Architecture Analysis (v2)")
def test_author_updating_existing_pending_revision(self):
"""Author can make additional edits to their pending revision before editor reviews it."""
post = Post.objects.create(
title="Live Research Paper",
content="Initial live text.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.PUBLISHED
)
self.client.force_authenticate(user=self.writer)
# First edit creates staged revision
self.client.patch(f"/api/v1/cms/posts/{post.id}/", {"title": "Title v2"}, format="json")
self.assertEqual(post.revisions.filter(status=PostRevision.Status.PENDING).count(), 1)
# Second edit updates existing pending revision rather than creating duplicate
res = self.client.patch(f"/api/v1/cms/posts/{post.id}/", {"title": "Title v3"}, format="json")
self.assertEqual(res.status_code, status.HTTP_202_ACCEPTED)
self.assertEqual(post.revisions.filter(status=PostRevision.Status.PENDING).count(), 1)
self.assertEqual(post.get_pending_revision().title, "Title v3")
def test_affiliated_editor_can_approve_staged_revision(self):
"""Affiliated editor approves staged revision, updating live post with zero downtime."""
post = Post.objects.create(
title="Original Title",
content="Original Content.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.PUBLISHED
)
revision = PostRevision.objects.create(
post=post,
author=self.writer,
title="Improved Live Title",
content="Improved Live Content.",
status=PostRevision.Status.PENDING
)
self.client.force_authenticate(user=self.editor_inst_a)
res = self.client.post(f"/api/v1/cms/posts/{post.id}/revisions/approve/")
self.assertEqual(res.status_code, status.HTTP_200_OK)
# Post is updated live seamlessly
post.refresh_from_db()
self.assertEqual(post.title, "Improved Live Title")
self.assertEqual(post.content, "Improved Live Content.")
self.assertEqual(post.status, Post.Status.PUBLISHED)
self.assertEqual(post.reviewed_by, self.editor_inst_a)
# Revision is marked approved
revision.refresh_from_db()
self.assertEqual(revision.status, PostRevision.Status.APPROVED)
self.assertFalse(post.has_pending_revision)
def test_affiliated_editor_can_reject_staged_revision_with_reason(self):
"""Rejecting a staged revision preserves the live post unchanged."""
post = Post.objects.create(
title="Stable Live Article",
content="Approved content.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.PUBLISHED
)
revision = PostRevision.objects.create(
post=post,
author=self.writer,
title="Unapproved Edits",
content="Controversial unverified claims.",
status=PostRevision.Status.PENDING
)
self.client.force_authenticate(user=self.editor_inst_a)
reason_msg = "Claims in section 3 are unverified by our editorial board."
res = self.client.post(f"/api/v1/cms/posts/{post.id}/revisions/reject/", {"reason": reason_msg}, format="json")
self.assertEqual(res.status_code, status.HTTP_200_OK)
# Live post remains unchanged and online
post.refresh_from_db()
self.assertEqual(post.title, "Stable Live Article")
self.assertEqual(post.status, Post.Status.PUBLISHED)
# Revision is rejected with reason
revision.refresh_from_db()
self.assertEqual(revision.status, PostRevision.Status.REJECTED)
self.assertEqual(revision.rejection_reason, reason_msg)
self.assertFalse(post.has_pending_revision)
def test_non_affiliated_editor_cannot_approve_revision(self):
"""Editor from Institution B cannot approve revision for Institution A."""
post = Post.objects.create(
title="Original Title",
content="Original Content.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.PUBLISHED
)
PostRevision.objects.create(
post=post,
author=self.writer,
title="Revised Title",
content="Revised Content.",
status=PostRevision.Status.PENDING
)
self.client.force_authenticate(user=self.editor_inst_b)
res = self.client.post(f"/api/v1/cms/posts/{post.id}/revisions/approve/")
self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN)
def test_author_delete_capability_enforcement(self):
"""Author can delete own post only if can_delete_own_posts capability flag is True."""
post = Post.objects.create(
title="Draft to Delete",
content="Content.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.DRAFT
)
# Explicitly revoke can_delete_own_posts dynamically in DB
self.writer_role.can_delete_own_posts = False
self.writer_role.save()
self.client.force_authenticate(user=self.writer)
res = self.client.delete(f"/api/v1/cms/posts/{post.id}/")
self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN)
# Grant can_delete_own_posts dynamically in DB
self.writer_role.can_delete_own_posts = True
self.writer_role.save()
res = self.client.delete(f"/api/v1/cms/posts/{post.id}/")
self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(Post.objects.filter(id=post.id).exists())
def test_post_creation_as_individual_user(self):
"""User can publish or draft as an individual author (author_type='user')."""
self.client.force_authenticate(user=self.writer)
payload = {
"title": "Individual Article",
"content": "Written by an individual researcher.",
"post_type": "article",
"category": self.category.id,
"author_type": "user",
"status": "draft",
}
res = self.client.post("/api/v1/cms/posts/", payload, format="json")
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
self.assertEqual(res.data["author_type"], "user")
self.assertEqual(res.data["author_display"]["type"], "user")
self.assertEqual(res.data["author_display"]["name"], self.writer.fullname)
self.assertEqual(res.data["author_display"]["id"], self.writer.id)
def test_post_creation_as_institution_authorized(self):
"""Authorized institution editor can author on behalf of institution (author_type='institution')."""
self.client.force_authenticate(user=self.editor_inst_a)
payload = {
"title": "Official Institution Report",
"content": "Official announcement issued by the cultural center.",
"post_type": "report",
"category": self.category.id,
"author_type": "institution",
"institution": self.inst_a.id,
"status": "published",
}
res = self.client.post("/api/v1/cms/posts/", payload, format="json")
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
self.assertEqual(res.data["author_type"], "institution")
self.assertEqual(res.data["author_display"]["type"], "institution")
self.assertEqual(res.data["author_display"]["name"], self.inst_a.name)
self.assertEqual(res.data["author_display"]["id"], self.inst_a.id)
self.assertEqual(res.data["author_display"]["submitted_by"]["id"], self.editor_inst_a.id)
def test_post_creation_as_institution_missing_institution_fails(self):
"""Authoring as institution without providing an institution ID fails validation."""
self.client.force_authenticate(user=self.editor_inst_a)
payload = {
"title": "Orphan Institution Article",
"content": "Missing institution FK.",
"post_type": "article",
"category": self.category.id,
"author_type": "institution",
"status": "draft",
}
res = self.client.post("/api/v1/cms/posts/", payload, format="json")
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
error_fields = [e.get("field") for e in res.data.get("errors", [])] if isinstance(res.data.get("errors"), list) else list(res.data.keys())
self.assertIn("institution", error_fields)
def test_post_creation_as_institution_unauthorized_user_fails(self):
"""User without editor/admin role in institution cannot author posts for it."""
self.client.force_authenticate(user=self.writer)
payload = {
"title": "Unauthorized Institutional Post",
"content": "Writer not an editor of Institution A.",
"post_type": "article",
"category": self.category.id,
"author_type": "institution",
"institution": self.inst_a.id,
"status": "draft",
}
res = self.client.post("/api/v1/cms/posts/", payload, format="json")
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
error_fields = [e.get("field") for e in res.data.get("errors", [])] if isinstance(res.data.get("errors"), list) else list(res.data.keys())
self.assertIn("institution", error_fields)
def test_post_filtering_by_author_type(self):
"""Posts endpoint supports filtering by author_type (user vs institution)."""
Post.objects.create(
title="User Post 1",
content="Content 1",
author=self.writer,
author_type=Post.AuthorType.USER,
category=self.category,
status=Post.Status.PUBLISHED,
)
Post.objects.create(
title="Institution Post 1",
content="Content 2",
author=self.editor_inst_a,
author_type=Post.AuthorType.INSTITUTION,
institution=self.inst_a,
category=self.category,
status=Post.Status.PUBLISHED,
)
res_user = self.client.get("/api/v1/cms/posts/?author_type=user")
self.assertEqual(res_user.status_code, status.HTTP_200_OK)
user_titles = [p["title"] for p in res_user.data["results"]]
self.assertIn("User Post 1", user_titles)
self.assertNotIn("Institution Post 1", user_titles)
res_inst = self.client.get("/api/v1/cms/posts/?author_type=institution")
self.assertEqual(res_inst.status_code, status.HTTP_200_OK)
inst_titles = [p["title"] for p in res_inst.data["results"]]
self.assertIn("Institution Post 1", inst_titles)
self.assertNotIn("User Post 1", inst_titles)
def test_author_can_unpublish_published_post_to_draft(self):
"""Author can unpublish their published article back to draft directly with 200 OK."""
post = Post.objects.create(
title="Post to Unpublish",
content="Content.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.PUBLISHED,
)
# Create a pending revision to ensure it gets cleaned up
PostRevision.objects.create(
post=post,
author=self.writer,
title="Revised title",
content="Revised content",
status=PostRevision.Status.PENDING,
)
self.assertTrue(post.has_pending_revision)
self.client.force_authenticate(user=self.writer)
res = self.client.patch(f"/api/v1/cms/posts/{post.id}/", {"status": "draft"}, format="json")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(res.data["status"], Post.Status.DRAFT)
post.refresh_from_db()
self.assertEqual(post.status, Post.Status.DRAFT)
self.assertFalse(post.has_pending_revision)
def test_author_can_delete_owned_published_post(self):
"""Author with can_delete_own_posts can delete their owned post even if already published."""
self.writer_role.can_delete_own_posts = True
self.writer_role.save()
post = Post.objects.create(
title="Published Post to Delete",
content="Content.",
author=self.writer,
institution=self.inst_a,
category=self.category,
status=Post.Status.PUBLISHED,
)
self.client.force_authenticate(user=self.writer)
res = self.client.delete(f"/api/v1/cms/posts/{post.id}/")
self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(Post.objects.filter(id=post.id).exists())
def test_institution_editor_can_delete_owned_institution_post(self):
"""Institution editor can delete post owned by their institution."""
post = Post.objects.create(
title="Institution Post to Delete",
content="Content.",
author=self.writer,
author_type=Post.AuthorType.INSTITUTION,
institution=self.inst_a,
category=self.category,
status=Post.Status.PUBLISHED,
)
self.client.force_authenticate(user=self.editor_inst_a)
res = self.client.delete(f"/api/v1/cms/posts/{post.id}/")
self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(Post.objects.filter(id=post.id).exists())
def test_institution_editor_can_unpublish_institution_post_to_draft(self):
"""Institution editor can unpublish their institution post back to draft."""
post = Post.objects.create(
title="Institution Post to Unpublish",
content="Content.",
author=self.writer,
author_type=Post.AuthorType.INSTITUTION,
institution=self.inst_a,
category=self.category,
status=Post.Status.PUBLISHED,
)
self.client.force_authenticate(user=self.editor_inst_a)
res = self.client.patch(f"/api/v1/cms/posts/{post.id}/", {"status": "draft"}, format="json")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(res.data["status"], Post.Status.DRAFT)
post.refresh_from_db()
self.assertEqual(post.status, Post.Status.DRAFT)

10
apps/cms/tests/test_phase4_cms.py

@ -175,11 +175,15 @@ class Phase4CMSTests(TestCase):
forbidden_resp = self.client.patch(f"/api/v1/cms/posts/{self.post.id}/", patch_payload, format="json")
self.assertEqual(forbidden_resp.status_code, status.HTTP_403_FORBIDDEN)
# 3. Author can successfully update
# 3. Author can successfully update (Option C returns 202 Accepted staged revision for published post)
self.client.force_authenticate(user=self.author_user)
author_resp = self.client.patch(f"/api/v1/cms/posts/{self.post.id}/", patch_payload, format="json")
self.assertEqual(author_resp.status_code, status.HTTP_200_OK)
self.assertEqual(author_resp.data["title"], patch_payload["title"])
self.assertIn(author_resp.status_code, [status.HTTP_200_OK, status.HTTP_202_ACCEPTED])
if author_resp.status_code == status.HTTP_202_ACCEPTED:
self.assertTrue(author_resp.data["has_pending_revision"])
self.assertEqual(author_resp.data["revision"]["title"], patch_payload["title"])
else:
self.assertEqual(author_resp.data["title"], patch_payload["title"])
def test_post_like_toggle(self):
self.client.force_authenticate(user=self.other_user)

11
apps/cms/urls.py

@ -7,12 +7,22 @@ from apps.cms.views import (
PostCategoryListView,
PostTagListView,
MediaAssetListCreateView,
PostApproveView,
PostRejectView,
PostRevisionListView,
PostRevisionApproveView,
PostRevisionRejectView,
)
urlpatterns = [
# Posts & Articles
path('posts/', PostListCreateView.as_view(), name='cms_post_list_create'),
path('posts/<str:pk_or_slug>/', PostDetailView.as_view(), name='cms_post_detail'),
path('posts/<str:pk_or_slug>/approve/', PostApproveView.as_view(), name='cms_post_approve'),
path('posts/<str:pk_or_slug>/reject/', PostRejectView.as_view(), name='cms_post_reject'),
path('posts/<str:pk_or_slug>/revisions/', PostRevisionListView.as_view(), name='cms_post_revisions_list'),
path('posts/<str:pk_or_slug>/revisions/approve/', PostRevisionApproveView.as_view(), name='cms_post_revision_approve'),
path('posts/<str:pk_or_slug>/revisions/reject/', PostRevisionRejectView.as_view(), name='cms_post_revision_reject'),
path('posts/<str:pk_or_slug>/like/', PostLikeToggleView.as_view(), name='cms_post_like_toggle'),
path('posts/<str:pk_or_slug>/comments/', PostCommentsView.as_view(), name='cms_post_comments'),
@ -23,3 +33,4 @@ urlpatterns = [
# Media Asset Library
path('media/', MediaAssetListCreateView.as_view(), name='cms_media'),
]

11
apps/cms/views/__init__.py

@ -6,6 +6,11 @@ from .post_views import (
PostCategoryListView,
PostTagListView,
MediaAssetListCreateView,
PostApproveView,
PostRejectView,
PostRevisionListView,
PostRevisionApproveView,
PostRevisionRejectView,
)
__all__ = [
@ -16,4 +21,10 @@ __all__ = [
'PostCategoryListView',
'PostTagListView',
'MediaAssetListCreateView',
'PostApproveView',
'PostRejectView',
'PostRevisionListView',
'PostRevisionApproveView',
'PostRevisionRejectView',
]

392
apps/cms/views/post_views.py

@ -1,26 +1,34 @@
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
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
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
from apps.cms.permissions import (
IsAuthorOrEditorOrReadOnly,
CanPublishPost,
can_publish_or_moderate_post,
)
from apps.cms.serializers import (
PostCategorySerializer,
PostTagSerializer,
PostRevisionSerializer,
PostRevisionCreateSerializer,
PostCommentSerializer,
PostCommentCreateSerializer,
PostListSerializer,
@ -35,10 +43,19 @@ logger = logging.getLogger(__name__)
def get_post_by_id_or_slug(lookup_val, user=None):
qs = Post.objects.all()
# Filter visible status if anonymous
if not user or not user.is_authenticated or not (user.is_staff or getattr(user, 'is_super_admin', False) or getattr(user, 'is_regional_admin', False)):
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))
@ -61,27 +78,45 @@ class PostListCreateView(GenericAPIView):
user = self.request.user
qs = Post.objects.all().select_related('author', 'category', 'institution')
# Visibility filter: non-staff only see published posts unless filtering by my_posts
is_admin = user.is_authenticated and (user.is_staff or getattr(user, 'is_super_admin', False) or getattr(user, 'is_regional_admin', False))
status_param = self.request.query_params.get('status')
if status_param and is_admin:
qs = qs.filter(status=status_param)
elif not is_admin:
# If requesting own posts, can see drafts
my_posts = self.request.query_params.get('my_posts')
if my_posts and user.is_authenticated:
qs = qs.filter(author=user)
if status_param:
qs = qs.filter(status=status_param)
else:
qs = qs.filter(status=Post.Status.PUBLISHED)
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)
@ -124,6 +159,7 @@ class PostListCreateView(GenericAPIView):
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'),
@ -203,16 +239,63 @@ class PostDetailView(GenericAPIView):
@extend_schema(
summary="Update post",
description="Updates an existing post. Permitted for original author, institution editors, or platform admins.",
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,
@ -222,9 +305,16 @@ class PostDetailView(GenericAPIView):
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.",
@ -406,3 +496,263 @@ class MediaAssetListCreateView(GenericAPIView):
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)
Loading…
Cancel
Save