bottleneckcrm/backend/cases/workflow.py
Ashwin 2be3f51efc Add comprehensive tests for ticket closing, calendar feed, deal board, macros, session caches, and web form models
- Implement tests for closing tickets via menu and status picker, ensuring correct API interactions and error handling.
- Create tests for calendar feed provider, verifying throttling behavior and success responses.
- Add tests for deal board provider, covering reading and moving deals, including edge cases for card movement and filters.
- Enhance macro tests to validate action parsing and application to tickets, including error handling.
- Introduce session cache tests to ensure proper cleanup on sign-out and validate session-scoped providers.
- Extend web form model tests to cover lead rotation functionality, ensuring correct serialization and defaults.
2026-09-27 06:28:09 +05:30

148 lines
5.3 KiB
Python

"""
Case workflow configuration.
Defines SLA defaults by priority.
"""
# Terminal statuses
TERMINAL_STATUSES = {"Closed", "Rejected", "Duplicate"}
# Default SLA by priority (in hours)
DEFAULT_FIRST_RESPONSE_SLA = {
"Low": 24,
"Normal": 8,
"High": 4,
"Urgent": 1,
}
DEFAULT_RESOLUTION_SLA = {
"Low": 72,
"Normal": 48,
"High": 24,
"Urgent": 4,
}
# How long a customer who writes back after the first reply should wait for the
# next one. Same promise as the first reply by default: a follow-up question is
# not less urgent than the opening one.
DEFAULT_NEXT_RESPONSE_SLA = {
"Low": 24,
"Normal": 8,
"High": 4,
"Urgent": 1,
}
# Upper bound on a configured SLA target. `business_hours.calendar` walks
# forward a day at a time and gives up after 5 years, so a target it cannot
# reach would come back as a junk deadline rather than an error. 8760 business
# hours is 4.2 years against a stock 40-hour week, which stays inside that cap
# while being far longer than any support promise worth writing down.
MAX_SLA_HOURS = 8760
# How much of a target has to be left for a case to still count as on track.
# Below this fraction it is "at risk": the amber band between on-track and
# breached, which is what makes the indicator actionable instead of a postmortem.
SLA_AT_RISK_FRACTION = 0.25
def resolve_sla_targets(org_id, priority):
"""Return ``(first_response_hours, resolution_hours)`` for an org+priority.
An active ``EscalationPolicy`` for that priority supplies either target.
The two fall back to the tables above independently, so an org that
configured only a first-response promise keeps the stock resolution one.
Returns the plain defaults when ``org_id`` is missing or no active policy
matches, which is also what an empty RLS context produces: the filter finds
no rows, and falling back is the fail-safe answer.
"""
first = DEFAULT_FIRST_RESPONSE_SLA.get(priority, 4)
resolution = DEFAULT_RESOLUTION_SLA.get(priority, 24)
if not org_id:
return first, resolution
from cases.models import EscalationPolicy
policy = (
EscalationPolicy.objects.filter(
org_id=org_id, priority=priority, is_active=True
)
.only("first_response_hours", "resolution_hours")
.first()
)
if policy is None:
return first, resolution
return (
first if policy.first_response_hours is None else policy.first_response_hours,
resolution if policy.resolution_hours is None else policy.resolution_hours,
)
def resolve_next_response_targets(org_id):
"""Return ``{priority: next_response_hours}`` for every priority of an org.
Unlike the first-response and resolution targets, this one is not stamped
on the case: a ticket can wait on a next reply many times, so there is no
single deadline to store at creation. The analytics read it from the org's
active ``EscalationPolicy`` rows when they score the waits, falling back to
``DEFAULT_NEXT_RESPONSE_SLA`` per priority, exactly as
``resolve_sla_targets`` falls back. One query for all priorities.
"""
targets = dict(DEFAULT_NEXT_RESPONSE_SLA)
if not org_id:
return targets
from cases.models import EscalationPolicy
for priority, hours in EscalationPolicy.objects.filter(
org_id=org_id, is_active=True, next_response_hours__isnull=False
).values_list("priority", "next_response_hours"):
targets[priority] = hours
return targets
DUPLICATE_BY_MERGE_ONLY = (
"A ticket becomes Duplicate only by merging it into another ticket."
)
def duplicate_refusal(old_status, new_status):
"""Refuse moving a ticket into Duplicate by anything but a merge.
Duplicate hides a ticket from the list and the board, and a merge is what
records where it went (`merged_into`) and moves its comments, attachments
and email thread over. Set by an edit, a board move or a stage mapped to
it, the ticket just vanished. The merge endpoint writes the status itself
and never comes through here. A ticket already Duplicate may still be
edited.
Returns ``{"status": message}`` or None, the shape ``close_refusal`` uses.
"""
if new_status == "Duplicate" and old_status != "Duplicate":
return {"status": DUPLICATE_BY_MERGE_ONLY}
return None
MERGED_STATUS_LOCKED = (
"This ticket is merged into another. Unmerge it first to change its status."
)
def merged_status_refusal(case, new_status):
"""Refuse changing the status of a merged ticket.
A merged ticket (``merged_into`` set) is Duplicate because the merge made
it so, and its comments, attachments and email thread now live on the
ticket it was merged into. Edited to another status it kept ``merged_into``,
so it was open and merged at once: back on the list and the board, still
redirecting to the target, and holding none of its own history. Unmerge is
the way back; it restores the status the ticket had before the merge.
Re-sending the status the ticket already has is not a change, so a full
PUT that edits only the description still goes through.
Returns ``{"status": message}`` or None, the shape ``close_refusal`` uses.
"""
if case is not None and case.merged_into_id and new_status != case.status:
return {"status": MERGED_STATUS_LOCKED}
return None