bottleneckcrm/backend/cases/views.py

1318 lines
53 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import statistics
from datetime import timedelta
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from drf_spectacular.utils import (
extend_schema,
inline_serializer,
)
from rest_framework import serializers, status
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from accounts.access import visible_accounts_qs
from accounts.serializer import AccountPickerSerializer
from cases import swagger_params
from cases.access import (
assert_case_delete_access,
assert_case_write_access,
get_case_or_404,
has_case_write_access,
is_org_admin,
visible_cases_qs,
)
from cases.approvals import find_matching_rule
from cases.merge_views import can_merge_case
from cases.models import Case, ReopenPolicy, Solution
from cases.models import EmailMessage as _EmailMessageModel # noqa: F401 (used below)
from cases.serializer import (
CaseCommentEditSwaggerSerializer,
CaseCreateSerializer,
CaseCreateSwaggerSerializer,
CaseDetailEditSwaggerSerializer,
CaseSerializer,
EmailMessageSerializer,
ReopenPolicySerializer,
parent_access_context,
)
from cases.signals import route_after_relations
from cases.solution_serializers import SolutionSerializer
from cases.tasks import send_email_to_assigned_user
from cases.updates import notify_newly_assigned, update_case
from common.custom_fields import validate_payload as validate_custom_fields_payload
from common.lookups import get_on_visible_record_or_404
from common.models import (
Activity,
Attachments,
Comment,
CustomFieldDefinition,
Profile,
Tags,
Teams,
)
from common.module_deletes import delete_refusal
from common.modules import only_available
from common.permissions import HasOrgContext
from common.serializer import (
ActivitySerializer,
AttachmentsSerializer,
CommentSerializer,
CustomFieldDefinitionSerializer,
)
from common.utils import (
CASE_TYPE,
PRIORITY_CHOICE,
STATUS_CHOICE,
create_attachment,
validate_attachment,
)
from common.validators import date_param, payload_id_list, uuid_list_param, uuid_param
from contacts.access import replace_visible_contacts, visible_contacts_qs
from contacts.serializer import ContactLinkSerializer, ContactPickerSerializer
# A ticket is "open" while somebody still owes the customer something. The
# other three values in STATUS_CHOICE (Closed, Rejected, Duplicate) are all
# ways of being finished with it.
OPEN_STATUSES = ("New", "Assigned", "Pending")
_ALLOWED_CASE_ORDERINGS = frozenset(
{
"-created_at",
"created_at",
"-priority",
"priority",
"-id",
"id",
"-name",
"name",
}
)
def apply_case_list_filters(queryset, params):
"""Apply the case-list query-param filters to ``queryset``.
Shared between CaseListView and WatchingListView so both endpoints accept
the same filter chips from the mobile client. Caller is responsible for
the base scope (org membership, watcher allowance, etc.). This only
layers in user-supplied filters.
"""
if not params:
return queryset
if params.get("name"):
queryset = queryset.filter(name__icontains=params.get("name"))
# Status can be a single value or a list. Mobile uses the list form for
# its Open/Closed quick chips (Open = New, Assigned, Pending).
status_values = [s for s in params.getlist("status") if s]
if len(status_values) > 1:
queryset = queryset.filter(status__in=status_values)
elif status_values:
queryset = queryset.filter(status=status_values[0])
if params.get("priority"):
queryset = queryset.filter(priority=params.get("priority"))
account = uuid_param(params, "account")
if account:
queryset = queryset.filter(account=account)
if params.get("case_type"):
queryset = queryset.filter(case_type=params.get("case_type"))
assigned_to = uuid_list_param(params, "assigned_to")
if assigned_to:
queryset = queryset.filter(assigned_to__id__in=assigned_to).distinct()
tags = uuid_list_param(params, "tags")
if tags:
queryset = queryset.filter(tags__id__in=tags).distinct()
if params.get("search"):
search = params.get("search")
queryset = queryset.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
created_at_gte = date_param(params, "created_at__gte")
if created_at_gte:
queryset = queryset.filter(created_at__date__gte=created_at_gte)
created_at_lte = date_param(params, "created_at__lte")
if created_at_lte:
queryset = queryset.filter(created_at__date__lte=created_at_lte)
if params.get("sla_breached") == "true":
# Wall-clock approximation matching the mobile card's
# `isFirstResponseSlaBreached` getter; `Case.is_sla_*_breached` uses
# business hours, but the badges on both clients compute from
# created_at + hours, so this filter mirrors what the user actually
# sees on the row. Postgres-specific (INTERVAL).
#
# Every column is table-qualified. Unqualified, these resolve fine
# against a bare Case queryset and raise
# `ProgrammingError: column reference "first_response_at" is ambiguous`
# the moment the queryset joins a table sharing any of these names,
# which is what a filter combination such as `?sla_breached=true` plus
# a status list produced: a 500 for any authenticated caller.
queryset = queryset.extra(
where=[
'("case"."first_response_at" IS NULL '
'AND "case"."sla_first_response_hours" IS NOT NULL '
'AND "case"."created_at" + "case"."sla_first_response_hours" '
"* INTERVAL '1 hour' < NOW()) "
'OR ("case"."resolved_at" IS NULL '
'AND "case"."sla_resolution_hours" IS NOT NULL '
'AND "case"."created_at" + "case"."sla_resolution_hours" '
"* INTERVAL '1 hour' < NOW())"
]
)
# Custom-field filters: ?cf_<key>=<value> -> custom_fields contains pair.
for raw_key, raw_value in params.items():
if raw_key.startswith("cf_") and raw_value:
cf_key = raw_key[3:]
if cf_key:
queryset = queryset.filter(custom_fields__contains={cf_key: raw_value})
# Ordering: whitelisted so callers can't sort on arbitrary cols.
ordering = params.get("ordering")
if ordering in _ALLOWED_CASE_ORDERINGS:
queryset = queryset.order_by(ordering, "-id")
return queryset
def case_list_queryset(profile, params):
"""The tickets ``GET /api/cases/`` lists for this caller and query.
The org, the read rule, the two defaults (soft-deleted and merged tickets
stay out unless asked for) and every filter, in one place. The list and the
CSV export both start here, so a downloaded file holds exactly the tickets
the queue would show with the same query string.
"""
# `-id` is a random UUID, so the default "newest first" was in fact no
# order at all, the queue came back shuffled and the page still said
# it was sorted. `-created_at` is the order the header promises;
# `-id` stays as a tiebreak so pagination is stable when a batch of
# cases shares a timestamp (which the seeded data does exactly).
queryset = Case.objects.filter(org=profile.org).order_by("-created_at", "-id")
# COORDINATION_DECISIONS.md D4: hide soft-deleted cases by default; admins
# may opt in. A member asking for them is answered as if they had not.
include_deleted = params.get("include_deleted") == "true" and is_org_admin(profile)
if not include_deleted:
queryset = queryset.filter(is_active=True)
# Hide merged duplicates by default. Agents can opt in with
# `?show_merged=true` to audit prior merges.
if params.get("show_merged") != "true":
queryset = queryset.filter(merged_into__isnull=True).exclude(status="Duplicate")
if not is_org_admin(profile):
# Watcher allowance: a non-admin who is a watcher must still be
# able to see the case even when un-assigned. The rule now lives
# in `cases.access` so the detail view enforces the same one. It
# used to drop the watcher clause, which meant this list handed
# somebody a ticket that answered 403 when they clicked it.
queryset = queryset.filter(pk__in=visible_cases_qs(profile).values("pk"))
return apply_case_list_filters(queryset, params)
class CaseListView(APIView, LimitOffsetPagination):
permission_classes = (IsAuthenticated, HasOrgContext)
model = Case
def get_context_data(self, **kwargs):
params = self.request.query_params
queryset = (
case_list_queryset(self.request.profile, params)
.select_related("account", "org", "created_by", "parent")
.prefetch_related("assigned_to__user", "contacts", "teams", "tags")
)
# The account read rule itself, which is what the save path accepts.
accounts = visible_accounts_qs(self.request.profile).order_by("-id")
# The contact read rule itself, which is what the save path accepts.
contacts = visible_contacts_qs(self.request.profile).order_by("-id")
profiles = Profile.objects.filter(is_active=True, org=self.request.profile.org)
if not is_org_admin(self.request.profile):
profiles = profiles.filter(role="ADMIN")
context = {}
# Queue totals, counted over the whole filtered queryset rather than
# the page, so a header that says "12 open" is not really saying
# "12 on this screen". All three are plain DB counts.
#
# `awaiting_first_reply` is deliberately not called "breaching": a
# breach depends on the org's business calendar and is a Python
# property per row, so counting it here would mean instantiating every
# case in the queue. Nobody having replied yet is the fact the queue
# can actually establish, and it is the one worth acting on.
open_cases = queryset.filter(status__in=OPEN_STATUSES)
context["open_count"] = open_cases.count()
context["urgent_count"] = open_cases.filter(priority="Urgent").count()
context["awaiting_first_reply"] = open_cases.filter(
first_response_at__isnull=True
).count()
results_cases = self.paginate_queryset(queryset, self.request, view=self)
cases = CaseSerializer(
results_cases,
many=True,
context=parent_access_context(self.request.profile, results_cases),
).data
if results_cases:
offset = queryset.filter(id__gte=results_cases[-1].id).count()
if offset == queryset.count():
offset = None
else:
offset = 0
context.update(
{
"cases_count": self.count,
"offset": offset,
}
)
context["cases"] = cases
context["status"] = STATUS_CHOICE
context["priority"] = PRIORITY_CHOICE
context["type_of_case"] = CASE_TYPE
# The account and contact catalogues exist for the case *form*, but
# they were serialized in full on every list call, 190 KB of response
# for a queue of five tickets in the seeded org, and it grows with the
# org rather than with the page. `?slim=true` omits them for callers
# that only want the queue. Accounts are sent as `id` and `name`, and
# contacts as `id` and their names, which is all the ticket form reads.
if params.get("slim") != "true":
context["accounts_list"] = AccountPickerSerializer(accounts, many=True).data
context["contacts_list"] = ContactPickerSerializer(contacts, many=True).data
# `profiles` was computed a few lines up, narrowed to admins for
# non-admins, even, and then dropped on the floor. So a ticket form
# had no way to populate an assignee picker from the endpoint that
# already knew the answer. Same `id` / `user__email` shape the
# contacts and accounts lists publish.
context["users"] = list(profiles.values("id", "user__email"))
return context
@extend_schema(
operation_id="cases_list",
tags=["Cases"],
parameters=swagger_params.cases_list_get_params,
responses={
200: inline_serializer(
name="CaseListResponse",
fields={
"cases_count": serializers.IntegerField(),
"offset": serializers.IntegerField(allow_null=True),
"cases": CaseSerializer(many=True),
"status": serializers.ListField(),
"priority": serializers.ListField(),
"type_of_case": serializers.ListField(),
"accounts_list": AccountPickerSerializer(many=True),
"contacts_list": ContactPickerSerializer(many=True),
},
)
},
)
def get(self, request, *args, **kwargs):
context = self.get_context_data(**kwargs)
return Response(context)
@extend_schema(
operation_id="cases_create",
tags=["Cases"],
parameters=swagger_params.organization_params,
request=CaseCreateSwaggerSerializer,
responses={
200: inline_serializer(
name="CaseCreateResponse",
fields={
"error": serializers.BooleanField(),
"message": serializers.CharField(),
"id": serializers.CharField(),
"cases_obj": CaseSerializer(),
},
)
},
)
def post(self, request, *args, **kwargs):
params = request.data
serializer = CaseCreateSerializer(data=params, request_obj=request)
if serializer.is_valid():
cf_payload = params.get("custom_fields")
if isinstance(cf_payload, str):
try:
cf_payload = json.loads(cf_payload)
except (TypeError, ValueError):
cf_payload = None
cleaned_cf, cf_errors = validate_custom_fields_payload(
"Case", cf_payload or {}, request.profile.org
)
if cf_errors:
return Response(
{"error": True, "errors": {"custom_fields": cf_errors}},
status=status.HTTP_400_BAD_REQUEST,
)
# Every id list, and the file, is checked before the first write,
# so a bad one is a 400 with no half-created case behind it.
contact_ids = payload_id_list(params.get("contacts"), "contacts")
team_ids = payload_id_list(params.get("teams"), "teams")
assigned_ids = payload_id_list(params.get("assigned_to"), "assigned_to")
tag_ids = payload_id_list(params.get("tags"), "tags")
validate_attachment(self.request.FILES.get("case_attachment"))
# Routed once the contacts, teams, assignees and tags are on it, so
# a rule on any of them can match; see `route_after_relations`.
with transaction.atomic(), route_after_relations():
cases_obj = serializer.save(
created_by=request.profile.user,
org=request.profile.org,
case_type=params.get("case_type"),
custom_fields=cleaned_cf,
)
replace_visible_contacts(
cases_obj.contacts, contact_ids, request.profile
)
if team_ids:
teams = Teams.objects.filter(
id__in=team_ids, org=request.profile.org
)
if teams.exists():
cases_obj.teams.add(*teams)
if assigned_ids:
profiles = Profile.objects.filter(
id__in=assigned_ids, org=request.profile.org, is_active=True
)
if profiles:
cases_obj.assigned_to.add(*profiles)
if tag_ids:
tag_objs = Tags.objects.filter(
id__in=tag_ids, org=request.profile.org, is_active=True
)
cases_obj.tags.add(*tag_objs)
if self.request.FILES.get("case_attachment"):
create_attachment(
self.request.FILES.get("case_attachment"),
cases_obj,
self.request.profile,
)
recipients = list(cases_obj.assigned_to.all().values_list("id", flat=True))
send_email_to_assigned_user.delay(
recipients,
cases_obj.id,
str(request.profile.org.id),
)
return Response(
{
"error": False,
"message": "Case Created Successfully",
"id": str(cases_obj.id),
"cases_obj": CaseSerializer(
cases_obj,
context=parent_access_context(request.profile, [cases_obj]),
).data,
},
status=status.HTTP_200_OK,
)
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
class CaseDetailView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext)
model = Case
def get_object(self, pk):
"""A case the requester may open, or 404 (see `get_case_or_404`).
This used to return ``None`` for a missing case and leave every caller
to notice. Only `get` did; `put`, `patch` and `delete` went straight
on to `cases_object.org` and answered **500**. A malformed id was a
500 on all four, because the UUID column raises rather than missing.
"""
return get_case_or_404(self.request.profile, pk)
@extend_schema(
operation_id="cases_update",
tags=["Cases"],
parameters=swagger_params.organization_params,
request=CaseCreateSwaggerSerializer,
responses={
200: inline_serializer(
name="CaseUpdateResponse",
fields={
"error": serializers.BooleanField(),
"message": serializers.CharField(),
},
)
},
)
def put(self, request, pk, format=None):
params = request.data
cases_object = self.get_object(pk=pk)
assert_case_write_access(request.profile, cases_object)
serializer = CaseCreateSerializer(
cases_object,
data=params,
request_obj=request,
)
if serializer.is_valid():
cf_payload = params.get("custom_fields")
if isinstance(cf_payload, str):
try:
cf_payload = json.loads(cf_payload)
except (TypeError, ValueError):
cf_payload = None
cleaned_cf, cf_errors = validate_custom_fields_payload(
"Case",
cf_payload or {},
request.profile.org,
existing=cases_object.custom_fields or {},
)
if cf_errors:
return Response(
{"error": True, "errors": {"custom_fields": cf_errors}},
status=status.HTTP_400_BAD_REQUEST,
)
# Parsed before the first write, so a malformed id or an oversized
# file leaves the case exactly as it was.
contact_ids = payload_id_list(params.get("contacts"), "contacts")
team_ids = payload_id_list(params.get("teams"), "teams")
assigned_ids = payload_id_list(params.get("assigned_to"), "assigned_to")
tag_ids = payload_id_list(params.get("tags"), "tags")
validate_attachment(self.request.FILES.get("case_attachment"))
cases_object = serializer.save(
case_type=params.get("case_type"),
custom_fields=cleaned_cf,
)
previous_assigned_to_users = list(
cases_object.assigned_to.all().values_list("id", flat=True)
)
replace_visible_contacts(
cases_object.contacts, contact_ids, request.profile
)
cases_object.teams.clear()
if team_ids:
teams = Teams.objects.filter(id__in=team_ids, org=request.profile.org)
if teams.exists():
cases_object.teams.add(*teams)
cases_object.assigned_to.clear()
if assigned_ids:
profiles = Profile.objects.filter(
id__in=assigned_ids, org=request.profile.org, is_active=True
)
if profiles:
cases_object.assigned_to.add(*profiles)
cases_object.tags.clear()
if tag_ids:
tag_objs = Tags.objects.filter(
id__in=tag_ids, org=request.profile.org, is_active=True
)
cases_object.tags.add(*tag_objs)
if self.request.FILES.get("case_attachment"):
create_attachment(
self.request.FILES.get("case_attachment"),
cases_object,
self.request.profile,
)
notify_newly_assigned(request, cases_object, previous_assigned_to_users)
return Response(
{"error": False, "message": "Case Updated Successfully"},
status=status.HTTP_200_OK,
)
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
@extend_schema(
operation_id="cases_destroy",
tags=["Cases"],
parameters=swagger_params.organization_params,
responses={
200: inline_serializer(
name="CaseDeleteResponse",
fields={
"error": serializers.BooleanField(),
"message": serializers.CharField(),
},
)
},
)
def delete(self, request, pk, format=None):
self.object = self.get_object(pk)
assert_case_delete_access(request.profile, self.object)
refusal = delete_refusal(self.object, "This ticket")
if refusal:
return Response(refusal, status=status.HTTP_409_CONFLICT)
self.object.delete()
return Response(
{"error": False, "message": "Case Deleted Successfully."},
status=status.HTTP_200_OK,
)
@extend_schema(
operation_id="cases_retrieve",
tags=["Cases"],
parameters=swagger_params.organization_params,
responses={
200: inline_serializer(
name="CaseDetailResponse",
fields={
"cases_obj": CaseSerializer(),
"attachments": AttachmentsSerializer(many=True),
"comments": CommentSerializer(many=True),
"comment_permission": serializers.BooleanField(),
"approval_rule": serializers.DictField(allow_null=True),
"users_mention": serializers.ListField(),
},
)
},
)
def get(self, request, pk, format=None):
# Authorised by the lookup itself, before anything about the case
# leaves: the merge redirect below used to answer first, handing any
# member the name of a ticket they cannot open and the id of the one
# it was merged into.
self.cases = self.get_object(pk=pk)
# Merged duplicate: tell the client to redirect. JSON form (200) keeps
# the SvelteKit route's error handling simple. The query param
# `?show_merged=true` lets agents view the duplicate directly via
# bookmark / list-view escape hatch.
if (
self.cases.merged_into_id
and request.query_params.get("show_merged") != "true"
):
return Response(
{
"redirect_to": str(self.cases.merged_into_id),
"merged_into": str(self.cases.merged_into_id),
"source_case_id": str(self.cases.id),
"source_case_name": self.cases.name,
},
status=status.HTTP_200_OK,
)
context = {}
context["cases_obj"] = CaseSerializer(
self.cases, context=parent_access_context(request.profile, [self.cases])
).data
# `comment_permission` used to be creator-or-admin while `post` below
# accepted creator, admin *or assignee*. The flag told an assignee they
# could not reply to their own ticket and the endpoint then let them.
# Both now read the same rule, so the button matches the answer.
comment_permission = has_case_write_access(request.profile, self.cases)
if is_org_admin(self.request.profile):
users_mention = list(
Profile.objects.filter(
is_active=True, org=self.request.profile.org
).values("user__email")
)
elif self.request.profile.user_id != self.cases.created_by_id:
# `created_by` is a `User`; `Profile.user` is the FK pointing at
# one, so `User.user` does not exist and this line raised
# AttributeError, a 500 for *every* non-admin on *every* ticket
# they were entitled to open. The guard above it compared a
# Profile to a User and was always true, so nothing shielded it.
# The admin branch above emits `user__email` keys, so this one does
# too. It used to say `username`, which meant a mention list that
# changed shape with the reader's role.
users_mention = (
[{"user__email": self.cases.created_by.email}]
if self.cases.created_by_id
else []
)
else:
users_mention = []
case_content_type = ContentType.objects.get_for_model(Case)
attachments = Attachments.objects.filter(
content_type=case_content_type,
object_id=self.cases.id,
org=self.request.profile.org,
).order_by("-id")
comments_qs = Comment.objects.filter(
content_type=case_content_type,
object_id=self.cases.id,
org=self.request.profile.org,
).order_by("-id")
public_comments = comments_qs.filter(is_internal=False)
internal_notes = comments_qs.filter(is_internal=True)
linked_solutions = only_available(
request.profile.org,
"knowledge_base",
self.cases.solutions.filter(org=request.profile.org),
)
recent_activities = Activity.objects.filter(
entity_type="Case",
entity_id=self.cases.id,
org=self.request.profile.org,
).select_related("user__user")[:20]
custom_field_defs = CustomFieldDefinition.objects.filter(
org=self.request.profile.org,
target_model="Case",
is_active=True,
).order_by("display_order", "label")
# Inbound emails associated with this case (most recent first), so the
# discussion tab can render them with an "Email" badge. Outbound rows
# are the per-recipient copies of a reply or status change the feed
# already shows as a comment or activity, so they stay out of it.
email_messages = _EmailMessageModel.objects.filter(
case=self.cases, drop_reason="", direction="inbound"
).order_by("-received_at")[:50]
# Reading the surviving ticket is not reading what was merged into it.
# A source the viewer may not open keeps its id and merge time, so the
# count stays right, but not its name: the `parent_summary` rule (D51).
# `can_unmerge` is the unmerge endpoint's own rule (`_can_merge` on the
# source and this ticket), so a client offers the button only where
# the endpoint would take it.
can_merge = can_merge_case(request.profile, self.cases)
approval_rule = find_matching_rule(self.cases, trigger_event="pre_close")
sources = list(
self.cases.merged_from_cases.filter(org=self.request.profile.org)
.order_by("-merged_at")
.only("id", "name", "merged_at", "created_by_id")
)
readable = (
set(
visible_cases_qs(request.profile)
.filter(id__in=[src.id for src in sources])
.values_list("id", flat=True)
)
if sources
else set()
)
merged_from = []
for src in sources:
restricted = src.id not in readable
merged_from.append(
{
"id": src.id,
"name": None if restricted else src.name,
"merged_at": src.merged_at,
"restricted": restricted,
"can_unmerge": can_merge and can_merge_case(request.profile, src),
}
)
context.update(
{
"attachments": AttachmentsSerializer(attachments, many=True).data,
"comments": CommentSerializer(public_comments, many=True).data,
"internal_notes": CommentSerializer(internal_notes, many=True).data,
# Same people as `cases_obj.contacts`, so the same fields.
"contacts": ContactLinkSerializer(
self.cases.contacts.all(), many=True
).data,
"solutions": SolutionSerializer(linked_solutions, many=True).data,
"activities": ActivitySerializer(recent_activities, many=True).data,
"email_messages": EmailMessageSerializer(
email_messages, many=True
).data,
"merged_from_cases": merged_from,
"custom_field_definitions": CustomFieldDefinitionSerializer(
custom_field_defs, many=True
).data,
"status": STATUS_CHOICE,
"priority": PRIORITY_CHOICE,
"type_of_case": CASE_TYPE,
"comment_permission": comment_permission,
# Whether "Merge into..." may be offered: this ticket's half
# of the merge rule. The target's half is enforced by the
# picker (`merge-targets/`) and again by the merge itself.
"can_merge": can_merge,
# The rule that gates closing this ticket, the one a
# `request-approval/` with no `rule_id` binds to, or null when
# none does. Lets a client offer "Request approval" only where
# a request can succeed. The close gate itself stays
# `close_refusal`.
"approval_rule": (
{"id": str(approval_rule.id), "name": approval_rule.name}
if approval_rule
else None
),
"users_mention": users_mention,
}
)
return Response(context)
@extend_schema(
operation_id="cases_comment_attachment",
tags=["Cases"],
parameters=swagger_params.organization_params,
request=CaseDetailEditSwaggerSerializer,
responses={
200: inline_serializer(
name="CaseCommentAttachmentResponse",
fields={
"cases_obj": CaseSerializer(),
"attachments": AttachmentsSerializer(many=True),
"comments": CommentSerializer(many=True),
},
)
},
)
def post(self, request, pk, **kwargs):
params = request.data
# `.get()` raised DoesNotExist, a 500, when the case was missing or
# belonged to another org. Replying to a ticket that is not there is a
# 404, not a crash.
self.cases_obj = self.get_object(pk)
assert_case_write_access(request.profile, self.cases_obj)
# Before the comment is saved, so a refused file does not leave it posted.
validate_attachment(self.request.FILES.get("case_attachment"))
context = {}
comment_text = params.get("comment")
if comment_text:
is_internal_raw = params.get("is_internal", False)
if isinstance(is_internal_raw, str):
is_internal = is_internal_raw.lower() in ("true", "1", "yes")
else:
is_internal = bool(is_internal_raw)
Comment.objects.create(
comment=comment_text,
content_type=ContentType.objects.get_for_model(Case),
object_id=self.cases_obj.id,
commented_by=self.request.profile,
is_internal=is_internal,
org=self.request.profile.org,
)
if self.request.FILES.get("case_attachment"):
create_attachment(
self.request.FILES.get("case_attachment"),
self.cases_obj,
self.request.profile,
)
case_content_type = ContentType.objects.get_for_model(Case)
attachments = Attachments.objects.filter(
content_type=case_content_type,
object_id=self.cases_obj.id,
org=request.profile.org,
).order_by("-id")
comments_qs = Comment.objects.filter(
content_type=case_content_type,
object_id=self.cases_obj.id,
org=request.profile.org,
).order_by("-id")
context.update(
{
"cases_obj": CaseSerializer(
self.cases_obj,
context=parent_access_context(request.profile, [self.cases_obj]),
).data,
"attachments": AttachmentsSerializer(attachments, many=True).data,
"comments": CommentSerializer(
comments_qs.filter(is_internal=False), many=True
).data,
"internal_notes": CommentSerializer(
comments_qs.filter(is_internal=True), many=True
).data,
}
)
return Response(context)
@extend_schema(
tags=["Cases"],
parameters=swagger_params.organization_params,
request=CaseCreateSwaggerSerializer,
description="Partial Case Update",
responses={
200: inline_serializer(
name="CasePatchResponse",
fields={
"error": serializers.BooleanField(),
"message": serializers.CharField(),
},
)
},
)
def patch(self, request, pk, format=None):
"""Handle partial updates to a case.
The write itself is `cases.updates.update_case`, shared with macro
actions and the bulk edit, so all three run the same gates.
"""
cases_object = self.get_object(pk=pk)
assert_case_write_access(request.profile, cases_object)
errors = update_case(request, cases_object, request.data)
if errors:
return Response(
{"error": True, "errors": errors},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{"error": False, "message": "Case Updated Successfully"},
status=status.HTTP_200_OK,
)
class CaseCommentView(APIView):
model = Comment
permission_classes = (IsAuthenticated, HasOrgContext)
def get_object(self, pk):
"""A comment on a ticket the caller may open, or 404.
It used to be fetched from the whole org, across every module's
comments, and then refused with 403 when the caller was not its author:
an oracle for comments on tickets (and leads and deals) they cannot
open, and a way for an author who had lost the ticket to keep editing
and deleting there. A comment on a hidden ticket, or on another
module's record, now answers exactly like an id that does not exist.
"""
return get_on_visible_record_or_404(
self.model,
pk,
self.request.profile.org,
visible_cases_qs(self.request.profile),
)
@extend_schema(
tags=["Cases"],
parameters=swagger_params.organization_params,
request=CaseCommentEditSwaggerSerializer,
responses={
200: inline_serializer(
name="CaseCommentUpdateResponse",
fields={
"error": serializers.BooleanField(),
"message": serializers.CharField(),
},
)
},
)
def put(self, request, pk, format=None):
params = request.data
obj = self.get_object(pk)
if is_org_admin(request.profile) or request.profile == obj.commented_by:
# No `if params.get("comment")` guard around the block below. It
# used to sit here, and a body with a blank or absent `comment`
# fell out of the authorization branch entirely and landed on the
# 403 at the bottom, telling an author they may not edit their own
# comment when the real answer is that the field is required.
# `comment` is a non-blank CharField, so the serializer already
# answers that with a 400 naming the field.
serializer = CommentSerializer(obj, data=params)
if serializer.is_valid():
serializer.save()
return Response(
{"error": False, "message": "Comment Submitted"},
status=status.HTTP_200_OK,
)
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{
"error": True,
"errors": "You don't have permission to perform this action.",
},
status=status.HTTP_403_FORBIDDEN,
)
@extend_schema(
tags=["Cases"],
parameters=swagger_params.organization_params,
request=CaseCommentEditSwaggerSerializer,
description="Partial Comment Update",
responses={
200: inline_serializer(
name="CaseCommentPatchResponse",
fields={
"error": serializers.BooleanField(),
"message": serializers.CharField(),
},
)
},
)
def patch(self, request, pk, format=None):
"""Handle partial updates to a comment."""
params = request.data
obj = self.get_object(pk)
if is_org_admin(request.profile) or request.profile == obj.commented_by:
serializer = CommentSerializer(obj, data=params, partial=True)
if serializer.is_valid():
serializer.save()
return Response(
{"error": False, "message": "Comment Updated"},
status=status.HTTP_200_OK,
)
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{
"error": True,
"errors": "You don't have permission to perform this action.",
},
status=status.HTTP_403_FORBIDDEN,
)
@extend_schema(
tags=["Cases"],
parameters=swagger_params.organization_params,
responses={
200: inline_serializer(
name="CaseCommentDeleteResponse",
fields={
"error": serializers.BooleanField(),
"message": serializers.CharField(),
},
)
},
)
def delete(self, request, pk, format=None):
self.object = self.get_object(pk)
if is_org_admin(request.profile) or request.profile == self.object.commented_by:
self.object.delete()
return Response(
{"error": False, "message": "Comment Deleted Successfully"},
status=status.HTTP_200_OK,
)
return Response(
{
"error": True,
"errors": "You do not have permission to perform this action",
},
status=status.HTTP_403_FORBIDDEN,
)
class CaseAttachmentView(APIView):
model = Attachments
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["Cases"],
parameters=swagger_params.organization_params,
responses={
200: inline_serializer(
name="CaseAttachmentDeleteResponse",
fields={
"error": serializers.BooleanField(),
"message": serializers.CharField(),
},
)
},
)
def delete(self, request, pk, format=None):
"""Delete one attachment hanging off a case.
Two defects, both proven live before the fix:
1. `objects.get(pk=pk)` had **no org filter**. `Attachments` is one
generic table shared by leads, accounts, contacts, deals, cases and
tasks, so this endpoint deleted any attachment in the database
belonging to any organisation, given only its UUID. RLS blocks that
in a correctly-configured deployment, but per CLAUDE.md the ORM
filter is the contract and RLS is the safety net, and the dev role
here is a superuser, so the probe went through.
2. `request.profile == self.object.created_by` compares a `Profile` to
a `User` FK and is therefore never true, which quietly made the
endpoint admin-only: the person who uploaded the file could not
remove it.
The same one-line lookup bug is still open in `leads`, `tasks` and
`opportunity`.
"""
# On a ticket the caller may open, or the 404 a missing id gets: an
# org-wide fetch answered 403 for an attachment on a hidden ticket or
# on any other module's record, which confirmed it existed.
self.object = get_on_visible_record_or_404(
self.model, pk, request.profile.org, visible_cases_qs(request.profile)
)
if (
is_org_admin(request.profile)
or request.profile.user_id == self.object.created_by_id
):
self.object.delete()
return Response(
{"error": False, "message": "Attachment Deleted Successfully"},
status=status.HTTP_200_OK,
)
return Response(
{
"error": True,
"errors": "You don't have permission to perform this action.",
},
status=status.HTTP_403_FORBIDDEN,
)
class CaseSolutionLinkView(APIView):
"""Link or unlink Solutions (Knowledge Base articles) to a Case."""
permission_classes = (IsAuthenticated, HasOrgContext)
def _get_solution(self, pk, org):
try:
return Solution.objects.filter(pk=pk, org=org).first()
except (DjangoValidationError, ValueError):
# A malformed UUID is a solution that does not exist, not a 500.
return None
@extend_schema(
tags=["Cases"],
request=inline_serializer(
name="CaseSolutionLinkRequest",
fields={"solution_id": serializers.CharField()},
),
)
def post(self, request, pk, solution_pk=None):
# This class is routed twice: `<pk>/solutions/` for POST and
# `<pk>/solutions/<solution_pk>/` for DELETE. Django hands every route's
# captured kwargs to whichever method it dispatches, so POST to the
# second route arrived with an argument it had no parameter for and
# DELETE to the first arrived missing one. Both were an uncaught
# TypeError, which is a 500 on a route the client is simply using the
# wrong verb on. 405 is the answer to a wrong verb.
if solution_pk is not None:
self.http_method_not_allowed(request)
case = get_case_or_404(request.profile, pk)
# Attaching an article to a ticket changes the ticket, so it takes the
# same permission as any other change to it. Org alone was the whole
# check, which meant somebody refused the case with a 403 could still
# link to it, and then read the case straight back out of the
# article's `linked_cases`. Closing that read-around at the serializer
# is not enough on its own: writing to a ticket you cannot open is a
# defect whichever way the data comes back.
assert_case_write_access(request.profile, case)
solution_id = request.data.get("solution_id")
if not solution_id:
return Response(
{"error": True, "errors": "solution_id required"},
status=status.HTTP_400_BAD_REQUEST,
)
sol = self._get_solution(solution_id, request.profile.org)
if not sol:
return Response(
{"error": True, "errors": "Solution not found"},
status=status.HTTP_404_NOT_FOUND,
)
already_linked = case.solutions.filter(pk=sol.pk).exists()
if not already_linked:
case.solutions.add(sol)
return Response(
{"error": False, "solution": SolutionSerializer(sol).data},
status=(status.HTTP_200_OK if already_linked else status.HTTP_201_CREATED),
)
@extend_schema(tags=["Cases"])
def delete(self, request, pk, solution_pk=None):
# See `post` above: reached without a solution id, this is the
# collection route being deleted, which is a wrong verb, not a crash.
if solution_pk is None:
self.http_method_not_allowed(request)
case = get_case_or_404(request.profile, pk)
assert_case_write_access(request.profile, case)
sol = self._get_solution(solution_pk, request.profile.org)
if not sol:
return Response(
{"error": True, "errors": "Solution not found"},
status=status.HTTP_404_NOT_FOUND,
)
case.solutions.remove(sol)
return Response(status=status.HTTP_204_NO_CONTENT)
class CaseActivityListView(APIView, LimitOffsetPagination):
"""Paginated audit-log feed for a single Case (newest-first)."""
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["Cases"],
parameters=swagger_params.organization_params,
responses={
200: inline_serializer(
name="CaseActivityListResponse",
fields={
"activities": ActivitySerializer(many=True),
"count": serializers.IntegerField(),
"offset": serializers.IntegerField(allow_null=True),
},
)
},
)
def get(self, request, pk):
case = get_case_or_404(request.profile, pk)
queryset = Activity.objects.filter(
entity_type="Case",
entity_id=case.id,
org=request.profile.org,
).select_related("user__user")
page = self.paginate_queryset(queryset, request, view=self)
data = ActivitySerializer(page, many=True).data
if page:
offset = queryset.filter(id__gte=page[-1].id).count()
if offset == queryset.count():
offset = None
else:
offset = 0
return Response({"activities": data, "count": self.count, "offset": offset})
def _reopen_analytics(org):
"""Honest 30-day reopen metrics for the settings page.
All three come from what the reopen path in `cases/signals.py` actually
persists:
- `reopened_last_30d`: one REOPENED Activity is written per reopen.
- `replies_after_window_30d`: when an external reply lands too late the
signal flags the COMMENT Activity `out_of_reopen_window=True`, the
authoritative "reopened nothing" record, judged against the window in
force at the time (which a recompute here could not reproduce).
- `median_days_to_reply`: days-from-close-to-reply across every post-close
external reply we can date. A reply that reopened nulled `closed_on`, so
its delta survives only in the REOPENED Activity's `days_since_close`; a
reply that did not reopen left its case Closed, so its delta is
`commented_on − closed_on`. The median unions both, with no double count.
A reopened case is no longer Closed, so it drops out of the comment side.
Every query is `org=`-scoped explicitly (RLS is inert for the app's DB role
in dev/test), so another org's cases, comments and activities cannot leak in.
"""
cutoff = timezone.now() - timedelta(days=30)
# Reopen side: REOPENED activities (count + the delta carried in each).
reopened_meta = list(
Activity.objects.filter(
org=org,
action="REOPENED",
entity_type="Case",
created_at__gte=cutoff,
).values_list("metadata", flat=True)
)
# Missed-window side: the signal's authoritative flag on COMMENT activities.
comment_meta = Activity.objects.filter(
org=org,
action="COMMENT",
entity_type="Case",
created_at__gte=cutoff,
).values_list("metadata", flat=True)
replies_after_window_30d = sum(
1 for m in comment_meta if (m or {}).get("out_of_reopen_window") is True
)
# Median: reopened replies contribute their metadata delta...
deltas = []
for metadata in reopened_meta:
try:
days = int((metadata or {}).get("days_since_close"))
except (TypeError, ValueError):
continue
if days >= 0:
deltas.append(days)
# ...and non-reopened post-close replies contribute commented_on − closed_on.
# `Comment` is a generic relation, so join to Case by hand.
case_ct = ContentType.objects.get_for_model(Case)
external_comments = list(
Comment.objects.filter(
org=org,
content_type=case_ct,
commented_by__isnull=True,
is_internal=False,
commented_on__gte=cutoff,
).values_list("object_id", "commented_on")
)
object_ids = {oid for oid, _ in external_comments}
closed_on_by_id = {}
if object_ids:
closed_on_by_id = dict(
Case.objects.filter(org=org, id__in=object_ids, status="Closed")
.exclude(closed_on__isnull=True)
.values_list("id", "closed_on")
)
for object_id, commented_on in external_comments:
closed_on = closed_on_by_id.get(object_id)
if closed_on is None:
continue # reopened / reassigned open / comment predates the close
days = (timezone.localdate(commented_on) - closed_on).days
if days >= 0:
deltas.append(days)
return {
"reopened_last_30d": len(reopened_meta),
"replies_after_window_30d": replies_after_window_30d,
"median_days_to_reply": (round(statistics.median(deltas), 1) if deltas else 0),
}
class ReopenPolicyView(APIView):
"""Per-org reopen policy. Admin-only. Auto-creates the singleton on first read."""
permission_classes = (IsAuthenticated, HasOrgContext)
def _get_or_create_policy(self, org):
policy = ReopenPolicy.objects.filter(org=org).first()
if policy is None:
policy = ReopenPolicy.objects.create(org=org)
return policy
@extend_schema(
tags=["Cases"],
parameters=swagger_params.organization_params,
responses={200: ReopenPolicySerializer},
)
def get(self, request, format=None):
if not is_org_admin(request.profile):
return Response(
{"error": True, "errors": "Admin access required"},
status=status.HTTP_403_FORBIDDEN,
)
policy = self._get_or_create_policy(request.profile.org)
data = ReopenPolicySerializer(policy).data
data.update(_reopen_analytics(request.profile.org))
return Response(data)
@extend_schema(
tags=["Cases"],
parameters=swagger_params.organization_params,
request=ReopenPolicySerializer,
responses={200: ReopenPolicySerializer},
)
def put(self, request, format=None):
if not is_org_admin(request.profile):
return Response(
{"error": True, "errors": "Admin access required"},
status=status.HTTP_403_FORBIDDEN,
)
policy = self._get_or_create_policy(request.profile.org)
serializer = ReopenPolicySerializer(policy, data=request.data, partial=True)
if not serializer.is_valid():
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
serializer.save()
return Response(serializer.data)