bottleneckcrm/backend/cases/bulk_views.py

271 lines
11 KiB
Python

"""Bulk update / bulk delete endpoints for the Cases module."""
import uuid
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 automation.capture import suppress_rules
from cases.access import has_case_write_access, is_org_admin, visible_cases_qs
from cases.models import Case
from cases.updates import update_case
from cases.workflow import DUPLICATE_BY_MERGE_ONLY, merged_status_refusal
from common.models import Activity
from common.permissions import HasOrgContext
from common.validators import payload_id_list
ALLOWED_FIELDS = {"status", "priority", "case_type", "closed_on"}
ALLOWED_M2M = {"assigned_to", "tags"}
# Scalar fields whose value must be one of the model's declared choices. A raw
# `setattr` + `save()` skips both DRF's ChoiceField and the model's `clean_fields`,
# so without this an authenticated caller could persist an off-enum status.
_CHOICE_FIELDS = ("status", "priority", "case_type")
# A batch runs the full single-ticket write for every ticket it matches, so its
# length is the request's cost: uncapped, a member's own tickets could hold a
# worker for minutes. The web selects from one list page, 25 by default.
MAX_BULK_IDS = 200
def _oversized_batch(raw):
"""The 400 for a batch over `MAX_BULK_IDS`, or None.
Counted before `_valid_ids` drops malformed entries, so padding a batch
with junk cannot slip it past the cap.
"""
if isinstance(raw, (list, tuple)) and len(raw) > MAX_BULK_IDS:
return Response(
{
"error": True,
"errors": f"Select at most {MAX_BULK_IDS} tickets at a time.",
},
status=status.HTTP_400_BAD_REQUEST,
)
return None
def _valid_ids(raw):
"""Keep only the well-formed UUIDs from a client-supplied `ids` list.
Case PKs are UUIDs, so `Case.objects.filter(pk__in=ids)` raises a
ValidationError (a 500) the moment the queryset is evaluated on a value
like "not-a-uuid". That evaluation happens in the bulk loops, outside any
try/except, so a single malformed id in the request body would crash the
whole call. A malformed id names no real case, so drop it here: this
matches how a well-formed-but-nonexistent or other-org id already produces
no result row rather than an error.
"""
ids = []
for value in raw if isinstance(raw, (list, tuple)) else []:
try:
ids.append(str(uuid.UUID(str(value))))
except (ValueError, TypeError, AttributeError):
continue
return ids
def _refusal_outcome(case_id, errors):
"""Map a refused `update_case` to a per-record outcome.
Keyed on the error dict's field name, not its text, so wording changes do
not move a ticket into the wrong bucket. `status` is the approval error
(Duplicate and off-enum values are refused for the whole batch before the
loop, and a merged ticket is caught per ticket before the write), and
anything else, a malformed date included, is a generic invalid. A close
with no date is not refused: `update_case` dates it today in the org's
timezone.
"""
if "status" in errors:
return {
"id": case_id,
"status": "approval_required",
"detail": errors["status"],
}
return {"id": case_id, "status": "invalid", "detail": errors}
class BulkUpdateCasesView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext)
def post(self, request):
refusal = _oversized_batch(request.data.get("ids"))
if refusal:
return refusal
ids = _valid_ids(request.data.get("ids"))
fields = request.data.get("fields") or {}
if not ids:
return Response(
{"error": True, "errors": "ids required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not fields:
return Response(
{"error": True, "errors": "fields required"},
status=status.HTTP_400_BAD_REQUEST,
)
unknown = set(fields) - ALLOWED_FIELDS - ALLOWED_M2M
if unknown:
return Response(
{"error": True, "errors": f"Unsupported fields: {sorted(unknown)}"},
status=status.HTTP_400_BAD_REQUEST,
)
for field_name in _CHOICE_FIELDS:
if field_name not in fields:
continue
value = fields[field_name]
model_field = Case._meta.get_field(field_name)
valid_values = {choice for choice, _ in model_field.choices}
# `value in valid_values` would raise TypeError (500) on an
# unhashable payload like `{"status": ["Closed"]}`, so gate the
# membership test on a string first: a list/dict/number is simply
# an invalid value and gets the clean 400.
if isinstance(value, str) and value in valid_values:
continue
if value is None and model_field.null:
continue
return Response(
{"error": True, "errors": f"Invalid value for '{field_name}'"},
status=status.HTTP_400_BAD_REQUEST,
)
if fields.get("status") == "Duplicate":
return Response(
{"error": True, "errors": DUPLICATE_BY_MERGE_ONLY},
status=status.HTTP_400_BAD_REQUEST,
)
# `closed_on` is a scalar date, not a choice field, so it skips the loop
# above. A non-string payload would setattr onto the model and blow up at
# `save()` as a DB error (500). Reject it here with the same clean 400.
if "closed_on" in fields:
closed_on = fields["closed_on"]
if closed_on is not None and not isinstance(closed_on, str):
return Response(
{"error": True, "errors": "Invalid value for 'closed_on'"},
status=status.HTTP_400_BAD_REQUEST,
)
# Parsed before the first ticket is written, so a malformed id is a 400
# for the whole batch rather than a failure half way through it.
for m2m_field in ALLOWED_M2M:
if m2m_field in fields:
payload_id_list(fields[m2m_field], m2m_field)
org = request.profile.org
results = []
updated_count = 0
# Only tickets the caller may open. One they may not is left out of
# `results` exactly as a missing id is, so the batch cannot be used to
# learn which ids exist. A bulk edit sets off no automation rule, as an
# import does not, or one bulk close would run them once per ticket.
with suppress_rules():
for case in Case.objects.filter(
org=org,
pk__in=visible_cases_qs(request.profile)
.filter(pk__in=ids)
.values("pk"),
):
# Per-case authorization, mirroring the single-case PUT path
# (`CaseDetailView.put` calls `assert_case_write_access`).
# Without this any org member could edit, reassign or close any
# case in the org. Cases the caller may open but not write are
# reported as `no_access` rather than silently skipped, so the
# caller can see which of their selected tickets were denied.
if not has_case_write_access(request.profile, case):
results.append({"id": str(case.pk), "status": "no_access"})
continue
# The single-case rule, reported per ticket like the close gate.
refusal = merged_status_refusal(case, fields.get("status", case.status))
if refusal:
results.append(
{
"id": str(case.pk),
"status": "merged",
"detail": refusal["status"],
}
)
continue
# The single-ticket write (`update_case`, the PATCH's own
# path), so the close gate, active-only assignees and tags, and
# the email to whoever is newly assigned all apply here too.
# Tags append: bulk-tagging must not wipe a ticket's other
# tags. `assigned_to` replaces. A savepoint per case, so one
# ticket's failure rolls back only itself and the rest of the
# batch still commits.
with transaction.atomic():
errors = update_case(request, case, fields, append_tags=True)
if errors:
results.append(_refusal_outcome(str(case.pk), errors))
continue
results.append({"id": str(case.pk), "status": "updated"})
updated_count += 1
return Response(
{"error": False, "updated": updated_count, "results": results},
status=status.HTTP_200_OK,
)
class BulkDeleteCasesView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext)
def post(self, request):
refusal = _oversized_batch(request.data.get("ids"))
if refusal:
return refusal
ids = _valid_ids(request.data.get("ids"))
if not ids:
return Response(
{"error": True, "errors": "ids required"},
status=status.HTTP_400_BAD_REQUEST,
)
org = request.profile.org
admin = is_org_admin(request.profile)
results = []
deletable = []
# Deleting is admin-or-creator only (`assert_case_delete_access`); an
# assignee may work a ticket but not erase it. A ticket the caller may
# not open is left out of `results`, as a missing id is.
for row in Case.objects.filter(
org=org,
pk__in=visible_cases_qs(request.profile).filter(pk__in=ids).values("pk"),
is_active=True,
).values("id", "name", "created_by_id"):
if admin or row["created_by_id"] == request.profile.user_id:
deletable.append(row)
else:
results.append({"id": str(row["id"]), "status": "no_access"})
deleted_count = 0
if deletable:
deleted_count = Case.objects.filter(
id__in=[row["id"] for row in deletable]
).update(is_active=False)
# queryset.update() bypasses signals, so emit Activity rows here.
Activity.objects.bulk_create(
[
Activity(
user=request.profile,
action="DELETE",
entity_type="Case",
entity_id=row["id"],
entity_name=(row["name"] or "")[:255],
metadata={"bulk": True},
org_id=org.id,
)
for row in deletable
]
)
for row in deletable:
results.append({"id": str(row["id"]), "status": "deleted"})
return Response(
{"error": False, "deleted": deleted_count, "results": results},
status=status.HTTP_200_OK,
)