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,33 @@
from django.contrib import admin
from core.admins.base import BaseAdmin, SoftDeleteListFilter
from apps.time_entries.models import TimeEntry
@admin.register(TimeEntry)
class TimeEntryAdmin(BaseAdmin):
list_display = (
"id",
"user",
"workspace",
"project",
"start_time",
"end_time",
"is_billable",
)
list_filter = (
SoftDeleteListFilter,
"workspace",
"project",
"is_billable",
)
search_fields = (
"user__mobile",
"project__name",
"description",
)
autocomplete_fields = (
"user",
"workspace",
"project",
)

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)

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class TimeEntriesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.time_entries"
verbose_name = "Time Entries"

View File

View File

@@ -0,0 +1,81 @@
from django.core.exceptions import ValidationError
from django.conf import settings
from django.db import models
from django.db.models import Q
from core.models.base import BaseModel
from apps.workspaces.models import Workspace
from apps.projects.models import Project
from apps.tags.models import Tag
User = settings.AUTH_USER_MODEL
class TimeEntry(BaseModel):
workspace = models.ForeignKey(
Workspace,
on_delete=models.CASCADE,
related_name="time_entries",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="time_entries",
)
project = models.ForeignKey(
Project,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="time_entries",
)
description = models.TextField(blank=True)
start_time = models.DateTimeField()
end_time = models.DateTimeField(null=True, blank=True)
duration = models.DurationField(null=True, blank=True)
tags = models.ManyToManyField(
Tag,
blank=True,
related_name="time_entries",
)
is_billable = models.BooleanField(default=False)
hourly_rate = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
)
currency = models.CharField(
max_length=3,
default="USD",
)
class Meta:
db_table = "time_entry"
ordering = ("-updated_at", "-created_at")
indexes = [
models.Index(fields=["workspace"], name="time_entry_workspace_idx"),
models.Index(fields=["user"], name="time_entry_user_idx"),
models.Index(fields=["project"], name="time_entry_project_idx"),
models.Index(fields=["start_time"], name="time_entry_start_idx"),
models.Index(fields=["workspace", "start_time"], name="time_entry_workspace_start_idx"),
]
constraints = [
models.UniqueConstraint(
fields=["workspace", "user"],
condition=Q(end_time__isnull=True, is_deleted=False),
name="unique_running_timer_per_user",
)
]
def __str__(self):
return f"{self.user} - {self.start_time}"
def clean(self):
if self.project and self.project.workspace_id != self.workspace_id:
raise ValidationError("Project must belong to the same workspace.")
for tag in self.tags.all():
if tag.workspace_id != self.workspace_id:
raise ValidationError("Tags must belong to the same workspace.")

View File

@@ -0,0 +1,22 @@
from apps.projects.models import ProjectRate, ProjectUserRate
def resolve_rate(user, project):
user_rate = ProjectUserRate.objects.filter(
user=user,
project=project,
is_active=True,
).order_by("-effective_from").first()
if user_rate:
return user_rate.hourly_rate, user_rate.currency
project_rate = ProjectRate.objects.filter(
project=project,
is_active=True,
).order_by("-effective_from").first()
if project_rate:
return project_rate.hourly_rate, project_rate.currency
return None, "USD"

View File

