Internal
Public Access
Added comprehensive authorization checks to prevent users from: 1. Sharing tasks/tags they don't own 2. Modifying tasks/tags that are only shared with them (read-only access) Changes: tasks/serializers.py (TaskShareSerializer): - Added validation in validate() method to verify task/tag ownership - Users can only share their own tasks and tags - Prevents malicious users from sharing other people's resources tasks/permissions.py (NEW): - Created IsOwnerOrReadOnlyIfShared permission class - Owners: full access (read, update, delete) - Shared users: read-only access - Prevents privilege escalation via shared access tasks/views.py: - Applied IsOwnerOrReadOnlyIfShared to TaskDetailAPIView - Applied IsOwnerOrReadOnlyIfShared to TagDetailAPIView - Enforces object-level permissions on all update/delete operations Security impact: - Prevents users from modifying or deleting resources they don't own - Prevents users from sharing resources they don't own - Maintains proper separation between owner and shared access levels 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
217 lines
7.8 KiB
Python
217 lines
7.8 KiB
Python
"""
|
|
Task serializers for KeepItGoing API.
|
|
"""
|
|
|
|
from rest_framework import serializers
|
|
from django.contrib.auth import get_user_model
|
|
from .models import Task, Tag, TimeEntry, TaskShare
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class TagSerializer(serializers.ModelSerializer):
|
|
"""Serializer for tags."""
|
|
|
|
task_count = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = Tag
|
|
fields = [
|
|
'id', 'name', 'description', 'color', 'icon', 'sort_order',
|
|
'is_archived', 'task_count', 'sync_id', 'created_at', 'updated_at'
|
|
]
|
|
read_only_fields = ['id', 'sync_id', 'created_at', 'updated_at']
|
|
|
|
def get_task_count(self, obj):
|
|
return obj.tasks.filter(status__in=['pending', 'in_progress']).count()
|
|
|
|
def create(self, validated_data):
|
|
validated_data['user'] = self.context['request'].user
|
|
return super().create(validated_data)
|
|
|
|
|
|
class TimeEntrySerializer(serializers.ModelSerializer):
|
|
"""Serializer for time entries."""
|
|
|
|
is_running = serializers.ReadOnlyField()
|
|
|
|
class Meta:
|
|
model = TimeEntry
|
|
fields = [
|
|
'id', 'task', 'started_at', 'ended_at', 'duration_seconds',
|
|
'notes', 'is_running', 'sync_id', 'created_at', 'updated_at'
|
|
]
|
|
read_only_fields = ['id', 'sync_id', 'created_at', 'updated_at', 'duration_seconds']
|
|
|
|
def create(self, validated_data):
|
|
validated_data['user'] = self.context['request'].user
|
|
return super().create(validated_data)
|
|
|
|
|
|
class TaskSerializer(serializers.ModelSerializer):
|
|
"""Serializer for tasks."""
|
|
|
|
tags = TagSerializer(many=True, read_only=True)
|
|
tag_ids = serializers.PrimaryKeyRelatedField(
|
|
queryset=Tag.objects.all(),
|
|
many=True,
|
|
write_only=True,
|
|
required=False,
|
|
source='tags'
|
|
)
|
|
tag_sync_ids = serializers.SerializerMethodField()
|
|
subtasks = serializers.SerializerMethodField()
|
|
is_overdue = serializers.ReadOnlyField()
|
|
total_time_spent = serializers.ReadOnlyField()
|
|
parent_sync_id = serializers.UUIDField(source='parent.sync_id', read_only=True, allow_null=True)
|
|
|
|
class Meta:
|
|
model = Task
|
|
fields = [
|
|
'id', 'parent', 'parent_sync_id',
|
|
'title', 'description', 'status', 'priority', 'due_date', 'due_time',
|
|
'reminder_at', 'completed_at', 'recurrence', 'recurrence_rule',
|
|
'recurrence_end_date', 'tags', 'tag_ids', 'tag_sync_ids', 'subtasks',
|
|
'sort_order', 'is_overdue', 'total_time_spent', 'sync_id',
|
|
'created_at', 'updated_at'
|
|
]
|
|
read_only_fields = ['id', 'sync_id', 'created_at', 'updated_at', 'completed_at']
|
|
|
|
def get_tag_sync_ids(self, obj):
|
|
"""Return list of tag sync_ids for sync purposes."""
|
|
return [str(tag.sync_id) for tag in obj.tags.all()]
|
|
|
|
def get_subtasks(self, obj):
|
|
# Only include subtasks for top-level tasks to avoid recursion
|
|
if obj.parent is None:
|
|
subtasks = obj.subtasks.all()
|
|
return TaskSerializer(subtasks, many=True, context=self.context).data
|
|
return []
|
|
|
|
def create(self, validated_data):
|
|
tags = validated_data.pop('tags', [])
|
|
validated_data['user'] = self.context['request'].user
|
|
task = super().create(validated_data)
|
|
task.tags.set(tags)
|
|
return task
|
|
|
|
def update(self, instance, validated_data):
|
|
tags = validated_data.pop('tags', None)
|
|
task = super().update(instance, validated_data)
|
|
if tags is not None:
|
|
task.tags.set(tags)
|
|
return task
|
|
|
|
|
|
class TaskListSerializer(serializers.ModelSerializer):
|
|
"""Lightweight serializer for task lists."""
|
|
|
|
tags = TagSerializer(many=True, read_only=True)
|
|
subtask_count = serializers.SerializerMethodField()
|
|
is_overdue = serializers.ReadOnlyField()
|
|
|
|
class Meta:
|
|
model = Task
|
|
fields = [
|
|
'id', 'title', 'status', 'priority', 'due_date', 'due_time',
|
|
'tags', 'subtask_count', 'is_overdue', 'sort_order', 'sync_id', 'updated_at'
|
|
]
|
|
|
|
def get_subtask_count(self, obj):
|
|
return obj.subtasks.count()
|
|
|
|
|
|
class TaskSyncSerializer(serializers.ModelSerializer):
|
|
"""Serializer for tasks in sync responses - uses sync_id as id."""
|
|
|
|
id = serializers.UUIDField(source='sync_id', read_only=True)
|
|
sync_id = serializers.UUIDField(read_only=True)
|
|
tag_sync_ids = serializers.SerializerMethodField()
|
|
parent_sync_id = serializers.UUIDField(source='parent.sync_id', read_only=True, allow_null=True)
|
|
|
|
class Meta:
|
|
model = Task
|
|
fields = [
|
|
'id', 'sync_id', 'parent', 'parent_sync_id',
|
|
'title', 'description', 'status', 'priority', 'due_date', 'due_time',
|
|
'reminder_at', 'completed_at', 'recurrence', 'recurrence_rule',
|
|
'recurrence_end_date', 'tag_sync_ids',
|
|
'sort_order', 'is_deleted', 'created_at', 'updated_at'
|
|
]
|
|
|
|
def get_tag_sync_ids(self, obj):
|
|
"""Return list of tag sync_ids for sync purposes."""
|
|
return [str(tag.sync_id) for tag in obj.tags.all()]
|
|
|
|
|
|
class TagSyncSerializer(serializers.ModelSerializer):
|
|
"""Serializer for tags in sync responses - uses sync_id as id."""
|
|
|
|
id = serializers.UUIDField(source='sync_id', read_only=True)
|
|
sync_id = serializers.UUIDField(read_only=True)
|
|
|
|
class Meta:
|
|
model = Tag
|
|
fields = ['id', 'sync_id', 'name', 'description', 'color', 'icon', 'sort_order',
|
|
'is_archived', 'is_deleted', 'created_at', 'updated_at']
|
|
|
|
|
|
class TimeEntrySyncSerializer(serializers.ModelSerializer):
|
|
"""Serializer for time entries in sync responses - uses sync_id as id."""
|
|
|
|
id = serializers.UUIDField(source='sync_id', read_only=True)
|
|
sync_id = serializers.UUIDField(read_only=True)
|
|
task = serializers.UUIDField(source='task.sync_id', read_only=True)
|
|
|
|
class Meta:
|
|
model = TimeEntry
|
|
fields = ['id', 'sync_id', 'task', 'started_at', 'ended_at', 'notes',
|
|
'is_deleted', 'created_at', 'updated_at']
|
|
|
|
|
|
class TaskShareSerializer(serializers.ModelSerializer):
|
|
"""Serializer for task sharing."""
|
|
|
|
shared_with_email = serializers.EmailField(write_only=True)
|
|
shared_with_username = serializers.CharField(source='shared_with.username', read_only=True)
|
|
|
|
class Meta:
|
|
model = TaskShare
|
|
fields = [
|
|
'id', 'task', 'tag', 'shared_with_email', 'shared_with_username',
|
|
'permission', 'sync_id', 'created_at'
|
|
]
|
|
read_only_fields = ['id', 'sync_id', 'created_at']
|
|
|
|
def validate_shared_with_email(self, value):
|
|
try:
|
|
user = User.objects.get(email=value)
|
|
if user == self.context['request'].user:
|
|
raise serializers.ValidationError("Cannot share with yourself.")
|
|
return value
|
|
except User.DoesNotExist:
|
|
raise serializers.ValidationError("User with this email does not exist.")
|
|
|
|
def validate(self, attrs):
|
|
if not attrs.get('task') and not attrs.get('tag'):
|
|
raise serializers.ValidationError("Must share either a task or a tag.")
|
|
if attrs.get('task') and attrs.get('tag'):
|
|
raise serializers.ValidationError("Cannot share both a task and a tag.")
|
|
|
|
# SECURITY: Verify that the task/tag belongs to the requesting user
|
|
user = self.context['request'].user
|
|
if attrs.get('task'):
|
|
if attrs['task'].user != user:
|
|
raise serializers.ValidationError("You can only share your own tasks.")
|
|
if attrs.get('tag'):
|
|
if attrs['tag'].user != user:
|
|
raise serializers.ValidationError("You can only share your own tags.")
|
|
|
|
return attrs
|
|
|
|
def create(self, validated_data):
|
|
email = validated_data.pop('shared_with_email')
|
|
validated_data['shared_with'] = User.objects.get(email=email)
|
|
validated_data['owner'] = self.context['request'].user
|
|
return super().create(validated_data)
|