feat(time_entries): add time_entries app's basic structure and endpoints
This commit is contained in:
141
apps/time_entries/services/time_entries.py
Normal file
141
apps/time_entries/services/time_entries.py
Normal 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)
|
||||
Reference in New Issue
Block a user