303 lines
9.9 KiB
Python
303 lines
9.9 KiB
Python
from datetime import timedelta
|
|
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.utils.translation import pgettext_lazy
|
|
|
|
from accounts.models import Account
|
|
from common.base import AssignableMixin, BaseModel
|
|
from common.models import Org, Profile, Tags, Teams
|
|
from common.utils import CASE_TYPE, PRIORITY_CHOICE, STATUS_CHOICE
|
|
from contacts.models import Contact
|
|
|
|
|
|
# Cleanup notes:
|
|
# - Removed 'created_on_arrow' property from Case and Solution (frontend computes its own timestamps)
|
|
# - Fixed case_type default from "" to None (empty string is bad default for nullable field)
|
|
|
|
|
|
class Case(AssignableMixin, BaseModel):
|
|
name = models.CharField(pgettext_lazy("Name of the case", "Name"), max_length=64)
|
|
status = models.CharField(choices=STATUS_CHOICE, max_length=64)
|
|
priority = models.CharField(choices=PRIORITY_CHOICE, max_length=64)
|
|
case_type = models.CharField(
|
|
choices=CASE_TYPE, max_length=255, blank=True, null=True, default=None
|
|
)
|
|
account = models.ForeignKey(
|
|
Account,
|
|
on_delete=models.CASCADE,
|
|
blank=True,
|
|
null=True,
|
|
related_name="accounts_cases",
|
|
)
|
|
contacts = models.ManyToManyField(Contact, related_name="case_contacts")
|
|
closed_on = models.DateField(blank=True, null=True)
|
|
description = models.TextField(blank=True, null=True)
|
|
assigned_to = models.ManyToManyField(Profile, related_name="case_assigned_users")
|
|
is_active = models.BooleanField(default=True)
|
|
teams = models.ManyToManyField(Teams, related_name="cases_teams")
|
|
tags = models.ManyToManyField(Tags, related_name="case_tags", blank=True)
|
|
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="cases")
|
|
|
|
# SLA Tracking Fields
|
|
first_response_at = models.DateTimeField(
|
|
_("First Response At"), blank=True, null=True
|
|
)
|
|
resolved_at = models.DateTimeField(_("Resolved At"), blank=True, null=True)
|
|
sla_first_response_hours = models.PositiveIntegerField(
|
|
_("First Response SLA (hours)"),
|
|
default=4,
|
|
help_text="Target hours for first response",
|
|
)
|
|
sla_resolution_hours = models.PositiveIntegerField(
|
|
_("Resolution SLA (hours)"),
|
|
default=24,
|
|
help_text="Target hours for resolution",
|
|
)
|
|
|
|
# Kanban/Pipeline support
|
|
stage = models.ForeignKey(
|
|
"CaseStage",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="cases",
|
|
help_text="Current pipeline stage (null = use status-based kanban)",
|
|
)
|
|
kanban_order = models.DecimalField(
|
|
_("Kanban Order"),
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
default=0,
|
|
help_text="Order within the kanban column for drag-drop positioning",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Case"
|
|
verbose_name_plural = "Cases"
|
|
db_table = "case"
|
|
ordering = ("-created_at",)
|
|
indexes = [
|
|
models.Index(fields=["status"]),
|
|
models.Index(fields=["priority"]),
|
|
models.Index(fields=["org", "-created_at"]),
|
|
models.Index(fields=["stage", "kanban_order"]),
|
|
models.Index(fields=["status", "kanban_order"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.name}"
|
|
|
|
def clean(self):
|
|
"""Validate case data."""
|
|
super().clean()
|
|
errors = {}
|
|
|
|
# Closed date required when status is Closed
|
|
if self.status == "Closed" and not self.closed_on:
|
|
errors["closed_on"] = _("Closed date is required when closing a case")
|
|
|
|
if errors:
|
|
raise ValidationError(errors)
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""Auto-set SLA values based on priority for new cases."""
|
|
from .workflow import DEFAULT_FIRST_RESPONSE_SLA, DEFAULT_RESOLUTION_SLA
|
|
|
|
# Set default SLA based on priority for new cases
|
|
if not self.pk:
|
|
if self.sla_first_response_hours == 4: # Default value
|
|
self.sla_first_response_hours = DEFAULT_FIRST_RESPONSE_SLA.get(
|
|
self.priority, 4
|
|
)
|
|
if self.sla_resolution_hours == 24: # Default value
|
|
self.sla_resolution_hours = DEFAULT_RESOLUTION_SLA.get(
|
|
self.priority, 24
|
|
)
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def is_sla_first_response_breached(self) -> bool:
|
|
"""Check if first response SLA has been breached."""
|
|
if self.first_response_at:
|
|
return False
|
|
if not self.created_at:
|
|
return False
|
|
deadline = self.created_at + timedelta(hours=self.sla_first_response_hours)
|
|
return timezone.now() > deadline
|
|
|
|
@property
|
|
def is_sla_resolution_breached(self) -> bool:
|
|
"""Check if resolution SLA has been breached."""
|
|
if self.resolved_at:
|
|
return False
|
|
if not self.created_at:
|
|
return False
|
|
deadline = self.created_at + timedelta(hours=self.sla_resolution_hours)
|
|
return timezone.now() > deadline
|
|
|
|
@property
|
|
def first_response_sla_deadline(self):
|
|
"""Return the deadline for first response."""
|
|
if self.created_at:
|
|
return self.created_at + timedelta(hours=self.sla_first_response_hours)
|
|
return None
|
|
|
|
@property
|
|
def resolution_sla_deadline(self):
|
|
"""Return the deadline for resolution."""
|
|
if self.created_at:
|
|
return self.created_at + timedelta(hours=self.sla_resolution_hours)
|
|
return None
|
|
|
|
|
|
class Solution(BaseModel):
|
|
"""
|
|
Knowledge Base Solution
|
|
|
|
Solutions are reusable answers/guides that can be linked to cases.
|
|
They form a knowledge base for common issues and their resolutions.
|
|
"""
|
|
|
|
title = models.CharField(max_length=255)
|
|
description = models.TextField()
|
|
|
|
STATUS_CHOICES = [
|
|
("draft", "Draft"),
|
|
("reviewed", "Reviewed"),
|
|
("approved", "Approved"),
|
|
]
|
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="draft")
|
|
is_published = models.BooleanField(default=False)
|
|
|
|
# Organization relation
|
|
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="solutions")
|
|
|
|
# Cases that use this solution
|
|
cases = models.ManyToManyField(Case, related_name="solutions", blank=True)
|
|
|
|
class Meta:
|
|
verbose_name = "Solution"
|
|
verbose_name_plural = "Solutions"
|
|
db_table = "solution"
|
|
ordering = ("-created_at",)
|
|
indexes = [
|
|
models.Index(fields=["status"]),
|
|
models.Index(fields=["is_published"]),
|
|
models.Index(fields=["org"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
def publish(self):
|
|
"""Publish the solution (must be approved first)"""
|
|
if self.status == "approved":
|
|
self.is_published = True
|
|
self.save()
|
|
|
|
def unpublish(self):
|
|
"""Unpublish the solution"""
|
|
self.is_published = False
|
|
self.save()
|
|
|
|
|
|
class CasePipeline(BaseModel):
|
|
"""
|
|
Custom pipeline for organizing cases into stages (Kanban columns).
|
|
Each organization can have multiple pipelines (e.g., Support, Engineering, Billing).
|
|
"""
|
|
|
|
name = models.CharField(_("Pipeline Name"), max_length=255)
|
|
description = models.TextField(_("Description"), blank=True, null=True)
|
|
org = models.ForeignKey(
|
|
Org, on_delete=models.CASCADE, related_name="case_pipelines"
|
|
)
|
|
is_default = models.BooleanField(
|
|
default=False,
|
|
help_text="If true, new cases without explicit pipeline go here",
|
|
)
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
verbose_name = "Case Pipeline"
|
|
verbose_name_plural = "Case Pipelines"
|
|
db_table = "case_pipeline"
|
|
ordering = ("-is_default", "name")
|
|
indexes = [
|
|
models.Index(fields=["org", "-created_at"]),
|
|
]
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["org"],
|
|
condition=models.Q(is_default=True),
|
|
name="unique_default_case_pipeline_per_org",
|
|
)
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.org.name})"
|
|
|
|
|
|
class CaseStage(BaseModel):
|
|
"""
|
|
Stage within a Case Pipeline (Kanban column).
|
|
"""
|
|
|
|
STAGE_TYPE_CHOICES = [
|
|
("open", "Open"),
|
|
("closed", "Closed"),
|
|
("rejected", "Rejected"),
|
|
]
|
|
|
|
pipeline = models.ForeignKey(
|
|
CasePipeline, on_delete=models.CASCADE, related_name="stages"
|
|
)
|
|
name = models.CharField(_("Stage Name"), max_length=100)
|
|
order = models.PositiveIntegerField(default=0)
|
|
color = models.CharField(max_length=7, default="#6B7280") # Hex color
|
|
|
|
# Business logic fields
|
|
stage_type = models.CharField(
|
|
max_length=20, choices=STAGE_TYPE_CHOICES, default="open"
|
|
)
|
|
maps_to_status = models.CharField(
|
|
_("Maps to Status"),
|
|
max_length=64,
|
|
blank=True,
|
|
null=True,
|
|
choices=STATUS_CHOICE,
|
|
help_text="When case enters this stage, also update Case.status",
|
|
)
|
|
|
|
# Kanban features
|
|
wip_limit = models.PositiveIntegerField(
|
|
_("WIP Limit"),
|
|
null=True,
|
|
blank=True,
|
|
help_text="Maximum cases allowed in this stage (null = unlimited)",
|
|
)
|
|
|
|
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="case_stages")
|
|
|
|
class Meta:
|
|
verbose_name = "Case Stage"
|
|
verbose_name_plural = "Case Stages"
|
|
db_table = "case_stage"
|
|
ordering = ("order",)
|
|
unique_together = ("pipeline", "name")
|
|
indexes = [
|
|
models.Index(fields=["org", "order"]),
|
|
models.Index(fields=["pipeline", "order"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.pipeline.name} - {self.name}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.org_id and self.pipeline_id:
|
|
self.org_id = self.pipeline.org_id
|
|
super().save(*args, **kwargs)
|