151 lines
5.7 KiB
Python
151 lines
5.7 KiB
Python
|
|
from django.utils import timezone
|
|
from rest_framework.exceptions import ValidationError, PermissionDenied
|
|
|
|
from apps.projects.services.access import user_has_project_access
|
|
from apps.time_entries.models import TimeEntry
|
|
from apps.time_entries.services.rates import resolve_rate
|
|
from apps.workspaces.models import Workspace
|
|
from apps.workspaces.services import TIME_ENTRIES_MANAGE_OWN, has_workspace_capability
|
|
|
|
|
|
def _verify_workspace_access(user, workspace_id):
|
|
"""
|
|
Ensures the user is an active member of the specified workspace.
|
|
"""
|
|
workspace = Workspace.objects.filter(id=workspace_id, is_deleted=False).first()
|
|
if not workspace or not has_workspace_capability(
|
|
user,
|
|
workspace,
|
|
TIME_ENTRIES_MANAGE_OWN,
|
|
):
|
|
raise PermissionDenied("You do not have access to this workspace.")
|
|
|
|
|
|
def create_time_entry(user, workspace_id, start_time=None, 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 is None:
|
|
if end_time is not None:
|
|
raise ValidationError({"start_time": "Start time is required when end time is provided."})
|
|
start_time = timezone.now()
|
|
|
|
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."})
|
|
if project and not user_has_project_access(user, project):
|
|
raise ValidationError({"project_id": "Selected project is unavailable."})
|
|
|
|
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."})
|
|
if project and not user_has_project_access(entry.user, project):
|
|
raise ValidationError({"project_id": "Selected project is unavailable."})
|
|
|
|
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)
|