nexus-5/core/services/session_service.py
2026-01-26 11:09:40 -05:00

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