diff --git a/api/tests/test_comments.py b/api/tests/test_comments.py
new file mode 100644
index 0000000..aa5fc4f
--- /dev/null
+++ b/api/tests/test_comments.py
@@ -0,0 +1,454 @@
+"""
+Tests for the task comment system.
+
+Covers:
+- CRUD operations for personal task comments
+- Role-based access control for project task comments
+- Edge cases: nonexistent task, unauthorized access, empty text
+"""
+
+from django.urls import reverse
+from rest_framework import status
+
+from projects.models import Project, Role, ProjectMembership
+from tasks.models import Task, TaskComment
+
+from .test_setup import BaseAPITestCase
+from .utils import TestHelper
+
+
+# ---------------------------------------------------------------------------
+# Helper mixins
+# ---------------------------------------------------------------------------
+
+class CommentTestMixin:
+ """Shared URL helpers for comment tests."""
+
+ @staticmethod
+ def comment_list_url(task_pk):
+ return reverse("task-comments-list", kwargs={"task_pk": task_pk})
+
+ @staticmethod
+ def comment_detail_url(task_pk, comment_pk):
+ return reverse("task-comments-detail", kwargs={"task_pk": task_pk, "pk": comment_pk})
+
+ @staticmethod
+ def project_comment_list_url(project_pk, task_pk):
+ return reverse(
+ "project-task-comments-list",
+ kwargs={"project_pk": project_pk, "task_pk": task_pk},
+ )
+
+ @staticmethod
+ def project_comment_detail_url(project_pk, task_pk, comment_pk):
+ return reverse(
+ "project-task-comments-detail",
+ kwargs={"project_pk": project_pk, "task_pk": task_pk, "pk": comment_pk},
+ )
+
+ def auth_as(self, token):
+ """Switch the test client to use the given JWT token."""
+ self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
+
+ def unauth(self):
+ """Remove credentials (simulate unauthenticated request)."""
+ self.client.credentials()
+
+
+# ---------------------------------------------------------------------------
+# Personal task comment tests
+# ---------------------------------------------------------------------------
+
+class PersonalTaskCommentTests(CommentTestMixin, BaseAPITestCase):
+ """
+ Tests for comments on personal (non-project) tasks.
+ Only the task owner may read and create comments.
+ """
+
+ @classmethod
+ def setUpTestData(cls):
+ super().setUpTestData()
+ cls.other_user, cls.other_token, _ = TestHelper.create_test_user_via_orm(
+ email="other_personal@example.com", password="otherpassword123"
+ )
+ cls.task = Task.objects.create(
+ title="Personal Task",
+ user=cls.user,
+ due_date="2099-01-01T00:00:00Z",
+ priority="M",
+ )
+
+ # -- CREATE --------------------------------------------------------------
+
+ def test_create_comment_as_owner(self):
+ self.auth_as(self.token)
+ url = self.comment_list_url(self.task.pk)
+ response = self.client.post(url, {"text": "My first comment"}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ self.assertEqual(response.data["text"], "My first comment")
+ self.assertEqual(response.data["author"], self.user.id)
+ self.assertIn("author_name", response.data)
+ self.assertIn("created_at", response.data)
+
+ def test_create_comment_unauthenticated(self):
+ self.unauth()
+ url = self.comment_list_url(self.task.pk)
+ response = self.client.post(url, {"text": "Hi"}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ def test_create_comment_non_owner_forbidden(self):
+ self.auth_as(self.other_token)
+ url = self.comment_list_url(self.task.pk)
+ response = self.client.post(url, {"text": "Sneaky comment"}, format="json")
+ self.assertIn(response.status_code, [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND])
+
+ def test_create_comment_empty_text(self):
+ self.auth_as(self.token)
+ url = self.comment_list_url(self.task.pk)
+ response = self.client.post(url, {"text": " "}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_create_comment_missing_text(self):
+ self.auth_as(self.token)
+ url = self.comment_list_url(self.task.pk)
+ response = self.client.post(url, {}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertIn("text", response.data)
+
+ def test_create_comment_on_nonexistent_task(self):
+ self.auth_as(self.token)
+ url = self.comment_list_url(99999)
+ response = self.client.post(url, {"text": "Hello"}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+ # -- READ ----------------------------------------------------------------
+
+ def test_list_comments_as_owner(self):
+ self.auth_as(self.token)
+ TaskComment.objects.create(task=self.task, author=self.user, text="First")
+ TaskComment.objects.create(task=self.task, author=self.user, text="Second")
+ url = self.comment_list_url(self.task.pk)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertGreaterEqual(len(response.data), 2)
+
+ def test_list_comments_non_owner_forbidden(self):
+ self.auth_as(self.other_token)
+ url = self.comment_list_url(self.task.pk)
+ response = self.client.get(url)
+ self.assertIn(response.status_code, [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND])
+
+ def test_retrieve_comment_as_owner(self):
+ self.auth_as(self.token)
+ comment = TaskComment.objects.create(task=self.task, author=self.user, text="Detail me")
+ url = self.comment_detail_url(self.task.pk, comment.pk)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data["text"], "Detail me")
+
+ # -- UPDATE --------------------------------------------------------------
+
+ def test_update_own_comment(self):
+ self.auth_as(self.token)
+ comment = TaskComment.objects.create(task=self.task, author=self.user, text="Original")
+ url = self.comment_detail_url(self.task.pk, comment.pk)
+ response = self.client.patch(url, {"text": "Updated"}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data["text"], "Updated")
+
+ def test_update_comment_empty_text(self):
+ self.auth_as(self.token)
+ comment = TaskComment.objects.create(task=self.task, author=self.user, text="Original")
+ url = self.comment_detail_url(self.task.pk, comment.pk)
+ response = self.client.patch(url, {"text": " "}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ # -- DELETE --------------------------------------------------------------
+
+ def test_delete_own_comment(self):
+ self.auth_as(self.token)
+ comment = TaskComment.objects.create(task=self.task, author=self.user, text="Delete me")
+ url = self.comment_detail_url(self.task.pk, comment.pk)
+ response = self.client.delete(url)
+ self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+ self.assertFalse(TaskComment.objects.filter(pk=comment.pk).exists())
+
+ def test_delete_nonexistent_comment(self):
+ self.auth_as(self.token)
+ url = self.comment_detail_url(self.task.pk, 99999)
+ response = self.client.delete(url)
+ self.assertIn(response.status_code, [status.HTTP_404_NOT_FOUND, status.HTTP_403_FORBIDDEN])
+
+ # -- ORDERING ------------------------------------------------------------
+
+ def test_comments_ordered_by_created_at(self):
+ self.auth_as(self.token)
+ task = Task.objects.create(
+ title="Ordering Task", user=self.user,
+ due_date="2099-01-01T00:00:00Z", priority="M",
+ )
+ c1 = TaskComment.objects.create(task=task, author=self.user, text="First")
+ c2 = TaskComment.objects.create(task=task, author=self.user, text="Second")
+ url = self.comment_list_url(task.pk)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ ids = [c["id"] for c in response.data]
+ self.assertIn(c1.pk, ids)
+ self.assertIn(c2.pk, ids)
+ self.assertLess(ids.index(c1.pk), ids.index(c2.pk))
+
+
+# ---------------------------------------------------------------------------
+# Project task comment tests
+# ---------------------------------------------------------------------------
+
+class ProjectTaskCommentSetup(CommentTestMixin, BaseAPITestCase):
+ """
+ Base setup for project task comment tests.
+ Creates project + roles + memberships for Viewer, Member, Moderator, Admin.
+ """
+
+ @classmethod
+ def setUpTestData(cls):
+ super().setUpTestData()
+ # Extra users with unique emails
+ cls.viewer_user, cls.viewer_token, _ = TestHelper.create_test_user_via_orm(
+ email="proj_viewer@example.com", password="password123"
+ )
+ cls.member_user, cls.member_token, _ = TestHelper.create_test_user_via_orm(
+ email="proj_member@example.com", password="password123"
+ )
+ cls.moderator_user, cls.moderator_token, _ = TestHelper.create_test_user_via_orm(
+ email="proj_moderator@example.com", password="password123"
+ )
+ cls.outsider_user, cls.outsider_token, _ = TestHelper.create_test_user_via_orm(
+ email="proj_outsider@example.com", password="password123"
+ )
+
+ # Roles
+ cls.viewer_role = Role.objects.get_or_create(name="Viewer")[0]
+ cls.member_role = Role.objects.get_or_create(name="Member")[0]
+ cls.moderator_role = Role.objects.get_or_create(name="Moderator")[0]
+ cls.admin_role = Role.objects.get_or_create(name="Admin")[0]
+
+ # Project owned by cls.user
+ cls.project = Project.objects.create(name="Comment Test Project", owner=cls.user)
+
+ # Memberships
+ ProjectMembership.objects.create(project=cls.project, user=cls.viewer_user, role=cls.viewer_role)
+ ProjectMembership.objects.create(project=cls.project, user=cls.member_user, role=cls.member_role)
+ ProjectMembership.objects.create(project=cls.project, user=cls.moderator_user, role=cls.moderator_role)
+
+ # Task inside the project
+ cls.proj_task = Task.objects.create(
+ title="Project Comment Task",
+ user=cls.user,
+ project=cls.project,
+ due_date="2099-01-01T00:00:00Z",
+ priority="M",
+ )
+
+
+class ProjectTaskCommentCRUDTests(ProjectTaskCommentSetup):
+ """CRUD tests for project task comments."""
+
+ # -- CREATE --------------------------------------------------------------
+
+ def test_owner_can_create_comment(self):
+ self.auth_as(self.token)
+ url = self.project_comment_list_url(self.project.pk, self.proj_task.pk)
+ response = self.client.post(url, {"text": "Owner comment"}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ self.assertEqual(response.data["text"], "Owner comment")
+
+ def test_member_can_create_comment(self):
+ self.auth_as(self.member_token)
+ url = self.project_comment_list_url(self.project.pk, self.proj_task.pk)
+ response = self.client.post(url, {"text": "Member comment"}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ def test_viewer_cannot_create_comment(self):
+ self.auth_as(self.viewer_token)
+ url = self.project_comment_list_url(self.project.pk, self.proj_task.pk)
+ response = self.client.post(url, {"text": "Viewer comment"}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_outsider_cannot_create_comment(self):
+ self.auth_as(self.outsider_token)
+ url = self.project_comment_list_url(self.project.pk, self.proj_task.pk)
+ response = self.client.post(url, {"text": "Outsider comment"}, format="json")
+ self.assertIn(response.status_code, [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND])
+
+ def test_unauthenticated_cannot_create_comment(self):
+ self.unauth()
+ url = self.project_comment_list_url(self.project.pk, self.proj_task.pk)
+ response = self.client.post(url, {"text": "Anon comment"}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ # -- READ ----------------------------------------------------------------
+
+ def test_viewer_can_list_comments(self):
+ self.auth_as(self.token)
+ TaskComment.objects.create(task=self.proj_task, author=self.user, text="Listed comment")
+ self.auth_as(self.viewer_token)
+ url = self.project_comment_list_url(self.project.pk, self.proj_task.pk)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ def test_member_can_list_comments(self):
+ self.auth_as(self.member_token)
+ url = self.project_comment_list_url(self.project.pk, self.proj_task.pk)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ def test_outsider_cannot_list_comments(self):
+ self.auth_as(self.outsider_token)
+ url = self.project_comment_list_url(self.project.pk, self.proj_task.pk)
+ response = self.client.get(url)
+ self.assertIn(response.status_code, [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND])
+
+ # -- UPDATE --------------------------------------------------------------
+
+ def test_author_can_update_own_comment(self):
+ self.auth_as(self.member_token)
+ # Create comment as member
+ comment = TaskComment.objects.create(
+ task=self.proj_task, author=self.member_user, text="Original member comment"
+ )
+ url = self.project_comment_detail_url(self.project.pk, self.proj_task.pk, comment.pk)
+ response = self.client.patch(url, {"text": "Updated member comment"}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data["text"], "Updated member comment")
+
+ def test_non_author_cannot_update_comment(self):
+ # moderator tries to patch member comment
+ comment = TaskComment.objects.create(
+ task=self.proj_task, author=self.member_user, text="Member comment"
+ )
+ self.auth_as(self.moderator_token)
+ url = self.project_comment_detail_url(self.project.pk, self.proj_task.pk, comment.pk)
+ response = self.client.patch(url, {"text": "Hacked"}, format="json")
+ self.assertIn(response.status_code, [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND])
+
+ def test_moderator_cannot_update_others_comment(self):
+ """PUT/PATCH is restricted to author only - Moderator cannot edit another user's comment."""
+ comment = TaskComment.objects.create(
+ task=self.proj_task, author=self.member_user, text="Protected comment"
+ )
+ self.auth_as(self.moderator_token)
+ url = self.project_comment_detail_url(self.project.pk, self.proj_task.pk, comment.pk)
+ response = self.client.patch(url, {"text": "Moderator edit"}, format="json")
+ self.assertIn(response.status_code, [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND])
+
+ # -- DELETE --------------------------------------------------------------
+
+ def test_author_can_delete_own_comment(self):
+ comment = TaskComment.objects.create(
+ task=self.proj_task, author=self.member_user, text="Delete me"
+ )
+ self.auth_as(self.member_token)
+ url = self.project_comment_detail_url(self.project.pk, self.proj_task.pk, comment.pk)
+ response = self.client.delete(url)
+ self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+ self.assertFalse(TaskComment.objects.filter(pk=comment.pk).exists())
+
+ def test_moderator_can_delete_any_comment(self):
+ comment = TaskComment.objects.create(
+ task=self.proj_task, author=self.member_user, text="Moderable comment"
+ )
+ self.auth_as(self.moderator_token)
+ url = self.project_comment_detail_url(self.project.pk, self.proj_task.pk, comment.pk)
+ response = self.client.delete(url)
+ self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+ self.assertFalse(TaskComment.objects.filter(pk=comment.pk).exists())
+
+ def test_viewer_cannot_delete_comment(self):
+ comment = TaskComment.objects.create(
+ task=self.proj_task, author=self.user, text="Owner comment to guard"
+ )
+ self.auth_as(self.viewer_token)
+ url = self.project_comment_detail_url(self.project.pk, self.proj_task.pk, comment.pk)
+ response = self.client.delete(url)
+ self.assertIn(response.status_code, [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND])
+ self.assertTrue(TaskComment.objects.filter(pk=comment.pk).exists())
+
+ def test_member_cannot_delete_others_comment(self):
+ comment = TaskComment.objects.create(
+ task=self.proj_task, author=self.user, text="Owner comment"
+ )
+ self.auth_as(self.member_token)
+ url = self.project_comment_detail_url(self.project.pk, self.proj_task.pk, comment.pk)
+ response = self.client.delete(url)
+ self.assertIn(response.status_code, [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND])
+ self.assertTrue(TaskComment.objects.filter(pk=comment.pk).exists())
+
+ def test_owner_can_delete_any_comment(self):
+ """Project owner has full access."""
+ comment = TaskComment.objects.create(
+ task=self.proj_task, author=self.member_user, text="Member comment"
+ )
+ self.auth_as(self.token)
+ url = self.project_comment_detail_url(self.project.pk, self.proj_task.pk, comment.pk)
+ response = self.client.delete(url)
+ self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+
+# ---------------------------------------------------------------------------
+# Edge cases
+# ---------------------------------------------------------------------------
+
+class CommentEdgeCaseTests(ProjectTaskCommentSetup):
+
+ def test_comment_on_nonexistent_project_task(self):
+ self.auth_as(self.token)
+ url = self.project_comment_list_url(self.project.pk, 99999)
+ response = self.client.post(url, {"text": "Ghost"}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+ def test_comment_on_task_from_wrong_project(self):
+ """Task belongs to another project - accessing via wrong project_pk should 404."""
+ other_project = Project.objects.create(name="Other Comment Project", owner=self.user)
+ other_task = Task.objects.create(
+ title="Other Task",
+ user=self.user,
+ project=other_project,
+ due_date="2099-01-01T00:00:00Z",
+ priority="M",
+ )
+ self.auth_as(self.token)
+ # Access via wrong project_pk
+ url = self.project_comment_list_url(self.project.pk, other_task.pk)
+ response = self.client.post(url, {"text": "Cross-project"}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+ def test_list_returns_only_task_comments(self):
+ """Comments from a different task do not appear in the current task list."""
+ task2 = Task.objects.create(
+ title="Another Task In Project",
+ user=self.user,
+ project=self.project,
+ due_date="2099-01-01T00:00:00Z",
+ priority="L",
+ )
+ self.auth_as(self.token)
+ c1 = TaskComment.objects.create(task=self.proj_task, author=self.user, text="Comment on proj_task")
+ TaskComment.objects.create(task=task2, author=self.user, text="Comment on task2")
+
+ url = self.project_comment_list_url(self.project.pk, self.proj_task.pk)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ ids = [c["id"] for c in response.data]
+ self.assertIn(c1.pk, ids)
+
+ def test_comment_text_validator_rejects_special_chars(self):
+ """TEXT_FIELD_VALIDATOR rejects characters outside the allowed set."""
+ self.auth_as(self.token)
+ task = Task.objects.create(
+ title="Validator Task",
+ user=self.user,
+ due_date="2099-01-01T00:00:00Z",
+ priority="M",
+ )
+ url = self.comment_list_url(task.pk)
+ response = self.client.post(url, {"text": ""}, format="json")
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
diff --git a/projects/urls.py b/projects/urls.py
index 7320cd6..3422479 100644
--- a/projects/urls.py
+++ b/projects/urls.py
@@ -1,7 +1,7 @@
from django.urls import path, include
from rest_framework_nested import routers
-from tasks.views import TaskViewSet
+from tasks.views import TaskViewSet, TaskCommentViewSet
from .views import (
ProjectShareLinkViewSet, ProjectViewSet, RoleViewSet,
@@ -20,8 +20,13 @@
projects_router.register(r"tasks", TaskViewSet, basename="project-tasks")
projects_router.register(r"share_links", ProjectShareLinkViewSet, basename="project-share-links")
+# Nested router for comments under project tasks
+project_tasks_router = routers.NestedDefaultRouter(projects_router, r"tasks", lookup="task")
+project_tasks_router.register(r"comments", TaskCommentViewSet, basename="project-task-comments")
+
urlpatterns = [
path("", include(router.urls)),
path("", include(projects_router.urls)),
+ path("", include(project_tasks_router.urls)),
path("join//", join_project, name="join-project"),
]
diff --git a/tasks/migrations/0006_add_taskcomment_model.py b/tasks/migrations/0006_add_taskcomment_model.py
new file mode 100644
index 0000000..55c01a0
--- /dev/null
+++ b/tasks/migrations/0006_add_taskcomment_model.py
@@ -0,0 +1,32 @@
+# Generated by Django 5.1.9 on 2026-06-03 17:52
+
+import django.core.validators
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tasks', '0005_alter_category_name_alter_task_description_and_more'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='TaskComment',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('text', models.TextField(validators=[django.core.validators.RegexValidator('^[a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ0-9_. , -]+$', 'Text can contain letters (latin/cyrillic), numbers, underscores, dots, dashes, commas and spaces')])),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_comments', to=settings.AUTH_USER_MODEL)),
+ ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='tasks.task')),
+ ],
+ options={
+ 'ordering': ['created_at'],
+ 'indexes': [models.Index(fields=['task'], name='tasks_taskc_task_id_8332b2_idx')],
+ },
+ ),
+ ]
diff --git a/tasks/models.py b/tasks/models.py
index cbd3657..969e3bc 100644
--- a/tasks/models.py
+++ b/tasks/models.py
@@ -110,3 +110,33 @@ def update_user_last_task_completed(sender, instance, created, **kwargs):
if instance.completed and instance.user:
instance.user.last_task_completed_at = timezone.now()
instance.user.save(update_fields=["last_task_completed_at"])
+
+
+class TaskComment(models.Model):
+ """
+ A comment on a task, authored by a user.
+ - Personal task comments are visible only to the task owner.
+ - Project task comments follow the project role hierarchy.
+ """
+
+ task = models.ForeignKey(
+ Task,
+ on_delete=models.CASCADE,
+ related_name="comments",
+ db_index=True,
+ )
+ author = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="task_comments",
+ )
+ text = models.TextField(validators=[TEXT_FIELD_VALIDATOR])
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ["created_at"]
+ indexes = [models.Index(fields=["task"])]
+
+ def __str__(self):
+ return f"Comment by {self.author.username} on task {self.task_id}"
diff --git a/tasks/permissions.py b/tasks/permissions.py
index 0708f13..9a08fa0 100644
--- a/tasks/permissions.py
+++ b/tasks/permissions.py
@@ -17,12 +17,8 @@ def has_object_permission(self, request, view, obj):
user_field = getattr(obj, "user", None) or getattr(obj, "owner", None)
if user_field is None:
- logger.error(
- f"Object {type(obj).__name__} has no ownership attribute"
- )
- raise PermissionDenied(
- "Access denied: missing ownership information"
- )
+ logger.error(f"Object {type(obj).__name__} has no ownership attribute")
+ raise PermissionDenied("Access denied: missing ownership information")
return user_field == request.user
@@ -48,5 +44,54 @@ def has_object_permission(self, request, view, obj):
return True
min_role = self._get_min_role(request.method)
-
return IsProjectMinRole(min_role).has_object_permission(request, view, obj)
+
+
+class IsCommentAuthor(BasePermission):
+ """Allows access only to the comment author."""
+
+ def has_object_permission(self, request, view, obj):
+ return obj.author == request.user
+
+
+class ProjectCommentPermission(BasePermission):
+ """
+ Permission for comments on project tasks.
+
+ obj is a TaskComment. We use obj.task.project for role checks.
+
+ - GET/HEAD/OPTIONS: Viewer+
+ - POST: Member+ (enforced in the viewset create() before get_object is called)
+ - PUT/PATCH: comment author only
+ - DELETE: comment author OR Moderator+
+ """
+
+ def has_permission(self, request, view):
+ return True
+
+ def has_object_permission(self, request, view, obj):
+ task = obj.task
+ project = getattr(task, "project", None)
+
+ if project is None:
+ # personal task fallback — treated as owner-only
+ return task.user_id == request.user.pk
+
+ def _min_role(role):
+ return IsProjectMinRole(role).has_object_permission(request, view, project)
+
+ if request.method in SAFE_METHODS:
+ return _min_role("Viewer")
+
+ if request.method == "POST":
+ return _min_role("Member")
+
+ if request.method in ("PUT", "PATCH"):
+ return obj.author_id == request.user.pk
+
+ if request.method == "DELETE":
+ if obj.author_id == request.user.pk:
+ return True
+ return _min_role("Moderator")
+
+ return False
diff --git a/tasks/serializers.py b/tasks/serializers.py
index 01a00e1..989e39d 100644
--- a/tasks/serializers.py
+++ b/tasks/serializers.py
@@ -1,7 +1,7 @@
from django.utils import timezone
from rest_framework import serializers
-from .models import Task, Category
+from .models import Task, Category, TaskComment
class TaskSerializer(serializers.ModelSerializer):
@@ -80,3 +80,19 @@ def validate_name(self, value):
def get_tasks_count(self, obj):
return obj.tasks.count()
+
+
+class TaskCommentSerializer(serializers.ModelSerializer):
+ author_name: serializers.StringRelatedField = serializers.StringRelatedField(
+ source="author.username", read_only=True
+ )
+
+ class Meta:
+ model = TaskComment
+ fields = ["id", "task", "author", "author_name", "text", "created_at", "updated_at"]
+ read_only_fields = ["id", "task", "author", "author_name", "created_at", "updated_at"]
+
+ def validate_text(self, value):
+ if not value.strip():
+ raise serializers.ValidationError("Comment text cannot be empty")
+ return value
diff --git a/tasks/services.py b/tasks/services.py
index 24ee079..b5a2066 100644
--- a/tasks/services.py
+++ b/tasks/services.py
@@ -91,3 +91,57 @@ def get_tasks_for_category(category):
Return all tasks associated with a given category
"""
return category.tasks.all()
+
+
+class CommentService:
+ """
+ Service for task comment operations.
+ """
+
+ @staticmethod
+ def create_comment(task, author, text: str):
+ """
+ Create a new comment on a task.
+
+ Args:
+ task: The Task instance to comment on.
+ author: The User instance creating the comment.
+ text: The comment text.
+
+ Returns:
+ TaskComment: The newly created comment.
+ """
+ from tasks.models import TaskComment
+
+ comment = TaskComment.objects.create(task=task, author=author, text=text)
+ logger.info(f"Comment {comment.id} created on task {task.id} by user {author.id}")
+ return comment
+
+ @staticmethod
+ def update_comment(comment, text: str):
+ """
+ Update the text of an existing comment.
+
+ Args:
+ comment: The TaskComment instance to update.
+ text: The new text.
+
+ Returns:
+ TaskComment: The updated comment.
+ """
+ comment.text = text
+ comment.save(update_fields=["text", "updated_at"])
+ logger.info(f"Comment {comment.id} updated")
+ return comment
+
+ @staticmethod
+ def delete_comment(comment):
+ """
+ Delete a comment.
+
+ Args:
+ comment: The TaskComment instance to delete.
+ """
+ comment_id = comment.id
+ comment.delete()
+ logger.info(f"Comment {comment_id} deleted")
diff --git a/tasks/urls.py b/tasks/urls.py
index cc6f848..c609091 100644
--- a/tasks/urls.py
+++ b/tasks/urls.py
@@ -1,7 +1,8 @@
from django.urls import path, include
from rest_framework.routers import SimpleRouter
+from rest_framework_nested import routers
-from .views import TaskViewSet, CategoryViewSet
+from .views import TaskViewSet, CategoryViewSet, TaskCommentViewSet
router = SimpleRouter()
router.register(r"", TaskViewSet, basename="task")
@@ -9,7 +10,12 @@
management_router = SimpleRouter()
management_router.register(r"categories", CategoryViewSet, basename="category")
+# Nested router: /tasks/{task_pk}/comments/
+tasks_router = routers.NestedSimpleRouter(router, r"", lookup="task")
+tasks_router.register(r"comments", TaskCommentViewSet, basename="task-comments")
+
urlpatterns = [
path("", include(router.urls)),
path("manage/", include(management_router.urls)),
+ path("", include(tasks_router.urls)),
]
diff --git a/tasks/views.py b/tasks/views.py
index 87ef7e8..bd1a5c0 100644
--- a/tasks/views.py
+++ b/tasks/views.py
@@ -19,14 +19,14 @@
from api.utils import error_response, status_response
from projects.permissions import IsProjectMinRole
-from .models import Task, Category
-from .permissions import IsOwner, ProjectTaskPermission
-from .services import TaskService, CategoryService
+from .models import Task, Category, TaskComment
+from .permissions import IsOwner, ProjectTaskPermission, ProjectCommentPermission
from .serializers import (
- TaskSerializer, CategorySerializer,
+ TaskSerializer, CategorySerializer, TaskCommentSerializer,
ToggleCompletedResponseSerializer, ToggleFavoriteResponseSerializer,
- MoveTaskResponseSerializer, MoveTaskSerializer,
+ MoveTaskResponseSerializer, MoveTaskSerializer,
)
+from .services import TaskService, CategoryService, CommentService
logger = logging.getLogger(__name__)
User = get_user_model()
@@ -35,10 +35,7 @@
class TaskViewSet(UserQuerysetMixin, viewsets.ModelViewSet):
"""
ViewSet for operations with tasks.
-
Allows viewing, creating, editing, and deleting tasks.
- Includes extra actions for toggling favorite/completed status
- and moving tasks between projects.
"""
queryset = Task.objects.all()
@@ -47,10 +44,7 @@ class TaskViewSet(UserQuerysetMixin, viewsets.ModelViewSet):
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["title", "description"]
filterset_fields = ["completed", "priority", "is_favorite", "category"]
- ordering_fields = [
- "title", "due_date", "priority",
- "created_at", "updated_at",
- ]
+ ordering_fields = ["title", "due_date", "priority", "created_at", "updated_at"]
def get_permissions(self):
if self.kwargs.get("project_pk"):
@@ -59,13 +53,10 @@ def get_permissions(self):
def get_queryset(self):
qs = super().get_queryset()
-
project_id = self.kwargs.get("project_pk")
if project_id is not None:
- # Nested: all tasks in given project
return qs.filter(project_id=project_id)
- # Nested: all tasks in given project
filters = Q(user=self.request.user, project__isnull=True)
if TaskService.is_today_filter(self.request):
filters &= Q(due_date__date=now().date())
@@ -101,65 +92,35 @@ def get_object(self):
raise
return obj
- @action(
- detail=True, methods=["post"],
- serializer_class=ToggleFavoriteResponseSerializer
- )
+ @action(detail=True, methods=["post"], serializer_class=ToggleFavoriteResponseSerializer)
def toggle_favorite(self, request, pk=None):
try:
task = self.get_object()
updated_task = TaskService.toggle_favorite(task)
- logger.info(
- f"Task {task.id} favorite status updated to {updated_task.is_favorite}"
- )
- return Response({
- "status": "favorite status updated",
- "is_favorite": updated_task.is_favorite,
- })
+ logger.info(f"Task {task.id} favorite status updated to {updated_task.is_favorite}")
+ return Response({"status": "favorite status updated", "is_favorite": updated_task.is_favorite})
except Exception as e:
logger.exception(f"Error toggling favorite for task {pk}")
- return error_response(
- "Failed to update favorite status",
- status.HTTP_500_INTERNAL_SERVER_ERROR,
- exc=e,
- )
-
- @action(
- detail=True, methods=["post"],
- serializer_class=ToggleCompletedResponseSerializer,
- permission_classes=[IsProjectMinRole('Member')],
- )
+ return error_response("Failed to update favorite status", status.HTTP_500_INTERNAL_SERVER_ERROR, exc=e)
+
+ @action(detail=True, methods=["post"], serializer_class=ToggleCompletedResponseSerializer,
+ permission_classes=[IsProjectMinRole("Member")])
def toggle_completed(self, request, project_pk=None, pk=None):
try:
task = self.get_object()
updated_task = TaskService.toggle_completed(task, self.request.user)
- logger.info(
- f"Task {task.id} completion status updated to {updated_task.completed}"
- )
- return Response(
- {
- "status": "completion status updated",
- "completed": updated_task.completed,
- "completed_at": updated_task.completed_at,
- "completed_by": (
- updated_task.completed_by.id
- if updated_task.completed_by
- else None
- ),
- }
- )
+ logger.info(f"Task {task.id} completion status updated to {updated_task.completed}")
+ return Response({
+ "status": "completion status updated",
+ "completed": updated_task.completed,
+ "completed_at": updated_task.completed_at,
+ "completed_by": updated_task.completed_by.id if updated_task.completed_by else None,
+ })
except Exception as e:
logger.exception(f"Error toggling completion for task {pk}")
- return error_response(
- "Failed to update completion status",
- status.HTTP_500_INTERNAL_SERVER_ERROR,
- exc=e,
- )
-
- @action(
- detail=False, methods=["get"],
- permission_classes=[IsProjectMinRole("Member")],
- )
+ return error_response("Failed to update completion status", status.HTTP_500_INTERNAL_SERVER_ERROR, exc=e)
+
+ @action(detail=False, methods=["get"], permission_classes=[IsProjectMinRole("Member")])
@method_decorator(cache_page(60))
def today(self, request):
queryset = self.get_queryset()
@@ -173,19 +134,14 @@ def favorites(self, request):
serializer = self.get_serializer(favorites_qs, many=True)
return Response(serializer.data)
- @swagger_auto_schema(
- method="post",
- request_body=MoveTaskSerializer,
- responses={200: MoveTaskResponseSerializer},
- )
+ @swagger_auto_schema(method="post", request_body=MoveTaskSerializer,
+ responses={200: MoveTaskResponseSerializer})
@action(detail=True, methods=["post"], serializer_class=MoveTaskSerializer)
def move_task(self, request, project_pk=None, pk=None):
if project_pk is not None:
raise NotFound("Use /tasks/{pk}/move_task/ to move tasks")
-
task = self.get_object()
project_id = request.data.get("project_id")
-
try:
TaskService.move_task_to_project(task, project_id, request.user)
return status_response("Task moved successfully")
@@ -193,16 +149,9 @@ def move_task(self, request, project_pk=None, pk=None):
return error_response(str(e), status.HTTP_404_NOT_FOUND, exc=e)
except Exception as e:
logger.exception(f"Error moving task: {e}")
- return error_response(
- "Failed to move task",
- status.HTTP_500_INTERNAL_SERVER_ERROR,
- exc=e,
- )
-
- @action(
- detail=True, methods=["post"],
- url_path='move_task', permission_classes=[IsAuthenticated],
- )
+ return error_response("Failed to move task", status.HTTP_500_INTERNAL_SERVER_ERROR, exc=e)
+
+ @action(detail=True, methods=["post"], url_path="move_task", permission_classes=[IsAuthenticated])
@swagger_auto_schema(auto_schema=None)
def nested_move_task(self, request, project_pk=None, pk=None):
if getattr(self, "swagger_fake_view", False):
@@ -211,12 +160,7 @@ def nested_move_task(self, request, project_pk=None, pk=None):
class CategoryViewSet(UserQuerysetMixin, viewsets.ModelViewSet):
- """
- ViewSet for operations with categories.
-
- Allows viewing, creating, editing, and deleting categories.
- Includes an extra action to list tasks within a category.
- """
+ """ViewSet for operations with categories."""
queryset = Category.objects.all()
serializer_class = CategorySerializer
@@ -226,7 +170,129 @@ class CategoryViewSet(UserQuerysetMixin, viewsets.ModelViewSet):
def tasks(self, request, pk=None):
category = self.get_object()
tasks = CategoryService.get_tasks_for_category(category)
- serializer = TaskSerializer(
- tasks, many=True, context={"request": request}
- )
+ serializer = TaskSerializer(tasks, many=True, context={"request": request})
return Response(serializer.data)
+
+
+class TaskCommentViewSet(viewsets.ModelViewSet):
+ """
+ ViewSet for task comments, nested under TaskViewSet.
+
+ Routes:
+ /tasks/{task_pk}/comments/
+ /projects/{project_pk}/tasks/{task_pk}/comments/
+
+ Access rules:
+ Personal tasks: only the task owner can read/create/edit/delete comments.
+ Project tasks:
+ GET/HEAD/OPTIONS Viewer+
+ POST Member+
+ PUT/PATCH comment author only
+ DELETE comment author OR Moderator+
+ """
+
+ serializer_class = TaskCommentSerializer
+ permission_classes = [IsAuthenticated]
+ pagination_class = None
+ http_method_names = ["get", "post", "put", "patch", "delete", "head", "options"]
+
+ # ── helpers ──────────────────────────────────────────────────────────────
+
+ def _get_task(self):
+ return get_object_or_404(
+ Task.objects.select_related("project"), pk=self.kwargs.get("task_pk")
+ )
+
+ def _is_project_task(self):
+ return self.kwargs.get("project_pk") is not None
+
+ def _assert_task_belongs_to_project(self, task):
+ project_pk = self.kwargs.get("project_pk")
+ if task.project_id is None or str(task.project_id) != str(project_pk):
+ raise NotFound("Task not found in this project")
+
+ def _check_personal_task_access(self, task):
+ if task.user_id != self.request.user.pk:
+ raise PermissionDenied()
+
+ def _check_project_min_role(self, task, min_role):
+ """Pass task.project (the Project instance) so membership lookup works correctly."""
+ if not IsProjectMinRole(min_role).has_object_permission(self.request, self, task.project):
+ raise PermissionDenied()
+
+ # ── queryset ──────────────────────────────────────────────────────────────
+
+ def get_queryset(self):
+ task = self._get_task()
+ if not self._is_project_task():
+ self._check_personal_task_access(task)
+ else:
+ self._assert_task_belongs_to_project(task)
+ self._check_project_min_role(task, "Viewer")
+ return TaskComment.objects.filter(task=task).select_related("author")
+
+ # ── object fetching ───────────────────────────────────────────────────────
+
+ def get_object(self):
+ task = self._get_task()
+ if self._is_project_task():
+ self._assert_task_belongs_to_project(task)
+
+ pk = self.kwargs.get(self.lookup_field)
+ comment = get_object_or_404(
+ TaskComment.objects.select_related("author", "task", "task__project"),
+ pk=pk, task=task,
+ )
+
+ if not self._is_project_task():
+ # personal task: only task owner
+ if comment.task.user_id != self.request.user.pk:
+ if self.request.method in SAFE_METHODS:
+ raise NotFound()
+ raise PermissionDenied()
+ return comment
+
+ # project task: delegate to ProjectCommentPermission
+ # project task: use ProjectCommentPermission directly
+ perm = ProjectCommentPermission()
+ if not perm.has_object_permission(self.request, self, comment):
+ if self.request.method in SAFE_METHODS:
+ raise NotFound()
+ raise PermissionDenied()
+ return comment
+
+ # ── create ────────────────────────────────────────────────────────────────
+
+ def create(self, request, *args, **kwargs):
+ task = self._get_task()
+ if not self._is_project_task():
+ if task.user_id != request.user.pk:
+ raise PermissionDenied()
+ else:
+ self._assert_task_belongs_to_project(task)
+ self._check_project_min_role(task, "Member")
+
+ serializer = TaskCommentSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ comment = CommentService.create_comment(
+ task=task, author=request.user, text=serializer.validated_data["text"]
+ )
+ out = TaskCommentSerializer(comment, context={"request": request})
+ return Response(out.data, status=status.HTTP_201_CREATED)
+
+ # ── update ────────────────────────────────────────────────────────────────
+
+ def update(self, request, *args, **kwargs):
+ partial = kwargs.pop("partial", False)
+ comment = self.get_object()
+ serializer = TaskCommentSerializer(comment, data=request.data, partial=partial)
+ serializer.is_valid(raise_exception=True)
+ updated = CommentService.update_comment(comment, serializer.validated_data["text"])
+ return Response(TaskCommentSerializer(updated, context={"request": request}).data)
+
+ # ── delete ────────────────────────────────────────────────────────────────
+
+ def destroy(self, request, *args, **kwargs):
+ comment = self.get_object()
+ CommentService.delete_comment(comment)
+ return Response(status=status.HTTP_204_NO_CONTENT)