Browse Source
feat: implement CMS application with models, admin, serializers, and views for editorial workflow and post management
main
feat: implement CMS application with models, admin, serializers, and views for editorial workflow and post management
main
16 changed files with 1721 additions and 73 deletions
-
10apps/account/models/user.py
-
61apps/account/serializers/auth_serializers.py
-
62apps/cms/admin.py
-
36apps/cms/migrations/0002_post_rejection_reason_post_reviewed_at_and_more.py
-
41apps/cms/migrations/0003_postrevision.py
-
32apps/cms/migrations/0004_post_author_type_alter_post_author_and_more.py
-
3apps/cms/models/__init__.py
-
188apps/cms/models/post.py
-
82apps/cms/permissions.py
-
6apps/cms/serializers/__init__.py
-
229apps/cms/serializers/post_serializers.py
-
620apps/cms/tests/test_editorial_workflow.py
-
10apps/cms/tests/test_phase4_cms.py
-
11apps/cms/urls.py
-
11apps/cms/views/__init__.py
-
392apps/cms/views/post_views.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'), |
||||
|
), |
||||
|
] |
||||
@ -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',), |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -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'), |
||||
|
), |
||||
|
] |
||||
@ -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) |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue