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)