from django.db import models from django.core.exceptions import ValidationError from django.db.models import Q, F, CheckConstraint, Index, UniqueConstraint from core.models.base import BaseModel, Note from core.models.profile import TeamProfile from core.models.service import Service from core.models.project import Project from core.models.scope import Scope, TaskCompletion from core.models.project_scope import ProjectScope, ProjectScopeTaskCompletion from core.models.customer import Customer from core.models.account import Account, AccountAddress class Session(BaseModel): """Session records""" created_by = models.ForeignKey( TeamProfile, on_delete=models.PROTECT, related_name="%(class)s_sessions", related_query_name="%(class)s_session", ) closed_by = models.ForeignKey( TeamProfile, on_delete=models.PROTECT, related_name="%(class)s_closed_sessions", related_query_name="%(class)s_closed_session", null=True, blank=True, ) date = models.DateField() class Meta: abstract = True get_latest_by = 'date' ordering = ['-date'] class ServiceSession(Session): """Service session records""" service = models.ForeignKey( Service, on_delete=models.PROTECT, related_name='sessions' ) account = models.ForeignKey( Account, on_delete=models.PROTECT, related_name='service_sessions' ) account_address = models.ForeignKey( AccountAddress, on_delete=models.PROTECT, related_name='service_sessions' ) customer = models.ForeignKey( Customer, on_delete=models.PROTECT, related_name='service_sessions' ) scope = models.ForeignKey( Scope, on_delete=models.PROTECT, related_name='service_sessions' ) start = models.DateTimeField() end = models.DateTimeField(null=True, blank=True) completed_tasks = models.ManyToManyField( TaskCompletion, related_name='service_sessions', blank=True, ) class Meta(Session.Meta): constraints = [ CheckConstraint( name='service_session_end_gt_start_or_null', condition=Q(end__isnull=True) | Q(end__gt=F('start')), ), UniqueConstraint( fields=['service'], condition=Q(end__isnull=True), name='unique_active_service_session', ), ] indexes = [ Index(fields=['service', 'start']), Index(fields=['account', 'start']), Index(fields=['created_by', 'start']), Index(fields=['date']), ] ordering = ['-start'] def clean(self): if self.start: self.date = self.start.date() errors = {} if self.end is not None and self.start is not None and self.end <= self.start: errors['end'] = "End must be after start." if self.account_address_id and self.account_id: if self.account_address.account_id != self.account_id: errors['account_address'] = "Account address must belong to the selected account." if self.account_id and self.customer_id: if getattr(self.account, 'customer_id', None) and self.account.customer_id != self.customer_id: errors['customer'] = "Customer must match the account's customer." if errors: raise ValidationError(errors) def save(self, *args, **kwargs): self.full_clean() return super().save(*args, **kwargs) @property def duration_seconds(self) -> int: if self.start and self.end: return int((self.end - self.start).total_seconds()) return 0 @property def is_active(self) -> bool: """A session is active if it has not been closed.""" return self.end is None class ProjectSession(Session): """Project session records""" project = models.ForeignKey( Project, on_delete=models.PROTECT, related_name='sessions', ) account = models.ForeignKey( Account, on_delete=models.PROTECT, related_name='project_sessions', null=True, blank=True, ) account_address = models.ForeignKey( AccountAddress, on_delete=models.PROTECT, related_name='project_sessions', null=True, blank=True, ) customer = models.ForeignKey( Customer, on_delete=models.PROTECT, related_name='project_sessions', ) scope = models.ForeignKey( ProjectScope, on_delete=models.PROTECT, related_name='project_sessions', ) start = models.DateTimeField() end = models.DateTimeField(null=True, blank=True) completed_tasks = models.ManyToManyField( ProjectScopeTaskCompletion, related_name='project_sessions', blank=True, ) class Meta(Session.Meta): constraints = [ CheckConstraint( name='project_session_end_gt_start_or_null', condition=Q(end__isnull=True) | Q(end__gt=F('start')), ), UniqueConstraint( fields=['project'], condition=Q(end__isnull=True), name='unique_active_project_session', ), ] indexes = [ Index(fields=['project', 'start']), Index(fields=['account', 'start']), Index(fields=['created_by', 'start']), Index(fields=['date']), ] ordering = ['-start'] def clean(self): if self.start: self.date = self.start.date() errors = {} if self.end is not None and self.start is not None and self.end <= self.start: errors['end'] = "End must be after start." # Account/address relationship if self.account_address_id and self.account_id: if self.account_address.account_id != self.account_id: errors['account_address'] = "Account address must belong to the selected account." # Customer/account relationship if self.account_id and self.customer_id: if getattr(self.account, 'customer_id', None) and self.account.customer_id != self.customer_id: errors['customer'] = "Customer must match the account's customer." # Project/linkage validations (when available on Project) # Ensure project.account_address aligns with session.account_address if getattr(self.project, 'account_address_id', None) and self.project.account_address_id != self.account_address_id: errors['project'] = "Project's account address must match the session's account address." # If project has an account_address, ensure session.account matches that address's account if getattr(self.project, 'account_address_id', None) and self.account_id: proj_account_id = getattr(self.project.account_address, 'account_id', None) if proj_account_id and proj_account_id != self.account_id: errors['account'] = "Project's account must match the session's account." # Customer must match the project's customer if getattr(self.project, 'customer_id', None) and self.project.customer_id != self.customer_id: errors['project'] = "Project's customer must match the session's customer." # Scope must belong to the same project if self.scope_id and self.project_id: if getattr(self.scope, 'project_id', None) != self.project_id: errors['scope'] = "Selected scope must belong to the current project." if errors: raise ValidationError(errors) def save(self, *args, **kwargs): self.full_clean() return super().save(*args, **kwargs) @property def duration_seconds(self) -> int: if self.start and self.end: return int((self.end - self.start).total_seconds()) return 0 @property def is_active(self) -> bool: """A session is active if it has not been closed.""" return self.end is None class SessionNote(Note): """ Abstract base model for session notes. Inherits content, author, internal, and timestamps from Note. """ class Meta: abstract = True ordering = ('-created_at',) class ServiceSessionNote(SessionNote): """Notes attached to service sessions""" session = models.ForeignKey( ServiceSession, on_delete=models.CASCADE, related_name='notes' ) class Meta(SessionNote.Meta): indexes = [ Index(fields=['session', '-created_at']), Index(fields=['author', '-created_at']), ] def __str__(self): preview = self.content[:50] + "..." if len(self.content) > 50 else self.content return f"ServiceSession {self.session_id}: {preview}" class ProjectSessionNote(SessionNote): """Notes attached to project sessions""" session = models.ForeignKey( ProjectSession, on_delete=models.CASCADE, related_name='notes' ) class Meta(SessionNote.Meta): indexes = [ Index(fields=['session', '-created_at']), Index(fields=['author', '-created_at']), ] def __str__(self): preview = self.content[:50] + "..." if len(self.content) > 50 else self.content return f"ProjectSession {self.session_id}: {preview}"