diff --git a/apps/account/models/user.py b/apps/account/models/user.py index 0b24664..9c02ba6 100644 --- a/apps/account/models/user.py +++ b/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 diff --git a/apps/account/serializers/auth_serializers.py b/apps/account/serializers/auth_serializers.py index 8eb7ed6..ea3080e 100644 --- a/apps/account/serializers/auth_serializers.py +++ b/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 diff --git a/apps/cms/admin.py b/apps/cms/admin.py index b187c6a..9ebb742 100644 --- a/apps/cms/admin.py +++ b/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.")) diff --git a/apps/cms/migrations/0002_post_rejection_reason_post_reviewed_at_and_more.py b/apps/cms/migrations/0002_post_rejection_reason_post_reviewed_at_and_more.py new file mode 100644 index 0000000..c6681d6 --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/cms/migrations/0003_postrevision.py b/apps/cms/migrations/0003_postrevision.py new file mode 100644 index 0000000..6c07220 --- /dev/null +++ b/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',), + }, + ), + ] diff --git a/apps/cms/migrations/0004_post_author_type_alter_post_author_and_more.py b/apps/cms/migrations/0004_post_author_type_alter_post_author_and_more.py new file mode 100644 index 0000000..42a3f20 --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/cms/models/__init__.py b/apps/cms/models/__init__.py index f631df2..e14abc3 100644 --- a/apps/cms/models/__init__.py +++ b/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', ] + diff --git a/apps/cms/models/post.py b/apps/cms/models/post.py index 17a3780..cda30c5 100644 --- a/apps/cms/models/post.py +++ b/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): diff --git a/apps/cms/permissions.py b/apps/cms/permissions.py index 7b0c920..fb001c3 100644 --- a/apps/cms/permissions.py +++ b/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') diff --git a/apps/cms/serializers/__init__.py b/apps/cms/serializers/__init__.py index 20b264a..ffb2a9b 100644 --- a/apps/cms/serializers/__init__.py +++ b/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', ] diff --git a/apps/cms/serializers/post_serializers.py b/apps/cms/serializers/post_serializers.py index 945a0ec..c3683f0 100644 --- a/apps/cms/serializers/post_serializers.py +++ b/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() diff --git a/apps/cms/tests/test_editorial_workflow.py b/apps/cms/tests/test_editorial_workflow.py new file mode 100644 index 0000000..1138e88 --- /dev/null +++ b/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="writer@center.org", + password="Password123!", + fullname="Sadiq Writer", + role=self.writer_role + ) + + self.editor_inst_a = User.objects.create_user( + email="editor.a@center.org", + password="Password123!", + fullname="Fatima Editor A", + role=self.editor_role + ) + + self.editor_inst_b = User.objects.create_user( + email="editor.b@center.org", + 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) diff --git a/apps/cms/tests/test_phase4_cms.py b/apps/cms/tests/test_phase4_cms.py index 580275f..4f7da38 100644 --- a/apps/cms/tests/test_phase4_cms.py +++ b/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) diff --git a/apps/cms/urls.py b/apps/cms/urls.py index ec5006c..c111114 100644 --- a/apps/cms/urls.py +++ b/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//', PostDetailView.as_view(), name='cms_post_detail'), + path('posts//approve/', PostApproveView.as_view(), name='cms_post_approve'), + path('posts//reject/', PostRejectView.as_view(), name='cms_post_reject'), + path('posts//revisions/', PostRevisionListView.as_view(), name='cms_post_revisions_list'), + path('posts//revisions/approve/', PostRevisionApproveView.as_view(), name='cms_post_revision_approve'), + path('posts//revisions/reject/', PostRevisionRejectView.as_view(), name='cms_post_revision_reject'), path('posts//like/', PostLikeToggleView.as_view(), name='cms_post_like_toggle'), path('posts//comments/', PostCommentsView.as_view(), name='cms_post_comments'), @@ -23,3 +33,4 @@ urlpatterns = [ # Media Asset Library path('media/', MediaAssetListCreateView.as_view(), name='cms_media'), ] + diff --git a/apps/cms/views/__init__.py b/apps/cms/views/__init__.py index e60ad3c..e06ac62 100644 --- a/apps/cms/views/__init__.py +++ b/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', ] + diff --git a/apps/cms/views/post_views.py b/apps/cms/views/post_views.py index 5e688a6..d50b94f 100644 --- a/apps/cms/views/post_views.py +++ b/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) +