bottleneckcrm/backend/cases/serializer.py

1443 lines
53 KiB
Python

from django.db.models import Count, Manager, Max, Q, Sum
from rest_framework import serializers
from accounts.access import has_account_access
from accounts.serializer import AccountPickerSerializer
from business_hours.calendar import get_default_calendar
from cases.access import (
has_case_read_access,
has_case_write_access,
visible_cases_qs,
)
from cases.approvals import Approval, ApprovalRule, close_refusal, closing_date
from cases.inbound.sns import topic_account_id
from cases.models import (
Case,
CasePipeline,
CaseStage,
EmailMessage,
EscalationPolicy,
InboundMailbox,
ReopenPolicy,
RoutingRule,
TimeEntry,
)
from cases.parent_guards import check_parent_link
from cases.routing import compile_pattern
from cases.workflow import (
DUPLICATE_BY_MERGE_ONLY,
duplicate_refusal,
merged_status_refusal,
)
from common.models import Profile, Teams
from common.modules import module_is_available, org_of
from common.permissions import is_org_admin
from common.serializer import (
OrganizationSerializer,
ProfileSerializer,
TagsSerializer,
TeamsSerializer,
UserSerializer,
)
from common.utils import STATUS_CHOICE
from contacts.serializer import ContactLinkSerializer
# Note: Removed unused serializer property:
# - created_on_arrow (frontend computes its own humanized timestamps)
# The refusal `/link/` gives, so the two doors into a tree answer alike.
PARENT_NOT_FOUND = "Parent case not found in this organization."
def parent_access_context(profile, cases):
"""`CaseSerializer` context: which of these cases' parents ``profile`` may open.
One query for the whole batch, so a page of tickets costs the same whether
it has one parent or twenty-five. Pass the same list or queryset object the
serializer is then given: iterating a queryset here fills its result cache,
which the serializer reads instead of querying again.
"""
parent_ids = {c.parent_id for c in cases if c.parent_id}
readable = set()
if parent_ids:
readable = set(
visible_cases_qs(profile)
.filter(id__in=parent_ids)
.values_list("id", flat=True)
)
return {"readable_parent_ids": readable}
class SharedCalendarListSerializer(serializers.ListSerializer):
"""A list of cases that reads each org's SLA calendar once.
Every case's deadline, breached and at-risk fields walk its org's default
business calendar, and each case looked the calendar up for itself, so a
list or a board of N tickets read the same row N times. The calendar is
read here once per org in the list and handed to every case.
"""
def to_representation(self, data):
cases = list(data.all() if isinstance(data, Manager) else data)
calendars = {}
for case in cases:
if case.org_id not in calendars:
calendars[case.org_id] = get_default_calendar(case.org_id)
case.use_sla_calendar(calendars[case.org_id])
return super().to_representation(cases)
def _no_time():
return {
"total_minutes": 0,
"billable_minutes": 0,
"last_entry_at": None,
"by_profile": [],
}
def time_summaries(cases):
"""Each case's stopped time, keyed by case id, in two queries for any
number of cases. A case with no stopped entry is absent.
Only stopped entries count; a running timer would otherwise double-count
every time somebody refreshes. `by_profile` is heaviest first.
"""
stopped = TimeEntry.objects.filter(
case_id__in=[c.pk for c in cases],
org_id__in={c.org_id for c in cases},
ended_at__isnull=False,
)
summaries = {}
for row in (
stopped.values("case_id")
.annotate(
total=Sum("duration_minutes"),
billable=Sum("duration_minutes", filter=Q(billable=True)),
last=Max("started_at"),
)
.order_by()
):
summaries[row["case_id"]] = {
"total_minutes": row["total"] or 0,
"billable_minutes": row["billable"] or 0,
"last_entry_at": row["last"],
"by_profile": [],
}
for row in (
stopped.values("case_id", "profile_id", "profile__user__email")
.annotate(minutes=Sum("duration_minutes"))
.order_by("case_id", "-minutes")
):
summaries[row["case_id"]]["by_profile"].append(
{
"profile_id": str(row["profile_id"]),
"name": row["profile__user__email"] or "",
"minutes": row["minutes"] or 0,
}
)
return summaries
class CaseRowsListSerializer(SharedCalendarListSerializer):
"""A page of `CaseSerializer` rows with its per-ticket counts batched.
`child_count` and `time_summary` cost a query or more per ticket when each
row asks for itself, so a page answers both in three queries here and
hands each case its own values, which the row methods read first.
`child_count` counts every child, readable or not, the same number the
single-ticket path gives: `/tree/` shows a hidden child as a redacted node,
and both clients count those when they warn what a cascading close takes.
"""
def to_representation(self, data):
cases = list(data.all() if isinstance(data, Manager) else data)
if cases:
counts = dict(
Case.objects.filter(
parent_id__in=[c.pk for c in cases],
org_id__in={c.org_id for c in cases},
)
.values("parent_id")
.annotate(n=Count("id"))
.order_by()
.values_list("parent_id", "n")
)
timed = {}
for case in cases:
if case.org_id not in timed:
timed[case.org_id] = module_is_available(org_of(case), "timesheet")
summaries = time_summaries([c for c in cases if timed[c.org_id]])
for case in cases:
case._child_count = counts.get(case.pk, 0)
case._time_summary = (
(summaries.get(case.pk) or _no_time())
if timed[case.org_id]
else None
)
return super().to_representation(cases)
class CaseSerializer(serializers.ModelSerializer):
# `{id, name}` only. Seeing this record is not access to its account, and
# both clients read only these two; the rest is on `/api/accounts/<id>/`.
account = AccountPickerSerializer(read_only=True)
# Name and email only; see `ContactLinkSerializer`.
contacts = ContactLinkSerializer(read_only=True, many=True)
assigned_to = ProfileSerializer(read_only=True, many=True)
created_by = UserSerializer(read_only=True)
teams = TeamsSerializer(read_only=True, many=True)
tags = TagsSerializer(read_only=True, many=True)
org = OrganizationSerializer()
first_response_sla_deadline = serializers.DateTimeField(read_only=True)
resolution_sla_deadline = serializers.DateTimeField(read_only=True)
is_sla_first_response_breached = serializers.BooleanField(read_only=True)
is_sla_resolution_breached = serializers.BooleanField(read_only=True)
is_sla_first_response_at_risk = serializers.BooleanField(read_only=True)
is_sla_resolution_at_risk = serializers.BooleanField(read_only=True)
# Tier 3 parent/child
parent_summary = serializers.SerializerMethodField()
child_count = serializers.SerializerMethodField()
# Tier 3 time-tracking
time_summary = serializers.SerializerMethodField()
def get_parent_summary(self, obj):
"""The parent's name and status, or only its id when the viewer may
not open it.
Reading a child is not reading its parent. This used to hand anyone
who could open the child the parent's name and status, so a member
assigned one sub-ticket learned the subject of a ticket the detail view
refuses them. Which parents are readable comes from
`parent_access_context`; a serializer built without it redacts every
parent, since not knowing who is asking is not permission. The redacted
shape matches the one `/tree/` returns for a hidden node.
"""
if not obj.parent_id:
return None
if obj.parent_id not in self.context.get("readable_parent_ids", ()):
return {
"id": str(obj.parent_id),
"name": None,
"status": None,
"restricted": True,
}
p = obj.parent
return {
"id": str(p.id),
"name": p.name,
"status": p.status,
"restricted": False,
}
def get_child_count(self, obj):
# Set for a whole page by `CaseRowsListSerializer`.
if hasattr(obj, "_child_count"):
return obj._child_count
return obj.children.count()
def get_time_summary(self, obj):
# Set for a whole page by `CaseRowsListSerializer`; one ticket asks
# the same function for itself. Null while the timesheet is off.
if hasattr(obj, "_time_summary"):
return obj._time_summary
if not module_is_available(org_of(obj), "timesheet"):
return None
return time_summaries([obj]).get(obj.pk) or _no_time()
class Meta:
model = Case
list_serializer_class = CaseRowsListSerializer
fields = (
"id",
"name",
"status",
"priority",
"case_type",
"closed_on",
"description",
"created_by",
"created_at",
"is_active",
"account",
"contacts",
"teams",
"assigned_to",
"tags",
"org",
"custom_fields",
"escalation_count",
"last_escalation_fired_at",
# SLA: Tier 2 business-hours-sla
"sla_first_response_hours",
"sla_resolution_hours",
"first_response_at",
"resolved_at",
"sla_paused_at",
"first_response_sla_deadline",
"resolution_sla_deadline",
"is_sla_first_response_breached",
"is_sla_resolution_breached",
"is_sla_first_response_at_risk",
"is_sla_resolution_at_risk",
# Tier 3 parent/child
"parent",
"is_problem",
"parent_summary",
"child_count",
# Tier 3 time-tracking
"time_summary",
)
class CaseCreateSerializer(serializers.ModelSerializer):
closed_on = serializers.DateField(required=False, allow_null=True)
org = serializers.PrimaryKeyRelatedField(read_only=True)
def __init__(self, *args, **kwargs):
request_obj = kwargs.pop("request_obj", None)
super().__init__(*args, **kwargs)
self.profile = request_obj.profile
self.org = request_obj.profile.org
# Make account read-only on updates (can only be set on creation)
if self.instance:
self.fields["account"].read_only = True
else:
# An id that matches no account at all reads the same as one the
# caller may not open; see `validate_account`.
self.fields["account"].error_messages["does_not_exist"] = "No such account."
# An id that matches no case reads the same as one in another org or
# one the caller may not open; see `validate_parent`.
self.fields["parent"].error_messages["does_not_exist"] = PARENT_NOT_FOUND
def validate_account(self, account):
"""An account belonging to another org is not a valid link.
`account` is a plain model FK, so DRF built it with a queryset of
*every* Account row. Posting a stranger's account UUID stored the
link and, because the create response echoes `cases_obj` through
`CaseSerializer`, which then nested `AccountSerializer`: handed the caller
that account's name, email, phone and website back. So the same hole
was both a cross-tenant write and a cross-tenant read. `validate_parent`
below already guarded the other FK on this serializer; this one had
been missed.
Nor is an account in this org that the caller may not open. Linking
one attached the ticket to a company the member cannot see, so the
rule is `has_account_access`, the one the account detail view applies.
An unknown id, another org's account and a hidden one all get the same
message, so a response never confirms that a hidden account exists.
`account` is read-only once the ticket exists (see `__init__`), so this
runs on create only.
"""
if account is None:
return account
if account.org_id != self.org.id or not has_account_access(
self.profile, account
):
raise serializers.ValidationError("No such account.")
return account
def validate(self, attrs):
"""Guard the close transition.
`Case.clean()` states two rules about closing a case: it needs a
`closed_on` date, and where an active `pre_close` ApprovalRule matches
it needs a recorded approval. Neither was ever enforced through the
API, because DRF does not call `Model.clean()` and nothing on the save
path calls `full_clean()`. Proven live: with a matching rule armed,
`PATCH {"status": "Closed"}` returned 200, closed the case and
recorded zero approvals, the whole approval feature, its inbox and
its settings page were decoration.
The date is supplied here rather than demanded of the client: a close
that sends none is dated today in the org's timezone (`closing_date`),
and a date the caller sends wins. Clients each computing "today" was
how one of them came to refuse closes outright for an org stored
under a legacy zone name.
Validating the *transition* rather than the target matters here: a
case that is already Closed can be edited without re-approving, which
is why this compares against the stored status instead of just looking
at the incoming one.
The rule itself is `cases.approvals.close_refusal`, which the board
move (`CaseMoveView.patch`) calls too, so the two cannot drift apart.
"""
attrs = super().validate(attrs)
new_status = attrs.get("status", getattr(self.instance, "status", None))
# First, so a merged ticket hears "unmerge first" and not a close-gate
# message about a transition it may not make at all.
refusal = merged_status_refusal(self.instance, new_status)
if refusal:
raise serializers.ValidationError(refusal)
# Parent linking, only when this request carries `parent`. Judging the
# stored parent on every save would reject an ordinary rename of a case
# that sits in a tree, and would make a record uneditable if its stored
# link were already bad.
if "parent" in attrs:
refusal = check_parent_link(
attrs["parent"], case=self.instance, case_status=new_status
)
if refusal:
raise serializers.ValidationError({"parent": refusal})
# A ticket left Closed always has a date: one the caller sends wins,
# an absent or null one keeps the stored date, else today in the org's
# timezone. See `closing_date`.
closed_on = attrs.get("closed_on", getattr(self.instance, "closed_on", None))
dated = closing_date(self.instance, status=new_status, closed_on=closed_on)
if dated != closed_on:
attrs["closed_on"] = dated
refusal = close_refusal(
self.instance,
status=new_status,
priority=attrs.get("priority", getattr(self.instance, "priority", None)),
case_type=attrs.get("case_type", getattr(self.instance, "case_type", None)),
)
if refusal:
raise serializers.ValidationError(refusal)
refusal = duplicate_refusal(getattr(self.instance, "status", None), new_status)
if refusal:
raise serializers.ValidationError(refusal)
return attrs
def validate_name(self, name):
"""Refuse a name one of the caller's own tickets already carries.
Ticket names carry no DB constraint, so this is a courtesy against
filing the same ticket twice, and it looks only at tickets the caller
may open (`visible_cases_qs`), as the CSV import does. Checked across
the whole org it was an oracle: the refusal for a name held only by a
ticket the caller cannot open confirmed that ticket existed.
"""
clash = visible_cases_qs(self.profile).filter(name__iexact=name)
if self.instance:
clash = clash.exclude(id=self.instance.id)
if clash.exists():
raise serializers.ValidationError("Case already exists with this name")
return name
def validate_parent(self, parent):
"""A parent the caller may name: in this org and readable by them.
The rest of the linking rules moved to `validate()`, which is the
first place with both the incoming `status` and the record being
moved. This one stays a field validator because it is the check that
decides whether `parent` may be used at all, and because the caller
should get it against the `parent` field either way.
The read rule is the one `/link/` applies: a parent the caller cannot
open answers exactly like one that does not exist. Checked by org
alone, this was the way round the link endpoint: it put the caller's
ticket under somebody else's and, since a missing id is refused and a
hidden one was not, confirmed the hidden ticket existed. Re-sending the
parent a ticket already has is not a new link, so it is let through
even when that parent is hidden from the caller.
"""
if parent is None:
return parent
if self.instance is not None and parent.id == self.instance.parent_id:
return parent
if parent.org_id != self.org.id or not has_case_read_access(
self.profile, parent
):
raise serializers.ValidationError(PARENT_NOT_FOUND)
return parent
class Meta:
model = Case
fields = (
"name",
"status",
"priority",
"case_type",
"closed_on",
"description",
"is_active",
"account",
"org",
"custom_fields",
"parent",
"is_problem",
)
read_only_fields = ("org",)
class CaseCreateSwaggerSerializer(serializers.ModelSerializer):
class Meta:
model = Case
fields = (
"name",
"status",
"priority",
"case_type",
"closed_on",
"teams",
"assigned_to",
"account",
"contacts",
"tags",
"description",
)
class CaseDetailEditSwaggerSerializer(serializers.Serializer):
comment = serializers.CharField()
case_attachment = serializers.FileField()
class CaseCommentEditSwaggerSerializer(serializers.Serializer):
comment = serializers.CharField()
# ============================================
# Kanban Serializers
# ============================================
def _visible_case_count(serializer, **lookup):
"""How many cases matching ``lookup`` the requester may open.
``case_count`` once counted every case in the org, so a member whose board
showed three cards read a count that included tickets they cannot open. It
follows ``visible_cases_qs`` now, the rule the board and the list use. The
request has to be in the serializer context: a missing one is a KeyError,
never an unscoped count.
"""
profile = serializer.context["request"].profile
return visible_cases_qs(profile).filter(**lookup).count()
class CaseStageSerializer(serializers.ModelSerializer):
"""Serializer for case stages. Needs ``request`` in its context."""
case_count = serializers.SerializerMethodField()
class Meta:
model = CaseStage
fields = [
"id",
"name",
"order",
"color",
"stage_type",
"maps_to_status",
"wip_limit",
"case_count",
"created_at",
"updated_at",
]
read_only_fields = ("id", "created_at", "updated_at", "org")
def validate_maps_to_status(self, value):
# A board move into this stage would set Duplicate without a merge.
if value == "Duplicate":
raise serializers.ValidationError(DUPLICATE_BY_MERGE_ONLY)
return value
def get_case_count(self, obj):
return _visible_case_count(self, stage=obj)
class CasePipelineSerializer(serializers.ModelSerializer):
"""Serializer for case pipelines with nested stages. Needs ``request`` in
its context."""
stages = CaseStageSerializer(many=True, read_only=True)
stage_count = serializers.SerializerMethodField()
case_count = serializers.SerializerMethodField()
class Meta:
model = CasePipeline
fields = [
"id",
"name",
"description",
"is_default",
"is_active",
"stages",
"stage_count",
"case_count",
"created_at",
"updated_at",
]
# `is_active` only goes False through DELETE, which refuses a pipeline
# that still has records, as on lead pipelines. Writable here, a PUT
# skipped that refusal.
read_only_fields = ("id", "is_active", "created_at", "updated_at", "org")
def get_stage_count(self, obj):
return obj.stages.count()
def get_case_count(self, obj):
return _visible_case_count(self, stage__pipeline=obj)
class CasePipelineListSerializer(serializers.ModelSerializer):
"""Simplified pipeline serializer for lists.
Both counts are read from annotations, so every queryset serialized here
must come through ``with_counts``: one query for the whole list, however
many pipelines. A pipeline without them raises AttributeError, which is
louder than a count that silently ignores who is asking.
"""
stage_count = serializers.SerializerMethodField()
case_count = serializers.SerializerMethodField()
class Meta:
model = CasePipeline
fields = [
"id",
"name",
"description",
"is_default",
"is_active",
"stage_count",
"case_count",
"created_at",
]
@staticmethod
def with_counts(pipelines, profile):
# The case join repeats a stage once per case in it, hence distinct on
# the stage count. A case sits in one stage, so it is counted once.
return pipelines.annotate(
stage_count=Count("stages", distinct=True),
case_count=Count(
"stages__cases",
filter=Q(stages__cases__in=visible_cases_qs(profile).values("pk")),
),
)
def get_stage_count(self, obj):
return obj.stage_count
def get_case_count(self, obj):
return obj.case_count
class CaseKanbanCardSerializer(serializers.ModelSerializer):
"""Lightweight serializer for kanban cards (minimal fields for performance)."""
assigned_to = ProfileSerializer(read_only=True, many=True)
account_name = serializers.SerializerMethodField()
is_sla_breached = serializers.SerializerMethodField()
is_sla_at_risk = serializers.SerializerMethodField()
can_move = serializers.SerializerMethodField()
class Meta:
model = Case
list_serializer_class = SharedCalendarListSerializer
fields = [
"id",
"name",
"status",
"priority",
"case_type",
"stage",
"kanban_order",
"account_name",
"assigned_to",
"is_sla_breached",
"is_sla_at_risk",
"is_sla_first_response_breached",
"is_sla_resolution_breached",
"escalation_count",
"last_escalation_fired_at",
"can_move",
"created_at",
]
def get_account_name(self, obj):
return obj.account.name if obj.account else None
def get_can_move(self, obj) -> bool:
"""Whether the viewer may move this card: the ticket write rule
`CaseMoveView` asserts. The board reads wider (a watcher sees the
tickets they follow), so this is False on a watched ticket the viewer
neither created nor holds, and the board offers no drag there. False
without a request in context. `assigned_to` is prefetched by the
board, so this adds no query per card.
"""
request = self.context.get("request")
if request is None:
return False
return has_case_write_access(request.profile, obj)
def get_is_sla_breached(self, obj):
"""Return True if any SLA is breached."""
return obj.is_sla_first_response_breached or obj.is_sla_resolution_breached
def get_is_sla_at_risk(self, obj):
"""Return True if either SLA is inside the warning band.
Card rendering treats this as the amber step below `is_sla_breached`,
and the two are exclusive per SLA, so a card that has breached one
target and is merely at risk on the other still reads as breached.
"""
return obj.is_sla_first_response_at_risk or obj.is_sla_resolution_at_risk
class ReopenPolicySerializer(serializers.ModelSerializer):
"""Per-org reopen policy. Only non-terminal statuses allowed for reopen_to_status."""
NON_TERMINAL_STATUSES = ("New", "Assigned", "Pending")
class Meta:
model = ReopenPolicy
fields = (
"is_enabled",
"reopen_window_days",
"reopen_to_status",
"notify_assigned",
)
def validate_reopen_to_status(self, value):
if value not in self.NON_TERMINAL_STATUSES:
raise serializers.ValidationError(
f"reopen_to_status must be one of {self.NON_TERMINAL_STATUSES}"
)
return value
def validate_reopen_window_days(self, value):
if value < 1 or value > 365:
raise serializers.ValidationError(
"reopen_window_days must be between 1 and 365"
)
return value
class EscalationPolicySerializer(serializers.ModelSerializer):
"""Per-org, per-priority escalation policy. See docs/cases/tier1/escalation.md."""
first_response_target = ProfileSerializer(read_only=True)
resolution_target = ProfileSerializer(read_only=True)
notify_team = TeamsSerializer(read_only=True)
# `*_id` write-only relational fields are scoped to the request's org in
# __init__ (a class-level queryset would leak cross-tenant rows in the
# browsable API). DRF requires a non-None placeholder here.
first_response_target_id = serializers.PrimaryKeyRelatedField(
source="first_response_target",
queryset=Profile.objects.none(),
write_only=True,
required=False,
allow_null=True,
)
resolution_target_id = serializers.PrimaryKeyRelatedField(
source="resolution_target",
queryset=Profile.objects.none(),
write_only=True,
required=False,
allow_null=True,
)
notify_team_id = serializers.PrimaryKeyRelatedField(
source="notify_team",
queryset=Teams.objects.none(),
write_only=True,
required=False,
allow_null=True,
)
class Meta:
model = EscalationPolicy
fields = (
"id",
"priority",
"first_response_hours",
"resolution_hours",
"next_response_hours",
"first_response_action",
"resolution_action",
"first_response_target",
"resolution_target",
"notify_team",
"first_response_target_id",
"resolution_target_id",
"notify_team_id",
"is_active",
"created_at",
"updated_at",
)
read_only_fields = ("id", "created_at", "updated_at")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get("request") if hasattr(self, "context") else None
org = (
getattr(getattr(request, "profile", None), "org", None) if request else None
)
if org is None:
org = self.context.get("org") if hasattr(self, "context") else None
if org is not None:
self.fields["first_response_target_id"].queryset = Profile.objects.filter(
org=org
)
self.fields["resolution_target_id"].queryset = Profile.objects.filter(
org=org
)
self.fields["notify_team_id"].queryset = Teams.objects.filter(org=org)
def validate(self, attrs):
priority = attrs.get("priority", getattr(self.instance, "priority", None))
if self.instance is None and priority is not None:
org = self.context.get("org")
if (
org
and EscalationPolicy.objects.filter(org=org, priority=priority).exists()
):
raise serializers.ValidationError(
{"priority": f"Policy already exists for priority {priority}."}
)
return attrs
class InboundMailboxSerializer(serializers.ModelSerializer):
"""Per-org inbound mailbox config. See docs/cases/tier1/email-to-ticket.md."""
default_assignee = ProfileSerializer(read_only=True)
default_assignee_id = serializers.PrimaryKeyRelatedField(
source="default_assignee",
queryset=Profile.objects.none(),
write_only=True,
required=False,
allow_null=True,
)
# Writable so an admin can paste a provider-issued signing key, never
# readable. Nothing in the codebase compares this value, so there is no
# caller that needs it back, and a stored credential returned by a list
# endpoint is reachable by anything holding an admin's session or personal
# access token. `has_webhook_secret` is what an admin screen needs: whether
# one is set, not what it is.
webhook_secret = serializers.CharField(
write_only=True, required=False, allow_blank=True, max_length=128
)
has_webhook_secret = serializers.SerializerMethodField()
# Whether the mailbox has its SNS TopicArn pin.
#
# Not a credential and not admin-only: it is delivery status. An active SES
# mailbox with no pin rejects every message (`InboundMailboxWebhookView
# .post` answers `_topic_rejected()` until an admin enters the ARN or a
# verified SubscriptionConfirmation from an allowed AWS account sets it),
# so without this a client can only report `is_active` and would draw a
# never-connected address as working. The ARN itself stays admin-only
# below, since it embeds the AWS account id.
has_topic_arn = serializers.SerializerMethodField()
class Meta:
model = InboundMailbox
fields = (
"id",
"address",
"provider",
"webhook_secret",
"has_webhook_secret",
"has_topic_arn",
"topic_arn",
"default_priority",
"default_case_type",
"default_assignee",
"default_assignee_id",
"is_active",
"created_at",
"updated_at",
)
read_only_fields = ("id", "created_at", "updated_at")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get("request") if hasattr(self, "context") else None
org = (
getattr(getattr(request, "profile", None), "org", None) if request else None
)
if org is None:
org = self.context.get("org") if hasattr(self, "context") else None
if org is not None:
self.fields["default_assignee_id"].queryset = Profile.objects.filter(
org=org
)
def validate_default_assignee_id(self, value):
"""A deactivated member cannot be made the mailbox's default assignee.
Inbound mail ignores one anyway (`cases.inbound.pipeline.ingest`), so
accepting it would save a setting that silently does nothing.
Keeping the one already stored is allowed. Both clients resend it on
every save, so refusing it would leave a mailbox whose default was
deactivated later unsaveable until the admin picked somebody else, and
the stored value is already ignored.
"""
stored = getattr(self.instance, "default_assignee_id", None)
if value is not None and not value.is_active and value.id != stored:
raise serializers.ValidationError(
"This user is deactivated. Choose an active member."
)
return value
def validate_address(self, value):
# Postgres enforces uniq(org, address); this is a nicer error than an
# IntegrityError, and it has to run on update too. When editing, the
# mailbox must be excluded from its own duplicate check or saving a
# record without changing its address would fail against itself.
org = self.context.get("org")
if org:
clash = InboundMailbox.objects.filter(org=org, address__iexact=value)
if self.instance is not None:
clash = clash.exclude(pk=self.instance.pk)
if clash.exists():
raise serializers.ValidationError(
f"A mailbox with address {value!r} already exists for this org."
)
return value
def validate_topic_arn(self, value):
"""Blank clears the pin; anything else must be a well-formed SNS topic
ARN, since the webhook compares it byte for byte and a near miss would
refuse every delivery without a word."""
if value and topic_account_id(value) is None:
raise serializers.ValidationError(
"Enter an SNS topic ARN, like "
"arn:aws:sns:us-east-1:123456789012:inbound-mail."
)
return value
def get_has_webhook_secret(self, instance) -> bool:
return bool(instance.webhook_secret)
def get_has_topic_arn(self, instance) -> bool:
return bool(instance.topic_arn)
def to_representation(self, instance):
"""`topic_arn` is the webhook's TopicArn pin and embeds the AWS account
id, so it is shown only to admins, who manage the integration and may
set, change or clear it, and only when the view passes the request in
context. Every write to this serializer is admin-only in the views. `has_webhook_secret` rides
with it because both describe the same admin-only integration config.
`has_topic_arn` deliberately does NOT ride with them. It says whether
mail to this address can become a ticket at all, which is the same kind
of fact as `is_active`, and every viewer of the row needs it to read the
row correctly.
`webhook_secret` itself is declared `write_only` above and so is never
in `data` to begin with. This method used to pop it for non-admins,
which meant admins read it back on every list and detail call. Nothing
reads that value: `InboundMailboxWebhookView.post` authenticates with
the SNS signature and the `topic_arn` pin. Returning a stored
credential-shaped value that no code consumes only creates a way to
lose it, and it would become a live leak the day a provider
integration starts checking it, with no change here to notice."""
data = super().to_representation(instance)
request = self.context.get("request")
profile = getattr(request, "profile", None) if request else None
if not is_org_admin(profile):
data.pop("has_webhook_secret", None)
data.pop("topic_arn", None)
return data
class EmailMessageSerializer(serializers.ModelSerializer):
"""Read-only view of an EmailMessage row, used by the case-detail feed."""
class Meta:
model = EmailMessage
fields = (
"id",
"case",
"direction",
"message_id",
"in_reply_to",
"from_address",
"to_addresses",
"cc_addresses",
"subject",
"body_text",
"body_html",
"received_at",
"drop_reason",
"created_at",
)
read_only_fields = fields
class RoutingRuleSerializer(serializers.ModelSerializer):
"""Per-org auto-routing rule. See docs/cases/tier1/auto-routing.md."""
target_assignees = ProfileSerializer(read_only=True, many=True)
target_team = TeamsSerializer(read_only=True)
target_assignee_ids = serializers.PrimaryKeyRelatedField(
source="target_assignees",
queryset=Profile.objects.none(),
write_only=True,
required=False,
many=True,
)
target_team_id = serializers.PrimaryKeyRelatedField(
source="target_team",
queryset=Teams.objects.none(),
write_only=True,
required=False,
allow_null=True,
)
class Meta:
model = RoutingRule
fields = (
"id",
"name",
"priority_order",
"is_active",
"conditions",
"strategy",
"stop_processing",
"target_assignees",
"target_team",
"target_assignee_ids",
"target_team_id",
"created_at",
"updated_at",
)
read_only_fields = ("id", "created_at", "updated_at")
SUPPORTED_OPS = ("eq", "in", "contains", "regex")
SUPPORTED_FIELDS = (
"priority",
"case_type",
"account",
"tags",
"from_email_domain",
"mailbox_id",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get("request") if hasattr(self, "context") else None
org = (
getattr(getattr(request, "profile", None), "org", None) if request else None
)
if org is None:
org = self.context.get("org") if hasattr(self, "context") else None
if org is not None:
self.fields[
"target_assignee_ids"
].child_relation.queryset = Profile.objects.filter(org=org)
self.fields["target_team_id"].queryset = Teams.objects.filter(org=org)
def validate_conditions(self, value):
if value in (None, ""):
return []
if not isinstance(value, list):
raise serializers.ValidationError("conditions must be a list of objects.")
for i, cond in enumerate(value):
if not isinstance(cond, dict):
raise serializers.ValidationError(f"conditions[{i}] must be an object.")
field = cond.get("field")
op = cond.get("op", "eq")
if not isinstance(field, str) or not field:
raise serializers.ValidationError(f"conditions[{i}].field is required.")
if not (
field in self.SUPPORTED_FIELDS or field.startswith("custom_fields.")
):
raise serializers.ValidationError(
f"conditions[{i}].field {field!r} is not supported."
)
if op not in self.SUPPORTED_OPS:
raise serializers.ValidationError(
f"conditions[{i}].op {op!r} is not supported."
)
if "value" not in cond:
raise serializers.ValidationError(f"conditions[{i}].value is required.")
if op == "regex":
try:
compile_pattern(cond["value"])
except ValueError as exc:
raise serializers.ValidationError(
f"conditions[{i}].value {exc}."
) from None
return value
def validate(self, attrs):
strategy = attrs.get("strategy", getattr(self.instance, "strategy", "direct"))
if strategy == "by_team":
target_team = attrs.get(
"target_team", getattr(self.instance, "target_team", None)
)
if target_team is None:
raise serializers.ValidationError(
{"target_team_id": "by_team strategy requires target_team_id."}
)
if strategy in ("round_robin", "least_busy", "direct"):
assignees = attrs.get("target_assignees")
if assignees is None and self.instance is None:
raise serializers.ValidationError(
{
"target_assignee_ids": (
f"{strategy} strategy requires at least one target assignee."
)
}
)
return attrs
class TimeEntrySerializer(serializers.ModelSerializer):
"""Read shape for time entries. See docs/cases/tier3/time-tracking.md.
``invoice`` is null while Invoices is off; ``invoiced`` still says the
entry is billed, and so locked against edits.
"""
profile = ProfileSerializer(read_only=True)
invoiced = serializers.SerializerMethodField()
def get_invoiced(self, obj) -> bool:
return obj.invoice_id is not None
def to_representation(self, instance):
data = super().to_representation(instance)
if instance.invoice_id and not module_is_available(
org_of(instance), "invoices"
):
data["invoice"] = None
return data
class Meta:
model = TimeEntry
fields = (
"id",
"case",
"profile",
"started_at",
"ended_at",
"duration_minutes",
"description",
"billable",
"hourly_rate",
"currency",
"invoice",
"invoiced",
"auto_stopped",
"created_at",
"updated_at",
)
read_only_fields = (
"id",
"duration_minutes",
"auto_stopped",
"invoice",
"created_at",
"updated_at",
)
class TimeEntryCreateSerializer(serializers.ModelSerializer):
"""Manual time entry creation (POST /api/cases/<pk>/time-entries/).
`case`, `profile`, and `org` are injected by the view and not accepted
from the client. Validation rejects negative durations and negative
hourly rates so the DB CheckConstraint is a backstop, not the first
line of defense.
"""
class Meta:
model = TimeEntry
fields = (
"started_at",
"ended_at",
"description",
"billable",
"hourly_rate",
"currency",
)
def validate_description(self, value):
if not value or not value.strip():
raise serializers.ValidationError(
"Description is required when logging time manually."
)
return value.strip()
def validate(self, attrs):
started = attrs.get("started_at")
ended = attrs.get("ended_at")
if started and ended and ended < started:
raise serializers.ValidationError(
{"ended_at": "ended_at must be on or after started_at."}
)
rate = attrs.get("hourly_rate")
if rate is not None and rate < 0:
raise serializers.ValidationError(
{"hourly_rate": "hourly_rate cannot be negative."}
)
return attrs
class TimeEntryUpdateSerializer(serializers.ModelSerializer):
"""Update path for /api/time-entries/<pk>/.
Lets the owner (or an admin) revise the start/end window, description,
billable flag, and hourly rate. `case`, `profile`, and `invoice` stay
read-only, moving an entry between cases or stealing someone else's
timer is intentionally not supported.
"""
class Meta:
model = TimeEntry
fields = (
"started_at",
"ended_at",
"description",
"billable",
"hourly_rate",
"currency",
)
def validate_description(self, value):
# Only blocks explicit attempts to clear the field; PATCH bodies that
# omit `description` (e.g., toggling billable) skip this entirely.
if value is not None and not value.strip():
raise serializers.ValidationError("Description cannot be empty.")
return value.strip() if value else value
def validate(self, attrs):
started = attrs.get(
"started_at", self.instance.started_at if self.instance else None
)
ended = attrs.get("ended_at", self.instance.ended_at if self.instance else None)
if started and ended and ended < started:
raise serializers.ValidationError(
{"ended_at": "ended_at must be on or after started_at."}
)
rate = attrs.get("hourly_rate")
if rate is not None and rate < 0:
raise serializers.ValidationError(
{"hourly_rate": "hourly_rate cannot be negative."}
)
return attrs
class CaseMoveSerializer(serializers.Serializer):
"""Serializer for moving cases in kanban."""
stage_id = serializers.UUIDField(required=False, allow_null=True)
status = serializers.ChoiceField(choices=STATUS_CHOICE, required=False)
kanban_order = serializers.DecimalField(
max_digits=15, decimal_places=6, required=False
)
above_case_id = serializers.UUIDField(required=False, allow_null=True)
below_case_id = serializers.UUIDField(required=False, allow_null=True)
def validate_status(self, value):
# The stage path, where the status comes from the stage's mapping, is
# refused in `CaseMoveView.patch`, which is where the stage is loaded.
if value == "Duplicate":
raise serializers.ValidationError(DUPLICATE_BY_MERGE_ONLY)
return value
def validate(self, attrs):
if not attrs.get("stage_id") and not attrs.get("status"):
raise serializers.ValidationError(
"Either stage_id or status must be provided"
)
return attrs
# ---------------------------------------------------------------------------
# Tier 3 approval workflows
class ApprovalRuleSerializer(serializers.ModelSerializer):
"""Read + write serializer for admin rule CRUD.
The approver pool is a list of profile IDs on writes; on reads we expose
a thin {id, email} per-approver shape so the admin UI can render names
without an N+1 fetch.
"""
approver_ids = serializers.PrimaryKeyRelatedField(
many=True,
write_only=True,
required=False,
source="approvers",
queryset=Approval._meta.get_field("requested_by").related_model.objects.all(),
)
approvers = serializers.SerializerMethodField()
match_team_id = serializers.PrimaryKeyRelatedField(
write_only=True,
required=False,
allow_null=True,
source="match_team",
queryset=Teams.objects.all(),
)
match_team = serializers.SerializerMethodField()
class Meta:
model = ApprovalRule
fields = (
"id",
"name",
"trigger_event",
"match_priority",
"match_case_type",
"match_team",
"match_team_id",
"approver_role",
"approvers",
"approver_ids",
"is_active",
"created_at",
"updated_at",
)
def get_approvers(self, obj):
return [
{"id": str(p.id), "email": getattr(p.user, "email", "") or ""}
for p in obj.approvers.all().select_related("user")
]
def get_match_team(self, obj):
if not obj.match_team_id:
return None
return {"id": str(obj.match_team_id), "name": obj.match_team.name}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get("request") if hasattr(self, "context") else None
org = (
getattr(getattr(request, "profile", None), "org", None) if request else None
)
if org is None:
org = self.context.get("org") if hasattr(self, "context") else None
if org is not None:
# Scope the relational querysets to the caller's org, the same
# __init__ scoping RoutingRule/Escalation serializers do. Without it
# approver_ids/match_team_id accept ANY org's rows, so a PUT (whose
# view does not re-run the POST's manual org check) could attach a
# foreign-tenant Profile or Team to the rule.
self.fields[
"approver_ids"
].child_relation.queryset = Profile.objects.filter(org=org)
self.fields["match_team_id"].queryset = Teams.objects.filter(org=org)
class ApprovalSerializer(serializers.ModelSerializer):
"""Read serializer used by the inbox + case pane.
``can_act``, ``can_cancel`` and ``is_own_request`` are viewer-relative and
need the request in serializer context; without it (e.g. the single-object action responses)
they default to False, which is safe. The queue re-reads the list after any
action, and that read carries the context.
"""
case_summary = serializers.SerializerMethodField()
rule_summary = serializers.SerializerMethodField()
requested_by = serializers.SerializerMethodField()
approver = serializers.SerializerMethodField()
can_act = serializers.SerializerMethodField()
can_cancel = serializers.SerializerMethodField()
is_own_request = serializers.SerializerMethodField()
class Meta:
model = Approval
fields = (
"id",
"state",
"note",
"reason",
"decided_at",
"case_summary",
"rule_summary",
"requested_by",
"approver",
"can_act",
"can_cancel",
"is_own_request",
"created_at",
"updated_at",
)
def get_case_summary(self, obj):
c = obj.case
account = None
if c.account_id:
account = {"id": str(c.account_id), "name": c.account.name}
return {
"id": str(c.id),
"name": c.name,
"status": c.status,
"priority": c.priority,
"account": account,
}
def get_rule_summary(self, obj):
r = obj.rule
return {
"id": str(r.id),
"name": r.name,
"trigger_event": r.trigger_event,
"approver_role": r.approver_role,
"approvers": [
{"id": str(p.id), "email": getattr(p.user, "email", "") or ""}
for p in r.approvers.all()
],
}
def _profile_label(self, profile):
if profile is None:
return None
email = getattr(profile.user, "email", "") if profile.user_id else ""
return {"id": str(profile.id), "email": email or ""}
def get_requested_by(self, obj):
return self._profile_label(obj.requested_by)
def get_approver(self, obj):
return self._profile_label(obj.approver)
def _viewer_profile(self):
request = self.context.get("request")
return getattr(request, "profile", None) if request else None
def get_can_act(self, obj):
"""True when the viewer may approve/reject this row right now.
Mirrors ``ApprovalApproveView`` exactly: still pending, the viewer is in
the rule's approver pool, and the viewer is not the requester (a
requester cannot decide their own request). The button guard and the
endpoint therefore agree by construction.
"""
profile = self._viewer_profile()
if profile is None or obj.state != "pending":
return False
if obj.requested_by_id == profile.id:
return False
return obj.can_be_acted_on_by(profile)
def get_can_cancel(self, obj):
"""True when the viewer may withdraw this row right now: still pending,
and `Approval.can_be_cancelled_by` (the requester or an org admin), the
rule `ApprovalCancelView` enforces."""
profile = self._viewer_profile()
return bool(
profile and obj.state == "pending" and obj.can_be_cancelled_by(profile)
)
def get_is_own_request(self, obj):
profile = self._viewer_profile()
return bool(profile and obj.requested_by_id == profile.id)
class ApprovalRequestSerializer(serializers.Serializer):
"""Body shape for ``POST /api/cases/<pk>/request-approval/``."""
rule_id = serializers.UUIDField(required=False, allow_null=True)
note = serializers.CharField(required=False, allow_blank=True, default="")