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