- 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.
384 lines
14 KiB
Python
384 lines
14 KiB
Python
"""
|
|
Parent/child case endpoints (Tier 3 parent-child).
|
|
|
|
Three endpoints over and above what `CaseDetailView` already exposes:
|
|
|
|
* ``GET /api/cases/<pk>/tree/``: descendant tree, max depth 3.
|
|
* ``POST /api/cases/<pk>/link/``: set/clear parent with explicit audit row.
|
|
* ``POST /api/cases/<pk>/close-with-children/``: close parent and (optionally) cascade-close descendants.
|
|
|
|
The link endpoint takes a row lock on both rows so two concurrent agents cannot
|
|
build a cycle. Cascade close honours ``Org.auto_close_children_on_parent_close``
|
|
as the default, and accepts ``cascade`` in the body to override.
|
|
|
|
Who may call them follows `cases.access`, the same as the ticket detail view:
|
|
|
|
* the tree needs read on the ticket asked about, and a node the caller cannot
|
|
read comes back redacted (see `_redacted`);
|
|
* linking needs write on the child and read on the new parent, and a parent
|
|
the caller cannot read answers exactly like one that does not exist;
|
|
* closing needs write on the ticket and on every descendant the cascade would
|
|
close, and each of them must pass `close_refusal`. Nothing is written unless
|
|
every one of them does.
|
|
"""
|
|
|
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
from django.db import transaction
|
|
from rest_framework import status
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from cases.access import (
|
|
assert_case_write_access,
|
|
get_case_or_404,
|
|
has_case_read_access,
|
|
has_case_write_access,
|
|
lock_case_or_404,
|
|
)
|
|
from cases.approvals import close_refusal, closing_date
|
|
from cases.models import Case
|
|
from cases.parent_guards import check_parent_link
|
|
from cases.workflow import MERGED_STATUS_LOCKED
|
|
from common.models import Activity
|
|
from common.permissions import HasOrgContext
|
|
|
|
TREE_MAX_DEPTH = Case.PARENT_MAX_DEPTH
|
|
|
|
|
|
def _summary(case):
|
|
"""Lightweight node payload used by the tree response."""
|
|
return {
|
|
"id": str(case.id),
|
|
"name": case.name,
|
|
"status": case.status,
|
|
"priority": case.priority,
|
|
"is_problem": case.is_problem,
|
|
"is_active": case.is_active,
|
|
"assigned_to": [str(p.id) for p in case.assigned_to.all()],
|
|
}
|
|
|
|
|
|
def _redacted(case):
|
|
"""Node payload for a ticket the caller may not open.
|
|
|
|
Kept in the tree rather than dropped, so a readable grandchild under a
|
|
hidden child still sits at its real depth instead of being lost or
|
|
re-parented. It carries the id (both clients key rows on it), ``status``
|
|
and ``is_active`` (both clients count the open descendants a cascading
|
|
close would take, and the close refuses when one of those is not the
|
|
caller's), and nothing else: no name, priority or assignees.
|
|
"""
|
|
return {
|
|
"id": str(case.id),
|
|
"name": None,
|
|
"restricted": True,
|
|
"status": case.status,
|
|
"is_active": case.is_active,
|
|
}
|
|
|
|
|
|
def _build_tree(case, profile, depth=0):
|
|
"""Recurse to ``TREE_MAX_DEPTH``. Returns ``{...summary, children: [...]}``.
|
|
|
|
Each node is the full summary when ``profile`` passes the read rule on it,
|
|
and `_redacted` otherwise.
|
|
"""
|
|
if has_case_read_access(profile, case):
|
|
node = _summary(case)
|
|
else:
|
|
node = _redacted(case)
|
|
if depth >= TREE_MAX_DEPTH:
|
|
node["children"] = []
|
|
node["truncated"] = True
|
|
return node
|
|
children = list(case.children.all().prefetch_related("assigned_to"))
|
|
node["children"] = [_build_tree(c, profile, depth + 1) for c in children]
|
|
return node
|
|
|
|
|
|
def _record(case, action, metadata, actor):
|
|
Activity.objects.create(
|
|
user=actor,
|
|
action=action,
|
|
entity_type="Case",
|
|
entity_id=case.pk,
|
|
entity_name=str(case)[:255],
|
|
metadata=metadata,
|
|
org_id=case.org_id,
|
|
)
|
|
|
|
|
|
class CaseTreeView(APIView):
|
|
permission_classes = (IsAuthenticated, HasOrgContext)
|
|
|
|
def get(self, request, pk):
|
|
org = request.profile.org
|
|
# The same answer the ticket detail GET gives: 404 outside the org and
|
|
# for a ticket in it that the caller may not open.
|
|
case = get_case_or_404(request.profile, pk)
|
|
# Return the root of the visible tree: walk up to the highest ancestor
|
|
# in the same org so a child URL still shows the full incident.
|
|
root = case
|
|
seen = {root.id}
|
|
while root.parent_id and root.parent and root.parent.org_id == org.id:
|
|
if root.parent_id in seen:
|
|
break
|
|
seen.add(root.parent_id)
|
|
root = root.parent
|
|
return Response(
|
|
{"root": _build_tree(root, request.profile), "focus_id": str(case.id)},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
class CaseLinkParentView(APIView):
|
|
permission_classes = (IsAuthenticated, HasOrgContext)
|
|
|
|
@transaction.atomic
|
|
def post(self, request, pk):
|
|
org = request.profile.org
|
|
parent_id = request.data.get("parent_id")
|
|
# Lock the case row so a parallel link from another agent cannot
|
|
# race on the cycle check. We look up the parent under the same lock.
|
|
case = lock_case_or_404(request.profile, pk)
|
|
# Moving a ticket in or out of a tree is a change to it, so it takes
|
|
# the write rule, as the detail PUT does. Unlinking included.
|
|
assert_case_write_access(request.profile, case)
|
|
|
|
former_parent_id = case.parent_id
|
|
|
|
if not parent_id:
|
|
# Detach.
|
|
if case.parent_id is None:
|
|
return Response(
|
|
{"detail": "Case is not linked to a parent."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
case.parent = None
|
|
case._parent_audit_skip = True
|
|
case.save(update_fields=["parent"])
|
|
_record(
|
|
case,
|
|
"UNLINKED_PARENT",
|
|
{"former_parent_id": str(former_parent_id)},
|
|
actor=request.profile,
|
|
)
|
|
return Response(
|
|
{"id": str(case.id), "parent": None},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
try:
|
|
parent = (
|
|
Case.objects.select_for_update().filter(id=parent_id, org=org).first()
|
|
)
|
|
except (DjangoValidationError, ValueError, TypeError):
|
|
# Not a UUID, so it names no case: the same answer as a missing one.
|
|
parent = None
|
|
# A parent the caller may not open answers exactly like a missing one.
|
|
# Otherwise the link would confirm the ticket exists, return its name,
|
|
# and put the caller's ticket under somebody else's.
|
|
if parent is None or not has_case_read_access(request.profile, parent):
|
|
return Response(
|
|
{"parent_id": "Parent case not found in this organization."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
# Self-parent, merged either end, cycle and depth, all four in
|
|
# `cases.parent_guards` so `CaseSerializer` enforces the same rules on
|
|
# the same records. The messages are the ones this endpoint has always
|
|
# returned; the walk now terminates on a cycle it did not create.
|
|
refusal = check_parent_link(parent, case=case)
|
|
if refusal:
|
|
return Response(
|
|
{"parent_id": refusal},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
case.parent = parent
|
|
case._parent_audit_skip = True
|
|
case.save(update_fields=["parent"])
|
|
|
|
_record(
|
|
case,
|
|
"LINKED_PARENT",
|
|
{
|
|
"parent_id": str(parent.id),
|
|
"former_parent_id": (
|
|
str(former_parent_id) if former_parent_id else None
|
|
),
|
|
},
|
|
actor=request.profile,
|
|
)
|
|
return Response(
|
|
{
|
|
"id": str(case.id),
|
|
"parent": {
|
|
"id": str(parent.id),
|
|
"name": parent.name,
|
|
"status": parent.status,
|
|
},
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
def _open_descendants(case, out=None, seen=None):
|
|
"""Collect all open (neither Closed nor Duplicate) active descendants,
|
|
depth-first.
|
|
|
|
Recursion goes through closed children too, so an open grandchild under a
|
|
closed child is still collected. A merged child is passed over the same
|
|
way: its status is not the cascade's to change (`merged_status_refusal`),
|
|
and what it held now lives on the ticket it was merged into. The
|
|
``merged_into`` test is not redundant with Duplicate: a merged ticket
|
|
edited to another status before that rule existed is still merged. ``seen``
|
|
stops the walk on a stored cycle, which would otherwise recurse until the
|
|
worker fell over. Rows are locked for the length of the caller's
|
|
transaction.
|
|
"""
|
|
if out is None:
|
|
out = []
|
|
if seen is None:
|
|
seen = {case.id}
|
|
for child in case.children.select_for_update().prefetch_related("assigned_to"):
|
|
if child.id in seen:
|
|
continue
|
|
seen.add(child.id)
|
|
if (
|
|
child.status not in ("Closed", "Duplicate")
|
|
and child.is_active
|
|
and not child.merged_into_id
|
|
):
|
|
out.append(child)
|
|
_open_descendants(child, out, seen)
|
|
return out
|
|
|
|
|
|
def _refused(errors):
|
|
"""The 400 a refused close answers.
|
|
|
|
``errors`` is `close_refusal`'s ``{field: message}`` for the ticket itself,
|
|
answered in `CaseMoveView`'s per-field shape, or a sentence about the
|
|
cascade, which is about no one field and so rides as a plain string. Both
|
|
clients render either shape.
|
|
"""
|
|
if isinstance(errors, dict):
|
|
errors = {field: [msg] for field, msg in errors.items()}
|
|
return Response(
|
|
{"error": True, "errors": errors}, status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
|
|
def _close(case):
|
|
"""Close one ticket the way the ordinary close path leaves it.
|
|
|
|
``closed_on`` comes from `closing_date`, the rule every close path uses.
|
|
``resolved_at`` is stamped by the ``case_pre_save_stamp_resolved_at``
|
|
signal on the transition into Closed, and is listed in ``update_fields``
|
|
so that stamp is saved.
|
|
"""
|
|
case.closed_on = closing_date(case, status="Closed", closed_on=case.closed_on)
|
|
case.status = "Closed"
|
|
case.save(update_fields=["status", "closed_on", "resolved_at", "updated_at"])
|
|
|
|
|
|
def _ticket_count(n):
|
|
return f"{n} linked ticket{'' if n == 1 else 's'}"
|
|
|
|
|
|
class CaseCloseWithChildrenView(APIView):
|
|
permission_classes = (IsAuthenticated, HasOrgContext)
|
|
|
|
@transaction.atomic
|
|
def post(self, request, pk):
|
|
org = request.profile.org
|
|
case = lock_case_or_404(request.profile, pk)
|
|
assert_case_write_access(request.profile, case)
|
|
if case.merged_into_id:
|
|
return _refused({"status": MERGED_STATUS_LOCKED})
|
|
if case.status == "Duplicate":
|
|
return Response(
|
|
{"detail": "Cannot close a merged case."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
resolution_comment = (request.data.get("resolution_comment") or "").strip()
|
|
cascade_override = request.data.get("cascade")
|
|
if cascade_override is None:
|
|
cascade = bool(getattr(org, "auto_close_children_on_parent_close", False))
|
|
else:
|
|
cascade = bool(cascade_override)
|
|
|
|
# Every check runs before anything is written, so a refusal leaves the
|
|
# whole tree as it was. The ticket itself takes the same close gate
|
|
# as a PATCH; `close_refusal` passes it when it is already Closed.
|
|
refusal = close_refusal(
|
|
case,
|
|
status="Closed",
|
|
priority=case.priority,
|
|
case_type=case.case_type,
|
|
)
|
|
if refusal:
|
|
return _refused(refusal)
|
|
|
|
descendants = _open_descendants(case) if cascade else []
|
|
|
|
# A cascade closes each descendant, so each one needs the write rule.
|
|
# Counted, never named: some of them the caller cannot even open.
|
|
not_writable = [
|
|
d for d in descendants if not has_case_write_access(request.profile, d)
|
|
]
|
|
if not_writable:
|
|
n = len(not_writable)
|
|
return _refused(
|
|
f"{_ticket_count(n)} under this one {'is' if n == 1 else 'are'} "
|
|
"not yours to close, so nothing was closed. Close this ticket "
|
|
"on its own, or ask an admin to close them all.",
|
|
)
|
|
|
|
# Named: every descendant left here is one the caller may write, and
|
|
# so may open.
|
|
gated = [
|
|
d
|
|
for d in descendants
|
|
if close_refusal(
|
|
d,
|
|
status="Closed",
|
|
priority=d.priority,
|
|
case_type=d.case_type,
|
|
)
|
|
]
|
|
if gated:
|
|
names = ", ".join(f'"{d.name}"' for d in gated)
|
|
return _refused(
|
|
f"{_ticket_count(len(gated))} under this one need an approval "
|
|
f"before closing ({names}), so nothing was closed.",
|
|
)
|
|
|
|
if case.status != "Closed":
|
|
_close(case)
|
|
|
|
cascaded = []
|
|
for child in descendants:
|
|
_close(child)
|
|
_record(
|
|
child,
|
|
"PARENT_CLOSED_CASCADE",
|
|
{
|
|
"parent_id": str(case.id),
|
|
"acted_via_cascade": True,
|
|
"resolution_comment": resolution_comment[:1000],
|
|
},
|
|
actor=request.profile,
|
|
)
|
|
cascaded.append(str(child.id))
|
|
|
|
return Response(
|
|
{
|
|
"id": str(case.id),
|
|
"status": case.status,
|
|
"cascaded_case_ids": cascaded,
|
|
"resolution_comment": resolution_comment,
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|