347 lines
15 KiB
Python
347 lines
15 KiB
Python
from dataclasses import dataclass
|
|
from uuid import UUID
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
|
|
from core.models.enums import ServiceChoices
|
|
from core.models.scope import Scope, TaskCompletion, Task
|
|
from core.models.project_scope import ProjectScope, ProjectScopeTask, ProjectScopeTaskCompletion
|
|
from core.models.session import ServiceSession, ProjectSession
|
|
from core.models.service import Service
|
|
from core.models.project import Project
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OpenSessionResult:
|
|
session_id: UUID
|
|
entity_id: UUID
|
|
entity_type: str
|
|
started_at_iso: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CloseSessionResult:
|
|
session_id: UUID
|
|
entity_id: UUID
|
|
entity_type: str
|
|
ended_at_iso: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RevertSessionResult:
|
|
session_id: UUID
|
|
entity_id: UUID
|
|
entity_type: str
|
|
reverted_at_iso: str
|
|
|
|
|
|
class SessionService:
|
|
@transaction.atomic
|
|
def open_session(self, *, entity_type: str, entity_id: UUID, actor) -> OpenSessionResult:
|
|
"""
|
|
Open a session for a service or project.
|
|
Changes status from 'scheduled' to 'in progress' and creates the session with tasks.
|
|
"""
|
|
if entity_type == "service":
|
|
entity = Service.objects.select_for_update().get(id=entity_id)
|
|
try:
|
|
scope = Scope.objects.select_for_update().get(is_active=True, account_address=entity.account_address)
|
|
except Scope.DoesNotExist:
|
|
raise ValidationError("No active scope found for this account address.")
|
|
except Scope.MultipleObjectsReturned:
|
|
raise ValidationError("Multiple active scopes found for this account address.")
|
|
# Get account - use service.account if set, otherwise get from account_address
|
|
account = entity.account if entity.account else entity.account_address.account
|
|
# Validate scope's account matches the service's account
|
|
if scope.account_id != account.id:
|
|
raise ValidationError("Resolved scope does not match the service's account.")
|
|
if entity.status != ServiceChoices.SCHEDULED:
|
|
raise ValidationError(f"Service must be scheduled to open session. Current status: {entity.status}")
|
|
if ServiceSession.objects.filter(service=entity, end__isnull=True).exists():
|
|
raise ValidationError("An active session already exists for this service.")
|
|
session = ServiceSession.objects.create(
|
|
service=entity,
|
|
account=account,
|
|
account_address=entity.account_address,
|
|
customer=account.customer,
|
|
scope=scope,
|
|
start=timezone.now(),
|
|
created_by=actor,
|
|
date=timezone.now().date()
|
|
)
|
|
entity.status = ServiceChoices.IN_PROGRESS
|
|
entity.save(update_fields=['status'])
|
|
|
|
elif entity_type == "project":
|
|
entity = Project.objects.select_for_update().get(id=entity_id)
|
|
scope_id = entity.scope_id
|
|
try:
|
|
scope = ProjectScope.objects.select_for_update().get(
|
|
id=scope_id, is_active=True, project=entity
|
|
)
|
|
except ProjectScope.DoesNotExist:
|
|
raise ValidationError("No active project scope found for this project.")
|
|
except ProjectScope.MultipleObjectsReturned:
|
|
raise ValidationError("Multiple active project scopes found for this project.")
|
|
if entity.status != ServiceChoices.SCHEDULED:
|
|
raise ValidationError(f"Project must be scheduled to open session. Current status: {entity.status}")
|
|
if ProjectSession.objects.filter(project=entity, end__isnull=True).exists():
|
|
raise ValidationError("An active session already exists for this project.")
|
|
session = ProjectSession.objects.create(
|
|
project=entity,
|
|
account=getattr(entity.account_address, 'account', None),
|
|
account_address=entity.account_address,
|
|
customer=entity.customer,
|
|
scope=scope,
|
|
start=timezone.now(),
|
|
created_by=actor,
|
|
date=timezone.now().date(),
|
|
)
|
|
entity.status = ServiceChoices.IN_PROGRESS
|
|
entity.save(update_fields=['status'])
|
|
|
|
else:
|
|
raise ValidationError(f"Invalid entity_type: {entity_type}")
|
|
|
|
return OpenSessionResult(
|
|
session_id=session.id,
|
|
entity_id=entity.id,
|
|
entity_type=entity_type,
|
|
started_at_iso=session.start.isoformat()
|
|
)
|
|
|
|
@transaction.atomic
|
|
def close_session(self, *, entity_type: str, entity_id: UUID, actor, tasks) -> CloseSessionResult:
|
|
"""
|
|
Close the active session for a service or project.
|
|
Creates task completions and changes status to 'completed'.
|
|
"""
|
|
# Check entity type and validate the active session
|
|
if entity_type == "service":
|
|
entity = Service.objects.select_for_update().get(id=entity_id)
|
|
session = ServiceSession.objects.select_for_update().get(
|
|
service=entity, end__isnull=True
|
|
)
|
|
if entity.status != ServiceChoices.IN_PROGRESS:
|
|
raise ValidationError(f"Service must be in progress to close session. Current status: {entity.status}")
|
|
if session.end is not None:
|
|
raise ValidationError("Service session is already closed.")
|
|
elif entity_type == "project":
|
|
entity = Project.objects.select_for_update().get(id=entity_id)
|
|
session = ProjectSession.objects.select_for_update().get(
|
|
project=entity, end__isnull=True
|
|
)
|
|
if entity.status != ServiceChoices.IN_PROGRESS:
|
|
raise ValidationError(f"Project must be in progress to close session. Current status: {entity.status}")
|
|
if session.end is not None:
|
|
raise ValidationError("Project session is already closed.")
|
|
else:
|
|
raise ValidationError(f"Invalid entity_type: {entity_type}")
|
|
|
|
# Handle task completions
|
|
now = timezone.now()
|
|
if tasks is not None:
|
|
existing_task_ids = set(session.completed_tasks.values_list('task_id', flat=True))
|
|
unique_tasks: list[Task] or list[ProjectScopeTask] = []
|
|
seen_ids = set()
|
|
for task in tasks:
|
|
if task.id in seen_ids:
|
|
continue
|
|
seen_ids.add(task.id)
|
|
if task.id in existing_task_ids:
|
|
continue
|
|
unique_tasks.append(task)
|
|
for task in unique_tasks:
|
|
if entity_type == "service":
|
|
if getattr(task, "area", None) and task.area.scope_id != session.scope_id:
|
|
raise ValidationError("Task does not belong to the service session's scope.")
|
|
task_completion = TaskCompletion.objects.create(
|
|
task=task,
|
|
service=session.service,
|
|
account_address=session.account_address,
|
|
completed_by=actor,
|
|
completed_at=now,
|
|
)
|
|
session.completed_tasks.add(task_completion)
|
|
elif entity_type == "project":
|
|
if getattr(task, "category", None) and task.category.scope_id != session.scope_id:
|
|
raise ValidationError("Task does not belong to the project session's scope.")
|
|
task_completion = ProjectScopeTaskCompletion.objects.create(
|
|
task=task,
|
|
project=session.project,
|
|
account_address=session.account_address,
|
|
completed_by=actor,
|
|
completed_at=now,
|
|
)
|
|
session.completed_tasks.add(task_completion)
|
|
|
|
# Close the session
|
|
session.end = now
|
|
session.closed_by = actor
|
|
session.save(update_fields=['end', 'closed_by'])
|
|
entity.status = ServiceChoices.COMPLETED
|
|
entity.save(update_fields=['status'])
|
|
return CloseSessionResult(
|
|
session_id=session.id,
|
|
entity_id=entity.id,
|
|
entity_type=entity_type,
|
|
ended_at_iso=now.isoformat()
|
|
)
|
|
|
|
@transaction.atomic
|
|
def revert_session(self, *, entity_type: str, entity_id: UUID, actor) -> RevertSessionResult:
|
|
"""
|
|
Revert an active session to a scheduled state for a service or project.
|
|
- Requires the entity to be IN_PROGRESS with an active (open) session.
|
|
- Deletes the active session and any task completion records associated with that session.
|
|
- Sets the entity status back to SCHEDULED.
|
|
"""
|
|
now = timezone.now()
|
|
if entity_type == "service":
|
|
entity = Service.objects.select_for_update().get(id=entity_id)
|
|
if entity.status != ServiceChoices.IN_PROGRESS:
|
|
raise ValidationError(
|
|
f"Service must be in progress to revert session. Current status: {entity.status}"
|
|
)
|
|
session = ServiceSession.objects.select_for_update().get(service=entity, end__isnull=True)
|
|
# Delete task completions associated to this session (and unlink)
|
|
completions = list(session.completed_tasks.all())
|
|
for tc in completions:
|
|
session.completed_tasks.remove(tc)
|
|
tc.delete()
|
|
# Delete the session itself
|
|
sid = session.id
|
|
session.delete()
|
|
# Reset status
|
|
entity.status = ServiceChoices.SCHEDULED
|
|
entity.save(update_fields=['status'])
|
|
return RevertSessionResult(
|
|
session_id=sid,
|
|
entity_id=entity.id,
|
|
entity_type=entity_type,
|
|
reverted_at_iso=now.isoformat(),
|
|
)
|
|
elif entity_type == "project":
|
|
entity = Project.objects.select_for_update().get(id=entity_id)
|
|
if entity.status != ServiceChoices.IN_PROGRESS:
|
|
raise ValidationError(
|
|
f"Project must be in progress to revert session. Current status: {entity.status}"
|
|
)
|
|
session = ProjectSession.objects.select_for_update().get(project=entity, end__isnull=True)
|
|
# Delete task completions associated to this session (and unlink)
|
|
completions = list(session.completed_tasks.all())
|
|
for ptc in completions:
|
|
session.completed_tasks.remove(ptc)
|
|
ptc.delete()
|
|
sid = session.id
|
|
session.delete()
|
|
entity.status = ServiceChoices.SCHEDULED
|
|
entity.save(update_fields=['status'])
|
|
return RevertSessionResult(
|
|
session_id=sid,
|
|
entity_id=entity.id,
|
|
entity_type=entity_type,
|
|
reverted_at_iso=now.isoformat(),
|
|
)
|
|
else:
|
|
raise ValidationError(f"Invalid entity_type: {entity_type}")
|
|
|
|
@transaction.atomic
|
|
def add_task_completion(self, *, service_id: UUID, task_id: UUID, actor, notes: str | None = None) -> UUID:
|
|
"""
|
|
Add a single task completion to the active session for a service.
|
|
"""
|
|
service = Service.objects.select_for_update().get(id=service_id)
|
|
session = ServiceSession.objects.select_for_update().get(service=service, end__isnull=True)
|
|
|
|
task = Task.objects.get(id=task_id)
|
|
if getattr(task, "area", None) and task.area.scope_id != session.scope_id:
|
|
raise ValidationError("Task does not belong to the session's scope.")
|
|
|
|
# Create or reuse existing completion (guarded by unique_task_per_service)
|
|
tc, _created = TaskCompletion.objects.get_or_create(
|
|
service=service,
|
|
task=task,
|
|
defaults={
|
|
"account_address": session.account_address,
|
|
"completed_by": actor,
|
|
"completed_at": timezone.now(),
|
|
"notes": notes or "",
|
|
},
|
|
)
|
|
# Ensure M2M link exists
|
|
session.completed_tasks.add(tc)
|
|
|
|
return session.id
|
|
|
|
@transaction.atomic
|
|
def remove_task_completion(self, *, service_id: UUID, task_id: UUID) -> UUID:
|
|
"""
|
|
Remove a single task completion from the active session for a service.
|
|
"""
|
|
service = Service.objects.select_for_update().get(id=service_id)
|
|
session = ServiceSession.objects.select_for_update().get(service=service, end__isnull=True)
|
|
|
|
tc = TaskCompletion.objects.filter(service=service, task_id=task_id).first()
|
|
if not tc:
|
|
# Idempotent: nothing to remove
|
|
return session.id
|
|
|
|
# Remove association and delete the completion record
|
|
session.completed_tasks.remove(tc)
|
|
tc.delete()
|
|
|
|
return session.id
|
|
|
|
@transaction.atomic
|
|
def add_project_task_completion(self, *, project_id: UUID, task_id: UUID, actor, notes: str | None = None) -> UUID:
|
|
"""
|
|
Add a single project-scope task completion to the active ProjectSession for a project.
|
|
Validates that the task belongs to the session's scope.
|
|
Returns the ProjectSession ID.
|
|
"""
|
|
# Load active project session
|
|
project = Project.objects.select_for_update().get(id=project_id)
|
|
session = ProjectSession.objects.select_for_update().get(project=project, end__isnull=True)
|
|
|
|
# Load the task and validate it belongs to the same ProjectScope as the session
|
|
pst = ProjectScopeTask.objects.get(id=task_id)
|
|
if getattr(pst, "category", None) and pst.category.scope_id != session.scope_id:
|
|
raise ValidationError("Task does not belong to the session's project scope.")
|
|
|
|
# Create or reuse existing completion for this (project, task)
|
|
now = timezone.now()
|
|
ptc, _created = ProjectScopeTaskCompletion.objects.get_or_create(
|
|
project=project,
|
|
task=pst,
|
|
defaults={
|
|
"account": session.account,
|
|
"account_address": session.account_address,
|
|
"completed_by": actor,
|
|
"completed_at": now,
|
|
"notes": notes or "",
|
|
},
|
|
)
|
|
# Ensure M2M link exists
|
|
session.completed_tasks.add(ptc)
|
|
|
|
return session.id
|
|
|
|
@transaction.atomic
|
|
def remove_project_task_completion(self, *, project_id: UUID, task_id: UUID) -> UUID:
|
|
"""
|
|
Remove a single project-scope task completion from the active ProjectSession for a project.
|
|
Idempotent: if not present, returns the current session ID without error.
|
|
"""
|
|
project = Project.objects.select_for_update().get(id=project_id)
|
|
session = ProjectSession.objects.select_for_update().get(project=project, end__isnull=True)
|
|
|
|
ptc = ProjectScopeTaskCompletion.objects.filter(project=project, task_id=task_id).first()
|
|
if not ptc:
|
|
return session.id
|
|
|
|
session.completed_tasks.remove(ptc)
|
|
ptc.delete()
|
|
return session.id
|