fix(time-entries): preserve deleted tags in timesheet edits

This commit is contained in:
2026-04-27 22:58:27 +03:30
parent 7bd60fd641
commit 02c9c17c30
5 changed files with 331 additions and 86 deletions

View File

@@ -1,80 +1,175 @@
from rest_framework import serializers
from core.serializers.base import BaseModelSerializer
from apps.time_entries.models import TimeEntry
from apps.projects.models import Project
from apps.tags.models import Tag
class TimeEntrySerializer(BaseModelSerializer):
"""
Output serializer for TimeEntry.
"""
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True)
class Meta:
model = TimeEntry
fields = BaseModelSerializer.Meta.fields + (
"workspace",
"user",
"project",
"description",
"start_time",
"end_time",
"duration",
"tags",
"is_billable",
"hourly_rate",
"currency",
)
read_only_fields = fields
class TimeEntryCreateSerializer(serializers.Serializer):
from rest_framework import serializers
from core.serializers.base import BaseModelSerializer
from apps.time_entries.models import TimeEntry
from apps.projects.models import Project
from apps.tags.models import Tag
class TimeEntryProjectDetailsSerializer(serializers.Serializer):
id = serializers.UUIDField(read_only=True)
name = serializers.CharField(read_only=True)
is_deleted = serializers.BooleanField(read_only=True)
client_name = serializers.CharField(read_only=True, allow_null=True)
class TimeEntryTagDetailsSerializer(serializers.Serializer):
id = serializers.UUIDField(read_only=True)
name = serializers.CharField(read_only=True)
color = serializers.CharField(read_only=True)
is_deleted = serializers.BooleanField(read_only=True)
class TimeEntrySerializer(BaseModelSerializer):
"""
Output serializer for TimeEntry.
"""
project = serializers.UUIDField(source="project_id", allow_null=True, read_only=True)
tags = serializers.SerializerMethodField()
project_details = serializers.SerializerMethodField()
tag_details = serializers.SerializerMethodField()
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True)
def get_tags(self, obj):
return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")]
def get_project_details(self, obj):
if not obj.project_id:
return None
project = Project.all_objects.select_related("client").filter(id=obj.project_id).first()
if not project:
return None
return TimeEntryProjectDetailsSerializer(
{
"id": project.id,
"name": project.name,
"is_deleted": project.is_deleted,
"client_name": project.client.name if project.client else None,
}
).data
def get_tag_details(self, obj):
tags = Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")
return TimeEntryTagDetailsSerializer(
[
{
"id": tag.id,
"name": tag.name,
"color": tag.color,
"is_deleted": tag.is_deleted,
}
for tag in tags
],
many=True,
).data
class Meta:
model = TimeEntry
fields = BaseModelSerializer.Meta.fields + (
"workspace",
"user",
"project",
"project_details",
"description",
"start_time",
"end_time",
"duration",
"tags",
"tag_details",
"is_billable",
"hourly_rate",
"currency",
)
read_only_fields = fields
class TimeEntryCreateSerializer(serializers.Serializer):
"""
Validates input data for creating/starting a time entry.
"""
workspace_id = serializers.UUIDField()
project_id = serializers.PrimaryKeyRelatedField(
queryset=Project.objects.filter(is_deleted=False),
required=False,
allow_null=True,
source='project'
)
start_time = serializers.DateTimeField()
end_time = serializers.DateTimeField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True, default="")
tags = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.filter(is_deleted=False),
many=True,
required=False
)
is_billable = serializers.BooleanField(default=False)
class TimeEntryUpdateSerializer(serializers.Serializer):
"""
Validates input data for updating an existing time entry.
"""
project_id = serializers.PrimaryKeyRelatedField(
queryset=Project.objects.filter(is_deleted=False),
required=False,
allow_null=True,
source='project'
)
start_time = serializers.DateTimeField(required=False)
end_time = serializers.DateTimeField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True)
tags = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.filter(is_deleted=False),
many=True,
required=False
)
is_billable = serializers.BooleanField(required=False)
class TimeEntryStopSerializer(serializers.Serializer):
workspace_id = serializers.UUIDField()
project_id = serializers.UUIDField(required=False, allow_null=True)
start_time = serializers.DateTimeField()
end_time = serializers.DateTimeField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True, default="")
tags = serializers.ListField(child=serializers.UUIDField(), required=False)
is_billable = serializers.BooleanField(default=False)
def validate(self, attrs):
project_id = attrs.pop("project_id", serializers.empty)
if project_id is not serializers.empty:
if project_id is None:
attrs["project"] = None
else:
project = Project.objects.filter(id=project_id).first()
if not project:
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
attrs["project"] = project
tag_ids = attrs.pop("tags", serializers.empty)
if tag_ids is not serializers.empty:
active_tags = list(Tag.objects.filter(id__in=tag_ids))
active_tag_ids = {str(tag.id) for tag in active_tags}
missing_ids = [str(tag_id) for tag_id in tag_ids if str(tag_id) not in active_tag_ids]
if missing_ids:
raise serializers.ValidationError({"tags": "One or more selected tags are unavailable."})
attrs["tags"] = active_tags
return attrs
class TimeEntryUpdateSerializer(serializers.Serializer):
"""
Validates input data for updating an existing time entry.
"""
project_id = serializers.UUIDField(required=False, allow_null=True)
start_time = serializers.DateTimeField(required=False)
end_time = serializers.DateTimeField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True)
tags = serializers.ListField(child=serializers.UUIDField(), required=False)
is_billable = serializers.BooleanField(required=False)
def validate(self, attrs):
entry = self.instance
project_id = attrs.pop("project_id", serializers.empty)
if project_id is not serializers.empty:
current_project = Project.all_objects.filter(id=entry.project_id).first() if entry and entry.project_id else None
if project_id is None:
attrs["project"] = None
elif current_project and str(current_project.id) == str(project_id):
attrs["project"] = current_project
else:
project = Project.objects.filter(id=project_id).first()
if not project:
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
attrs["project"] = project
tag_ids = attrs.pop("tags", serializers.empty)
if tag_ids is not serializers.empty:
active_tags = list(Tag.objects.filter(id__in=tag_ids))
active_tag_ids = {str(tag.id) for tag in active_tags}
current_tag_ids = {
str(tag_id)
for tag_id in Tag.all_objects.filter(time_entries=entry).values_list("id", flat=True)
} if entry else set()
requested_tag_ids = [str(tag_id) for tag_id in tag_ids]
missing_ids = [tag_id for tag_id in requested_tag_ids if tag_id not in active_tag_ids]
if missing_ids:
if not set(missing_ids).issubset(current_tag_ids):
raise serializers.ValidationError({"tags": "One or more selected tags are unavailable."})
attrs["tags"] = list(Tag.all_objects.filter(id__in=tag_ids))
else:
attrs["tags"] = active_tags
return attrs
class TimeEntryStopSerializer(serializers.Serializer):
"""
Optional specific serializer for stopping a timer manually.
"""

