from rest_framework import serializers from django.utils import timezone from core.serializers.base import BaseModelSerializer from apps.time_entries.models import TimeEntry from apps.projects.models import Project from apps.projects.services.access import ensure_project_access 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) start_time_ms = serializers.SerializerMethodField() end_time_ms = serializers.SerializerMethodField() server_now_ms = serializers.SerializerMethodField() @staticmethod def _epoch_ms(value): if value is None: return None if timezone.is_naive(value): value = timezone.make_aware(value, timezone.get_current_timezone()) return int(value.timestamp() * 1000) def get_start_time_ms(self, obj): return self._epoch_ms(obj.start_time) def get_end_time_ms(self, obj): return self._epoch_ms(obj.end_time) def get_server_now_ms(self, obj): server_now = self.context.get("server_now") or timezone.now() return self._epoch_ms(server_now) 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", "start_time_ms", "end_time", "end_time_ms", "server_now_ms", "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.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, default="") tags = serializers.ListField(child=serializers.UUIDField(), required=False) is_billable = serializers.BooleanField(default=False) def validate(self, attrs): user = self.context.get("request").user if self.context.get("request") else None workspace_id = attrs.get("workspace_id") start_time = attrs.get("start_time") end_time = attrs.get("end_time") if end_time is not None and start_time is None: raise serializers.ValidationError({"start_time": "Start time is required when end time is provided."}) 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."}) if workspace_id and str(project.workspace_id) != str(workspace_id): raise serializers.ValidationError({"project_id": "Selected project is unavailable."}) if user: ensure_project_access(user, project) 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 user = self.context.get("request").user if self.context.get("request") else None 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."}) if entry and str(project.workspace_id) != str(entry.workspace_id): raise serializers.ValidationError({"project_id": "Selected project is unavailable."}) if user: ensure_project_access(user, project) 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. """ end_time = serializers.DateTimeField(required=False, allow_null=True)