feat(time_entries): add time_entries app's basic structure and endpoints

This commit is contained in:
2026-03-11 19:46:45 +08:00
parent 4d66293804
commit 720adbe8a3
11 changed files with 490 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
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.
"""
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):
"""
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):
"""
Optional specific serializer for stopping a timer manually.
"""
end_time = serializers.DateTimeField(required=False, allow_null=True)

View File

@@ -0,0 +1,13 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.time_entries.api.views import TimeEntryViewSet
app_name = "time_entries"
router = DefaultRouter()
router.register(r"time-entries", TimeEntryViewSet, basename="time-entry")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -0,0 +1,113 @@
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 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
)
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.
"""
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()
def get_serializer_class(self):
if self.action == "create":
return TimeEntryCreateSerializer
elif self.action in ["update", "partial_update"]:
return TimeEntryUpdateSerializer
elif self.action == "stop":
return TimeEntryStopSerializer
return TimeEntrySerializer
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,
workspace_id=serializer.validated_data.pop("workspace_id"),
**serializer.validated_data
)
output_serializer = TimeEntrySerializer(entry)
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs):
partial = kwargs.pop("partial", False)
entry = self.get_object()
serializer = self.get_serializer(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)
@action(detail=True, methods=["post"])
def stop(self, request, pk=None):
"""
Dedicated endpoint to stop an actively running timer.
"""
entry = self.get_object()
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
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)
def destroy(self, request, *args, **kwargs):
"""
Soft deletes the time entry.
"""
entry = self.get_object()
entry.is_deleted = True
entry.save(update_fields=["is_deleted", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)