diff --git a/apps/time_entries/admin.py b/apps/time_entries/admin.py new file mode 100644 index 0000000..3d2e6d6 --- /dev/null +++ b/apps/time_entries/admin.py @@ -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", + ) diff --git a/apps/time_entries/api/serializers.py b/apps/time_entries/api/serializers.py new file mode 100644 index 0000000..d13e6e8 --- /dev/null +++ b/apps/time_entries/api/serializers.py @@ -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) diff --git a/apps/time_entries/api/urls.py b/apps/time_entries/api/urls.py new file mode 100644 index 0000000..2de9df5 --- /dev/null +++ b/apps/time_entries/api/urls.py @@ -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)), +] diff --git a/apps/time_entries/api/views.py b/apps/time_entries/api/views.py new file mode 100644 index 0000000..835c5e7 --- /dev/null +++ b/apps/time_entries/api/views.py @@ -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) diff --git a/apps/time_entries/apps.py b/apps/time_entries/apps.py new file mode 100644 index 0000000..e00ce74 --- /dev/null +++ b/apps/time_entries/apps.py @@ -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" diff --git a/apps/time_entries/migrations/__init__.py b/apps/time_entries/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/time_entries/models.py b/apps/time_entries/models.py new file mode 100644 index 0000000..6d25f6b --- /dev/null +++ b/apps/time_entries/models.py @@ -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.") diff --git a/apps/time_entries/services/rates.py b/apps/time_entries/services/rates.py new file mode 100644 index 0000000..73b4bf3 --- /dev/null +++ b/apps/time_entries/services/rates.py @@ -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" diff --git a/apps/time_entries/services/time_entries.py b/apps/time_entries/services/time_entries.py new file mode 100644 index 0000000..3aac920 --- /dev/null +++ b/apps/time_entries/services/time_entries.py @@ -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) diff --git a/config/settings/base.py b/config/settings/base.py index 2eb501b..0738ae6 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -44,6 +44,7 @@ LOCAL_APPS = [ "apps.clients", "apps.projects", "apps.tags", + "apps.time_entries", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/config/urls.py b/config/urls.py index 1f53109..bb94a9e 100644 --- a/config/urls.py +++ b/config/urls.py @@ -20,6 +20,7 @@ urlpatterns = [ path('api/', include('apps.clients.api.urls'), name="clients"), path('api/', include('apps.projects.api.urls'), name="projects"), path('api/', include('apps.tags.api.urls'), name="tags"), + path('api/', include('apps.time_entries.api.urls'), name="time_entries"), ] if settings.DEBUG: