Files
qlockify-backend-deployment/apps/time_entries/services/time_entries.py

142 lines
5.0 KiB
Python

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)