View File

@@ -138,9 +138,9 @@ class TimeEntryViewSet(ModelViewSet):
return TimeEntryStopSerializer
return TimeEntrySerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
entry = create_time_entry(
user=request.user,
@@ -148,8 +148,8 @@ class TimeEntryViewSet(ModelViewSet):
**serializer.validated_data
)
output_serializer = TimeEntrySerializer(entry)
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
output_serializer = TimeEntrySerializer(entry, context=self.get_serializer_context())
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs):
partial = kwargs.pop("partial", False)
@@ -160,16 +160,16 @@ class TimeEntryViewSet(ModelViewSet):
status=status.HTTP_403_FORBIDDEN,
)
serializer = self.get_serializer(data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
serializer = self.get_serializer(entry, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
updated_entry = update_time_entry(
entry=entry,
**serializer.validated_data
)
output_serializer = TimeEntrySerializer(updated_entry)
return Response(output_serializer.data, status=status.HTTP_200_OK)
output_serializer = TimeEntrySerializer(updated_entry, context=self.get_serializer_context())
return Response(output_serializer.data, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
def stop(self, request, pk=None):
@@ -189,8 +189,8 @@ class TimeEntryViewSet(ModelViewSet):
end_time = serializer.validated_data.get("end_time")
stopped_entry = stop_time_entry(entry, end_time=end_time)
output_serializer = TimeEntrySerializer(stopped_entry)
return Response(output_serializer.data, status=status.HTTP_200_OK)
output_serializer = TimeEntrySerializer(stopped_entry, context=self.get_serializer_context())
return Response(output_serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs):
"""