feat(time-entries): add grouped timesheet filters and responses

This commit is contained in:
2026-04-24 22:19:09 +03:30
parent 9910b386d2
commit 83bc3568d0
3 changed files with 164 additions and 34 deletions

View File

@@ -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":