From 57bd7e7b3efa21081c0d313e3db2dfb1bccabd09 Mon Sep 17 00:00:00 2001 From: Obvious Agent Date: Wed, 3 Jun 2026 17:55:42 +0000 Subject: [PATCH 1/3] feat: add TaskComment model, migration, serializer, permissions, service, viewset, and URL wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TaskComment model in tasks/models.py: task FK, author FK, text (TextField + TEXT_FIELD_VALIDATOR), created_at (auto_now_add), updated_at (auto_now). Ordered by created_at asc. Index on task. - Auto-generated Django migration 0006_add_taskcomment_model.py - TaskCommentSerializer: read-only author_name, author ID, timestamps; writable text - ProjectCommentPermission: GET/HEAD/OPTIONS → Viewer+, POST → Member+, PUT/PATCH → author only, DELETE → author OR Moderator+ - IsCommentAuthor permission helper - CommentService with create_comment, update_comment, delete_comment static methods - TaskCommentViewSet nested under TaskViewSet; works for both personal and project tasks - tasks/urls.py: NestedSimpleRouter wires /tasks/{task_pk}/comments/ - projects/urls.py: NestedDefaultRouter wires /projects/{project_pk}/tasks/{task_pk}/comments/ Co-authored-by: Dasha Shifrina --- projects/urls.py | 7 +- .../migrations/0006_add_taskcomment_model.py | 32 +++++ tasks/models.py | 30 +++++ tasks/permissions.py | 51 ++++++++ tasks/serializers.py | 18 ++- tasks/services.py | 54 +++++++++ tasks/urls.py | 8 +- tasks/views.py | 114 +++++++++++++++++- 8 files changed, 306 insertions(+), 8 deletions(-) create mode 100644 tasks/migrations/0006_add_taskcomment_model.py 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..022e946 100644 --- a/tasks/permissions.py +++ b/tasks/permissions.py @@ -50,3 +50,54 @@ def has_object_permission(self, request, view, obj): 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. + + - GET/HEAD/OPTIONS: Viewer+ + - POST: Member+ + - 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): + """ + obj is a TaskComment. obj.task.project is the project. + We delegate role checks to IsProjectMinRole, passing obj.task + (which has a .project attribute) so _get_project_from_obj works. + """ + task = obj.task + project = getattr(task, "project", None) + if project is None: + # personal task — fall through to personal-task logic + return task.user == request.user + + if request.method in SAFE_METHODS: + return IsProjectMinRole("Viewer").has_object_permission(request, view, task) + + if request.method == "POST": + return IsProjectMinRole("Member").has_object_permission(request, view, task) + + if request.method in ("PUT", "PATCH"): + return obj.author == request.user + + if request.method == "DELETE": + if obj.author == request.user: + return True + return IsProjectMinRole("Moderator").has_object_permission(request, view, task) + + 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..8388634 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() @@ -230,3 +230,107 @@ def tasks(self, request, pk=None): 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/ + """ + + serializer_class = TaskCommentSerializer + permission_classes = [IsAuthenticated] + http_method_names = ["get", "post", "put", "patch", "delete", "head", "options"] + + def _get_task(self): + task_pk = self.kwargs.get("task_pk") + return get_object_or_404(Task, pk=task_pk) + + def _is_project_task(self): + return self.kwargs.get("project_pk") is not None + + def get_queryset(self): + task = self._get_task() + + if not self._is_project_task(): + # Personal task: only the task owner can see comments + if task.user != self.request.user: + raise PermissionDenied() + else: + # Project task: verify this task belongs to the given project + 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") + # Viewer+ check: delegate to IsProjectMinRole + if not IsProjectMinRole("Viewer").has_object_permission(self.request, self, task): + raise PermissionDenied() + + return TaskComment.objects.filter(task=task).select_related("author") + + def get_permissions(self): + if not self._is_project_task(): + return [IsAuthenticated(), IsOwner()] + return [IsAuthenticated(), ProjectCommentPermission()] + + def get_object(self): + task = self._get_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, + ) + try: + self.check_object_permissions(self.request, comment) + except PermissionDenied: + if self.request.method in SAFE_METHODS: + raise NotFound() + raise + return comment + + def perform_create(self, serializer): + # Handled in create() directly + pass + + def create(self, request, *args, **kwargs): + task = self._get_task() + + # Personal task guard: only owner can comment + if not self._is_project_task() and task.user != request.user: + raise PermissionDenied() + + # Project task Member+ guard + if self._is_project_task(): + if not IsProjectMinRole("Member").has_object_permission(request, self, task): + raise PermissionDenied() + + 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_serializer = TaskCommentSerializer(comment, context={"request": request}) + return Response(out_serializer.data, status=status.HTTP_201_CREATED) + + 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"]) + out = TaskCommentSerializer(updated, context={"request": request}) + return Response(out.data) + + def destroy(self, request, *args, **kwargs): + comment = self.get_object() + CommentService.delete_comment(comment) + return Response(status=status.HTTP_204_NO_CONTENT) From 488df6348b12d1b66832837137d08c73dec6dca2 Mon Sep 17 00:00:00 2001 From: Obvious Agent Date: Wed, 3 Jun 2026 18:20:29 +0000 Subject: [PATCH 2/3] feat: add TaskCommentViewSet, permissions, and tests for comment system - Fix ProjectCommentPermission to call has_object_permission directly in get_object() rather than relying on check_object_permissions (which uses permission_classes, not ProjectCommentPermission) - TaskCommentViewSet.get_object: explicitly calls ProjectCommentPermission for project tasks instead of using DRF's check_object_permissions dispatcher - Add IsCommentAuthor and ProjectCommentPermission classes in tasks/permissions.py - Add 34 tests in api/tests/test_comments.py covering: - Personal task CRUD: owner-only access, empty text, non-owner rejection - Project task CRUD: Viewer read, Member create, author edit, Moderator delete - Edge cases: wrong project, nonexistent task, validator rejection, ordering - All 108 tests pass (74 existing + 34 new) Co-authored-by: Dasha Shifrina --- api/tests/test_comments.py | 454 +++++++++++++++++++++++++++++++++++++ tasks/permissions.py | 40 ++-- tasks/views.py | 224 ++++++++---------- 3 files changed, 564 insertions(+), 154 deletions(-) create mode 100644 api/tests/test_comments.py 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/tasks/permissions.py b/tasks/permissions.py index 022e946..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,14 +44,11 @@ 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. - """ + """Allows access only to the comment author.""" def has_object_permission(self, request, view, obj): return obj.author == request.user @@ -65,8 +58,10 @@ 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+ + - POST: Member+ (enforced in the viewset create() before get_object is called) - PUT/PATCH: comment author only - DELETE: comment author OR Moderator+ """ @@ -75,29 +70,28 @@ def has_permission(self, request, view): return True def has_object_permission(self, request, view, obj): - """ - obj is a TaskComment. obj.task.project is the project. - We delegate role checks to IsProjectMinRole, passing obj.task - (which has a .project attribute) so _get_project_from_obj works. - """ task = obj.task project = getattr(task, "project", None) + if project is None: - # personal task — fall through to personal-task logic - return task.user == request.user + # 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 IsProjectMinRole("Viewer").has_object_permission(request, view, task) + return _min_role("Viewer") if request.method == "POST": - return IsProjectMinRole("Member").has_object_permission(request, view, task) + return _min_role("Member") if request.method in ("PUT", "PATCH"): - return obj.author == request.user + return obj.author_id == request.user.pk if request.method == "DELETE": - if obj.author == request.user: + if obj.author_id == request.user.pk: return True - return IsProjectMinRole("Moderator").has_object_permission(request, view, task) + return _min_role("Moderator") return False diff --git a/tasks/views.py b/tasks/views.py index 8388634..bd1a5c0 100644 --- a/tasks/views.py +++ b/tasks/views.py @@ -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,109 +170,127 @@ 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. + ViewSet for task comments, nested under TaskViewSet. Routes: - - /tasks/{task_pk}/comments/ - - /projects/{project_pk}/tasks/{task_pk}/comments/ + /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): - task_pk = self.kwargs.get("task_pk") - return get_object_or_404(Task, pk=task_pk) + 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(): - # Personal task: only the task owner can see comments - if task.user != self.request.user: - raise PermissionDenied() + self._check_personal_task_access(task) else: - # Project task: verify this task belongs to the given project - 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") - # Viewer+ check: delegate to IsProjectMinRole - if not IsProjectMinRole("Viewer").has_object_permission(self.request, self, task): - raise PermissionDenied() - + self._assert_task_belongs_to_project(task) + self._check_project_min_role(task, "Viewer") return TaskComment.objects.filter(task=task).select_related("author") - def get_permissions(self): - if not self._is_project_task(): - return [IsAuthenticated(), IsOwner()] - return [IsAuthenticated(), ProjectCommentPermission()] + # ── 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, + pk=pk, task=task, ) - try: - self.check_object_permissions(self.request, comment) - except PermissionDenied: + + 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 + raise PermissionDenied() return comment - def perform_create(self, serializer): - # Handled in create() directly - pass + # ── create ──────────────────────────────────────────────────────────────── def create(self, request, *args, **kwargs): task = self._get_task() - - # Personal task guard: only owner can comment - if not self._is_project_task() and task.user != request.user: - raise PermissionDenied() - - # Project task Member+ guard - if self._is_project_task(): - if not IsProjectMinRole("Member").has_object_permission(request, self, 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"], + 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) - out_serializer = TaskCommentSerializer(comment, context={"request": request}) - return Response(out_serializer.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"]) - out = TaskCommentSerializer(updated, context={"request": request}) - return Response(out.data) + return Response(TaskCommentSerializer(updated, context={"request": request}).data) + + # ── delete ──────────────────────────────────────────────────────────────── def destroy(self, request, *args, **kwargs): comment = self.get_object() From 52ceb24d3ef40e8c5248f5f15fbbe1bbf9e95583 Mon Sep 17 00:00:00 2001 From: Obvious Agent Date: Wed, 3 Jun 2026 18:25:48 +0000 Subject: [PATCH 3/3] ci: trigger CI after retargeting PR to master Co-authored-by: Dasha Shifrina