feat(time-entries): add grouped timesheet filters and responses
This commit is contained in:
49
apps/time_entries/api/filters.py
Normal file
49
apps/time_entries/api/filters.py
Normal file
@@ -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
|
||||
@@ -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 + (
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user