Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
454 changes: 454 additions & 0 deletions api/tests/test_comments.py

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion projects/urls.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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/<uuid:token>/", join_project, name="join-project"),
]
32 changes: 32 additions & 0 deletions tasks/migrations/0006_add_taskcomment_model.py
Original file line number Diff line number Diff line change
@@ -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')],
},
),
]
30 changes: 30 additions & 0 deletions tasks/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
59 changes: 52 additions & 7 deletions tasks/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
18 changes: 17 additions & 1 deletion tasks/serializers.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
54 changes: 54 additions & 0 deletions tasks/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
8 changes: 7 additions & 1 deletion tasks/urls.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
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")

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)),
]
Loading
Loading