From 83bc3568d0f60dab8e08d9e0c211819e9cb182e5 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 24 Apr 2026 22:19:09 +0330 Subject: [PATCH] feat(time-entries): add grouped timesheet filters and responses --- apps/time_entries/api/filters.py | 49 +++++++++ apps/time_entries/api/serializers.py | 3 + apps/time_entries/api/views.py | 146 ++++++++++++++++++++------- 3 files changed, 164 insertions(+), 34 deletions(-) create mode 100644 apps/time_entries/api/filters.py diff --git a/apps/time_entries/api/filters.py b/apps/time_entries/api/filters.py new file mode 100644 index 0000000..0123eb5 --- /dev/null +++ b/apps/time_entries/api/filters.py @@ -0,0 +1,49 @@ +import django_filters as filters + +from apps.time_entries.models import TimeEntry +from core.filters.base import BaseFilterSet + + +class TimeEntryFilter(BaseFilterSet): + status = filters.CharFilter(method="filter_status") + client = filters.UUIDFilter(field_name="project__client_id") + started_after = filters.DateFilter(method="filter_started_after") + started_before = filters.DateFilter(method="filter_started_before") + tags = filters.CharFilter(method="filter_tags") + + class Meta: + model = TimeEntry + fields = ["workspace", "project", "client", "is_billable"] + + def filter_started_after(self, queryset, name, value): + if not value: + return queryset + return queryset.filter(start_time__date__gte=value) + + def filter_started_before(self, queryset, name, value): + if not value: + return queryset + return queryset.filter(start_time__date__lte=value) + + def filter_tags(self, queryset, name, value): + raw_values = self.request.query_params.getlist("tags") if self.request else [] + if value and not raw_values: + raw_values = [value] + + tag_ids = [] + for raw in raw_values: + tag_ids.extend([item.strip() for item in str(raw).split(",") if item.strip()]) + + if not tag_ids: + return queryset + + return queryset.filter(tags__id__in=tag_ids).distinct() + + def filter_status(self, queryset, name, value): + if not value or value == "all": + return queryset + if value == "running": + return queryset.filter(end_time__isnull=True) + if value == "ended": + return queryset.filter(end_time__isnull=False) + return queryset diff --git a/apps/time_entries/api/serializers.py b/apps/time_entries/api/serializers.py index d13e6e8..3eacd7e 100644 --- a/apps/time_entries/api/serializers.py +++ b/apps/time_entries/api/serializers.py @@ -10,6 +10,9 @@ 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 + ( diff --git a/apps/time_entries/api/views.py b/apps/time_entries/api/views.py index 835c5e7..63ab478 100644 --- a/apps/time_entries/api/views.py +++ b/apps/time_entries/api/views.py @@ -1,54 +1,132 @@ -from rest_framework import status -from rest_framework.viewsets import ModelViewSet -from rest_framework.decorators import action -from rest_framework.response import Response +from datetime import timedelta + +from django.utils import timezone +from rest_framework import status +from rest_framework.viewsets import ModelViewSet +from rest_framework.decorators import action +from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated -from rest_framework.filters import SearchFilter, OrderingFilter +from rest_framework.filters import SearchFilter from django_filters.rest_framework import DjangoFilterBackend from core.paginations.limit_offset import CustomLimitOffsetPagination from apps.time_entries.models import TimeEntry -from apps.time_entries.api.serializers import ( - TimeEntrySerializer, - TimeEntryCreateSerializer, - TimeEntryUpdateSerializer, - TimeEntryStopSerializer -) -from apps.time_entries.services.time_entries import ( - create_time_entry, - update_time_entry, - stop_time_entry -) +from apps.time_entries.api.serializers import ( + TimeEntrySerializer, + TimeEntryCreateSerializer, + TimeEntryUpdateSerializer, + TimeEntryStopSerializer +) +from apps.time_entries.api.filters import TimeEntryFilter +from apps.time_entries.services.time_entries import ( + create_time_entry, + update_time_entry, + stop_time_entry +) -class TimeEntryViewSet(ModelViewSet): +class TimeEntryViewSet(ModelViewSet): """ API endpoints for managing time entries. """ pagination_class = CustomLimitOffsetPagination permission_classes = [IsAuthenticated] - filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] - filterset_fields = ["workspace", "project", "is_billable"] - search_fields = ["description", "project__name", "tags__name"] - ordering_fields = ["start_time", "end_time", "created_at", "updated_at"] - ordering = ["-start_time"] - - def get_queryset(self): - """ - Users can only interact with their own time entries within workspaces - where they hold an active membership. - """ + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_class = TimeEntryFilter + search_fields = ["description", "project__name", "project__client__name", "tags__name"] + + @staticmethod + def _serialize_duration_ms(entry): + if entry.duration is not None: + return int(entry.duration.total_seconds() * 1000) + if entry.end_time is None: + return 0 + return int(max((entry.end_time - entry.start_time).total_seconds(), 0) * 1000) + + @staticmethod + def _get_week_start(local_dt): + days_since_sunday = (local_dt.weekday() + 1) % 7 + return (local_dt - timedelta(days=days_since_sunday)).date() + + def _build_grouped_entries(self, entries): + serialized_entries = TimeEntrySerializer(entries, many=True, context=self.get_serializer_context()).data + serialized_by_id = {item["id"]: item for item in serialized_entries} + weeks = [] + weeks_by_key = {} + + for entry in entries: + local_start = timezone.localtime(entry.start_time) + day_date = local_start.date() + week_start = self._get_week_start(local_start) + week_end = week_start + timedelta(days=6) + week_key = week_start.isoformat() + day_key = day_date.isoformat() + duration_ms = self._serialize_duration_ms(entry) + + if week_key not in weeks_by_key: + week_payload = { + "key": week_key, + "week_start": week_key, + "week_end": week_end.isoformat(), + "total_ms": 0, + "days": [], + } + weeks_by_key[week_key] = week_payload + weeks.append(week_payload) + + week_payload = weeks_by_key[week_key] + week_payload["total_ms"] += duration_ms + + day_payload = next((day for day in week_payload["days"] if day["key"] == day_key), None) + if day_payload is None: + day_payload = { + "key": day_key, + "date": day_key, + "total_ms": 0, + "entries": [], + } + week_payload["days"].append(day_payload) + + day_payload["total_ms"] += duration_ms + day_payload["entries"].append(serialized_by_id[str(entry.id)]) + + return weeks + + def get_queryset(self): + """ + Users can only interact with their own time entries within workspaces + where they hold an active membership. + """ if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated: return TimeEntry.objects.none() - return TimeEntry.objects.filter( - user=self.request.user, - workspace__memberships__user=self.request.user, - workspace__memberships__is_active=True, - is_deleted=False - ).distinct() + return TimeEntry.objects.filter( + user=self.request.user, + workspace__memberships__user=self.request.user, + workspace__memberships__is_active=True, + is_deleted=False + ).distinct().select_related("project", "project__client", "workspace", "user").prefetch_related("tags").order_by("-start_time", "-created_at") + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request, view=self) + current_items_count = len(page) + has_more = (paginator.offset + current_items_count) < paginator.count + + return Response( + { + "items_per_page": paginator.limit, + "current_page_items_count": current_items_count, + "total_items": paginator.count, + "offset": paginator.offset, + "next_offset": paginator.offset + current_items_count if has_more else None, + "has_more": has_more, + "groups": self._build_grouped_entries(page), + } + ) def get_serializer_class(self): if self.action == "create":