290 lines
9.4 KiB
Python
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}"
|