@@ -0,0 +1,141 @@
from django.utils import timezone
from rest_framework.exceptions import ValidationError, PermissionDenied
from apps.time_entries.models import TimeEntry
from apps.time_entries.services.rates import resolve_rate
from apps.workspaces.models import WorkspaceMembership
def _verify_workspace_access(user, workspace_id):
"""
Ensures the user is an active member of the specified workspace.
"""
has_access = WorkspaceMembership.objects.filter(
workspace_id=workspace_id,
user=user,
is_active=True,
is_deleted=False
).exists()
if not has_access:
raise PermissionDenied("You do not have access to this workspace.")
def create_time_entry(user, workspace_id, start_time, end_time=None, project=None, tags=None, description="", is_billable=False):
"""
Creates a new time entry. If end_time is None, it acts as a running timer.
"""
_verify_workspace_access(user, workspace_id)
if not end_time:
has_running_timer = TimeEntry.objects.filter(
workspace_id=workspace_id,
user=user,
end_time__isnull=True,
is_deleted=False
).exists()
if has_running_timer:
raise ValidationError({"non_field_errors": "You already have a running timer in this workspace."})
if start_time and end_time and start_time >= end_time:
raise ValidationError({"end_time": "End time must be strictly after start time."})
if project and project.workspace_id != workspace_id:
raise ValidationError({"project": "Project must belong to the same workspace."})
duration = (end_time - start_time) if end_time else None
hourly_rate, currency = None, "USD"
if is_billable and project:
hourly_rate, currency = resolve_rate(user, project)
entry = TimeEntry.objects.create(
workspace_id=workspace_id,
user=user,
project=project,
description=description,
start_time=start_time,
end_time=end_time,
duration=duration,
is_billable=is_billable,
hourly_rate=hourly_rate,
currency=currency
)
if tags:
for tag in tags:
if tag.workspace_id != workspace_id:
raise ValidationError({"tags": f"Tag '{tag.name}' does not belong to the workspace."})
entry.tags.set(tags)
return entry
def update_time_entry(entry, **kwargs):
"""
Updates an existing time entry, recalculating duration and rates if necessary.
"""
# Verify Project Workspace if changing
project = kwargs.get("project", entry.project)
if project and project.workspace_id != entry.workspace_id:
raise ValidationError({"project": "Project must belong to the same workspace."})
start_time = kwargs.get("start_time", entry.start_time)
end_time = kwargs.get("end_time", entry.end_time)
# Check if attempting to resume a timer (setting end_time to null)
if "end_time" in kwargs and end_time is None and entry.end_time is not None:
has_running_timer = TimeEntry.objects.filter(
workspace_id=entry.workspace_id,
user=entry.user,
end_time__isnull=True,
is_deleted=False
).exclude(id=entry.id).exists()
if has_running_timer:
raise ValidationError({"non_field_errors": "You already have a running timer in this workspace."})
if start_time and end_time and start_time >= end_time:
raise ValidationError({"end_time": "End time must be strictly after start time."})
kwargs["duration"] = (end_time - start_time) if end_time else None
# Recalculate rates if billing status or project changes
is_billable = kwargs.get("is_billable", entry.is_billable)
if "project" in kwargs or "is_billable" in kwargs:
if is_billable and project:
kwargs["hourly_rate"], kwargs["currency"] = resolve_rate(entry.user, project)
else:
kwargs["hourly_rate"] = None
tags = kwargs.pop("tags", None)
update_fields = []
for field, value in kwargs.items():
if hasattr(entry, field) and getattr(entry, field) != value:
setattr(entry, field, value)
update_fields.append(field)
if update_fields:
update_fields.append("updated_at")
entry.save(update_fields=update_fields)
# Handle tags update
if tags is not None:
for tag in tags:
if tag.workspace_id != entry.workspace_id:
raise ValidationError({"tags": f"Tag '{tag.name}' does not belong to the workspace."})
entry.tags.set(tags)
return entry
def stop_time_entry(entry, end_time=None):
"""
Helper function specifically for stopping an active timer.
"""
if entry.end_time is not None:
raise ValidationError({"non_field_errors": "This time entry is already stopped."})
stop_time = end_time or timezone.now()
return update_time_entry(entry, end_time=stop_time)

View File

@@ -44,6 +44,7 @@ LOCAL_APPS = [
"apps.clients", "apps.clients",
"apps.projects", "apps.projects",
"apps.tags", "apps.tags",
"apps.time_entries",
] ]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS

View File

@@ -20,6 +20,7 @@ urlpatterns = [
path('api/', include('apps.clients.api.urls'), name="clients"), path('api/', include('apps.clients.api.urls'), name="clients"),
path('api/', include('apps.projects.api.urls'), name="projects"), path('api/', include('apps.projects.api.urls'), name="projects"),
path('api/', include('apps.tags.api.urls'), name="tags"), path('api/', include('apps.tags.api.urls'), name="tags"),
path('api/', include('apps.time_entries.api.urls'), name="time_entries"),
] ]
if settings.DEBUG: if settings.DEBUG: