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

290 lines
9.4 KiB
Python

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}"