Add comprehensive tests for ticket closing, calendar feed, deal board, macros, session caches, and web form models

- Implement tests for closing tickets via menu and status picker, ensuring correct API interactions and error handling.
- Create tests for calendar feed provider, verifying throttling behavior and success responses.
- Add tests for deal board provider, covering reading and moving deals, including edge cases for card movement and filters.
- Enhance macro tests to validate action parsing and application to tickets, including error handling.
- Introduce session cache tests to ensure proper cleanup on sign-out and validate session-scoped providers.
- Extend web form model tests to cover lead rotation functionality, ensuring correct serialization and defaults.
This commit is contained in:
Ashwin 2026-09-27 06:28:09 +05:30
parent 66421bbad0
commit 2be3f51efc
146 changed files with 11861 additions and 1059 deletions

View file

@ -17,6 +17,7 @@ declare an explicit ``org`` FK; RLS is enforced by the migration that adds
from __future__ import annotations
from django.db import models
from django.utils import timezone
from common.base import BaseModel
from common.models import Org, Profile, Teams
@ -134,7 +135,32 @@ def find_matching_rule(case, trigger_event: str = "pre_close"):
return candidates[0]
def close_refusal(case, *, status, closed_on, priority, case_type):
def closing_date(case, *, status, closed_on):
"""The ``closed_on`` a write should leave on ``case``.
``closed_on`` is the date the write would leave (the one it sends, else
the stored one). A write that moves the ticket into Closed without one is
dated today in the org's timezone, which `GetProfileAndOrg` activates for
the request, so ``timezone.localdate()`` is the org's day. A date the
caller sends always wins.
This is the one place a close is dated. Clients used to compute "today"
themselves, each with its own idea of the org's timezone, and one of them
could not date a close at all for an org stored under a legacy zone name
such as ``US/Eastern``. Every close path calls this: the serializer (POST,
PUT, PATCH, bulk and macro apply) and the board move.
Only the transition is dated: a ticket already Closed keeps whatever it
has, so an edit to an old closed ticket does not re-date it.
"""
if closed_on or status != "Closed":
return closed_on
if case is not None and case.status == "Closed":
return closed_on
return timezone.localdate()
def close_refusal(case, *, status, priority, case_type):
"""Why a write may not close ``case``, as ``{field: message}``, or ``None``.
The close gate, one rule for both API paths that close a single ticket:
@ -144,19 +170,20 @@ def close_refusal(case, *, status, closed_on, priority, case_type):
approval.
``case`` is the stored record, ``None`` on create; ``status``,
``closed_on``, ``priority`` and ``case_type`` are the values the write
would leave on it. Only the transition into Closed is judged, so a case
that is already Closed can be edited without re-approving. A rule matches
on priority, case_type and team, so it is evaluated against the incoming
values, or a caller could re-target the case out of the rule and close it
in the same request. ``case`` is restored either way; nothing is saved.
``priority`` and ``case_type`` are the values the write would leave on it.
Only the transition into Closed is judged, so a case that is already
Closed can be edited without re-approving. A rule matches on priority,
case_type and team, so it is evaluated against the incoming values, or a
caller could re-target the case out of the rule and close it in the same
request. ``case`` is restored either way; nothing is saved.
The closing date is not judged here: `closing_date` supplies one to a
close that sends none, so the only refusal left is the approval.
"""
if status != "Closed":
return None
if case is not None and case.status == "Closed":
return None
if not closed_on:
return {"closed_on": "Closed date is required when closing a case"}
if case is None:
return None

View file

@ -2,7 +2,6 @@
import uuid
from django.core.exceptions import ValidationError
from django.db import transaction
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
@ -11,12 +10,14 @@ from rest_framework.views import APIView
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, Profile, Tags
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": Profile, "tags": Tags}
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`,
@ -44,25 +45,24 @@ def _valid_ids(raw):
return ids
def _close_gate_outcome(case_id, exc):
"""Map a `Case.clean()` ValidationError to a per-record outcome.
def _refusal_outcome(case_id, errors):
"""Map a refused `update_case` to a per-record outcome.
Keyed on the message dict's field name, not its text, so wording changes do
not move a ticket into the wrong bucket. `status` is the approval error,
`closed_on` is the missing-date error, anything else is a generic invalid.
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.
"""
detail = (
exc.message_dict if hasattr(exc, "message_dict") else {"error": exc.messages}
)
if "status" in detail:
if "status" in errors:
return {
"id": case_id,
"status": "approval_required",
"detail": detail["status"],
"detail": errors["status"],
}
if "closed_on" in detail:
return {"id": case_id, "status": "closed_on_required"}
return {"id": case_id, "status": "invalid", "detail": detail}
return {"id": case_id, "status": "invalid", "detail": errors}
class BulkUpdateCasesView(APIView):
@ -82,7 +82,7 @@ class BulkUpdateCasesView(APIView):
status=status.HTTP_400_BAD_REQUEST,
)
unknown = set(fields) - ALLOWED_FIELDS - set(ALLOWED_M2M)
unknown = set(fields) - ALLOWED_FIELDS - ALLOWED_M2M
if unknown:
return Response(
{"error": True, "errors": f"Unsupported fields: {sorted(unknown)}"},
@ -125,8 +125,11 @@ class BulkUpdateCasesView(APIView):
status=status.HTTP_400_BAD_REQUEST,
)
scalar_updates = {k: v for k, v in fields.items() if k in ALLOWED_FIELDS}
m2m_updates = {k: v for k, v in fields.items() if k in ALLOWED_M2M}
# 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 = []
@ -158,39 +161,16 @@ class BulkUpdateCasesView(APIView):
}
)
continue
try:
# A savepoint per case, so a blocked close rolls back only
# itself and the rest of the batch still commits.
with transaction.atomic():
for k, v in scalar_updates.items():
setattr(case, k, v)
if scalar_updates:
# `Case.clean()` carries the close-transition guard: a
# `closed_on` is required and, where a pre_close
# ApprovalRule matches, a recorded approval is too. A
# raw `save()` skips it, which is how the bulk path
# let a case be closed with no approval; the
# single-case path runs the same rule through the
# serializer.
case.clean()
case.save()
for m2m_field, model in ALLOWED_M2M.items():
if m2m_field not in m2m_updates:
continue
related_ids = m2m_updates[m2m_field] or []
related = list(
model.objects.filter(pk__in=related_ids, org=org)
)
manager = getattr(case, m2m_field)
if m2m_field == "tags":
# Append: bulk-tagging must not wipe a ticket's
# other tags. Reassign (`assigned_to`) still
# replaces.
manager.add(*related)
else:
manager.set(related)
except ValidationError as exc:
results.append(_close_gate_outcome(str(case.pk), exc))
# 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

View file

@ -6,7 +6,6 @@ Supports both status-based (default) and custom pipeline-based kanban boards.
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
@ -18,7 +17,7 @@ from cases.access import (
lock_case_or_404,
visible_cases_qs,
)
from cases.approvals import close_refusal
from cases.approvals import close_refusal, closing_date
from cases.models import Case, CasePipeline, CaseStage
from cases.serializer import (
CaseKanbanCardSerializer,
@ -332,20 +331,17 @@ class CaseMoveView(APIView):
# A drag into Closed is a close, so it takes the gate PATCH takes and
# answers a refusal the same way, before anything is written. The board
# has no date field, so the closing date is today, as it is for the
# ticket page's quick status change. Moving back out of Closed needs
# nothing here: the pre_save signal clears `closed_on` and
# has no date field, so the close is dated by `closing_date`, the rule
# PATCH uses: today in the org's timezone. Moving back out of Closed
# needs nothing here: the pre_save signal clears `closed_on` and
# `resolved_at` on every save that leaves Closed, this one included.
closed_on = case.closed_on
if new_status == "Closed" and case.status != "Closed":
closed_on = timezone.localdate()
closed_on = closing_date(case, status=new_status, closed_on=case.closed_on)
refusal = (
merged_status_refusal(case, new_status)
or duplicate_refusal(case.status, new_status)
or close_refusal(
case,
status=new_status,
closed_on=closed_on,
priority=case.priority,
case_type=case.case_type,
)

View file

@ -195,13 +195,14 @@ class Case(AssignableMixin, BaseModel):
super().clean()
errors = {}
# Closed date required when status is Closed
# A Closed case carries a date. The API paths never leave one without
# it: `cases.approvals.closing_date` dates a close that sends none.
if self.status == "Closed" and not self.closed_on:
errors["closed_on"] = _("Closed date is required when closing a case")
# The close gate is `cases.approvals.close_refusal`, the rule the
# detail PUT/PATCH and the board move call too, so the bulk path that
# calls this method cannot drift from them. It judges the transition
# detail PUT/PATCH and the board move call too, so this method cannot
# drift from them. It judges the transition
# against the stored row: `self` already carries the incoming values.
# An unsaved case has no stored row, so it is judged as a create.
from cases.approvals import close_refusal
@ -210,7 +211,6 @@ class Case(AssignableMixin, BaseModel):
refusal = close_refusal(
stored,
status=self.status,
closed_on=self.closed_on,
priority=self.priority,
case_type=self.case_type,
)

View file

@ -24,7 +24,6 @@ Who may call them follows `cases.access`, the same as the ticket detail view:
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@ -37,7 +36,7 @@ from cases.access import (
has_case_write_access,
lock_case_or_404,
)
from cases.approvals import close_refusal
from cases.approvals import close_refusal, closing_date
from cases.models import Case
from cases.parent_guards import check_parent_link
from cases.workflow import MERGED_STATUS_LOCKED
@ -271,15 +270,16 @@ def _refused(errors):
)
def _close(case, today):
def _close(case):
"""Close one ticket the way the ordinary close path leaves it.
``closed_on`` is today. ``resolved_at`` is stamped by the
``case_pre_save_stamp_resolved_at`` signal on the transition into Closed,
and is listed in ``update_fields`` so that stamp is saved.
``closed_on`` comes from `closing_date`, the rule every close path uses.
``resolved_at`` is stamped by the ``case_pre_save_stamp_resolved_at``
signal on the transition into Closed, and is listed in ``update_fields``
so that stamp is saved.
"""
case.closed_on = closing_date(case, status="Closed", closed_on=case.closed_on)
case.status = "Closed"
case.closed_on = today
case.save(update_fields=["status", "closed_on", "resolved_at", "updated_at"])
@ -309,15 +309,12 @@ class CaseCloseWithChildrenView(APIView):
else:
cascade = bool(cascade_override)
today = timezone.localdate()
# Every check runs before anything is written, so a refusal leaves the
# whole tree as it was. The ticket itself takes the same close gate
# as a PATCH; `close_refusal` passes it when it is already Closed.
refusal = close_refusal(
case,
status="Closed",
closed_on=today,
priority=case.priority,
case_type=case.case_type,
)
@ -347,7 +344,6 @@ class CaseCloseWithChildrenView(APIView):
if close_refusal(
d,
status="Closed",
closed_on=today,
priority=d.priority,
case_type=d.case_type,
)
@ -360,11 +356,11 @@ class CaseCloseWithChildrenView(APIView):
)
if case.status != "Closed":
_close(case, today)
_close(case)
cascaded = []
for child in descendants:
_close(child, today)
_close(child)
_record(
child,
"PARENT_CLOSED_CASCADE",

View file

@ -9,7 +9,7 @@ from cases.access import (
has_case_write_access,
visible_cases_qs,
)
from cases.approvals import Approval, ApprovalRule, close_refusal
from cases.approvals import Approval, ApprovalRule, close_refusal, closing_date
from cases.models import (
Case,
CasePipeline,
@ -341,6 +341,12 @@ class CaseCreateSerializer(serializers.ModelSerializer):
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
@ -370,10 +376,16 @@ class CaseCreateSerializer(serializers.ModelSerializer):
if refusal:
raise serializers.ValidationError({"parent": refusal})
# A close that sends no date is dated today in the org's timezone;
# one the caller sends wins. 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,
closed_on=attrs.get("closed_on", getattr(self.instance, "closed_on", None)),
priority=attrs.get("priority", getattr(self.instance, "priority", None)),
case_type=attrs.get("case_type", getattr(self.instance, "case_type", None)),
)

View file

@ -260,10 +260,10 @@ def case_pre_save_stamp_resolved_at(sender, instance, **kwargs):
a reopened case is not still carrying the timestamp of its last life.
`closed_on` is cleared on the way out for the same reason, and for a second
one: the serializer's close gate requires a closing date, and it accepts a
date already on the record. A ticket reopened without clearing it would sit
one: a close keeps a date already on the record (`closing_date` dates only
a close that has none). A ticket reopened without clearing it would sit
there as status=New holding last month's closing date, and the next close
would satisfy the gate with it instead of a real one. The customer-reply
would keep it instead of being dated today. The customer-reply
reopen path in `_evaluate_reopen` has always cleared it; a status change
made by hand did not.
"""

View file

@ -38,6 +38,8 @@ answered the wrong thing. What was broken, in the order the classes appear:
breached and the escalation scan re-fired on tickets answered hours ago.
"""
import datetime
import pytest
from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
@ -305,14 +307,15 @@ class TestAccountLinkStaysInsideTheOrg:
@pytest.mark.django_db
class TestClosingACase:
def test_closed_on_is_required(self, admin_client, case_a):
def test_a_close_without_a_date_is_dated_by_the_server(self, admin_client, case_a):
"""The org-day cases are in `test_close_dated_by_server.py`."""
response = admin_client.patch(
_detail(case_a.id), {"status": "Closed"}, format="json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "closed_on" in response.json()["errors"]
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.status == "New"
assert case_a.status == "Closed"
assert case_a.closed_on is not None
def test_close_with_a_date_succeeds(self, admin_client, case_a):
response = admin_client.patch(
@ -427,12 +430,11 @@ class TestResolvedAtIsRecorded:
assert case_a.resolved_at is None
def test_reopening_clears_the_closing_date_too(self, admin_client, case_a):
"""Otherwise the next close satisfies the gate with a stale date.
"""Otherwise the next close keeps a stale date.
The gate accepts a `closed_on` already on the record, which is what
lets an edit to a closed ticket through. A ticket reopened while still
holding last month's closing date would then close again on that date
without anybody supplying one.
A close keeps a `closed_on` already on the record and dates only one
that has none. A ticket reopened while still holding last month's
closing date would then close again on that date.
"""
admin_client.patch(
_detail(case_a.id),
@ -443,11 +445,14 @@ class TestResolvedAtIsRecorded:
case_a.refresh_from_db()
assert case_a.closed_on is None
# And so closing it again has to name a date of its own.
refused = admin_client.patch(
# And so closing it again is dated today, not with the old date.
response = admin_client.patch(
_detail(case_a.id), {"status": "Closed"}, format="json"
)
assert refused.status_code == status.HTTP_400_BAD_REQUEST
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.closed_on is not None
assert case_a.closed_on != datetime.date(2026, 7, 29)
def test_a_second_edit_while_closed_does_not_move_it(self, admin_client, case_a):
admin_client.patch(

View file

@ -50,7 +50,7 @@ def _url(case):
class TestPutIsAFullReplace:
"""The verb the phone uses. Documented here so the shape is not a surprise."""
@patch("cases.views.send_email_to_assigned_user")
@patch("cases.updates.send_email_to_assigned_user")
def test_put_without_contacts_clears_them(
self, _email, admin_client, admin_user, org_a
):
@ -64,7 +64,7 @@ class TestPutIsAFullReplace:
assert response.status_code == 200
assert case.contacts.count() == 0
@patch("cases.views.send_email_to_assigned_user")
@patch("cases.updates.send_email_to_assigned_user")
def test_put_keeps_the_contacts_it_is_sent(
self, _email, admin_client, admin_user, org_a
):
@ -88,7 +88,7 @@ class TestPutIsAFullReplace:
class TestPatchLeavesUnsentFieldsAlone:
"""The verb the web app uses, and the one the phone now uses too."""
@patch("cases.views.send_email_to_assigned_user")
@patch("cases.updates.send_email_to_assigned_user")
def test_patch_without_contacts_keeps_them(
self, _email, admin_client, admin_user, org_a
):
@ -99,7 +99,7 @@ class TestPatchLeavesUnsentFieldsAlone:
assert response.status_code == 200
assert list(case.contacts.values_list("id", flat=True)) == [contact.id]
@patch("cases.views.send_email_to_assigned_user")
@patch("cases.updates.send_email_to_assigned_user")
def test_patch_with_an_empty_list_clears_them(
self, _email, admin_client, admin_user, org_a
):
@ -110,7 +110,7 @@ class TestPatchLeavesUnsentFieldsAlone:
assert response.status_code == 200
assert case.contacts.count() == 0
@patch("cases.views.send_email_to_assigned_user")
@patch("cases.updates.send_email_to_assigned_user")
def test_patch_without_teams_keeps_them(
self, _email, admin_client, admin_user, org_a
):
@ -123,35 +123,49 @@ class TestPatchLeavesUnsentFieldsAlone:
class TestAssignmentNotifiesOnBothVerbs:
@patch("cases.views.send_email_to_assigned_user")
@patch("cases.updates.send_email_to_assigned_user")
def test_put_emails_the_newly_assigned(
self, email, admin_client, admin_user, org_a, user_profile
self,
email,
admin_client,
admin_user,
org_a,
user_profile,
django_capture_on_commit_callbacks,
):
case = _case(org_a, admin_user)
admin_client.put(
_url(case),
{
"name": case.name,
"status": "New",
"priority": "Normal",
"assigned_to": [str(user_profile.id)],
},
format="json",
)
with django_capture_on_commit_callbacks(execute=True):
admin_client.put(
_url(case),
{
"name": case.name,
"status": "New",
"priority": "Normal",
"assigned_to": [str(user_profile.id)],
},
format="json",
)
assert email.delay.call_args[0][0] == [user_profile.id]
@patch("cases.views.send_email_to_assigned_user")
@patch("cases.updates.send_email_to_assigned_user")
def test_patch_emails_the_newly_assigned(
self, email, admin_client, admin_user, org_a, user_profile
self,
email,
admin_client,
admin_user,
org_a,
user_profile,
django_capture_on_commit_callbacks,
):
"""The half that did not exist. The web app assigns with PATCH."""
case = _case(org_a, admin_user)
admin_client.patch(
_url(case), {"assigned_to": [str(user_profile.id)]}, format="json"
)
with django_capture_on_commit_callbacks(execute=True):
admin_client.patch(
_url(case), {"assigned_to": [str(user_profile.id)]}, format="json"
)
assert email.delay.call_args[0][0] == [user_profile.id]
@patch("cases.views.send_email_to_assigned_user")
@patch("cases.updates.send_email_to_assigned_user")
def test_an_edit_that_changes_nobody_emails_nobody(
self, email, admin_client, admin_user, org_a, user_profile
):

View file

@ -1,5 +1,7 @@
"""Tests for the bulk update / bulk delete Case endpoints."""
from unittest.mock import patch
import pytest
from cases.approvals import ApprovalRule
@ -172,10 +174,9 @@ class TestBulkUpdateCasesAuthz:
class TestBulkUpdateCasesCloseGate:
"""Closing through the bulk path runs the same guard as the single case."""
def test_close_requires_closed_on(self, admin_client, case_a):
# Per-record outcomes mean a blocked close is now a 200 with a
# `closed_on_required` outcome for this ticket, not a request-level
# 400: see `TestBulkUpdatePerRecord.test_close_missing_date`.
def test_close_without_a_date_is_dated_by_the_server(self, admin_client, case_a):
# The server dates a close that sends none, today in the org's
# timezone; see `test_close_dated_by_server.py` for the org-day cases.
response = admin_client.post(
"/api/cases/bulk/update/",
{"ids": [str(case_a.pk)], "fields": {"status": "Closed"}},
@ -183,10 +184,11 @@ class TestBulkUpdateCasesCloseGate:
)
assert response.status_code == 200
body = response.json()
assert body["updated"] == 0
assert body["results"][0]["status"] == "closed_on_required"
assert body["updated"] == 1
assert body["results"][0]["status"] == "updated"
case_a.refresh_from_db()
assert case_a.status == "New"
assert case_a.status == "Closed"
assert case_a.closed_on is not None
def test_close_requires_approval_when_rule_matches(
self, admin_client, case_a, org_a
@ -268,17 +270,6 @@ class TestBulkUpdatePerRecord:
case_a.refresh_from_db()
assert case_a.status == "New"
def test_close_missing_date(self, admin_client, case_a):
response = admin_client.post(
"/api/cases/bulk/update/",
{"ids": [str(case_a.pk)], "fields": {"status": "Closed"}},
content_type="application/json",
)
assert response.status_code == 200
body = response.json()
assert body["results"][0]["status"] == "closed_on_required"
assert body["updated"] == 0
def test_tags_append_not_replace(self, admin_client, org_a, case_a):
from common.models import Tags
@ -412,3 +403,127 @@ class TestBulkDeletePerRecord:
assert by_id[str(case_a.pk)] == "no_access"
case_a.refresh_from_db()
assert case_a.is_active is True
@pytest.mark.django_db
class TestBulkUpdateSharesTheTicketWrite:
"""The bulk edit writes through `cases.updates.update_case`, the PATCH's
own path. Its private copy had drifted: it assigned deactivated people and
archived tags, and emailed nobody it assigned."""
def _member(self, org, email, active=True):
from common.models import Profile, User
user = User.objects.create_user(email=email, password="x")
return Profile.objects.create(user=user, org=org, role="USER", is_active=active)
def test_a_deactivated_member_is_not_assigned(self, admin_client, org_a, case_a):
live = self._member(org_a, "live@a.test")
gone = self._member(org_a, "gone@a.test", active=False)
with patch("cases.updates.send_email_to_assigned_user"):
response = admin_client.post(
"/api/cases/bulk/update/",
{
"ids": [str(case_a.pk)],
"fields": {"assigned_to": [str(live.id), str(gone.id)]},
},
content_type="application/json",
)
assert response.status_code == 200
assert list(case_a.assigned_to.all()) == [live]
def test_an_archived_tag_is_not_added(self, admin_client, org_a, case_a):
from common.models import Tags
live = Tags.objects.create(name="live", org=org_a)
old = Tags.objects.create(name="old", org=org_a, is_active=False)
response = admin_client.post(
"/api/cases/bulk/update/",
{"ids": [str(case_a.pk)], "fields": {"tags": [str(live.id), str(old.id)]}},
content_type="application/json",
)
assert response.status_code == 200
assert list(case_a.tags.values_list("name", flat=True)) == ["live"]
def test_only_the_newly_assigned_are_emailed(
self,
admin_client,
org_a,
case_a,
case_b_same_org,
django_capture_on_commit_callbacks,
):
kept = self._member(org_a, "kept@a.test")
added = self._member(org_a, "added@a.test")
case_a.assigned_to.add(kept)
with patch("cases.updates.send_email_to_assigned_user") as task:
with django_capture_on_commit_callbacks(execute=True):
response = admin_client.post(
"/api/cases/bulk/update/",
{
"ids": [str(case_a.pk), str(case_b_same_org.pk)],
"fields": {"assigned_to": [str(kept.id), str(added.id)]},
},
content_type="application/json",
)
assert response.status_code == 200
recipients = sorted(
(call.args[1], sorted(call.args[0])) for call in task.delay.call_args_list
)
assert recipients == sorted(
[
(case_a.pk, [added.id]),
(case_b_same_org.pk, sorted([kept.id, added.id])),
]
)
def test_the_email_is_queued_only_after_the_commit(
self, admin_client, org_a, case_a, django_capture_on_commit_callbacks
):
"""Queued inside the per-ticket `atomic()`, a worker could read the
ticket before its new assignees were committed."""
added = self._member(org_a, "added@a.test")
with patch("cases.updates.send_email_to_assigned_user") as task:
with django_capture_on_commit_callbacks(execute=False) as callbacks:
response = admin_client.post(
"/api/cases/bulk/update/",
{
"ids": [str(case_a.pk)],
"fields": {"assigned_to": [str(added.id)]},
},
content_type="application/json",
)
assert response.status_code == 200
task.delay.assert_not_called()
assert len(callbacks) == 1
callbacks[0]()
task.delay.assert_called_once_with([added.id], case_a.pk, str(org_a.id))
def test_a_malformed_assignee_id_is_400_before_any_write(
self, admin_client, case_a, case_b_same_org
):
response = admin_client.post(
"/api/cases/bulk/update/",
{
"ids": [str(case_a.pk), str(case_b_same_org.pk)],
"fields": {"priority": "Low", "assigned_to": ["not-a-uuid"]},
},
content_type="application/json",
)
assert response.status_code == 400
case_a.refresh_from_db()
assert case_a.priority == "High"
def test_a_malformed_closed_on_is_invalid_not_missing(self, admin_client, case_a):
response = admin_client.post(
"/api/cases/bulk/update/",
{
"ids": [str(case_a.pk)],
"fields": {"status": "Closed", "closed_on": "someday"},
},
content_type="application/json",
)
assert response.status_code == 200
assert response.json()["results"][0]["status"] == "invalid"
case_a.refresh_from_db()
assert case_a.status == "New"

View file

@ -0,0 +1,343 @@
"""A close that sends no `closed_on` is dated by the server, in the org's day.
Closing a ticket needs a date, and each client used to compute "today" itself:
the web from the org's zone through `Intl`, the phone by looking the zone up in
`/api/org/timezones/`. That list leaves out legacy names such as `US/Eastern`,
which `validate_iana_timezone` accepts and which an org may well be stored
under, so the phone refused to close any of that org's tickets. The server
already knows the org's day (`GetProfileAndOrg` activates its timezone), so
`cases.approvals.closing_date` dates the close there, for every close path.
The clock is frozen at instants where the UTC day and the org's day differ, so
a close dated in UTC fails these instead of passing for most of the day.
"""
import datetime
from unittest.mock import patch
import pytest
from rest_framework import status
from cases.approvals import Approval, ApprovalRule
from cases.models import Case
pytestmark = pytest.mark.django_db
# 23:30 UTC on 6 August is 05:00 on 7 August in Asia/Kolkata.
KOLKATA_AHEAD = datetime.datetime(2026, 8, 6, 23, 30, tzinfo=datetime.timezone.utc)
# 02:00 UTC on 7 August is 22:00 on 6 August in US/Eastern (EDT).
EASTERN_BEHIND = datetime.datetime(2026, 8, 7, 2, 0, tzinfo=datetime.timezone.utc)
@pytest.fixture
def frozen():
"""Freeze `timezone.now()`, which `timezone.localdate()` reads."""
def freeze(instant):
return patch("django.utils.timezone.now", return_value=instant)
return freeze
@pytest.fixture
def mailer():
with patch("cases.updates.send_email_to_assigned_user") as task:
yield task
def _in_zone(org, name):
org.timezone = name
org.save(update_fields=["timezone"])
def _detail(case):
return f"/api/cases/{case.pk}/"
class TestPatchClose:
def test_dated_in_the_org_day_not_utc(self, admin_client, case_a, org_a, frozen):
_in_zone(org_a, "Asia/Kolkata")
with frozen(KOLKATA_AHEAD):
resp = admin_client.patch(
_detail(case_a), {"status": "Closed"}, format="json"
)
assert resp.status_code == status.HTTP_200_OK, resp.content
case_a.refresh_from_db()
assert case_a.status == "Closed"
assert case_a.closed_on == datetime.date(2026, 8, 7)
def test_a_legacy_zone_name_is_dated_too(self, admin_client, case_a, org_a, frozen):
# The name the phone's zone list does not carry. Behind UTC here, so
# a UTC day would be the 7th.
_in_zone(org_a, "US/Eastern")
with frozen(EASTERN_BEHIND):
resp = admin_client.patch(
_detail(case_a), {"status": "Closed"}, format="json"
)
assert resp.status_code == status.HTTP_200_OK, resp.content
case_a.refresh_from_db()
assert case_a.closed_on == datetime.date(2026, 8, 6)
def test_an_explicit_null_is_dated_like_an_absent_one(
self, admin_client, case_a, org_a, frozen
):
_in_zone(org_a, "Asia/Kolkata")
with frozen(KOLKATA_AHEAD):
resp = admin_client.patch(
_detail(case_a), {"status": "Closed", "closed_on": None}, format="json"
)
assert resp.status_code == status.HTTP_200_OK, resp.content
case_a.refresh_from_db()
assert case_a.closed_on == datetime.date(2026, 8, 7)
def test_a_date_the_caller_sends_wins(self, admin_client, case_a, org_a, frozen):
_in_zone(org_a, "Asia/Kolkata")
with frozen(KOLKATA_AHEAD):
resp = admin_client.patch(
_detail(case_a),
{"status": "Closed", "closed_on": "2026-08-01"},
format="json",
)
assert resp.status_code == status.HTTP_200_OK, resp.content
case_a.refresh_from_db()
assert case_a.closed_on == datetime.date(2026, 8, 1)
def test_a_malformed_date_is_a_400_and_writes_nothing(self, admin_client, case_a):
resp = admin_client.patch(
_detail(case_a),
{"status": "Closed", "closed_on": "not-a-date"},
format="json",
)
assert resp.status_code == status.HTTP_400_BAD_REQUEST
assert "closed_on" in resp.json()["errors"]
case_a.refresh_from_db()
assert (case_a.status, case_a.closed_on) == ("New", None)
def test_the_approval_gate_still_refuses_and_writes_nothing(
self, admin_client, case_a, org_a
):
ApprovalRule.objects.create(
org=org_a, name="Close needs sign-off", trigger_event="pre_close"
)
resp = admin_client.patch(_detail(case_a), {"status": "Closed"}, format="json")
assert resp.status_code == status.HTTP_400_BAD_REQUEST
assert resp.json()["errors"]["status"] == [
"An approval is required before this case can be closed "
"(rule: Close needs sign-off)."
]
case_a.refresh_from_db()
assert (case_a.status, case_a.closed_on, case_a.resolved_at) == (
"New",
None,
None,
)
def test_an_approved_close_is_dated(
self, admin_client, case_a, org_a, admin_profile, frozen
):
rule = ApprovalRule.objects.create(
org=org_a, name="Close needs sign-off", trigger_event="pre_close"
)
Approval.objects.create(
org=org_a,
case=case_a,
rule=rule,
requested_by=admin_profile,
state="approved",
)
_in_zone(org_a, "Asia/Kolkata")
with frozen(KOLKATA_AHEAD):
resp = admin_client.patch(
_detail(case_a), {"status": "Closed"}, format="json"
)
assert resp.status_code == status.HTTP_200_OK, resp.content
case_a.refresh_from_db()
assert case_a.closed_on == datetime.date(2026, 8, 7)
def test_an_edit_to_a_closed_ticket_keeps_its_date(
self, admin_client, case_a, org_a, frozen
):
"""Only the transition into Closed is dated."""
Case.objects.filter(pk=case_a.pk).update(
status="Closed", closed_on=datetime.date(2026, 7, 1)
)
_in_zone(org_a, "Asia/Kolkata")
with frozen(KOLKATA_AHEAD):
resp = admin_client.patch(
_detail(case_a), {"priority": "Low"}, format="json"
)
assert resp.status_code == status.HTTP_200_OK, resp.content
case_a.refresh_from_db()
assert case_a.closed_on == datetime.date(2026, 7, 1)
def test_leaving_closed_still_clears_the_date(self, admin_client, case_a):
admin_client.patch(_detail(case_a), {"status": "Closed"}, format="json")
resp = admin_client.patch(_detail(case_a), {"status": "New"}, format="json")
assert resp.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.closed_on is None
class TestPutAndCreate:
def test_put_close_without_a_date(
self, admin_client, case_a, org_a, frozen, mailer
):
_in_zone(org_a, "Asia/Kolkata")
with frozen(KOLKATA_AHEAD):
resp = admin_client.put(
_detail(case_a),
{"name": case_a.name, "status": "Closed", "priority": "High"},
format="json",
)
assert resp.status_code == status.HTTP_200_OK, resp.content
case_a.refresh_from_db()
assert (case_a.status, case_a.closed_on) == (
"Closed",
datetime.date(2026, 8, 7),
)
def test_put_on_a_closed_ticket_without_a_date_keeps_it(
self, admin_client, case_a, org_a, mailer
):
# PUT used to pass `closed_on=None` for an absent key, which left a
# Closed ticket with no date at all.
Case.objects.filter(pk=case_a.pk).update(
status="Closed", closed_on=datetime.date(2026, 7, 1)
)
resp = admin_client.put(
_detail(case_a),
{"name": case_a.name, "status": "Closed", "priority": "Low"},
format="json",
)
assert resp.status_code == status.HTTP_200_OK, resp.content
case_a.refresh_from_db()
assert case_a.closed_on == datetime.date(2026, 7, 1)
def test_create_as_closed_without_a_date(self, admin_client, org_a, frozen, mailer):
_in_zone(org_a, "US/Eastern")
with frozen(EASTERN_BEHIND):
resp = admin_client.post(
"/api/cases/",
{"name": "Filed closed", "status": "Closed", "priority": "Low"},
format="json",
)
assert resp.status_code == status.HTTP_200_OK, resp.content
assert Case.objects.get(name="Filed closed").closed_on == datetime.date(
2026, 8, 6
)
class TestOtherClosePaths:
def test_kanban_move_is_dated_in_the_org_day(
self, admin_client, case_a, org_a, frozen
):
_in_zone(org_a, "US/Eastern")
with frozen(EASTERN_BEHIND):
resp = admin_client.patch(
f"/api/cases/{case_a.pk}/move/", {"status": "Closed"}, format="json"
)
assert resp.status_code == status.HTTP_200_OK, resp.content
case_a.refresh_from_db()
assert (case_a.status, case_a.closed_on) == (
"Closed",
datetime.date(2026, 8, 6),
)
def test_kanban_move_still_takes_the_approval_gate(
self, admin_client, case_a, org_a
):
ApprovalRule.objects.create(
org=org_a, name="Close needs sign-off", trigger_event="pre_close"
)
resp = admin_client.patch(
f"/api/cases/{case_a.pk}/move/", {"status": "Closed"}, format="json"
)
assert resp.status_code == status.HTTP_400_BAD_REQUEST
assert list(resp.json()["errors"]) == ["status"]
case_a.refresh_from_db()
assert (case_a.status, case_a.closed_on) == ("New", None)
def test_bulk_close_without_a_date(
self, admin_client, case_a, org_a, frozen, mailer
):
_in_zone(org_a, "Asia/Kolkata")
with frozen(KOLKATA_AHEAD):
resp = admin_client.post(
"/api/cases/bulk/update/",
{"ids": [str(case_a.pk)], "fields": {"status": "Closed"}},
content_type="application/json",
)
assert resp.status_code == status.HTTP_200_OK
assert resp.json()["results"] == [{"id": str(case_a.pk), "status": "updated"}]
case_a.refresh_from_db()
assert (case_a.status, case_a.closed_on) == (
"Closed",
datetime.date(2026, 8, 7),
)
def test_bulk_close_with_a_picked_date_keeps_it(self, admin_client, case_a, mailer):
resp = admin_client.post(
"/api/cases/bulk/update/",
{
"ids": [str(case_a.pk)],
"fields": {"status": "Closed", "closed_on": "2026-08-01"},
},
content_type="application/json",
)
assert resp.json()["updated"] == 1
case_a.refresh_from_db()
assert case_a.closed_on == datetime.date(2026, 8, 1)
def test_bulk_close_with_a_malformed_date_is_invalid(
self, admin_client, case_a, mailer
):
resp = admin_client.post(
"/api/cases/bulk/update/",
{
"ids": [str(case_a.pk)],
"fields": {"status": "Closed", "closed_on": "not-a-date"},
},
content_type="application/json",
)
assert resp.json()["results"][0]["status"] == "invalid"
case_a.refresh_from_db()
assert (case_a.status, case_a.closed_on) == ("New", None)
def test_macro_close_is_dated_in_the_org_day(
self, admin_client, case_a, org_a, frozen, mailer
):
from macros.models import Macro
macro = Macro.objects.create(
org=org_a, title="Wrap up", body="", scope="org", set_status="Closed"
)
_in_zone(org_a, "US/Eastern")
with frozen(EASTERN_BEHIND):
resp = admin_client.post(
f"/api/macros/{macro.pk}/apply/",
{"case_id": str(case_a.pk)},
format="json",
)
assert resp.status_code == status.HTTP_200_OK, resp.content
case_a.refresh_from_db()
assert (case_a.status, case_a.closed_on) == (
"Closed",
datetime.date(2026, 8, 6),
)
def test_close_with_children_is_dated_in_the_org_day(
self, admin_client, case_a, org_a, frozen
):
_in_zone(org_a, "Asia/Kolkata")
with frozen(KOLKATA_AHEAD):
resp = admin_client.post(
f"/api/cases/{case_a.pk}/close-with-children/",
{"cascade": False},
format="json",
)
assert resp.status_code == status.HTTP_200_OK, resp.content
case_a.refresh_from_db()
assert (case_a.status, case_a.closed_on) == (
"Closed",
datetime.date(2026, 8, 7),
)

137
backend/cases/updates.py Normal file
View file

@ -0,0 +1,137 @@
"""One path for changing a ticket that already exists.
The ticket PATCH (`CaseDetailView.patch`), a macro's actions
(`macros.views.MacroApplyView`) and the bulk edit
(`cases.bulk_views.BulkUpdateCasesView`) all write through `update_case`, so
each of them runs the same gates in the same order:
* `CaseCreateSerializer.validate`: the merged-ticket lock, the close gate
(the approval a matching `pre_close` rule asks for, with a close that sends
no `closed_on` dated today in the org's timezone) and Duplicate only by
merge;
* assignees and tags filtered to active rows in the caller's org;
* the email to whoever the write newly assigned.
The bulk edit used to carry its own copy of the write, and that copy had lost
the last two: it assigned deactivated people and archived tags, and nobody it
assigned was told.
Authorization is the caller's job and happens first: `get_case_or_404` (the
read rule, 404 for a ticket the caller may not open) and then the write rule
(`assert_case_write_access` or `has_case_write_access`).
"""
import json
from django.db import transaction
from cases.serializer import CaseCreateSerializer
from cases.tasks import send_email_to_assigned_user
from common.custom_fields import validate_payload as validate_custom_fields_payload
from common.models import Profile, Tags, Teams
from common.validators import payload_id_list
from contacts.access import replace_visible_contacts
def notify_newly_assigned(request, case, previous_assignee_ids):
"""Email whoever the edit just put on this ticket, and nobody else.
Shared by PUT and every `update_case` caller. It used to live only in PUT,
so the web app, which edits with PATCH, assigned people who were never
told. The phone edits with PUT and did notify them, which is how the two
clients came to disagree about whether assignment says anything to the
person assigned.
Queued once the surrounding transaction commits, never before: the bulk
edit writes each ticket inside its own `atomic()`, so a task queued inside
it could reach a worker before the new assignees are committed, and a
write that raises and rolls back would already have emailed. Outside a
transaction it is queued at once. `robust` so a broker outage is logged
rather than turning a saved edit into a 500.
"""
current = set(case.assigned_to.all().values_list("id", flat=True))
recipients = list(current - set(previous_assignee_ids))
if not recipients:
return
case_id = case.id
org_id = str(request.profile.org.id)
transaction.on_commit(
lambda: send_email_to_assigned_user.delay(recipients, case_id, org_id),
robust=True,
)
def update_case(request, case, data, *, append_tags=False):
"""Apply ``data`` to ``case`` as a partial update.
Returns ``None`` once saved, or the error dict a 400 carries, before
anything is written. Only the keys present in ``data`` change. ``tags``
replaces the ticket's tags unless ``append_tags`` is set, in which case it
adds to them (bulk tagging and macros must not wipe a ticket's other tags).
``assigned_to`` always replaces. A malformed id in ``contacts``, ``teams``,
``assigned_to`` or ``tags`` raises DRF's ``ValidationError`` (a 400).
"""
serializer = CaseCreateSerializer(
case,
data=data,
request_obj=request,
partial=True,
)
if not serializer.is_valid():
return serializer.errors
org = request.profile.org
previous_assignee_ids = list(case.assigned_to.all().values_list("id", flat=True))
# `closed_on` is not passed here: the validated data carries it, dated by
# the serializer when a close sends none, and a value passed to `save()`
# would override that date with the stored empty one.
save_kwargs = {
"case_type": data.get("case_type") if "case_type" in data else case.case_type,
}
if "custom_fields" in data:
cf_payload = data.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 {},
org,
existing=case.custom_fields or {},
)
if cf_errors:
return {"custom_fields": cf_errors}
save_kwargs["custom_fields"] = cleaned_cf
# Parsed before the first write, so a malformed id leaves the ticket as it
# was. An absent key parses to [].
contact_ids = payload_id_list(data.get("contacts"), "contacts")
team_ids = payload_id_list(data.get("teams"), "teams")
assigned_ids = payload_id_list(data.get("assigned_to"), "assigned_to")
tag_ids = payload_id_list(data.get("tags"), "tags")
case = serializer.save(**save_kwargs)
if "contacts" in data:
replace_visible_contacts(case.contacts, contact_ids, request.profile)
if "teams" in data:
case.teams.clear()
if team_ids:
case.teams.add(*Teams.objects.filter(id__in=team_ids, org=org))
if "assigned_to" in data:
case.assigned_to.clear()
if assigned_ids:
case.assigned_to.add(
*Profile.objects.filter(id__in=assigned_ids, org=org, is_active=True)
)
if "tags" in data:
if not append_tags:
case.tags.clear()
if tag_ids:
case.tags.add(*Tags.objects.filter(id__in=tag_ids, org=org, is_active=True))
notify_newly_assigned(request, case, previous_assignee_ids)
return None

View file

@ -45,6 +45,7 @@ from cases.serializer import (
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 (
@ -358,7 +359,6 @@ class CaseListView(APIView, LimitOffsetPagination):
cases_obj = serializer.save(
created_by=request.profile.user,
org=request.profile.org,
closed_on=params.get("closed_on"),
case_type=params.get("case_type"),
custom_fields=cleaned_cf,
)
@ -419,25 +419,6 @@ class CaseListView(APIView, LimitOffsetPagination):
)
def notify_newly_assigned(request, case, previous_assignee_ids):
"""Email whoever the edit just put on this ticket, and nobody else.
Shared by PUT and PATCH. It used to live only in PUT, so the web app,
which edits with PATCH, assigned people who were never told. The phone
edits with PUT and did notify them, which is how the two clients came to
disagree about whether assignment says anything to the person assigned.
"""
current = set(case.assigned_to.all().values_list("id", flat=True))
recipients = list(current - set(previous_assignee_ids))
if not recipients:
return
send_email_to_assigned_user.delay(
recipients,
case.id,
str(request.profile.org.id),
)
class CaseDetailView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext)
model = Case
@ -504,7 +485,6 @@ class CaseDetailView(APIView):
tag_ids = payload_id_list(params.get("tags"), "tags")
validate_attachment(self.request.FILES.get("case_attachment"))
cases_object = serializer.save(
closed_on=params.get("closed_on"),
case_type=params.get("case_type"),
custom_fields=cleaned_cf,
)
@ -858,98 +838,22 @@ class CaseDetailView(APIView):
},
)
def patch(self, request, pk, format=None):
"""Handle partial updates to a case."""
params = request.data
"""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)
serializer = CaseCreateSerializer(
cases_object,
data=params,
request_obj=request,
partial=True,
)
if serializer.is_valid():
previous_assigned_to_users = list(
cases_object.assigned_to.all().values_list("id", flat=True)
)
save_kwargs = {
"closed_on": (
params.get("closed_on")
if "closed_on" in params
else cases_object.closed_on
),
"case_type": (
params.get("case_type")
if "case_type" in params
else cases_object.case_type
),
}
if "custom_fields" in params:
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,
)
save_kwargs["custom_fields"] = cleaned_cf
# Parsed before the first write, as in PUT. An absent key parses to [].
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")
cases_object = serializer.save(**save_kwargs)
# Handle M2M fields if present in request
if "contacts" in params:
replace_visible_contacts(
cases_object.contacts, contact_ids, request.profile
)
if "teams" in params:
cases_object.teams.clear()
if team_ids:
teams = Teams.objects.filter(
id__in=team_ids, org=request.profile.org
)
cases_object.teams.add(*teams)
if "assigned_to" in params:
cases_object.assigned_to.clear()
if assigned_ids:
profiles = Profile.objects.filter(
id__in=assigned_ids, org=request.profile.org, is_active=True
)
cases_object.assigned_to.add(*profiles)
if "tags" in params:
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)
notify_newly_assigned(request, cases_object, previous_assigned_to_users)
errors = update_case(request, cases_object, request.data)
if errors:
return Response(
{"error": False, "message": "Case Updated Successfully"},
status=status.HTTP_200_OK,
{"error": True, "errors": errors},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
{"error": False, "message": "Case Updated Successfully"},
status=status.HTTP_200_OK,
)

View file

@ -7,9 +7,6 @@ Defines SLA defaults by priority.
# Terminal statuses
TERMINAL_STATUSES = {"Closed", "Rejected", "Duplicate"}
# Statuses that require closed_on date
CLOSED_DATE_REQUIRED_STATUSES = {"Closed"}
# Default SLA by priority (in hours)
DEFAULT_FIRST_RESPONSE_SLA = {
"Low": 24,

View file

@ -18,6 +18,7 @@ Usage:
import logging
from django.core.cache import cache
from django.db import models
from common.base import BaseModel
@ -25,6 +26,45 @@ from common.request_meta import client_ip
logger = logging.getLogger("security.audit")
# Failed sign-ins are written by anonymous callers into a table with no org and
# no RLS policy, so each client IP gets at most this many rows per hour. The
# attempts past it are refused exactly as before; they just leave no row.
LOGIN_FAILURE_ROWS_PER_IP_PER_HOUR = 20
_LOGIN_FAILURE_WINDOW_SECONDS = 60 * 60
# The width of an email address (RFC 5321), and of `User.email`.
_LOGIN_FAILURE_EMAIL_MAX_LENGTH = 254
def _login_failure_row_allowed(request):
"""Count one failed sign-in against the caller's IP and say whether it
still fits under `LOGIN_FAILURE_ROWS_PER_IP_PER_HOUR`.
The window starts at the first failure and is not extended by later ones.
Callers with no address we can vouch for share one bucket.
With the cache down the cap cannot be counted, so the row is skipped
rather than written uncapped: a flood during an outage must not reach
the unscoped audit table, and the refused sign-in must still answer its
own 400 rather than a 500. The warning names only the exception type.
"""
ip = client_ip(request) if request is not None else None
key = f"audit:login_failure:{ip or 'unknown'}"
try:
cache.add(key, 0, timeout=_LOGIN_FAILURE_WINDOW_SECONDS)
try:
count = cache.incr(key)
except ValueError:
# Evicted between add and incr: count this one as the first.
cache.add(key, 1, timeout=_LOGIN_FAILURE_WINDOW_SECONDS)
count = 1
except Exception as exc: # the backend's own error types vary (redis, memcached)
logger.warning(
"Login failure audit row skipped: cache unavailable (%s)",
type(exc).__name__,
)
return False
return count <= LOGIN_FAILURE_ROWS_PER_IP_PER_HOUR
class SecurityAuditLog(BaseModel):
"""
@ -176,10 +216,21 @@ class AuditLogger:
)
def login_failure(self, email, reason, request=None):
"""Log failed login attempt."""
"""Log a refused staff sign-in (see `common.views.auth_views`).
``reason`` is a short stable code. ``email`` is whatever the attempt
claimed, or empty when it named none; it is stored truncated and never
alongside the token or code that was tried. No org: the caller has
none yet, so an org's audit viewer never lists these rows. Rate-capped
per client IP by `_login_failure_row_allowed`.
"""
if not _login_failure_row_allowed(request):
return
from common.models import User
user = User.objects.filter(email=email).first()
email = (email or "")[:_LOGIN_FAILURE_EMAIL_MAX_LENGTH]
user = User.objects.filter(email=email).first() if email else None
self._log(
"LOGIN_FAILURE",

View file

@ -0,0 +1,105 @@
"""The iCalendar (RFC 5545) writer behind the per-user task feed (G14).
Hand-written because the output is small and fixed: one VCALENDAR of all-day
VEVENTs. No third-party library is installed for it and none is wanted.
This module imports nothing from Django's model layer, so
``common.public_tokens`` (the log and Sentry scrubber for the feed URL, whose
token is the whole credential) can import it while settings are still loading.
What an event carries is an owner decision, not a formatting choice: the task's
title and priority and a link back into the app, nothing else. No notes, and no
contact, account or other record names, because a subscribed calendar is read by
a third-party service (Google, Microsoft, Apple) on the user's behalf.
"""
import re
from datetime import timedelta
from datetime import timezone as dt_timezone
CRLF = "\r\n"
# RFC 5545 3.1: "Lines of text SHOULD NOT be longer than 75 octets, excluding
# the line break."
FOLD_OCTETS = 75
PRODID = "-//BottleCRM//Task calendar feed//EN"
CALENDAR_NAME = "BottleCRM tasks"
# Every control character except HTAB is illegal in a TEXT value (RFC 5545
# 3.3.11). Line breaks are turned into the `\n` escape before this runs.
_CONTROL = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
def escape_text(value):
"""Escape a TEXT property value: backslash, semicolon, comma, line breaks."""
value = (value or "").replace("\\", "\\\\")
value = value.replace(";", "\\;").replace(",", "\\,")
value = value.replace("\r\n", "\\n").replace("\r", "\\n").replace("\n", "\\n")
return _CONTROL.sub("", value)
def fold_line(line):
"""Fold one content line at 75 octets without splitting a UTF-8 character.
Continuation lines start with a single space, which counts toward their own
75, so they carry at most 74 octets of content.
"""
parts = []
current = ""
size = 0
for char in line:
width = len(char.encode("utf-8"))
if size + width > FOLD_OCTETS:
parts.append(current)
current = " "
size = 1
current += char
size += width
parts.append(current)
return CRLF.join(parts)
def _stamp(moment):
"""A UTC DATE-TIME, e.g. 20260927T101500Z. ``moment`` must be aware."""
return moment.astimezone(dt_timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def render_calendar(events):
"""Render ``events`` as a complete VCALENDAR string.
Each event is a dict with ``uid``, ``stamp`` (aware datetime), ``date`` (the
day it falls on), ``summary``, ``description`` and ``url``. Every event is
all-day: DTEND is the following day, which RFC 5545 treats as exclusive.
"""
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:{PRODID}",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
f"X-WR-CALNAME:{escape_text(CALENDAR_NAME)}",
]
for event in events:
day = event["date"]
lines += [
"BEGIN:VEVENT",
f"UID:{event['uid']}",
f"DTSTAMP:{_stamp(event['stamp'])}",
f"DTSTART;VALUE=DATE:{day.strftime('%Y%m%d')}",
f"DTEND;VALUE=DATE:{(day + timedelta(days=1)).strftime('%Y%m%d')}",
f"SUMMARY:{escape_text(event['summary'])}",
f"DESCRIPTION:{escape_text(event['description'])}",
f"URL:{event['url']}",
# A task is something to do that day, not a meeting: it should not
# mark the whole day busy for anyone checking free/busy.
"TRANSP:TRANSPARENT",
"END:VEVENT",
]
lines.append("END:VCALENDAR")
return "".join(fold_line(line) + CRLF for line in lines)
# The feed's path; the segment after it is the token, which
# `common.public_tokens` keeps out of the logs and out of Sentry.
FEED_PATH_PREFIX = "/api/public/calendar/"

View file

@ -341,10 +341,18 @@ def merge_records(keeper, loser):
``_meta`` rather than listed by hand, so one added later moves too;
`common/tests/test_duplicate_merge.py` pins the list so that a new one is
looked at, not just moved.
The loser's ``.deleted`` webhook payload is built here, before anything
moves, because its assignees may move to the keeper below and a snapshot
taken at the delete would then list none. `webhooks.signals` keeps a
snapshot that is already set.
"""
from common.models import Activity
from webhooks.emit import SPECS
model = type(keeper)
_prefix, build = SPECS[model._meta.label_lower]
loser._webhook_snapshot = build(loser)
for field in model._meta.many_to_many:
through = field.remote_field.through

View file

@ -1,4 +1,4 @@
"""Absolute links into the web app, for emails.
"""Absolute links into the web app, for emails, and to this API's public routes.
Every link that leaves this system in an email points at the SvelteKit frontend,
not at the API: ``/portal/invoice/<token>`` and ``/portal/estimate/<token>`` for
@ -39,3 +39,20 @@ def frontend_url(path):
base = (getattr(settings, "FRONTEND_URL", "") or "").rstrip("/")
suffix = path if path.startswith("/") else f"/{path}"
return f"{base}{suffix}"
def api_url(path):
"""Return an absolute URL to ``path`` on this API's public origin.
For URLs a person copies out of the app and uses elsewhere: the calendar
feed a calendar app subscribes to, and a web form's embed snippet pasted
onto a customer's site. They cannot come from the incoming request. The web
app asks for them from its own server, through ``PUBLIC_DJANGO_API_URL``,
which in Docker is ``http://backend:8000``, a host nobody outside can
reach; and behind a TLS terminator the request reads as ``http://`` unless
the proxy header is trusted. ``DOMAIN_NAME`` names the API's public origin
and is checked at startup outside dev (``crm/settings.py``).
"""
base = (getattr(settings, "DOMAIN_NAME", "") or "").rstrip("/")
suffix = path if path.startswith("/") else f"/{path}"
return f"{base}{suffix}"

View file

@ -165,6 +165,12 @@ class RequireOrgContext:
# read, like the web form entry above. Prefix-matched, so this covers
# `/api/public/help/<slug>/...` and nothing else under /api/public/.
"/api/public/help/",
# Task calendar feed (G14). Anonymous by design: a calendar app polls
# it with no credential but the token in the path. The view resolves
# the profile and org from the token (`calendar_feed_token` has no RLS
# policy, like `personal_access_token`) and sets the context before it
# reads `task`. See common/views/calendar_feed_views.py.
"/api/public/calendar/",
# Customer portal sign-in. Anonymous by design: the caller has no org
# claim yet, so the view takes the org from the URL and sets the RLS
# context itself before it reads `contacts`.

View file

@ -0,0 +1,92 @@
# Generated by Django 6.1.1 on 2026-09-26 18:51
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0046_securityauditlog_record_merged"),
]
operations = [
migrations.CreateModel(
name="CalendarFeedToken",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("token_hash", models.CharField(max_length=64, unique=True)),
("last_used_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"org",
models.ForeignKey(
help_text="Organization this record belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="common.org",
),
),
(
"profile",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="calendar_feed_token",
to="common.profile",
),
),
(
"updated_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
],
options={
"db_table": "calendar_feed_token",
"indexes": [
models.Index(
fields=["org", "-created_at"],
name="calendar_fe_org_id_627edf_idx",
)
],
},
),
]

View file

@ -10,7 +10,7 @@ import uuid
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db import models, transaction
from django.db.models.functions import Lower
from django.utils import timezone
from django.utils.timesince import timesince
@ -1117,6 +1117,64 @@ class PersonalAccessToken(BaseOrgModel):
return True
class CalendarFeedToken(BaseOrgModel):
"""The secret in one member's calendar subscription URL (G14).
A calendar app (Google Calendar, Outlook, Apple Calendar) polls
``/api/public/calendar/<token>.ics`` with no credential of its own, so the
URL is the credential. One per profile, which is one per org membership.
Only the SHA-256 of the raw token is stored; the raw value is returned once,
when it is issued, and cannot be read back.
Like ``personal_access_token``, this table is deliberately NOT under RLS: the
feed looks it up by ``token_hash`` before any tenant context exists, which is
how it learns the org to set. Management is filtered on
``profile=request.profile`` in ``common/views/calendar_feed_views.py``.
The token grants nothing by itself. Every fetch re-checks that the profile,
its user and its org are still active and reads tasks through
``tasks.access.visible_tasks_qs``, so a demotion or a removal takes effect
on the next poll.
"""
profile = models.OneToOneField(
"common.Profile",
on_delete=models.CASCADE,
related_name="calendar_feed_token",
)
token_hash = models.CharField(max_length=64, unique=True)
# Moved forward at most once an hour, so a calendar app polling every few
# minutes does not turn every fetch into a write.
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "calendar_feed_token"
indexes = [models.Index(fields=["org", "-created_at"])]
@staticmethod
def hash_token(raw):
return hashlib.sha256(raw.encode()).hexdigest()
@classmethod
def issue(cls, profile):
"""Give ``profile`` a new token, destroying any previous one.
Returns ``(raw, row)``. The old row is deleted in the same transaction,
so the old URL stops working the moment this commits. The profile row
is locked first so that two concurrent requests (a double-click on
Regenerate) queue up instead of both inserting and one hitting the
one-per-profile constraint.
"""
raw = f"bcrm_cal_{secrets.token_urlsafe(32)}"
with transaction.atomic():
Profile.objects.select_for_update().filter(pk=profile.pk).first()
cls.objects.filter(profile=profile).delete()
row = cls.objects.create(
org=profile.org, profile=profile, token_hash=cls.hash_token(raw)
)
return raw, row
class PortalAccessToken(models.Model):
"""Unscoped ``token_hash → org`` lookup for anonymous portal requests.

View file

@ -0,0 +1,68 @@
"""Credentials that travel in a public URL path, kept out of logs and Sentry.
Each prefix below is followed by a path segment that is itself the credential:
whoever holds the URL reads, and for an estimate accepts or declines, without
signing in. Anything that records a request's path (Django's `django.request`
warnings such as ``Not Found: <path>`` and ``Too Many Requests: <path>``, and
a Sentry event's request block, transaction, breadcrumbs and spans) would hand
that credential on. Sentry's event scrubber matches keys, not values inside a
URL, so it cannot catch this.
Imported by ``crm/server_settings.py`` and by ``LOGGING`` while settings load,
so it must not need Django.
"""
import logging
import re
from common.calendar_feed import FEED_PATH_PREFIX
TOKEN_PATH_PREFIXES = (
FEED_PATH_PREFIX, # the task calendar feed, /api/public/calendar/<token>.ics
"/api/public/csat/", # a satisfaction survey
"/api/public/invoice/", # an invoice's portal link, and its /pdf/
"/api/public/estimate/", # an estimate's, and its /pdf/, /accept/, /decline/
)
_TOKEN = re.compile(
"(" + "|".join(re.escape(prefix) for prefix in TOKEN_PATH_PREFIXES) + ")"
r"[^/?#\s\"']+"
)
def redact(value):
"""``value`` with every public token cut from it, walking containers."""
if isinstance(value, str):
return _TOKEN.sub(r"\1[Filtered]", value)
if isinstance(value, dict):
return {key: redact(item) for key, item in value.items()}
if isinstance(value, list):
return [redact(item) for item in value]
if isinstance(value, tuple):
return tuple(redact(item) for item in value)
return value
def scrub_public_tokens(event, hint):
"""Sentry ``before_send`` / ``before_send_transaction``: drop the tokens.
Walks the whole event rather than naming the fields a URL can appear in,
because that list belongs to the SDK and grows between releases.
"""
return redact(event)
class RedactPublicTokens(logging.Filter):
"""A logging filter that rewrites a record's message without the tokens.
The message is formatted once here and its arguments dropped, so no
handler downstream can format the raw path back in.
"""
def filter(self, record):
message = record.getMessage()
redacted = redact(message)
if redacted != message:
record.msg = redacted
record.args = None
return True

View file

@ -156,6 +156,10 @@ ORG_SCOPED_TABLES = [
# auth-bootstrap table (looked up by token_hash before any tenant context
# exists), mirroring the Org table. Isolation for token management is enforced
# by explicit org+profile filters in common/views/pat_views.py.
# NOTE: calendar_feed_token is intentionally NOT RLS-protected either, for
# the same reason: the public feed looks it up by token_hash to learn which
# org to set as the context. Management filters on org+profile in
# common/views/calendar_feed_views.py.
]
# Centralized RLS configuration

View file

@ -16,7 +16,11 @@ THE GRAMMAR
A scope is ``<resource>:<action>``.
``resource`` is an API root segment (the first path segment after ``/api/``)
or ``*`` for all of them. ``action`` is ``read`` or ``write``.
``read`` covers GET/HEAD/OPTIONS; ``write`` covers POST/PUT/PATCH/DELETE.
``read`` covers GET/HEAD/OPTIONS; ``write`` covers POST/PUT/PATCH/DELETE,
except a POST to one of the exact paths in ``READ_ONLY_POST_PATHS``, which
is ``read``. The action is decided by method and path together, never by
anything in the body. A path in ``CROSS_RESOURCE_PATHS`` acts on a second
resource's records and needs that resource's scope as well.
``write`` does NOT imply ``read``. A token that may create leads but not list
them is a coherent thing to want, and a scope whose name understates what it
@ -45,6 +49,8 @@ THE DENY-LIST IS NOT A SCOPE
entry that can never fire reads like a protection and is not one.
"""
import re
# Every first path segment under `api/` that the project serves. Hand-maintained
# so a change to what a token can reach shows up in a diff, and guarded by
# `test_api_resources_covers_the_live_urlconf`, which walks the real URLconf.
@ -117,6 +123,40 @@ WILDCARD = "*"
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
# POSTs that write nothing. The duplicate search on a record that is not saved
# yet (`common.views.duplicate_views.DuplicateCheckView`) takes an email, a
# phone number and a name, and takes them in a body only to keep them out of
# access logs, so it needs the resource's `read` scope. Matched exactly: a path
# with a segment more or less, or without its trailing slash, is a write.
READ_ONLY_POST_PATHS = frozenset(
{
"/api/leads/duplicates/",
"/api/contacts/duplicates/",
"/api/accounts/duplicates/",
}
)
# The macro endpoints that act on a ticket. The first path segment scopes them
# as `macros`, which alone let a token holding only `macros:write` change a
# ticket it has no `cases:write` for (create a personal macro, then apply it),
# and let a macros token read a ticket's subject and its contact's name and
# email through render. Render writes no record (it bumps the macro's usage
# counter), so like the duplicate search it is a read. The id segment matches
# whatever the `<uid:pk>` converter routes (any one segment, see
# `common.converters`), not only the canonical UUID spelling, so an id written
# as bare hex or in braces cannot route around the rule. Matched in full.
_MACRO_BY_ID = r"/api/macros/[^/]+/"
MACRO_RENDER_PATH = re.compile(_MACRO_BY_ID + "render/")
MACRO_APPLY_PATH = re.compile(_MACRO_BY_ID + "apply/")
READ_ONLY_POST_PATTERNS = (MACRO_RENDER_PATH,)
# Paths that also need a second resource's scope, on top of their own.
CROSS_RESOURCE_PATHS = (
(MACRO_RENDER_PATH, "cases:read"),
(MACRO_APPLY_PATH, "cases:write"),
)
# Read or write access to a credential, from a credential. See the module
# docstring: no scope opens these, and neither does an empty scope list.
CREDENTIAL_PATHS = (
@ -132,6 +172,10 @@ CREDENTIAL_PATHS = (
# refused. Read from a signed-in admin session only, like webhooks: a
# leaked token should not also reveal what the org has noticed about it.
"/api/org/audit-log/",
# The task calendar feed URL is itself a credential: it reads the member's
# tasks with no other check, and it keeps working after the token that
# minted it is revoked. Managed from a signed-in session only.
"/api/profile/calendar-feed/",
)
# What the organization API key is worth once this module is enforcing. It reads,
@ -174,8 +218,38 @@ def resource_for_path(path):
return segment or None
def action_for_method(method):
return "read" if (method or "").upper() in SAFE_METHODS else "write"
def action_for(method, path):
"""``read`` or ``write`` for this request; see THE GRAMMAR above."""
method = (method or "").upper()
if method in SAFE_METHODS:
return "read"
if method == "POST" and (
path in READ_ONLY_POST_PATHS
or any(pattern.fullmatch(path) for pattern in READ_ONLY_POST_PATTERNS)
):
return "read"
return "write"
def required_scopes(method, path):
"""Every scope this request needs, or ``None`` when no scope can match it."""
resource = resource_for_path(path)
if resource is None:
return None
needed = [f"{resource}:{action_for(method, path)}"]
needed += [
scope for pattern, scope in CROSS_RESOURCE_PATHS if pattern.fullmatch(path)
]
return needed
def _first_missing(held, needed):
"""The first scope in ``needed`` that ``held`` does not grant, else ``None``."""
for scope in needed:
action = scope.split(":")[1]
if scope not in held and f"{WILDCARD}:{action}" not in held:
return scope
return None
def _parsed(scopes):
@ -203,11 +277,10 @@ def scopes_allow(scopes, method, path):
held = _parsed(scopes)
if not held:
return False
resource = resource_for_path(path)
if resource is None:
needed = required_scopes(method, path)
if needed is None:
return False
action = action_for_method(method)
return f"{resource}:{action}" in held or f"{WILDCARD}:{action}" in held
return _first_missing(held, needed) is None
def credential_path_denial(path):
@ -229,7 +302,16 @@ def check_request(scopes, method, path):
if denial is not None:
return denial
if not scopes_allow(scopes, method, path):
resource = resource_for_path(path) or "this endpoint"
action = action_for_method(method)
return f"This token is not scoped for {action} access to {resource}."
needed = required_scopes(method, path)
if needed is None:
action = action_for(method, path)
return f"This token is not scoped for {action} access to this endpoint."
# `scopes_allow` refused, so some needed scope is missing, or every
# stored scope was unparseable and the first one needed is named.
scope = _first_missing(_parsed(scopes), needed)
resource, action = scope.split(":")
return (
f"This token is not scoped for {action} access to {resource}. "
f"It needs the {scope} scope."
)
return None

View file

@ -0,0 +1,533 @@
"""The per-user task calendar feed (G14): management API and the public feed.
What these pin:
* only the token's hash is stored, and the URL is shown once;
* regenerating or disabling kills the old URL, and every kind of miss is the
same 404 (malformed, unknown, disabled, regenerated, inactive member, user or
org, removed membership);
* the feed shows the member's own tasks (created or assigned), for admins too,
never more than `visible_tasks_qs` at fetch time, in their org only, open and
dated, in the window, capped to the tasks nearest today;
* the URL is built from `DOMAIN_NAME`, never from the host that asked;
* an event carries the title, the priority and an app link, and nothing else;
* no personal access token and not the org API key can reach the management
endpoint, while a signed-in session can.
"""
import datetime as dt
import hashlib
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
import pytest
from django.core.cache import cache
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.models import Account
from common.models import CalendarFeedToken, Org, PersonalAccessToken, Profile, User
from common.testing import clear_rls_context, rls_org
from common.views import calendar_feed_views
from common.views.calendar_feed_views import (
CalendarFeedIPThrottle,
CalendarFeedTokenThrottle,
PublicCalendarFeedView,
)
from contacts.models import Contact
from tasks.models import Task
MANAGE = "/api/profile/calendar-feed/"
APP = "https://app.example.com"
@pytest.fixture(autouse=True)
def _fresh_throttle():
cache.clear()
yield
cache.clear()
@pytest.fixture
def anon():
return APIClient()
def _task(org, title, due, status="New", priority="Medium", creator=None, to=()):
with rls_org(org):
task = Task.objects.create(
title=title, status=status, priority=priority, due_date=due, org=org
)
if creator is not None:
Task.objects.filter(pk=task.pk).update(created_by=creator)
for profile in to:
task.assigned_to.add(profile)
return task
def _enable(client):
resp = client.post(MANAGE)
assert resp.status_code == 201, resp.content
return resp.json()["url"]
def _path(url):
return urlparse(url).path
def _raw(url):
return _path(url).rsplit("/", 1)[1].removesuffix(".ics")
def _today():
return timezone.localdate()
def _feed(anon, url, **extra):
resp = anon.get(_path(url), **extra)
assert resp.status_code == 200, resp.content
return resp.content.decode("utf-8").replace("\r\n ", "")
def _pat_client(profile):
raw, _ = PersonalAccessToken.generate(profile=profile, name="cli")
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {raw}")
return client
def _key_client(org):
client = APIClient()
client.credentials(HTTP_TOKEN=org.api_key)
return client
class TestManagement:
def test_disabled_by_default(self, user_client):
body = user_client.get(MANAGE).json()
assert body["enabled"] is False
assert body["created_at"] is None
assert "url" not in body
def test_enable_shows_the_url_once_and_stores_only_the_hash(
self, user_client, user_profile
):
url = _enable(user_client)
raw = _raw(url)
assert url.startswith("https://api.example.com/api/public/calendar/")
assert url.endswith(".ics")
assert raw.startswith("bcrm_cal_") and len(raw) > 40
row = CalendarFeedToken.objects.get(profile=user_profile)
assert row.token_hash == hashlib.sha256(raw.encode()).hexdigest()
assert row.org_id == user_profile.org_id
assert raw not in repr(CalendarFeedToken.objects.values().get())
again = user_client.get(MANAGE).json()
assert again["enabled"] is True and again["created_at"]
assert "url" not in again
assert raw not in str(again)
def test_regenerate_kills_the_old_url(self, user_client, user_profile, anon):
old = _enable(user_client)
new = _enable(user_client)
assert old != new
assert anon.get(_path(old)).status_code == 404
assert anon.get(_path(new)).status_code == 200
assert CalendarFeedToken.objects.filter(profile=user_profile).count() == 1
def test_disable_kills_the_url_and_is_idempotent(self, user_client, anon):
url = _enable(user_client)
assert user_client.delete(MANAGE).json()["enabled"] is False
assert user_client.delete(MANAGE).status_code == 200
assert user_client.get(MANAGE).json()["enabled"] is False
assert anon.get(_path(url)).status_code == 404
def test_each_member_manages_only_their_own(
self, user_client, admin_client, user_profile, admin_profile, anon
):
mine = _enable(user_client)
assert admin_client.get(MANAGE).json()["enabled"] is False
admin_client.delete(MANAGE)
assert anon.get(_path(mine)).status_code == 200
assert CalendarFeedToken.objects.filter(profile=user_profile).exists()
def test_anonymous_is_refused(self, anon):
assert anon.get(MANAGE).status_code in (401, 403)
assert anon.post(MANAGE).status_code in (401, 403)
assert not CalendarFeedToken.objects.exists()
class TestCredentialsCannotManageTheFeed:
"""The URL is a credential, so no credential may read or mint it.
Each refusal is paired with the signed-in session succeeding, so the gate is
proved to open as well as to close.
"""
def test_session_can(self, admin_client):
assert admin_client.get(MANAGE).status_code == 200
assert admin_client.post(MANAGE).status_code == 201
assert admin_client.delete(MANAGE).status_code == 200
def test_personal_access_token_cannot(self, admin_profile):
client = _pat_client(admin_profile)
assert client.get(MANAGE).status_code == 403
assert client.post(MANAGE).status_code == 403
assert not CalendarFeedToken.objects.exists()
def test_personal_access_token_cannot_disable(self, admin_client, admin_profile):
_enable(admin_client)
assert _pat_client(admin_profile).delete(MANAGE).status_code == 403
assert CalendarFeedToken.objects.filter(profile=admin_profile).exists()
def test_org_api_key_cannot(self, org_a, admin_profile):
client = _key_client(org_a)
assert client.get(MANAGE).status_code == 403
assert client.post(MANAGE).status_code == 403
assert not CalendarFeedToken.objects.exists()
class TestEveryMissIsTheSame404:
def _misses(self, user_client):
first = _enable(user_client)
second = _enable(user_client) # `first` is now a regenerated URL
return [
"/api/public/calendar/x.ics",
"/api/public/calendar/bcrm_cal_" + "A" * 43 + ".ics",
_path(first),
], second
def test_malformed_unknown_and_regenerated(self, user_client, anon):
misses, _ = self._misses(user_client)
responses = [anon.get(path) for path in misses]
assert {r.status_code for r in responses} == {404}
assert len({r.content for r in responses}) == 1
def test_disabled_answers_the_same_body(self, user_client, anon):
unknown = anon.get("/api/public/calendar/nope.ics")
url = _enable(user_client)
user_client.delete(MANAGE)
disabled = anon.get(_path(url))
assert (disabled.status_code, disabled.content) == (404, unknown.content)
@pytest.mark.parametrize("what", ["profile", "user", "org", "membership"])
def test_an_inactive_or_removed_member_is_the_same_404(
self, what, user_client, user_profile, anon
):
url = _enable(user_client)
assert anon.get(_path(url)).status_code == 200
unknown = anon.get("/api/public/calendar/nope.ics")
if what == "profile":
Profile.objects.filter(pk=user_profile.pk).update(is_active=False)
elif what == "user":
User.objects.filter(pk=user_profile.user_id).update(is_active=False)
elif what == "org":
Org.objects.filter(pk=user_profile.org_id).update(is_active=False)
else:
user_profile.delete()
resp = anon.get(_path(url))
assert (resp.status_code, resp.content) == (404, unknown.content)
def test_reactivation_brings_the_same_url_back(
self, user_client, user_profile, anon
):
"""The check is live on every fetch rather than a one-way kill switch."""
url = _enable(user_client)
Profile.objects.filter(pk=user_profile.pk).update(is_active=False)
assert anon.get(_path(url)).status_code == 404
Profile.objects.filter(pk=user_profile.pk).update(is_active=True)
assert anon.get(_path(url)).status_code == 200
class TestWhatTheFeedShows:
@pytest.fixture(autouse=True)
def _app_url(self, settings):
settings.FRONTEND_URL = APP
def test_member_sees_created_and_assigned_only(
self, org_a, user_client, user_profile, regular_user, admin_user, anon
):
soon = _today() + dt.timedelta(days=3)
_task(org_a, "Mine by creation", soon, creator=regular_user)
_task(org_a, "Mine by assignment", soon, creator=admin_user, to=[user_profile])
_task(org_a, "Someone else's", soon, creator=admin_user)
body = _feed(anon, _enable(user_client))
assert "SUMMARY:Mine by creation" in body
assert "SUMMARY:Mine by assignment" in body
assert "Someone else's" not in body
def test_admin_sees_only_their_own_tasks(
self, org_a, admin_client, admin_user, admin_profile, regular_user, anon
):
"""An admin may open every task, but the feed is theirs, not the org's."""
_task(org_a, "Admin made", _today(), creator=admin_user)
_task(org_a, "Admin handed", _today(), creator=regular_user, to=[admin_profile])
_task(org_a, "A colleague's", _today(), creator=regular_user)
body = _feed(anon, _enable(admin_client))
assert "SUMMARY:Admin made" in body
assert "SUMMARY:Admin handed" in body
assert "A colleague's" not in body
def test_a_task_assigned_twice_appears_once(
self, org_a, admin_client, admin_user, admin_profile, user_profile, anon
):
_task(
org_a,
"Shared",
_today(),
creator=admin_user,
to=[admin_profile, user_profile],
)
assert _feed(anon, _enable(admin_client)).count("SUMMARY:Shared") == 1
def test_demotion_changes_nothing_and_unassignment_applies_next_fetch(
self, org_a, admin_client, admin_profile, regular_user, anon
):
task = _task(
org_a, "Handed over", _today(), creator=regular_user, to=[admin_profile]
)
_task(org_a, "Not yours", _today(), creator=regular_user)
url = _enable(admin_client)
admin_profile.role = "USER"
admin_profile.save()
body = _feed(anon, url)
assert "Handed over" in body and "Not yours" not in body
with rls_org(org_a):
task.assigned_to.remove(admin_profile)
assert "Handed over" not in _feed(anon, url)
def test_another_orgs_tasks_never_appear(
self, org_a, org_b, admin_client, admin_user, regular_user, anon
):
"""Same user, a membership in each org: each feed is its own org."""
other = Profile.objects.create(
user=regular_user, org=org_b, role="ADMIN", is_active=True
)
_task(org_a, "Org A task", _today(), creator=admin_user)
_task(org_b, "Org B task", _today(), to=[other])
body_a = _feed(anon, _enable(admin_client))
assert "Org A task" in body_a and "Org B task" not in body_a
raw, _ = CalendarFeedToken.issue(other)
body_b = _feed(anon, f"/api/public/calendar/{raw}.ics")
assert "Org B task" in body_b and "Org A task" not in body_b
def test_completed_and_undated_are_left_out(
self, org_a, admin_client, admin_user, anon
):
_task(org_a, "Open one", _today(), creator=admin_user)
_task(org_a, "Started one", _today(), status="In Progress", creator=admin_user)
_task(org_a, "Done one", _today(), status="Completed", creator=admin_user)
_task(org_a, "Someday", None, creator=admin_user)
body = _feed(anon, _enable(admin_client))
assert "Open one" in body and "Started one" in body
assert "Done one" not in body and "Someday" not in body
def test_window_is_org_local_today(
self, org_a, admin_client, admin_user, anon, monkeypatch
):
"""12:00 UTC on 27 Sep is already 28 Sep in Kiritimati (UTC+14).
A window computed from the server's UTC day would drop the last day at
the far end, which is what the `+365` row checks.
"""
org_a.timezone = "Pacific/Kiritimati"
org_a.save()
url = _enable(admin_client)
fixed = dt.datetime(2026, 9, 27, 12, 0, tzinfo=dt.timezone.utc)
monkeypatch.setattr(timezone, "now", lambda: fixed)
local_today = fixed.astimezone(ZoneInfo("Pacific/Kiritimati")).date()
assert local_today == dt.date(2026, 9, 28)
for days, title in [
(-91, "Too old"),
(-90, "Oldest kept"),
(365, "Furthest kept"),
(366, "Too far"),
]:
_task(
org_a, title, local_today + dt.timedelta(days=days), creator=admin_user
)
body = _feed(anon, url)
assert "Oldest kept" in body and "Furthest kept" in body
assert "Too old" not in body and "Too far" not in body
def test_capped_and_ordered_by_due_date(
self, org_a, admin_client, admin_user, anon, monkeypatch
):
monkeypatch.setattr(calendar_feed_views, "MAX_EVENTS", 3)
for n in (5, 1, 4, 2, 3):
_task(
org_a, f"Day {n}", _today() + dt.timedelta(days=n), creator=admin_user
)
body = _feed(anon, _enable(admin_client))
assert body.count("BEGIN:VEVENT") == 3
assert body.index("Day 1") < body.index("Day 2") < body.index("Day 3")
assert "Day 4" not in body and "Day 5" not in body
def test_the_cap_keeps_upcoming_before_overdue(
self, org_a, admin_client, admin_user, anon, monkeypatch
):
"""Old overdue work must not push today and next week off the calendar."""
monkeypatch.setattr(calendar_feed_views, "MAX_EVENTS", 4)
for n in (80, 60, 40, 20, 2, 1):
_task(
org_a, f"Late {n}", _today() - dt.timedelta(days=n), creator=admin_user
)
for n in (0, 7):
_task(
org_a, f"Due {n}", _today() + dt.timedelta(days=n), creator=admin_user
)
body = _feed(anon, _enable(admin_client))
assert body.count("BEGIN:VEVENT") == 4
# Upcoming soonest first, then overdue most recent first.
assert "Due 0" in body and "Due 7" in body
assert "Late 1" in body and "Late 2" in body
for dropped in ("Late 20", "Late 40", "Late 60", "Late 80"):
assert dropped not in body
def test_a_full_cap_of_upcoming_leaves_no_room_for_overdue(
self, org_a, admin_client, admin_user, anon, monkeypatch
):
monkeypatch.setattr(calendar_feed_views, "MAX_EVENTS", 2)
_task(org_a, "Late 1", _today() - dt.timedelta(days=1), creator=admin_user)
for n in (0, 1, 2):
_task(
org_a, f"Due {n}", _today() + dt.timedelta(days=n), creator=admin_user
)
body = _feed(anon, _enable(admin_client))
assert body.count("BEGIN:VEVENT") == 2
assert "Due 0" in body and "Due 1" in body
assert "Due 2" not in body and "Late 1" not in body
def test_the_url_ignores_the_host_that_asked(self, user_client, settings):
"""The web app asks from its server, as `http://backend:8000` in Docker.
The URL a member pastes into a calendar app has to name the public
origin, whatever host and scheme the request arrived on.
"""
settings.ALLOWED_HOSTS = ["*"]
settings.DOMAIN_NAME = "https://api.crm.example.org"
resp = user_client.post(MANAGE, HTTP_HOST="backend:8000")
assert resp.status_code == 201, resp.content
url = resp.json()["url"]
assert url.startswith("https://api.crm.example.org/api/public/calendar/")
assert "backend" not in url
def test_event_carries_title_priority_and_link_and_nothing_else(
self, org_a, admin_client, admin_user, anon
):
account = Account.objects.create(name="Umbrella Holdings", org=org_a)
contact = Contact.objects.create(
first_name="Wilhelmina", last_name="Quarrie", org=org_a
)
task = _task(
org_a, "Quarterly review", _today(), priority="High", creator=admin_user
)
with rls_org(org_a):
Task.objects.filter(pk=task.pk).update(
description="Secret notes about pricing", account=account
)
task.contacts.add(contact)
body = _feed(anon, _enable(admin_client))
link = f"{APP}/tasks/{task.id}"
assert "SUMMARY:Quarterly review\r\n" in body
assert f"DESCRIPTION:Priority: High\\n{link}\r\n" in body
assert f"URL:{link}\r\n" in body
assert f"UID:task-{task.id}@bottlecrm\r\n" in body
today = _today()
assert f"DTSTART;VALUE=DATE:{today:%Y%m%d}\r\n" in body
assert f"DTEND;VALUE=DATE:{today + dt.timedelta(days=1):%Y%m%d}\r\n" in body
for leaked in ("Secret notes", "Umbrella", "Wilhelmina", "Quarrie"):
assert leaked not in body
def test_headers(self, admin_client, anon):
url = _enable(admin_client)
resp = anon.get(_path(url), HTTP_ACCEPT="text/calendar")
assert resp.status_code == 200
assert resp["Content-Type"] == "text/calendar; charset=utf-8"
assert resp["Cache-Control"] == "private, max-age=300"
assert resp["X-Robots-Tag"] == "noindex"
def test_a_calendar_accept_header_still_gets_the_json_404(self, anon):
resp = anon.get("/api/public/calendar/nope.ics", HTTP_ACCEPT="text/calendar")
assert resp.status_code == 404
assert resp.json() == {"error": "Not found"}
def test_a_staff_credential_sent_along_changes_nothing(
self, org_a, user_client, admin_user, anon
):
"""The feed answers for the token's member, never for the caller."""
_task(org_a, "Admin only", _today(), creator=admin_user)
url = _enable(user_client)
staff = APIClient()
staff.credentials(HTTP_AUTHORIZATION="Bearer not-a-real-jwt")
assert "Admin only" not in _feed(staff, url)
def test_last_used_is_written_at_most_hourly(self, user_client, anon):
url = _enable(user_client)
_feed(anon, url)
first = CalendarFeedToken.objects.get().last_used_at
assert first is not None
_feed(anon, url)
assert CalendarFeedToken.objects.get().last_used_at == first
CalendarFeedToken.objects.update(last_used_at=first - dt.timedelta(hours=2))
_feed(anon, url)
assert CalendarFeedToken.objects.get().last_used_at > first
class TestThrottle:
def test_the_feed_is_throttled_and_takes_no_credential(self):
assert PublicCalendarFeedView.throttle_classes == [
CalendarFeedIPThrottle,
CalendarFeedTokenThrottle,
]
assert PublicCalendarFeedView.authentication_classes == []
def test_per_token_limit(self, user_client, admin_client, anon, monkeypatch):
monkeypatch.setattr(
CalendarFeedTokenThrottle,
"THROTTLE_RATES",
{"calendar_feed_token": "2/hour"},
)
url = _enable(user_client)
codes = [
anon.get(_path(url), REMOTE_ADDR=f"198.51.100.{n}").status_code
for n in range(1, 4)
]
assert codes == [200, 200, 429]
# Another member's feed has its own bucket.
assert anon.get(_path(_enable(admin_client))).status_code == 200
def test_per_address_limit(self, user_client, anon, monkeypatch):
monkeypatch.setattr(
CalendarFeedIPThrottle, "THROTTLE_RATES", {"calendar_feed_ip": "2/hour"}
)
url = _enable(user_client)
codes = [anon.get("/api/public/calendar/nope.ics").status_code]
codes.append(anon.get(_path(url)).status_code)
codes.append(anon.get(_path(url)).status_code)
assert codes == [404, 200, 429]
other = anon.get(_path(url), REMOTE_ADDR="198.51.100.9")
assert other.status_code == 200
def test_the_raw_token_is_not_the_cache_key(self, user_client, anon):
raw = _raw(_enable(user_client))
request = type("R", (), {})()
view = type("V", (), {"kwargs": {"token": raw}})()
key = CalendarFeedTokenThrottle().get_cache_key(request, view)
assert raw not in key
@pytest.mark.postgres_only
def test_feed_reads_under_the_tokens_org_context(org_a, admin_client, admin_user, anon):
"""Under a non-superuser role an empty context returns zero tasks, so a
listed task proves the view set the context from the token's org."""
from django.db import connection
if connection.vendor != "postgresql":
pytest.skip("RLS requires PostgreSQL")
_task(org_a, "Visible under RLS", _today(), creator=admin_user)
url = _enable(admin_client)
clear_rls_context()
assert "Visible under RLS" in _feed(anon, url)

View file

@ -0,0 +1,119 @@
"""The hand-written RFC 5545 writer behind the task calendar feed.
Pure functions, no database: `common.calendar_feed` is imported by
`common.public_tokens` while settings load, so it must not need Django. The
scrubber for the feed URL is tested in `test_public_tokens.py`.
"""
from datetime import date, datetime
from datetime import timezone as dt_timezone
from zoneinfo import ZoneInfo
from common.calendar_feed import escape_text, fold_line, render_calendar
def _unfold(text):
return text.replace("\r\n ", "")
def _event(**overrides):
event = {
"uid": "task-1@bottlecrm",
"stamp": datetime(2026, 9, 27, 10, 15, tzinfo=dt_timezone.utc),
"date": date(2026, 9, 30),
"summary": "Call back",
"description": "Priority: High\nhttps://app.example.com/tasks/1",
"url": "https://app.example.com/tasks/1",
}
event.update(overrides)
return event
class TestEscapeText:
def test_escapes_the_four_specials(self):
assert escape_text("a\\b;c,d\ne") == "a\\\\b\\;c\\,d\\ne"
def test_every_line_break_form_becomes_one_escape(self):
assert escape_text("a\r\nb\rc\nd") == "a\\nb\\nc\\nd"
def test_backslash_is_escaped_before_the_others(self):
"""Escaping `;` first and `\\` second would double the new backslash."""
assert escape_text(";") == "\\;"
assert escape_text("\\;") == "\\\\\\;"
def test_control_characters_are_dropped_but_tab_stays(self):
assert escape_text("a\x00b\x07c\td\x7f") == "abc\td"
def test_none_is_empty(self):
assert escape_text(None) == ""
class TestFoldLine:
def test_short_line_is_untouched(self):
assert fold_line("SUMMARY:short") == "SUMMARY:short"
def test_long_ascii_line_folds_at_75_octets(self):
line = "SUMMARY:" + "x" * 200
folded = fold_line(line)
parts = folded.split("\r\n")
assert len(parts) > 1
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
assert len(parts[0].encode("utf-8")) == 75
assert all(p.startswith(" ") for p in parts[1:])
assert _unfold(folded) == line
def test_multibyte_characters_are_never_split(self):
# 3-byte and 4-byte characters, offset so a naive byte cut would land
# inside one of them.
line = "SUMMARY:a" + "€" * 40 + "\U0001f4c5" * 20
folded = fold_line(line)
parts = folded.split("\r\n")
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
for part in parts:
part.encode("utf-8").decode("utf-8") # raises if a char was cut
assert _unfold(folded) == line
class TestRenderCalendar:
def test_envelope_and_crlf(self):
body = render_calendar([])
assert body.startswith("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n")
assert "PRODID:" in body
assert "CALSCALE:GREGORIAN\r\n" in body
assert "X-WR-CALNAME:BottleCRM tasks\r\n" in body
assert body.endswith("END:VCALENDAR\r\n")
assert "\n" not in body.replace("\r\n", "")
def test_all_day_event(self):
body = render_calendar([_event()])
assert "UID:task-1@bottlecrm\r\n" in body
assert "DTSTAMP:20260927T101500Z\r\n" in body
assert "DTSTART;VALUE=DATE:20260930\r\n" in body
assert "DTEND;VALUE=DATE:20261001\r\n" in body
assert "SUMMARY:Call back\r\n" in body
assert "URL:https://app.example.com/tasks/1\r\n" in body
assert (
"DESCRIPTION:Priority: High\\nhttps://app.example.com/tasks/1\r\n" in body
)
def test_dtend_crosses_the_year(self):
body = render_calendar([_event(date=date(2026, 12, 31))])
assert "DTSTART;VALUE=DATE:20261231\r\n" in body
assert "DTEND;VALUE=DATE:20270101\r\n" in body
def test_dtstamp_is_converted_to_utc(self):
stamp = datetime(2026, 9, 27, 10, 0, tzinfo=ZoneInfo("Asia/Kolkata"))
body = render_calendar([_event(stamp=stamp)])
assert "DTSTAMP:20260927T043000Z\r\n" in body
def test_summary_is_escaped_and_folded(self):
title = "Renew, sign; send\\file " + "é" * 80
body = render_calendar([_event(summary=title)])
for line in body.split("\r\n"):
assert len(line.encode("utf-8")) <= 75
assert f"SUMMARY:{escape_text(title)}" in _unfold(body)
def test_a_title_cannot_inject_a_property(self):
"""A newline in a title must not start a new content line."""
body = render_calendar([_event(summary="x\r\nATTENDEE:mailto:a@b.c")])
assert "\r\nATTENDEE" not in _unfold(body)

View file

@ -0,0 +1,75 @@
"""``DOMAIN_NAME`` is the base of the API URLs a person copies out of the app.
``common.links.api_url`` builds the task calendar feed URL and the web form
embed snippet from it. Both leave the building: a calendar app subscribes to
the one, a customer's site embeds the other. Its default is
``http://localhost:8000``, so outside dev ``crm/settings.py`` refuses a
loopback or non-absolute value at import, the same way it refuses one for
``FRONTEND_URL``. Subprocess imports for the reason given in
``test_frontend_url_guard.py``.
"""
import os
import subprocess
import sys
from pathlib import Path
import pytest
from common.links import api_url
BACKEND_DIR = Path(__file__).resolve().parents[2]
def _import_settings(domain_name, env_type):
env = {
key: value
for key, value in os.environ.items()
if key not in ("SECRET_KEY", "ENV_TYPE", "FRONTEND_URL", "DOMAIN_NAME")
}
env["SECRET_KEY"] = "k" * 48
env["ENV_TYPE"] = env_type
env["FRONTEND_URL"] = "https://app.example.com"
env["DOMAIN_NAME"] = domain_name
return subprocess.run(
[sys.executable, "-c", "import crm.settings"],
cwd=BACKEND_DIR,
env=env,
capture_output=True,
text=True,
)
@pytest.mark.parametrize(
"value",
[
"http://localhost:8000", # the default an operator gets by not setting it
"http://127.0.0.1:8000",
"http://0.0.0.0:8000",
"",
"api.example.com", # no scheme
"/api",
],
)
def test_a_loopback_or_relative_value_fails_the_import_outside_dev(value):
result = _import_settings(value, "production")
assert result.returncode != 0
assert "DOMAIN_NAME" in result.stderr
assert "https://api.example.com" in result.stderr
@pytest.mark.parametrize("value", ["https://api.example.com", "http://api.example.com"])
def test_a_public_value_imports_outside_dev(value):
result = _import_settings(value, "production")
assert result.returncode == 0, result.stderr
def test_dev_keeps_the_loopback_default():
result = _import_settings("http://localhost:8000", "dev")
assert result.returncode == 0, result.stderr
def test_api_url_joins_the_origin_and_path(settings):
settings.DOMAIN_NAME = "https://api.example.com/"
assert api_url("/api/x/") == "https://api.example.com/api/x/"
assert api_url("api/x/") == "https://api.example.com/api/x/"

View file

@ -143,58 +143,6 @@ class TestAssignmentEmailsLinkToTheRecord:
assert _button_href(mail.outbox[0]) == f"{FRONTEND}/pipeline/{deal.id}"
@pytest.mark.django_db
class TestTheSecondLeadMailerAgreesWithTheFirst:
"""Two tasks send "you were assigned a lead" from the same template.
They filled different context keys, so the template hedged with
``{{ url|default:lead_detail_url }}``. Both now fill ``url`` with the same
value, which is what lets the template say ``{{ url }}`` like its five
siblings.
"""
def test_the_webhook_mailer_links_to_the_lead(
self, org_a, admin_user, admin_profile
):
"""This one hands the rendered HTML to a second task to send.
`send_email.delay(...)` needs a broker, and the test settings point
Celery at `memory://` without eager mode, so the mail never leaves the
queue. The assertion is on what this task renders and dispatches,
which is the part under test.
"""
from unittest.mock import patch
from leads.tasks import send_lead_assigned_emails
with impersonate(admin_user):
lead = Lead.objects.create(title="Web form", org=org_a, status="assigned")
with patch("leads.tasks.send_email.delay") as dispatched:
send_lead_assigned_emails(lead.id, [admin_profile.id], str(org_a.id))
assert dispatched.call_count == 1
html = dispatched.call_args.kwargs["html_content"]
assert f'href="{FRONTEND}/leads/{lead.id}"' in html
# The render itself is the other half: this template used to raise
# `VariableDoesNotExist` for whichever sender filled the other key.
assert "Web form" in html
def test_it_no_longer_takes_a_host_supplied_base(self):
"""The dropped argument was ``request.META["HTTP_HOST"]``.
Its one caller is the website-lead webhook, so the base of a link in
mail this system sends to its own staff came off the wire. Pinning the
signature keeps a future caller from reintroducing it.
"""
import inspect
from leads.tasks import send_lead_assigned_emails
params = list(inspect.signature(send_lead_assigned_emails.run).parameters)
assert params == ["lead_id", "new_assigned_to_list", "org_id"]
@pytest.mark.django_db
class TestAlertEmailsLinkToTheirPage:
def test_stale_deal_alert_links_to_the_rotten_filter(

View file

@ -34,16 +34,18 @@ def _import_settings(frontend_url, env_type):
"""Import ``crm.settings`` in a clean subprocess and report how it went.
``load_dotenv()`` does not override variables already in the environment, so
what is passed here wins over the developer's own ``.env``.
what is passed here wins over the developer's own ``.env``. ``DOMAIN_NAME``
has its own guard (``test_domain_name_guard.py``), so it is pinned public.
"""
env = {
key: value
for key, value in os.environ.items()
if key not in ("SECRET_KEY", "ENV_TYPE", "FRONTEND_URL")
if key not in ("SECRET_KEY", "ENV_TYPE", "FRONTEND_URL", "DOMAIN_NAME")
}
env["SECRET_KEY"] = GOOD_SECRET_KEY
env["ENV_TYPE"] = env_type
env["FRONTEND_URL"] = frontend_url
env["DOMAIN_NAME"] = "https://api.example.com"
return subprocess.run(
[sys.executable, "-c", "import crm.settings"],
cwd=BACKEND_DIR,

View file

@ -3,8 +3,8 @@
`MagicLinkVerifyView` and `MagicLinkVerifyCodeView` have always called
`audit_log.login_success`; the web Google callback and the mobile ID-token
endpoint never did, so most sign-ins left no trace in the security audit log.
Magic link writes no failure row, so neither does Google: a refused sign-in
writes nothing. The row's address comes from `client_ip`, so a relayed
A refused sign-in writes no success row; the failure row it writes instead is
pinned in `test_login_failure_audit.py`. The row's address comes from `client_ip`, so a relayed
sign-in records the visitor the relay names, and a forged header records the
socket peer.
"""
@ -77,14 +77,14 @@ class TestWebCallback:
assert _rows().get().ip_address == "203.0.113.50"
def test_an_unverified_email_writes_no_row(self, unauthenticated_client):
def test_an_unverified_email_writes_no_success_row(self, unauthenticated_client):
with _google_exchange("ada@example.com", email_verified=False):
response = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert response.status_code == 400
assert not SecurityAuditLog.objects.exists()
assert not _rows().exists()
def test_a_deactivated_user_writes_no_row(self, unauthenticated_client):
def test_a_deactivated_user_writes_no_success_row(self, unauthenticated_client):
User.objects.create_user(
email="gone@example.com", password="x", is_active=False
)
@ -111,7 +111,7 @@ class TestMobileIdToken:
assert row.user == User.objects.get(email="ada@example.com")
assert row.ip_address == "198.51.100.9"
def test_an_invalid_token_writes_no_row(self, unauthenticated_client):
def test_an_invalid_token_writes_no_success_row(self, unauthenticated_client):
verify, transport = _google_verify(None)
with verify, transport:
response = unauthenticated_client.post(
@ -119,9 +119,9 @@ class TestMobileIdToken:
)
assert response.status_code == 400
assert not SecurityAuditLog.objects.exists()
assert not _rows().exists()
def test_an_unverified_email_writes_no_row(self, unauthenticated_client):
def test_an_unverified_email_writes_no_success_row(self, unauthenticated_client):
verify, transport = _google_verify(
{"email": "ada@example.com", "email_verified": False}
)
@ -131,4 +131,4 @@ class TestMobileIdToken:
)
assert response.status_code == 400
assert not SecurityAuditLog.objects.exists()
assert not _rows().exists()

View file

@ -0,0 +1,434 @@
"""Refused staff sign-ins write one LOGIN_FAILURE row each, capped per IP.
`audit_log.login_failure` had no callers, so a run of refused sign-ins left no
trace. Every refusal on the Google web callback, the mobile Google ID-token
endpoint, magic-link verify and verify-code now writes one row with a short
reason code. Token refresh and the magic-link request write none.
The rows are written by anonymous callers into `security_audit_log`, which has
no org and no RLS policy, so a client IP gets at most
`LOGIN_FAILURE_ROWS_PER_IP_PER_HOUR` of them. The refusal itself is unchanged:
same status, same body.
"""
import secrets
from datetime import timedelta
from unittest.mock import MagicMock, patch
import pytest
import requests
from django.contrib.auth.hashers import make_password
from django.core.cache import cache
from django.utils import timezone
from common.audit_log import (
LOGIN_FAILURE_ROWS_PER_IP_PER_HOUR,
AuditLogger,
SecurityAuditLog,
)
from common.models import MagicLinkToken, User
from common.tests.test_auth import _make_fake_id_token
CALLBACK = "/api/auth/google/callback/"
ID_TOKEN = "/api/auth/google/"
VERIFY = "/api/auth/magic-link/verify/"
VERIFY_CODE = "/api/auth/magic-link/verify-code/"
CODE = {"code": "c", "code_verifier": "v", "redirect_uri": "http://app.test/cb"}
@pytest.fixture(autouse=True)
def _fresh_cap():
"""The cap counts in the shared test cache; start each test at zero."""
cache.clear()
yield
cache.clear()
def _failures():
return SecurityAuditLog.objects.filter(event_type="LOGIN_FAILURE")
def _only_failure(reason, email=""):
row = _failures().get()
assert row.metadata == {"email": email, "reason": reason}
assert row.success is False
assert row.org is None
return row
def _exchange(status_code=200, body=None, raises=None):
response = MagicMock(status_code=status_code, content=b"x")
response.json.return_value = body or {}
if raises:
return patch("common.views.auth_views.requests.post", side_effect=raises)
return patch("common.views.auth_views.requests.post", return_value=response)
def _google_verify(claims):
verify = patch(
"google.oauth2.id_token.verify_oauth2_token",
side_effect=ValueError("bad") if claims is None else None,
return_value=claims,
)
return verify, patch("google.auth.transport.requests.Request")
def _link(email, **kwargs):
defaults = {"expires_at": timezone.now() + timedelta(minutes=10)}
defaults.update(kwargs)
return MagicLinkToken.objects.create(
email=email, token=secrets.token_hex(32), **defaults
)
def _code(email, code="123456", **kwargs):
return _link(email, delivery="code", code_hash=make_password(code), **kwargs)
class TestGoogleWebCallback:
def test_google_unreachable(self, unauthenticated_client):
with _exchange(raises=requests.ConnectionError()):
response = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert response.status_code == 502
assert response.json() == {"error": "Failed to communicate with Google"}
_only_failure("google_unreachable")
def test_code_rejected(self, unauthenticated_client):
with _exchange(400, {"error_description": "Bad Request"}):
response = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert response.status_code == 400
assert response.json() == {"error": "Bad Request"}
_only_failure("google_code_rejected")
@pytest.mark.parametrize("status_code", [400, 502])
def test_a_rejection_that_is_not_json(self, unauthenticated_client, status_code):
"""A proxy or outage page in place of Google's JSON used to be a 500."""
response = MagicMock(status_code=status_code, content=b"<html>Bad</html>")
response.json.side_effect = requests.JSONDecodeError("x", "<html>", 0)
with patch("common.views.auth_views.requests.post", return_value=response):
reply = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert reply.status_code == 400
assert reply.json() == {"error": "Token exchange failed"}
_only_failure("google_code_rejected")
def test_a_rejection_that_is_a_json_list(self, unauthenticated_client):
with _exchange(400, ["not", "an", "object"]):
reply = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert reply.status_code == 400
assert reply.json() == {"error": "Token exchange failed"}
_only_failure("google_code_rejected")
def test_a_success_that_is_not_json(self, unauthenticated_client):
response = MagicMock(status_code=200, content=b"<html>ok</html>")
response.json.side_effect = requests.JSONDecodeError("x", "<html>", 0)
with patch("common.views.auth_views.requests.post", return_value=response):
reply = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert reply.status_code == 400
assert reply.json() == {"error": "No ID token received"}
_only_failure("google_invalid_id_token")
def test_an_id_token_that_is_not_a_string(self, unauthenticated_client):
with _exchange(200, {"id_token": 12345}):
reply = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert reply.status_code == 400
assert reply.json() == {"error": "Invalid ID token format"}
_only_failure("google_invalid_id_token")
def test_no_id_token(self, unauthenticated_client):
with _exchange(200, {"access_token": "a"}):
response = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert response.status_code == 400
assert response.json() == {"error": "No ID token received"}
_only_failure("google_invalid_id_token")
def test_malformed_id_token(self, unauthenticated_client):
with _exchange(200, {"id_token": "no-dots"}):
response = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert response.status_code == 400
assert response.json() == {"error": "Invalid ID token format"}
_only_failure("google_invalid_id_token")
def test_no_email(self, unauthenticated_client):
with _exchange(200, {"id_token": _make_fake_id_token("")}):
response = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert response.status_code == 400
assert response.json() == {"error": "No email in token"}
_only_failure("google_no_email")
def test_unverified_email(self, unauthenticated_client):
token = _make_fake_id_token("ada@example.com", email_verified=False)
with _exchange(200, {"id_token": token}):
response = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert response.status_code == 400
assert response.json() == {"error": "Google account email is not verified"}
_only_failure("google_email_unverified", "ada@example.com")
assert not User.objects.filter(email="ada@example.com").exists()
def test_disabled_account(self, unauthenticated_client):
user = User.objects.create_user(
email="gone@example.com", password="x", is_active=False
)
with _exchange(200, {"id_token": _make_fake_id_token("gone@example.com")}):
response = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert response.status_code == 403
assert response.json() == {"error": "User account is disabled"}
assert _only_failure("account_disabled", "gone@example.com").user == user
def test_success_writes_no_failure_row(self, unauthenticated_client):
with _exchange(200, {"id_token": _make_fake_id_token("ada@example.com")}):
response = unauthenticated_client.post(CALLBACK, CODE, format="json")
assert response.status_code == 200
assert not _failures().exists()
class TestGoogleMobileIdToken:
def _post(self, client, claims):
verify, transport = _google_verify(claims)
with verify, transport:
return client.post(ID_TOKEN, {"idToken": "t"}, format="json")
def test_invalid_token(self, unauthenticated_client):
response = self._post(unauthenticated_client, None)
assert response.status_code == 400
assert response.json() == {"error": "Invalid token"}
_only_failure("google_invalid_token")
def test_no_email(self, unauthenticated_client):
response = self._post(unauthenticated_client, {"email_verified": True})
assert response.status_code == 400
assert response.json() == {"error": "No email in token"}
_only_failure("google_no_email")
def test_unverified_email(self, unauthenticated_client):
response = self._post(
unauthenticated_client,
{"email": "ada@example.com", "email_verified": False},
)
assert response.status_code == 400
assert response.json() == {"error": "Google account email is not verified"}
_only_failure("google_email_unverified", "ada@example.com")
def test_disabled_account(self, unauthenticated_client):
User.objects.create_user(
email="gone@example.com", password="x", is_active=False
)
response = self._post(
unauthenticated_client,
{"email": "gone@example.com", "email_verified": True},
)
assert response.status_code == 403
assert response.json() == {"error": "User account is disabled"}
_only_failure("account_disabled", "gone@example.com")
def test_success_writes_no_failure_row(self, unauthenticated_client):
response = self._post(
unauthenticated_client,
{"email": "ada@example.com", "email_verified": True},
)
assert response.status_code == 200
assert not _failures().exists()
class TestMagicLinkVerify:
def test_unknown_link(self, unauthenticated_client):
response = unauthenticated_client.post(
VERIFY, {"token": "nonexistent"}, format="json"
)
assert response.status_code == 400
assert response.json() == {"error": "Invalid or expired link"}
_only_failure("magic_link_invalid")
def test_replayed_link_names_its_email_but_not_the_token(
self, unauthenticated_client
):
link = _link("ada@example.com", is_used=True)
response = unauthenticated_client.post(
VERIFY, {"token": link.token}, format="json"
)
assert response.status_code == 400
row = _only_failure("magic_link_invalid", "ada@example.com")
assert link.token not in row.description
def test_expired_link(self, unauthenticated_client):
link = _link(
"ada@example.com", expires_at=timezone.now() - timedelta(minutes=1)
)
response = unauthenticated_client.post(
VERIFY, {"token": link.token}, format="json"
)
assert response.status_code == 400
# The expired row is swept before the lookup, so no email survives.
_only_failure("magic_link_invalid")
def test_disabled_account(self, unauthenticated_client):
User.objects.create_user(
email="gone@example.com", password="x", is_active=False
)
link = _link("gone@example.com")
response = unauthenticated_client.post(
VERIFY, {"token": link.token}, format="json"
)
assert response.status_code == 403
assert response.json() == {"error": "User account is disabled"}
_only_failure("account_disabled", "gone@example.com")
def test_success_writes_no_failure_row(self, unauthenticated_client):
link = _link("ada@example.com")
response = unauthenticated_client.post(
VERIFY, {"token": link.token}, format="json"
)
assert response.status_code == 200
assert not _failures().exists()
class TestMagicLinkVerifyCode:
def _post(self, client, email="ada@example.com", code="123456"):
return client.post(VERIFY_CODE, {"email": email, "code": code}, format="json")
def test_no_active_code(self, unauthenticated_client):
response = self._post(unauthenticated_client)
assert response.status_code == 400
assert response.json() == {"error": "Invalid or expired code"}
_only_failure("code_not_found", "ada@example.com")
def test_wrong_code_keeps_its_attempt_and_names_no_code(
self, unauthenticated_client
):
token = _code("ada@example.com", code="123456")
response = self._post(unauthenticated_client, code="999999")
assert response.status_code == 400
assert response.json() == {"error": "Invalid or expired code"}
row = _only_failure("code_wrong", "ada@example.com")
assert "999999" not in row.description
token.refresh_from_db()
assert token.attempts == 1
assert token.is_used is False
def test_the_attempt_that_burns_the_code(self, unauthenticated_client):
token = _code("ada@example.com", attempts=4)
response = self._post(unauthenticated_client, code="999999")
assert response.status_code == 400
_only_failure("code_attempts_exhausted", "ada@example.com")
token.refresh_from_db()
assert token.is_used is True
def test_a_burned_code_is_not_found(self, unauthenticated_client):
_code("ada@example.com", is_used=True)
response = self._post(unauthenticated_client)
assert response.status_code == 400
_only_failure("code_not_found", "ada@example.com")
def test_disabled_account(self, unauthenticated_client):
User.objects.create_user(
email="gone@example.com", password="x", is_active=False
)
_code("gone@example.com")
response = self._post(unauthenticated_client, email="gone@example.com")
assert response.status_code == 403
assert response.json() == {"error": "User account is disabled"}
_only_failure("account_disabled", "gone@example.com")
def test_success_writes_no_failure_row(self, unauthenticated_client):
_code("ada@example.com")
response = self._post(unauthenticated_client)
assert response.status_code == 200
assert not _failures().exists()
class TestNotAudited:
def test_a_bad_refresh_token_writes_no_failure_row(self, unauthenticated_client):
response = unauthenticated_client.post(
"/api/auth/refresh-token/", {"refresh": "junk"}, format="json"
)
assert response.status_code >= 400
assert not _failures().exists()
def test_a_magic_link_request_writes_no_failure_row(self, unauthenticated_client):
response = unauthenticated_client.post(
"/api/auth/magic-link/request/", {"email": "nope"}, format="json"
)
assert response.status_code == 200
assert not _failures().exists()
class TestCap:
def _fail(self, client, ip):
return client.post(
VERIFY, {"token": "nonexistent"}, format="json", REMOTE_ADDR=ip
)
def test_rows_stop_at_the_cap_per_ip_and_another_ip_still_writes(
self, unauthenticated_client
):
for _ in range(LOGIN_FAILURE_ROWS_PER_IP_PER_HOUR + 5):
response = self._fail(unauthenticated_client, "198.51.100.7")
# Refused the same way past the cap: only the row is dropped.
assert response.status_code == 400
assert response.json() == {"error": "Invalid or expired link"}
assert LOGIN_FAILURE_ROWS_PER_IP_PER_HOUR == 20
assert _failures().filter(ip_address="198.51.100.7").count() == 20
self._fail(unauthenticated_client, "203.0.113.9")
assert _failures().filter(ip_address="203.0.113.9").count() == 1
def test_the_window_resets(self, unauthenticated_client):
for _ in range(LOGIN_FAILURE_ROWS_PER_IP_PER_HOUR):
self._fail(unauthenticated_client, "198.51.100.7")
cache.clear() # the hour has passed
self._fail(unauthenticated_client, "198.51.100.7")
assert _failures().count() == LOGIN_FAILURE_ROWS_PER_IP_PER_HOUR + 1
@pytest.mark.parametrize("method", ["add", "incr"])
def test_a_cache_outage_skips_the_row_and_keeps_the_400(
self, unauthenticated_client, caplog, method
):
"""Redis down must not turn a refused sign-in into a 500, and must not
let a flood write uncapped rows into the unscoped audit table."""
_code("ada@example.com", code="123456")
caplog.set_level("WARNING", logger="security.audit")
with patch(
f"common.audit_log.cache.{method}",
side_effect=ConnectionError("redis down"),
):
response = unauthenticated_client.post(
VERIFY_CODE,
{"email": "ada@example.com", "code": "999999"},
format="json",
)
assert response.status_code == 400
assert response.json() == {"error": "Invalid or expired code"}
assert not _failures().exists()
warnings = [r.getMessage() for r in caplog.records]
assert (
"Login failure audit row skipped: cache unavailable (ConnectionError)"
in (warnings)
)
assert not any("ada@example.com" in w or "999999" in w for w in warnings)
class TestRowShape:
def test_the_stored_email_is_truncated(self):
AuditLogger().login_failure("a" * 400 + "@example.com", "code_wrong")
row = _failures().get()
assert row.metadata["email"] == "a" * 254
assert len(row.metadata["email"]) == 254
def test_an_org_admin_never_sees_failure_rows(
self, admin_client, admin_user, unauthenticated_client
):
admin_user.is_active = False
admin_user.save(update_fields=["is_active"])
_code(admin_user.email)
unauthenticated_client.post(
VERIFY_CODE, {"email": admin_user.email, "code": "123456"}, format="json"
)
admin_user.is_active = True
admin_user.save(update_fields=["is_active"])
row = _only_failure("account_disabled", admin_user.email)
assert row.user == admin_user
response = admin_client.get("/api/org/audit-log/")
assert response.status_code == 200
ids = [entry["id"] for entry in response.json()["results"]]
assert str(row.id) not in ids

View file

@ -0,0 +1,126 @@
"""Public-link tokens stay out of the application log and out of Sentry.
The calendar feed, a satisfaction survey, and an invoice's or estimate's portal
link each carry their credential as a path segment under `/api/public/`.
Django's `django.request` logger writes `Not Found: <path>` and
`Too Many Requests: <path>`, and a Sentry event records the path in several
places, so both are scrubbed by `common.public_tokens`.
"""
import logging
import pytest
from django.core.cache import cache
from rest_framework.test import APIClient
from common.models import CalendarFeedToken
from common.public_tokens import redact, scrub_public_tokens
from common.views.calendar_feed_views import CalendarFeedTokenThrottle
TOKEN = "SECRETsecretSECRETsecret0123456789"
# Every public route shape whose path segment is a credential.
TOKEN_PATHS = [
f"/api/public/calendar/{TOKEN}.ics",
f"/api/public/csat/{TOKEN}/",
f"/api/public/invoice/{TOKEN}/",
f"/api/public/invoice/{TOKEN}/pdf/",
f"/api/public/estimate/{TOKEN}/",
f"/api/public/estimate/{TOKEN}/pdf/",
f"/api/public/estimate/{TOKEN}/accept/",
f"/api/public/estimate/{TOKEN}/decline/",
]
@pytest.fixture(autouse=True)
def _fresh_throttle():
cache.clear()
yield
cache.clear()
class TestSentryScrubber:
@pytest.mark.parametrize("path", TOKEN_PATHS)
def test_redacts_every_place_a_url_can_appear(self, path):
event = {
"request": {"url": f"https://api.example.com{path}", "method": "GET"},
"transaction": path,
"breadcrumbs": {"values": [{"message": f"Not Found: {path}"}]},
"spans": [{"description": f"GET {path}", "data": {"url": path}}],
"extra": {"tuple": (path,)},
}
scrubbed = scrub_public_tokens(event, {})
assert TOKEN not in repr(scrubbed)
prefix = path[: path.index(TOKEN)]
assert scrubbed["request"]["url"].startswith(
f"https://api.example.com{prefix}[Filtered]"
)
assert scrubbed["request"]["method"] == "GET"
def test_keeps_what_follows_the_token(self):
assert (
redact(f"/api/public/estimate/{TOKEN}/accept/?x=1")
== "/api/public/estimate/[Filtered]/accept/?x=1"
)
@pytest.mark.parametrize(
"path",
[
"/api/tasks/?x=1",
"/api/public/help/acme/", # an org's public help-center slug
"/api/public/forms/9d3e/5a1b/embed/", # ids, not credentials
"/api/invoices/abc/",
],
)
def test_leaves_other_paths_alone(self, path):
event = {"request": {"url": f"https://api.example.com{path}"}}
assert scrub_public_tokens(event, {}) == event
@pytest.mark.django_db
class TestApplicationLog:
def _messages(self, caplog):
return [record.getMessage() for record in caplog.records]
@pytest.mark.parametrize("path", TOKEN_PATHS)
def test_a_refused_request_names_no_token(self, caplog, path):
"""Unknown token: a 404, a 400 for the survey, a 405 for a GET to a
POST-only route. Each is logged with its path, which is the point."""
caplog.set_level(logging.WARNING, logger="django.request")
response = APIClient().get(path)
assert response.status_code in (400, 404, 405)
messages = self._messages(caplog)
prefix = path[: path.index(TOKEN)]
assert any(f": {prefix}[Filtered]" in m for m in messages), messages
assert not any(TOKEN in m for m in messages)
def test_a_disabled_feed_names_no_token(self, admin_profile, caplog):
raw, _ = CalendarFeedToken.issue(admin_profile)
CalendarFeedToken.objects.all().delete() # the member turns the feed off
caplog.set_level(logging.WARNING, logger="django.request")
assert APIClient().get(f"/api/public/calendar/{raw}.ics").status_code == 404
messages = self._messages(caplog)
assert "Not Found: /api/public/calendar/[Filtered]" in messages
assert not any(raw in m for m in messages)
def test_a_throttled_live_feed_names_no_token(
self, admin_profile, caplog, monkeypatch
):
monkeypatch.setattr(
CalendarFeedTokenThrottle,
"THROTTLE_RATES",
{"calendar_feed_token": "1/hour"},
)
raw, _ = CalendarFeedToken.issue(admin_profile)
client = APIClient()
assert client.get(f"/api/public/calendar/{raw}.ics").status_code == 200
caplog.set_level(logging.WARNING, logger="django.request")
assert client.get(f"/api/public/calendar/{raw}.ics").status_code == 429
messages = self._messages(caplog)
assert "Too Many Requests: /api/public/calendar/[Filtered]" in messages
assert not any(raw in m for m in messages)
def test_other_paths_are_logged_as_they_were(self, caplog):
caplog.set_level(logging.WARNING, logger="django.request")
APIClient().get("/api/public/help/no-such-org/")
assert "Not Found: /api/public/help/no-such-org/" in self._messages(caplog)

View file

@ -0,0 +1,132 @@
"""The duplicate search is a read, although it is a POST.
`POST /api/{leads,contacts,accounts}/duplicates/` writes nothing; it takes its
criteria in a body only to keep them out of access logs. A `leads:read` token
used to be refused there because the scope action was decided by method alone.
It is now decided by method and path together, against an exact set of paths,
so nothing near those paths turns into a read by accident.
"""
import uuid
import pytest
from django.core.cache import cache
from common import scopes
from common.tests.test_org_api_key_scope import _key_client
from common.tests.test_pat_scope_enforcement import _pat_client
from leads.models import Lead
SEARCHES = (
"/api/leads/duplicates/",
"/api/contacts/duplicates/",
"/api/accounts/duplicates/",
)
@pytest.fixture(autouse=True)
def _fresh_throttle():
cache.clear()
yield
cache.clear()
class TestMatcher:
@pytest.mark.parametrize("path", SEARCHES)
def test_a_post_to_a_search_is_a_read(self, path):
assert scopes.action_for("POST", path) == "read"
assert scopes.action_for("post", path) == "read"
@pytest.mark.parametrize("method", ["PUT", "PATCH", "DELETE"])
def test_other_writes_to_a_search_path_stay_writes(self, method):
assert scopes.action_for(method, "/api/leads/duplicates/") == "write"
@pytest.mark.parametrize(
"path",
[
"/api/leads/duplicates", # missing slash
"/api/leads/duplicates/extra/", # extra segment
"/api/leads/duplicates//",
f"/api/leads/{uuid.uuid4()}/duplicates/", # a record's own list
f"/api/leads/{uuid.uuid4()}/merge/",
"/api/Leads/duplicates/",
"/api/leads/duplicates/../",
"/api/tasks/duplicates/",
"/api/leads/",
"/leads/duplicates/",
],
)
def test_lookalike_paths_are_writes(self, path):
assert scopes.action_for("POST", path) == "write"
def test_the_denial_names_the_scope_it_needs(self):
denial = scopes.check_request(
["contacts:read"], "POST", "/api/leads/duplicates/"
)
assert denial == (
"This token is not scoped for read access to leads. "
"It needs the leads:read scope."
)
class TestOverHttp:
def test_a_leads_read_token_may_search_leads(self, admin_profile, org_a):
Lead.objects.create(
org=org_a, title="T", first_name="Ada", last_name="L", email="a@x.io"
)
client = _pat_client(admin_profile, scopes=["leads:read"])
response = client.post(
"/api/leads/duplicates/", {"email": "a@x.io"}, format="json"
)
assert response.status_code == 200, response.content
assert len(response.json()["duplicates"]) == 1
def test_the_same_token_still_cannot_create_or_merge(self, admin_profile, org_a):
keep = Lead.objects.create(org=org_a, title="K", first_name="K", last_name="K")
lose = Lead.objects.create(org=org_a, title="L", first_name="L", last_name="L")
client = _pat_client(admin_profile, scopes=["leads:read"])
create = client.post(
"/api/leads/", {"title": "N", "first_name": "N", "last_name": "N"}
)
assert create.status_code == 403
assert "leads:write" in create.json()["detail"]
merge = client.post(
f"/api/leads/{keep.id}/merge/", {"merge_id": str(lose.id)}, format="json"
)
assert merge.status_code == 403
assert Lead.objects.filter(id=lose.id).exists()
def test_a_contacts_read_token_is_refused_on_the_leads_search(self, admin_profile):
client = _pat_client(admin_profile, scopes=["contacts:read"])
response = client.post(
"/api/leads/duplicates/", {"email": "a@x.io"}, format="json"
)
assert response.status_code == 403
assert "leads:read" in response.json()["detail"]
# and it may search its own resource
ok = client.post(
"/api/contacts/duplicates/", {"email": "a@x.io"}, format="json"
)
assert ok.status_code == 200
def test_a_read_token_is_refused_on_a_lookalike(self, admin_profile):
client = _pat_client(admin_profile, scopes=["leads:read"])
response = client.post(
"/api/leads/duplicates/extra/", {"email": "a@x.io"}, format="json"
)
assert response.status_code == 403
@pytest.mark.parametrize("path", SEARCHES)
def test_the_org_api_key_may_search(self, org_a, admin_profile, path):
response = _key_client(org_a).post(path, {"email": "a@x.io"}, format="json")
assert response.status_code == 200, response.content
def test_the_org_api_key_still_cannot_create(self, org_a, admin_profile):
before = Lead.objects.count()
response = _key_client(org_a).post(
"/api/leads/", {"title": "N", "first_name": "N", "last_name": "N"}
)
assert response.status_code in (401, 403)
assert Lead.objects.count() == before

View file

@ -58,14 +58,14 @@ class TestResourceForPath:
assert scopes.resource_for_path("/api/") is None
class TestActionForMethod:
class TestActionFor:
@pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS", "get"])
def test_safe_methods_read(self, method):
assert scopes.action_for_method(method) == "read"
assert scopes.action_for(method, "/api/leads/") == "read"
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE", "delete"])
def test_unsafe_methods_write(self, method):
assert scopes.action_for_method(method) == "write"
assert scopes.action_for(method, "/api/leads/") == "write"
class TestScopesAllow:

View file

@ -32,19 +32,21 @@ def _import_settings(secret_key, env_type):
``load_dotenv()`` does not override variables already in the environment, so
what is passed here wins over the developer's own ``.env``.
``FRONTEND_URL`` is pinned to a public value because it carries its own
production guard (see ``test_frontend_url_guard.py``), and its default is a
loopback URL. Without this, every ``env_type="production"`` case below would
fail the import for a reason that has nothing to do with the signing key.
``FRONTEND_URL`` and ``DOMAIN_NAME`` are pinned to public values because
each carries its own production guard (see ``test_frontend_url_guard.py``
and ``test_domain_name_guard.py``), and both default to a loopback URL.
Without this, every ``env_type="production"`` case below would fail the
import for a reason that has nothing to do with the signing key.
"""
env = {
key: value
for key, value in os.environ.items()
if key not in ("SECRET_KEY", "ENV_TYPE", "FRONTEND_URL")
if key not in ("SECRET_KEY", "ENV_TYPE", "FRONTEND_URL", "DOMAIN_NAME")
}
env["SECRET_KEY"] = secret_key
env["ENV_TYPE"] = env_type
env["FRONTEND_URL"] = "https://app.example.com"
env["DOMAIN_NAME"] = "https://api.example.com"
return subprocess.run(
[sys.executable, "-W", "always", "-c", "import crm.settings"],
cwd=BACKEND_DIR,

View file

@ -195,6 +195,7 @@ class TestTagsUsageAndTotals:
"api_settings": 0,
"solutions": 0,
"web_forms": 0,
"macros": 0,
}
def test_unused_tag_reports_zero_usage(self, admin_client, org_a):
@ -210,6 +211,7 @@ class TestTagsUsageAndTotals:
"api_settings": 0,
"solutions": 0,
"web_forms": 0,
"macros": 0,
}
@pytest.mark.parametrize("kind", ["contacts", "tasks", "api_settings", "solutions"])

View file

@ -13,6 +13,10 @@ from common.views.auth_views import (
OrgAwareTokenRefreshView,
OrgSwitchView,
)
from common.views.calendar_feed_views import (
CalendarFeedView,
PublicCalendarFeedView,
)
from common.views.custom_field_views import (
CustomFieldDefinitionDetailView,
CustomFieldDefinitionListCreateView,
@ -138,6 +142,16 @@ urlpatterns = [
PersonalAccessTokenDetailView.as_view(),
name="pat_detail",
),
# Your own task calendar feed (G14). Session-only: on the credential
# deny-list in common/scopes.py, because the feed URL is a credential.
path("profile/calendar-feed/", CalendarFeedView.as_view(), name="calendar_feed"),
# What a calendar app polls. Anonymous; the token in the path is the whole
# credential. See common/views/calendar_feed_views.py.
path(
"public/calendar/<str:token>.ics",
PublicCalendarFeedView.as_view(),
name="public_calendar_feed",
),
# User management
path("users/get-teams-and-users/", GetTeamsAndUsersView.as_view()),
path("users/", UsersListView.as_view()),

View file

@ -74,6 +74,20 @@ def _google_email_is_verified(claims):
return verified is True or str(verified).strip().lower() == "true"
def _json_object(response):
"""``response``'s body as a dict, or ``{}`` when it is not a JSON object.
Google's token endpoint answers JSON, but a proxy or an outage in between
can answer an HTML error page, and ``.json()`` on that raised straight out
of the view as a 500.
"""
try:
body = response.json()
except ValueError:
return {}
return body if isinstance(body, dict) else {}
def _disabled_account_response():
"""403 for a login attempt by a deactivated account.
@ -121,6 +135,8 @@ class GoogleOAuthCallbackView(APIView):
from django.utils import timezone
from common.audit_log import audit_log
code = request.data.get("code")
code_verifier = request.data.get("code_verifier")
redirect_uri = request.data.get("redirect_uri")
@ -146,21 +162,23 @@ class GoogleOAuthCallbackView(APIView):
timeout=30,
)
except requests.RequestException:
audit_log.login_failure("", "google_unreachable", request)
return Response(
{"error": "Failed to communicate with Google"},
status=status.HTTP_502_BAD_GATEWAY,
)
if token_response.status_code != 200:
error_data = token_response.json() if token_response.content else {}
audit_log.login_failure("", "google_code_rejected", request)
error_data = _json_object(token_response)
return Response(
{"error": error_data.get("error_description", "Token exchange failed")},
status=status.HTTP_400_BAD_REQUEST,
)
token_data = token_response.json()
id_token = token_data.get("id_token")
id_token = _json_object(token_response).get("id_token")
if not id_token:
audit_log.login_failure("", "google_invalid_id_token", request)
return Response(
{"error": "No ID token received"},
status=status.HTTP_400_BAD_REQUEST,
@ -175,13 +193,17 @@ class GoogleOAuthCallbackView(APIView):
email = payload.get("email")
picture = payload.get("picture", "")
google_name = (payload.get("name") or "").strip()[:255]
except (IndexError, ValueError, json.JSONDecodeError):
except (IndexError, ValueError, AttributeError):
# AttributeError: an `id_token` that is not a string, or a payload
# that is not a JSON object. JSONDecodeError is a ValueError.
audit_log.login_failure("", "google_invalid_id_token", request)
return Response(
{"error": "Invalid ID token format"},
status=status.HTTP_400_BAD_REQUEST,
)
if not email:
audit_log.login_failure("", "google_no_email", request)
return Response(
{"error": "No email in token"},
status=status.HTTP_400_BAD_REQUEST,
@ -190,6 +212,7 @@ class GoogleOAuthCallbackView(APIView):
# Reject before touching the database so an unverified address cannot
# even provision an account.
if not _google_email_is_verified(payload):
audit_log.login_failure(email, "google_email_unverified", request)
return Response(
{"error": "Google account email is not verified"},
status=status.HTTP_400_BAD_REQUEST,
@ -209,6 +232,7 @@ class GoogleOAuthCallbackView(APIView):
created = True
if not user.is_active:
audit_log.login_failure(email, "account_disabled", request)
return _disabled_account_response()
# Backfill name from Google when the user hasn't set one yet.
@ -229,8 +253,6 @@ class GoogleOAuthCallbackView(APIView):
# The same row a magic-link sign-in writes. No org yet: this token is
# org-less until the user picks one.
from common.audit_log import audit_log
audit_log.login_success(user, None, request)
return Response(
@ -274,6 +296,8 @@ class GoogleIdTokenView(APIView):
from google.auth.transport import requests as google_requests
from google.oauth2 import id_token
from common.audit_log import audit_log
id_token_str = request.data.get("idToken")
if not id_token_str:
return Response(
@ -293,12 +317,14 @@ class GoogleIdTokenView(APIView):
google_name = (idinfo.get("name") or "").strip()[:255]
except ValueError:
logger.warning("Google OAuth token validation failed", exc_info=True)
audit_log.login_failure("", "google_invalid_token", request)
return Response(
{"error": "Invalid token"},
status=status.HTTP_400_BAD_REQUEST,
)
if not email:
audit_log.login_failure("", "google_no_email", request)
return Response(
{"error": "No email in token"},
status=status.HTTP_400_BAD_REQUEST,
@ -307,6 +333,7 @@ class GoogleIdTokenView(APIView):
# Reject before touching the database so an unverified address cannot
# even provision an account.
if not _google_email_is_verified(idinfo):
audit_log.login_failure(email, "google_email_unverified", request)
return Response(
{"error": "Google account email is not verified"},
status=status.HTTP_400_BAD_REQUEST,
@ -323,6 +350,7 @@ class GoogleIdTokenView(APIView):
)
if not user.is_active:
audit_log.login_failure(email, "account_disabled", request)
return _disabled_account_response()
# Backfill name from Google for existing users who don't have one.
@ -345,8 +373,6 @@ class GoogleIdTokenView(APIView):
token = OrgAwareRefreshToken.for_user_and_org(user, None)
# The same row a magic-link sign-in writes; org-less, as above.
from common.audit_log import audit_log
audit_log.login_success(user, None, request)
return Response(
@ -834,6 +860,14 @@ class MagicLinkVerifyView(APIView):
).update(is_used=True, used_at=timezone.now())
if not updated:
# The email is the link's own, when the link exists at all (a
# replayed one, say); the token itself is never recorded.
link_email = (
MagicLinkToken.objects.filter(token=token_value)
.values_list("email", flat=True)
.first()
)
audit_log.login_failure(link_email, "magic_link_invalid", request)
return Response(
{"error": "Invalid or expired link"},
status=status.HTTP_400_BAD_REQUEST,
@ -855,6 +889,7 @@ class MagicLinkVerifyView(APIView):
created = True
if not user.is_active:
audit_log.login_failure(email, "account_disabled", request)
return _disabled_account_response()
if created:
@ -948,6 +983,9 @@ class MagicLinkVerifyCodeView(APIView):
status=status.HTTP_400_BAD_REQUEST,
)
# The failure row is written after the block commits, so an audit
# insert that fails cannot roll back the attempt counter with it.
failure = None
with transaction.atomic():
token_obj = (
MagicLinkToken.objects.select_for_update()
@ -961,19 +999,23 @@ class MagicLinkVerifyCodeView(APIView):
.first()
)
if not token_obj:
return invalid
if not check_password(code, token_obj.code_hash):
failure = "code_not_found"
elif not check_password(code, token_obj.code_hash):
token_obj.attempts = (token_obj.attempts or 0) + 1
failure = "code_wrong"
if token_obj.attempts >= self.MAX_ATTEMPTS:
token_obj.is_used = True
token_obj.used_at = timezone.now()
failure = "code_attempts_exhausted"
token_obj.save(update_fields=["attempts", "is_used", "used_at"])
return invalid
else:
token_obj.is_used = True
token_obj.used_at = timezone.now()
token_obj.save(update_fields=["is_used", "used_at"])
token_obj.is_used = True
token_obj.used_at = timezone.now()
token_obj.save(update_fields=["is_used", "used_at"])
if failure:
audit_log.login_failure(email, failure, request)
return invalid
# Get or create user
created = False
@ -988,6 +1030,7 @@ class MagicLinkVerifyCodeView(APIView):
created = True
if not user.is_active:
audit_log.login_failure(email, "account_disabled", request)
return _disabled_account_response()
if created:

View file

@ -0,0 +1,236 @@
"""The per-user task calendar feed (G14): managing it, and serving it.
Two halves with opposite trust models.
``CalendarFeedView`` (``/api/profile/calendar-feed/``) is the signed-in member
managing their own feed. It is on the credential deny-list in
``common/scopes.py``, so no personal access token and not the org API key can
read or mint a feed URL: the URL is itself a standing credential, and minting
one from a token would outlive revoking that token. Every query filters on
``org=request.profile.org`` AND ``profile=request.profile``.
``PublicCalendarFeedView`` (``/api/public/calendar/<token>.ics``) is what a
calendar app polls. It takes no credential except the token in the path.
READ THE ORDER BEFORE CHANGING IT, for the reason ``cases/help_center_views.py``
gives: ``task`` is under RLS, so it reads nothing until the context is set. The
token row (not under RLS, see ``CalendarFeedToken``) names the profile; the
profile names the org; the context is set from that org; only then are tasks
read. The feed is the member's own tasks (ones they created or are assigned),
for everyone including admins, whose org-wide view would bury their own work.
It is also narrowed by ``visible_tasks_qs``, so it never shows a task the
member could not open in the app today.
"""
from datetime import timedelta
from django.db.models import Q
from django.http import HttpResponse
from django.utils import timezone
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import status
from rest_framework.negotiation import BaseContentNegotiation
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.throttling import SimpleRateThrottle
from rest_framework.views import APIView
from common.calendar_feed import FEED_PATH_PREFIX, render_calendar
from common.links import api_url, frontend_url
from common.models import CalendarFeedToken
from common.org_time import activate_org_timezone
from common.permissions import HasOrgContext
from common.request_meta import client_ip
from common.tasks import set_rls_context
from tasks.access import visible_tasks_qs
# What the feed carries, all owner decisions: open tasks only, due from 90 days
# ago (so recent overdue work is still on the calendar) to a year ahead, and a
# hard cap so one feed can never become an unbounded export. The cap keeps the
# tasks nearest today: upcoming ones soonest first, then overdue ones most
# recent first.
OPEN_STATUSES = ("New", "In Progress")
PAST_DAYS = 90
FUTURE_DAYS = 365
MAX_EVENTS = 1000
# How stale `last_used_at` may get before a fetch writes it again.
TOUCH_INTERVAL = timedelta(hours=1)
# One body for every miss: a malformed token, an unknown one, a regenerated or
# disabled one, and one whose member, user or org is no longer active. Telling
# any two apart would tell a stranger holding an old URL something about the
# account behind it.
NOT_FOUND = {"error": "Not found"}
def _state(feed):
return {
"error": False,
"enabled": feed is not None,
"created_at": feed.created_at if feed else None,
"last_used_at": feed.last_used_at if feed else None,
}
class CalendarFeedView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext)
def _mine(self, request):
return CalendarFeedToken.objects.filter(
org=request.profile.org, profile=request.profile
)
@extend_schema(tags=["Calendar feed"], operation_id="calendar_feed_status")
def get(self, request):
return Response(_state(self._mine(request).first()))
@extend_schema(
tags=["Calendar feed"],
operation_id="calendar_feed_issue",
request=None,
description=(
"Create the feed, or replace it. Any previous URL stops working. "
"The response carries `url` once; it cannot be read back."
),
)
def post(self, request):
raw, feed = CalendarFeedToken.issue(request.profile)
data = _state(feed)
# Not from the request: the web app asks from its own server, whose
# view of this host is internal (see `common.links.api_url`).
data["url"] = api_url(f"{FEED_PATH_PREFIX}{raw}.ics")
return Response(data, status=status.HTTP_201_CREATED)
@extend_schema(tags=["Calendar feed"], operation_id="calendar_feed_disable")
def delete(self, request):
self._mine(request).delete()
return Response(_state(None))
def _token_ident(view):
return CalendarFeedToken.hash_token(str(view.kwargs.get("token", "")))
class CalendarFeedIPThrottle(SimpleRateThrottle):
"""Per-address limit, bucketed on `client_ip`.
Generous on purpose: calendar services fetch from shared addresses, so one
Google or Microsoft fetcher can carry many users' feeds at once.
"""
scope = "calendar_feed_ip"
def get_cache_key(self, request, view):
return self.cache_format % {
"scope": self.scope,
"ident": client_ip(request) or "unknown",
}
class CalendarFeedTokenThrottle(SimpleRateThrottle):
"""Per-feed limit, bucketed on the token's hash (never the raw token)."""
scope = "calendar_feed_token"
def get_cache_key(self, request, view):
return self.cache_format % {"scope": self.scope, "ident": _token_ident(view)}
class _AlwaysJSON(BaseContentNegotiation):
"""Answer errors in JSON whatever the client asked for.
A calendar app sends `Accept: text/calendar`, which no DRF renderer offers,
so default negotiation turned every fetch, the successful ones included,
into a 406. The feed itself is a plain `HttpResponse` and never rendered.
"""
def select_parser(self, request, parsers):
return parsers[0]
def select_renderer(self, request, renderers, format_suffix=None):
return (renderers[0], renderers[0].media_type)
class PublicCalendarFeedView(APIView):
authentication_classes: list = []
permission_classes = (AllowAny,)
throttle_classes = [CalendarFeedIPThrottle, CalendarFeedTokenThrottle]
renderer_classes = [JSONRenderer]
content_negotiation_class = _AlwaysJSON
def _live_feed(self, token):
"""The feed row for ``token`` if its member may still read it, else None."""
feed = (
CalendarFeedToken.objects.select_related("profile__user", "profile__org")
.filter(token_hash=CalendarFeedToken.hash_token(token))
.first()
)
if feed is None:
return None
profile = feed.profile
if not (profile.is_active and profile.user.is_active and profile.org.is_active):
return None
return feed
@extend_schema(
tags=["Calendar feed"],
operation_id="calendar_feed_ics",
responses={
(200, "text/calendar"): OpenApiResponse(description="iCalendar feed"),
404: OpenApiResponse(description="Unknown, disabled or inactive"),
},
)
def get(self, request, token):
feed = self._live_feed(token)
if feed is None:
return Response(NOT_FOUND, status=status.HTTP_404_NOT_FOUND)
profile = feed.profile
org = profile.org
set_rls_context(org.id)
activate_org_timezone(org)
today = timezone.localdate()
mine = (
visible_tasks_qs(profile)
.filter(Q(created_by=profile.user) | Q(assigned_to=profile))
.filter(status__in=OPEN_STATUSES)
.only("id", "title", "priority", "due_date", "updated_at")
.distinct()
)
upcoming = list(
mine.filter(
due_date__gte=today, due_date__lte=today + timedelta(days=FUTURE_DAYS)
).order_by("due_date", "id")[:MAX_EVENTS]
)
overdue = list(
mine.filter(
due_date__gte=today - timedelta(days=PAST_DAYS), due_date__lt=today
).order_by("-due_date", "id")[: MAX_EVENTS - len(upcoming)]
)
tasks = upcoming + overdue
events = []
for task in tasks:
link = frontend_url(f"/tasks/{task.id}")
events.append(
{
"uid": f"task-{task.id}@bottlecrm",
"stamp": task.updated_at,
"date": task.due_date,
"summary": task.title,
"description": f"Priority: {task.priority}\n{link}",
"url": link,
}
)
now = timezone.now()
if feed.last_used_at is None or now - feed.last_used_at >= TOUCH_INTERVAL:
CalendarFeedToken.objects.filter(pk=feed.pk).update(last_used_at=now)
response = HttpResponse(
render_calendar(events), content_type="text/calendar; charset=utf-8"
)
response["Cache-Control"] = "private, max-age=300"
response["X-Robots-Tag"] = "noindex"
return response

View file

@ -17,6 +17,7 @@ from common.permissions import HasOrgContext, is_org_admin
from common.serializer import TagsSerializer
from contacts.models import Contact
from leads.models import Lead
from macros.models import Macro
from opportunity.models import Opportunity
from tasks.models import Task
from webforms.models import WebForm
@ -45,6 +46,9 @@ _TAGGABLE = (
("api_settings", APISettings),
("solutions", Solution),
("web_forms", WebForm),
# A macro that adds a tag uses it; archiving the tag makes the macro skip
# it, and a merge must move the macro onto the surviving tag.
("macros", Macro),
)

View file

@ -4,6 +4,8 @@ import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber
from common.public_tokens import scrub_public_tokens
DEBUG = False
AWS_STORAGE_BUCKET_NAME = AWS_BUCKET_NAME = os.environ["AWS_BUCKET_NAME"]
@ -151,6 +153,12 @@ sentry_sdk.init(
denylist=[*DEFAULT_DENYLIST, "x-bottlecrm-relay-secret"],
send_default_pii=True,
),
# The calendar feed, survey, invoice and estimate links carry their
# credential in the URL path, which the denylist above cannot see (it
# matches keys, not values). Traces are sampled at 1.0, so every fetch
# would otherwise send its token. See `common.public_tokens`.
before_send=scrub_public_tokens,
before_send_transaction=scrub_public_tokens,
)
RAVEN_CONFIG = {

View file

@ -278,6 +278,11 @@ LOGGING = {
"require_debug_true": {
"()": "django.utils.log.RequireDebugTrue",
},
# `Not Found: <path>` and `Too Many Requests: <path>` would write a
# calendar feed, survey, invoice or estimate token into the log.
"redact_public_tokens": {
"()": "common.public_tokens.RedactPublicTokens",
},
},
"formatters": {
"django.server": {
@ -329,8 +334,14 @@ LOGGING = {
],
"level": "INFO",
},
# No handlers of its own: records propagate to "django" above, already
# redacted, because a logger's filter runs before propagation.
"django.request": {
"filters": ["redact_public_tokens"],
},
"django.server": {
"handlers": ["django.server"],
"filters": ["redact_public_tokens"],
"level": "INFO",
"propagate": False,
},
@ -385,6 +396,14 @@ REST_FRAMEWORK = {
"help_center_global": os.environ.get(
"HELP_CENTER_THROTTLE_GLOBAL", "10000/hour"
),
# Task calendar feed (G14), per address. Generous because Google,
# Microsoft and Apple poll from shared fetchers carrying many users.
"calendar_feed_ip": os.environ.get("CALENDAR_FEED_THROTTLE_IP", "1000/hour"),
# Task calendar feed, per feed. A calendar app polls every few minutes
# at most; this is the ceiling for one URL however many ask for it.
"calendar_feed_token": os.environ.get(
"CALENDAR_FEED_THROTTLE_TOKEN", "60/hour"
),
},
}
@ -541,6 +560,29 @@ JWT_ALGO = "HS256"
DOMAIN_NAME = os.environ.get("DOMAIN_NAME", "http://localhost:8000")
# This API's public origin, the base of the URLs a person copies out of the app
# and uses somewhere else: the task calendar feed and a web form's embed
# snippet (`common.links.api_url`). Left at the dev default in production, a
# calendar subscribes to a port on the member's own machine and a customer's
# site embeds one, and nothing on the server notices. Checked the same way as
# FRONTEND_URL below.
if not IS_DEV_ENV:
_api = urlparse(DOMAIN_NAME)
if _api.scheme not in ("http", "https") or not _api.netloc:
raise ValueError(
f"DOMAIN_NAME is {DOMAIN_NAME!r}, which is not an absolute URL. "
"Calendar feed URLs and web form embed snippets are built from it. "
"Set it to this API's public base URL, for example "
"https://api.example.com."
)
if _api.hostname in ("localhost", "127.0.0.1", "0.0.0.0", "::1"):
raise ValueError(
f"DOMAIN_NAME is {DOMAIN_NAME!r}, which points at this machine. "
"Calendar feed URLs and the web form embed snippets customers paste "
"onto their own sites are built from it. Set it to this API's "
"public base URL, for example https://api.example.com."
)
# The organization API key (`Token: <org.api_key>` header) is one non-expiring
# key per tenant that resolves to an arbitrary active ADMIN. Even now that it is
# read-only and barred from credential endpoints (see common/scopes.py), it

View file

@ -34,3 +34,9 @@ PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
import tempfile # noqa: E402
MEDIA_ROOT = tempfile.mkdtemp(prefix="bottlecrm-test-media-")
# This API's public origin (`common.links.api_url`), pinned so the calendar feed
# URL and web form embed snippets do not depend on the developer's own `.env`.
# Deliberately not `testserver`: a test that sees this host knows the URL came
# from the setting and not from the incoming request.
DOMAIN_NAME = "https://api.example.com"

View file

@ -3,7 +3,6 @@ import logging
from celery import shared_task
from django.conf import settings
from django.core.mail import EmailMessage, EmailMultiAlternatives
from django.db.models import Q
from django.template.loader import render_to_string
from common.links import frontend_url
@ -14,13 +13,6 @@ from leads.models import Lead
logger = logging.getLogger(__name__)
def get_rendered_html(template_name, context=None):
if context is None:
context = {}
html_content = render_to_string(template_name, context)
return html_content
@shared_task
def send_email(
subject,
@ -55,47 +47,6 @@ def send_email(
email.send()
@shared_task
def send_lead_assigned_emails(lead_id, new_assigned_to_list, org_id):
set_rls_context(org_id)
lead_instance = Lead.objects.filter(
~Q(status="converted"), pk=lead_id, is_active=True
).first()
if not (lead_instance and new_assigned_to_list):
return False
users = Profile.objects.filter(id__in=new_assigned_to_list).distinct()
subject = f"Lead '{lead_instance}' has been assigned to you"
from_email = settings.DEFAULT_FROM_EMAIL
template_name = "assigned_to/leads_assigned.html"
# The `site_address` argument this used to take was built by its one caller
# from `request.META["HTTP_HOST"]`, on an endpoint an unauthenticated web
# form posts to. That named the API host, where `/leads/<id>` does not
# exist, and it put a client-supplied value into the link of an email this
# system sends to its own users.
context = {
# `lead`, not `lead_instance`: the template's two senders used
# different names for the same object and it hedged with
# `{{ lead.title|default:lead_instance }}`. Django resolves a filter's
# argument eagerly, so that expression raised `VariableDoesNotExist`
# out of `render_to_string` for whichever sender supplied `lead`,
# which is the one that runs on an ordinary assignment. That email
# never rendered, let alone sent.
"lead": lead_instance,
"url": frontend_url(f"/leads/{lead_instance.id}"),
}
mail_kwargs = {"subject": subject, "from_email": from_email}
for profile in users:
if profile.user.email:
context["user"] = profile.user
html_content = get_rendered_html(template_name, context)
mail_kwargs["html_content"] = html_content
mail_kwargs["recipients"] = [profile.user.email]
send_email.delay(**mail_kwargs)
return None
@shared_task
def send_email_to_assigned_user(recipients, lead_id, org_id, source=""):
"""Send Mail To Users When they are assigned to a lead"""

View file

@ -22,7 +22,7 @@ from leads.serializer import (
)
from webforms.dynamic_serializer import build_serializer
from webforms.legacy import ensure_web_form
from webforms.service import active_assignee, submit_form
from webforms.service import submit_form
from webforms.tasks import send_webform_submission_email
logger = logging.getLogger(__name__)
@ -369,13 +369,17 @@ class CreateLeadFromSite(APIView):
created_by=form.created_by,
is_active=True,
)
# Only a contact this submission created takes the form's
# assignee. Adding them to an existing one would let anybody who
# knows an address make the form's assignee able to open that
# person's record, which nobody ever gave them.
assignee = active_assignee(form)
if created and assignee is not None:
contact.assigned_to.add(assignee)
# Only a contact this submission created takes the lead's active
# assignees (the form's person, or whoever its rotation picked).
# Adding them to an existing one would let anybody who knows an
# address make them able to open that person's record, which
# nobody ever gave them.
if created:
contact.assigned_to.add(
*submission.lead.assigned_to.filter(
org=api_setting.org, is_active=True
)
)
submission.lead.contacts.add(contact)
except (IntegrityError, ValidationError, ValueError):
logger.warning(

View file

@ -0,0 +1,68 @@
# Generated by Django 6.1.1 on 2026-09-26 18:49
#
# The two join tables (`macro_tags`, `macro_set_assignees`) carry no
# org_id, like every other M2M table here, so they take no RLS policy of their
# own: both ends are org-scoped rows, and the serializer admits only same-org
# profiles and tags.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0046_securityauditlog_record_merged"),
("macros", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="macro",
name="tags",
field=models.ManyToManyField(
blank=True, related_name="macros_tagging", to="common.tags"
),
),
migrations.AddField(
model_name="macro",
name="set_assignees",
field=models.ManyToManyField(
blank=True, related_name="macros_assigning", to="common.profile"
),
),
migrations.AddField(
model_name="macro",
name="set_priority",
field=models.CharField(
blank=True,
choices=[
("Low", "Low"),
("Normal", "Normal"),
("High", "High"),
("Urgent", "Urgent"),
],
default="",
max_length=64,
),
),
migrations.AddField(
model_name="macro",
name="set_status",
field=models.CharField(
blank=True,
choices=[
("New", "New"),
("Assigned", "Assigned"),
("Pending", "Pending"),
("Closed", "Closed"),
("Rejected", "Rejected"),
],
default="",
max_length=64,
),
),
migrations.AlterField(
model_name="macro",
name="body",
field=models.TextField(blank=True, default=""),
),
]

View file

@ -1,7 +1,16 @@
from django.db import models
from common.base import BaseModel
from common.models import Org, Profile
from common.models import Org, Profile, Tags
from common.utils import PRIORITY_CHOICE, STATUS_CHOICE
# A macro may move a ticket to any status a person may choose, which is every
# one but Duplicate: that status belongs to the merge, which also records where
# the ticket went (`cases.workflow.duplicate_refusal`).
MACRO_STATUS_CHOICES = [c for c in STATUS_CHOICE if c[0] != "Duplicate"]
# The four actions, by the names `POST /macros/<id>/apply/` takes in `only`.
ACTION_KEYS = ("status", "priority", "assignees", "tags")
class Macro(BaseModel):
@ -11,6 +20,12 @@ class Macro(BaseModel):
declare our own org FK rather than using BaseOrgModel. Macros are either
`org`-scoped (visible to everyone in the org, admin-managed) or
`personal` (visible only to the owning Profile).
Optional actions ride along with the text: a status, a priority, a set of
assignees (replacing the ticket's) and tags (added to the ticket's). They
are applied through `cases.updates.update_case`, the ticket PATCH's own
path, by `POST /macros/<id>/apply/`. Blank or empty means "no change".
A macro may be actions only, with an empty body.
"""
SCOPE_ORG = "org"
@ -21,7 +36,7 @@ class Macro(BaseModel):
]
title = models.CharField(max_length=255)
body = models.TextField()
body = models.TextField(blank=True, default="")
scope = models.CharField(max_length=10, choices=SCOPE_CHOICES, default=SCOPE_ORG)
owner = models.ForeignKey(
Profile,
@ -33,6 +48,20 @@ class Macro(BaseModel):
is_active = models.BooleanField(default=True)
usage_count = models.PositiveIntegerField(default=0)
set_status = models.CharField(
max_length=64, choices=MACRO_STATUS_CHOICES, blank=True, default=""
)
set_priority = models.CharField(
max_length=64, choices=PRIORITY_CHOICE, blank=True, default=""
)
set_assignees = models.ManyToManyField(
Profile, blank=True, related_name="macros_assigning"
)
# `add_tags` in the API. Named `tags` here because the tag registry
# (`common.views.tags_views._TAGGABLE`) counts usage and merges tags
# through a `tags` relation, and a macro that adds a tag is a use of it.
tags = models.ManyToManyField(Tags, blank=True, related_name="macros_tagging")
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="macros")
class Meta:
@ -56,3 +85,16 @@ class Macro(BaseModel):
def __str__(self):
return f"{self.title} ({self.scope})"
def action_keys(self):
"""The actions this macro carries, as `ACTION_KEYS` names, in order."""
keys = []
if self.set_status:
keys.append("status")
if self.set_priority:
keys.append("priority")
if self.set_assignees.exists():
keys.append("assignees")
if self.tags.exists():
keys.append("tags")
return keys

View file

@ -2,19 +2,38 @@
from rest_framework import serializers
from common.models import Profile, Tags
from macros.models import Macro
from macros.render import find_unknown_placeholders
class MacroSerializer(serializers.ModelSerializer):
"""Serialize Macro for read and (admin) write paths.
"""Serialize Macro for read and write paths.
Scope/owner enforcement happens in the view because it depends on the
requesting profile (admin can create org macros, others cannot).
`set_assignees` and `add_tags` take id lists and resolve them only among
the caller's own org (`context["org"]`), so another org's id is refused
with a 400 like an id that does not exist. `profile` is outside
ORG_SCOPED_TABLES, so there is no RLS policy behind this lookup: the
filter is the control. With no org in context both lookups are empty and
every id is refused.
"""
owner_name = serializers.SerializerMethodField()
unknown_placeholders = serializers.SerializerMethodField()
set_assignees = serializers.PrimaryKeyRelatedField(
many=True, required=False, queryset=Profile.objects.none()
)
add_tags = serializers.PrimaryKeyRelatedField(
source="tags", many=True, required=False, queryset=Tags.objects.none()
)
# Who and what the ids name, with `is_active`, so a client can show a
# deactivated assignee or an archived tag the macro still carries rather
# than dropping it from a picker built from active rows only.
set_assignees_details = serializers.SerializerMethodField()
add_tags_details = serializers.SerializerMethodField()
class Meta:
model = Macro
@ -28,6 +47,12 @@ class MacroSerializer(serializers.ModelSerializer):
"is_active",
"usage_count",
"unknown_placeholders",
"set_status",
"set_priority",
"set_assignees",
"add_tags",
"set_assignees_details",
"add_tags_details",
"created_at",
"updated_at",
)
@ -41,6 +66,73 @@ class MacroSerializer(serializers.ModelSerializer):
"updated_at",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
org = self.context.get("org")
if org is not None:
self.fields[
"set_assignees"
].child_relation.queryset = Profile.objects.filter(org=org)
self.fields["add_tags"].child_relation.queryset = Tags.objects.filter(
org=org
)
def _stored_ids(self, name):
if self.instance is None:
return set()
return set(getattr(self.instance, name).values_list("id", flat=True))
def validate_set_assignees(self, value):
"""Only active members may be newly named.
One the macro already carries is kept even after they are deactivated:
both clients resend the whole list on every save, so refusing it would
make the macro unsaveable until somebody noticed, and applying skips a
deactivated assignee anyway (`MacroApplyView`). The same allowance
`WebFormDetailSerializer.validate_assign_to` makes.
"""
stored = self._stored_ids("set_assignees")
for profile in value:
if not profile.is_active and profile.id not in stored:
raise serializers.ValidationError(
"This user is deactivated. Choose an active member."
)
return value
def validate_add_tags(self, value):
"""Only active tags may be newly added; a stored archived one is kept,
for the reason `validate_set_assignees` gives."""
stored = self._stored_ids("tags")
for tag in value:
if not tag.is_active and tag.id not in stored:
raise serializers.ValidationError(
"This tag is archived. Choose an active tag."
)
return value
def validate(self, attrs):
"""A macro has to do something: say something, act, or both."""
attrs = super().validate(attrs)
def current(name):
if name in attrs:
return attrs[name]
if self.instance is None:
return None
value = getattr(self.instance, name)
return list(value.all()) if name in ("set_assignees", "tags") else value
has_body = bool((current("body") or "").strip())
has_action = any(
current(name)
for name in ("set_status", "set_priority", "set_assignees", "tags")
)
if not has_body and not has_action:
raise serializers.ValidationError(
{"body": "A macro needs a body, an action, or both."}
)
return attrs
def get_owner_name(self, obj):
# `User` only stores `email`, no first_name/last_name on this model
# , so we surface the email directly. Frontend can display whatever
@ -54,3 +146,25 @@ class MacroSerializer(serializers.ModelSerializer):
# on the ticket (see render_macro). Surfacing them here gives mobile
# and API consumers the same warning the web UI computes client-side.
return find_unknown_placeholders(obj.body)
def get_set_assignees_details(self, obj):
return [
{
"id": str(profile.id),
"email": profile.user.email,
"name": profile.user.name,
"is_active": profile.is_active,
}
for profile in obj.set_assignees.all()
]
def get_add_tags_details(self, obj):
return [
{
"id": str(tag.id),
"name": tag.name,
"color": tag.color,
"is_active": tag.is_active,
}
for tag in obj.tags.all()
]

View file

@ -0,0 +1,636 @@
"""Macro actions (G18): what a macro stores, and what applying one does.
Applying goes through `cases.updates.update_case`, the ticket PATCH's own
write, so every gate a PATCH meets is met here too; these tests pin the ones a
macro can reach.
"""
from unittest.mock import patch
import pytest
from django.utils import timezone
from cases.approvals import Approval, ApprovalRule
from cases.models import Case, CaseWatcher
from common.models import Profile, Tags, User
from macros.models import Macro
LIST_URL = "/api/macros/"
MISSING = "00000000-0000-0000-0000-000000000000"
def _detail_url(pk):
return f"/api/macros/{pk}/"
def _apply_url(pk):
return f"/api/macros/{pk}/apply/"
def _member(org, email, *, active=True):
user = User.objects.create_user(email=email, password="x")
return Profile.objects.create(user=user, org=org, role="USER", is_active=active)
def _tag(org, name, *, active=True):
return Tags.objects.create(org=org, name=name, is_active=active)
def _case(org, *, creator=None, status="New", priority="Normal", name="Printer"):
case = Case.objects.create(org=org, name=name, status=status, priority=priority)
if creator is not None:
Case.objects.filter(pk=case.pk).update(created_by=creator)
case.refresh_from_db()
return case
def _macro(org, **fields):
assignees = fields.pop("assignees", [])
tags = fields.pop("tags", [])
fields.setdefault("title", "Escalate")
fields.setdefault("body", "")
macro = Macro.objects.create(org=org, **fields)
macro.set_assignees.set(assignees)
macro.tags.set(tags)
return macro
@pytest.fixture
def mailer():
with patch("cases.updates.send_email_to_assigned_user") as task:
yield task
# ---------------------------------------------------------------------------
# Storing actions
# ---------------------------------------------------------------------------
class TestMacroActionsCrud:
def test_admin_creates_org_macro_with_every_action(self, admin_client, org_a):
agent = _member(org_a, "agent@a.test")
tag = _tag(org_a, "vip")
resp = admin_client.post(
LIST_URL,
{
"title": "Escalate",
"body": "Passing this on.",
"scope": "org",
"set_status": "Pending",
"set_priority": "Urgent",
"set_assignees": [str(agent.id)],
"add_tags": [str(tag.id)],
},
format="json",
)
assert resp.status_code == 201, resp.json()
body = resp.json()
assert body["set_status"] == "Pending"
assert body["set_priority"] == "Urgent"
assert body["set_assignees"] == [str(agent.id)]
assert body["add_tags"] == [str(tag.id)]
assert body["set_assignees_details"] == [
{
"id": str(agent.id),
"email": "agent@a.test",
"name": "agent",
"is_active": True,
}
]
assert body["add_tags_details"][0]["name"] == "vip"
def test_actions_only_macro_with_empty_body_is_accepted(self, user_client):
resp = user_client.post(
LIST_URL,
{
"title": "Close it",
"body": "",
"scope": "personal",
"set_status": "Closed",
},
format="json",
)
assert resp.status_code == 201, resp.json()
def test_empty_body_and_no_action_is_refused(self, user_client):
resp = user_client.post(
LIST_URL,
{"title": "Nothing", "body": " ", "scope": "personal"},
format="json",
)
assert resp.status_code == 400
assert "body" in resp.json()
def test_patch_cannot_strip_the_last_thing_a_macro_does(
self, user_client, org_a, user_profile
):
macro = _macro(org_a, scope="personal", owner=user_profile, set_priority="High")
resp = user_client.patch(
_detail_url(macro.id), {"set_priority": ""}, format="json"
)
assert resp.status_code == 400
def test_duplicate_status_is_refused(self, user_client):
resp = user_client.post(
LIST_URL,
{"title": "t", "body": "b", "scope": "personal", "set_status": "Duplicate"},
format="json",
)
assert resp.status_code == 400
assert "set_status" in resp.json()
def test_off_enum_priority_is_refused(self, user_client):
resp = user_client.post(
LIST_URL,
{"title": "t", "body": "b", "scope": "personal", "set_priority": "Now"},
format="json",
)
assert resp.status_code == 400
assert "set_priority" in resp.json()
def test_another_orgs_profile_is_refused(self, user_client, profile_b):
resp = user_client.post(
LIST_URL,
{
"title": "t",
"body": "b",
"scope": "personal",
"set_assignees": [str(profile_b.id)],
},
format="json",
)
assert resp.status_code == 400
assert "set_assignees" in resp.json()
def test_another_orgs_tag_is_refused(self, user_client, org_b):
foreign = _tag(org_b, "theirs")
resp = user_client.post(
LIST_URL,
{
"title": "t",
"body": "b",
"scope": "personal",
"add_tags": [str(foreign.id)],
},
format="json",
)
assert resp.status_code == 400
assert "add_tags" in resp.json()
def test_a_malformed_id_is_400_not_500(self, user_client):
resp = user_client.post(
LIST_URL,
{"title": "t", "body": "b", "scope": "personal", "add_tags": ["nope"]},
format="json",
)
assert resp.status_code == 400
def test_a_deactivated_member_cannot_be_newly_named(self, user_client, org_a):
gone = _member(org_a, "gone@a.test", active=False)
resp = user_client.post(
LIST_URL,
{
"title": "t",
"body": "b",
"scope": "personal",
"set_assignees": [str(gone.id)],
},
format="json",
)
assert resp.status_code == 400
assert "deactivated" in str(resp.json()["set_assignees"])
def test_an_archived_tag_cannot_be_newly_added(self, user_client, org_a):
old = _tag(org_a, "old", active=False)
resp = user_client.post(
LIST_URL,
{
"title": "t",
"body": "b",
"scope": "personal",
"add_tags": [str(old.id)],
},
format="json",
)
assert resp.status_code == 400
assert "add_tags" in resp.json()
def test_a_stored_deactivated_assignee_and_archived_tag_survive_an_edit(
self, user_client, org_a, user_profile
):
gone = _member(org_a, "gone@a.test")
old = _tag(org_a, "old")
macro = _macro(
org_a,
scope="personal",
owner=user_profile,
body="b",
assignees=[gone],
tags=[old],
)
Profile.objects.filter(pk=gone.pk).update(is_active=False)
Tags.objects.filter(pk=old.pk).update(is_active=False)
resp = user_client.patch(
_detail_url(macro.id),
{
"title": "Renamed",
"set_assignees": [str(gone.id)],
"add_tags": [str(old.id)],
},
format="json",
)
assert resp.status_code == 200, resp.json()
assert resp.json()["set_assignees_details"][0]["is_active"] is False
assert resp.json()["add_tags_details"][0]["is_active"] is False
def test_patch_without_the_relations_leaves_them(
self, user_client, org_a, user_profile
):
agent = _member(org_a, "agent@a.test")
macro = _macro(
org_a, scope="personal", owner=user_profile, body="b", assignees=[agent]
)
resp = user_client.patch(_detail_url(macro.id), {"title": "x"}, format="json")
assert resp.status_code == 200
assert list(macro.set_assignees.all()) == [agent]
def test_member_cannot_edit_org_macro_actions(self, user_client, org_a):
macro = _macro(org_a, scope="org", body="b")
resp = user_client.patch(
_detail_url(macro.id), {"set_status": "Closed"}, format="json"
)
assert resp.status_code == 403
macro.refresh_from_db()
assert macro.set_status == ""
def test_admin_can_edit_org_macro_actions(self, admin_client, org_a):
macro = _macro(org_a, scope="org", body="b")
resp = admin_client.patch(
_detail_url(macro.id), {"set_status": "Closed"}, format="json"
)
assert resp.status_code == 200
macro.refresh_from_db()
assert macro.set_status == "Closed"
def test_list_carries_the_actions(self, user_client, org_a):
agent = _member(org_a, "agent@a.test")
_macro(org_a, scope="org", set_priority="High", assignees=[agent])
row = user_client.get(LIST_URL).json()["results"][0]
assert row["set_priority"] == "High"
assert row["set_assignees_details"][0]["email"] == "agent@a.test"
# ---------------------------------------------------------------------------
# Applying actions
# ---------------------------------------------------------------------------
class TestApplyAccess:
def test_writer_may_apply(self, user_client, org_a, regular_user, mailer):
case = _case(org_a, creator=regular_user)
macro = _macro(org_a, scope="org", set_priority="Urgent")
resp = user_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 200, resp.json()
assert resp.json()["applied"] == ["priority"]
assert resp.json()["skipped"] == []
case.refresh_from_db()
assert case.priority == "Urgent"
def test_admin_may_apply_to_any_org_ticket(self, admin_client, org_a, mailer):
case = _case(org_a)
macro = _macro(org_a, scope="org", set_priority="Low")
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 200
case.refresh_from_db()
assert case.priority == "Low"
def test_watcher_is_403_and_nothing_changes(self, user_client, org_a, user_profile):
case = _case(org_a)
CaseWatcher.objects.create(case=case, profile=user_profile, org=org_a)
macro = _macro(org_a, scope="org", set_priority="Urgent")
resp = user_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 403
case.refresh_from_db()
assert case.priority == "Normal"
def test_hidden_same_org_ticket_is_404_like_a_missing_one(self, user_client, org_a):
case = _case(org_a)
macro = _macro(org_a, scope="org", set_priority="Urgent")
hidden = user_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
missing = user_client.post(
_apply_url(macro.id), {"case_id": MISSING}, format="json"
)
assert hidden.status_code == missing.status_code == 404
assert hidden.json() == missing.json()
case.refresh_from_db()
assert case.priority == "Normal"
def test_malformed_case_id_is_404(self, user_client, org_a):
macro = _macro(org_a, scope="org", set_priority="Urgent")
resp = user_client.post(
_apply_url(macro.id), {"case_id": "nope"}, format="json"
)
assert resp.status_code == 404
def test_other_orgs_ticket_is_404(self, admin_client, org_a, org_b):
case = _case(org_b)
macro = _macro(org_a, scope="org", set_priority="Urgent")
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 404
def test_someone_elses_personal_macro_is_404(
self, user_client, org_a, regular_user, admin_profile
):
case = _case(org_a, creator=regular_user)
macro = _macro(
org_a, scope="personal", owner=admin_profile, set_priority="Urgent"
)
resp = user_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 404
def test_own_personal_macro_applies(
self, user_client, org_a, regular_user, user_profile, mailer
):
case = _case(org_a, creator=regular_user)
macro = _macro(
org_a, scope="personal", owner=user_profile, set_priority="Urgent"
)
resp = user_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 200
def test_inactive_macro_is_400(self, admin_client, org_a):
case = _case(org_a)
macro = _macro(org_a, scope="org", set_priority="Urgent", is_active=False)
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 400
assert resp.json() == {"error": "Macro is inactive."}
def test_macro_with_no_actions_is_400(self, admin_client, org_a):
case = _case(org_a)
macro = _macro(org_a, scope="org", body="Just words")
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 400
def test_case_id_is_required(self, admin_client, org_a):
macro = _macro(org_a, scope="org", set_priority="Urgent")
resp = admin_client.post(_apply_url(macro.id), {}, format="json")
assert resp.status_code == 400
def test_apply_does_not_count_a_use(self, admin_client, org_a, mailer):
case = _case(org_a)
macro = _macro(org_a, scope="org", set_priority="Urgent")
admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
macro.refresh_from_db()
assert macro.usage_count == 0
class TestApplyGates:
def test_merged_ticket_status_is_400(self, admin_client, org_a):
target = _case(org_a, name="Target")
merged = _case(org_a, name="Merged", status="Duplicate")
Case.objects.filter(pk=merged.pk).update(merged_into=target)
macro = _macro(org_a, scope="org", set_status="Pending")
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(merged.id)}, format="json"
)
assert resp.status_code == 400
assert "Unmerge" in str(resp.json()["errors"])
merged.refresh_from_db()
assert merged.status == "Duplicate"
def test_close_under_a_pre_close_rule_is_400(self, admin_client, org_a):
ApprovalRule.objects.create(
name="Close gate", org=org_a, trigger_event="pre_close", is_active=True
)
case = _case(org_a)
macro = _macro(org_a, scope="org", set_status="Closed", set_priority="Low")
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 400
assert "approval" in str(resp.json()["errors"]["status"]).lower()
case.refresh_from_db()
# Refused whole: the priority in the same macro did not land either.
assert (case.status, case.priority) == ("New", "Normal")
def test_close_with_the_approval_recorded_goes_through(
self, admin_client, org_a, admin_profile, mailer
):
rule = ApprovalRule.objects.create(
name="Close gate", org=org_a, trigger_event="pre_close", is_active=True
)
case = _case(org_a)
Approval.objects.create(
case=case,
rule=rule,
state="approved",
requested_by=admin_profile,
org=org_a,
)
macro = _macro(org_a, scope="org", set_status="Closed")
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 200, resp.json()
case.refresh_from_db()
assert case.status == "Closed"
assert case.closed_on == timezone.localdate()
def test_close_supplies_todays_date(self, admin_client, org_a, mailer):
case = _case(org_a)
macro = _macro(org_a, scope="org", set_status="Closed")
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 200, resp.json()
case.refresh_from_db()
assert case.status == "Closed" and case.closed_on is not None
def test_a_forced_duplicate_status_is_still_refused(self, admin_client, org_a):
# The serializer refuses Duplicate; a row written around it still
# meets the ticket's own rule, Duplicate only by merge.
case = _case(org_a)
macro = _macro(org_a, scope="org", body="b")
Macro.objects.filter(pk=macro.pk).update(set_status="Duplicate")
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 400
case.refresh_from_db()
assert case.status == "New"
class TestApplyRelations:
def test_assignees_replace_and_only_the_newly_added_are_emailed(
self, admin_client, org_a, mailer, django_capture_on_commit_callbacks
):
stays = _member(org_a, "stays@a.test")
leaves = _member(org_a, "leaves@a.test")
joins = _member(org_a, "joins@a.test")
case = _case(org_a)
case.assigned_to.add(stays, leaves)
macro = _macro(org_a, scope="org", assignees=[stays, joins])
with django_capture_on_commit_callbacks(execute=True):
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 200
assert set(case.assigned_to.all()) == {stays, joins}
mailer.delay.assert_called_once()
assert mailer.delay.call_args[0][0] == [joins.id]
def test_tags_are_appended(self, admin_client, org_a, mailer):
keep = _tag(org_a, "keep")
add = _tag(org_a, "add")
case = _case(org_a)
case.tags.add(keep)
macro = _macro(org_a, scope="org", tags=[add])
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 200
assert set(case.tags.values_list("name", flat=True)) == {"keep", "add"}
def test_a_deactivated_assignee_is_dropped_the_rest_apply(
self, admin_client, org_a, mailer
):
live = _member(org_a, "live@a.test")
gone = _member(org_a, "gone@a.test")
macro = _macro(org_a, scope="org", assignees=[live, gone])
Profile.objects.filter(pk=gone.pk).update(is_active=False)
case = _case(org_a)
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 200
assert list(case.assigned_to.all()) == [live]
def test_only_deactivated_assignees_are_skipped_not_unassigned(
self, admin_client, org_a, mailer
):
current = _member(org_a, "current@a.test")
gone = _member(org_a, "gone@a.test")
macro = _macro(org_a, scope="org", set_priority="High", assignees=[gone])
Profile.objects.filter(pk=gone.pk).update(is_active=False)
case = _case(org_a)
case.assigned_to.add(current)
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 200
body = resp.json()
assert body["applied"] == ["priority"]
assert [s["action"] for s in body["skipped"]] == ["assignees"]
assert list(case.assigned_to.all()) == [current]
case.refresh_from_db()
assert case.priority == "High"
def test_only_archived_tags_are_skipped(self, admin_client, org_a, mailer):
old = _tag(org_a, "old")
macro = _macro(org_a, scope="org", tags=[old])
Tags.objects.filter(pk=old.pk).update(is_active=False)
case = _case(org_a)
resp = admin_client.post(
_apply_url(macro.id), {"case_id": str(case.id)}, format="json"
)
assert resp.status_code == 200
assert resp.json()["applied"] == []
assert resp.json()["skipped"][0]["action"] == "tags"
assert case.tags.count() == 0
class TestApplyOnly:
def test_only_the_kept_actions_apply(self, admin_client, org_a, mailer):
agent = _member(org_a, "agent@a.test")
case = _case(org_a)
macro = _macro(
org_a,
scope="org",
set_status="Pending",
set_priority="Urgent",
assignees=[agent],
)
resp = admin_client.post(
_apply_url(macro.id),
{"case_id": str(case.id), "only": ["priority"]},
format="json",
)
assert resp.status_code == 200
assert resp.json()["applied"] == ["priority"]
case.refresh_from_db()
assert (case.status, case.priority) == ("New", "Urgent")
assert case.assigned_to.count() == 0
def test_an_unknown_action_name_is_400(self, admin_client, org_a):
case = _case(org_a)
macro = _macro(org_a, scope="org", set_priority="Urgent")
resp = admin_client.post(
_apply_url(macro.id),
{"case_id": str(case.id), "only": ["delete"]},
format="json",
)
assert resp.status_code == 400
def test_only_not_a_list_is_400(self, admin_client, org_a):
case = _case(org_a)
macro = _macro(org_a, scope="org", set_priority="Urgent")
resp = admin_client.post(
_apply_url(macro.id),
{"case_id": str(case.id), "only": "priority"},
format="json",
)
assert resp.status_code == 400
def test_nothing_left_after_only_is_400(self, admin_client, org_a):
case = _case(org_a)
macro = _macro(org_a, scope="org", set_priority="Urgent")
resp = admin_client.post(
_apply_url(macro.id),
{"case_id": str(case.id), "only": ["status"]},
format="json",
)
assert resp.status_code == 400
case.refresh_from_db()
assert case.priority == "Normal"
class TestMacroTagsAreTagUsage:
"""A macro that adds a tag is a use of it (`_TAGGABLE`), so the tag does
not read as unused and a merge moves the macro onto the surviving tag."""
def test_counted_in_usage(self, admin_client, org_a):
tag = _tag(org_a, "vip")
_macro(org_a, scope="org", tags=[tag])
rows = admin_client.get("/api/tags/").json()["tags"]
row = next(r for r in rows if r["id"] == str(tag.id))
assert row["usage"]["macros"] == 1
def test_merge_moves_the_macro(self, admin_client, org_a):
source = _tag(org_a, "old")
target = _tag(org_a, "new")
macro = _macro(org_a, scope="org", tags=[source])
resp = admin_client.post(
f"/api/tags/{source.id}/merge/", {"into": str(target.id)}, format="json"
)
assert resp.status_code == 200, resp.json()
assert list(macro.tags.all()) == [target]

View file

@ -0,0 +1,193 @@
"""A macro that touches a ticket needs the ticket's scope too.
`/api/macros/<id>/apply/` changes a case and `/api/macros/<id>/render/` returns
its subject and its contact's name and email. Scoped by their first path
segment alone, a `macros:write` token could change a ticket it holds no
`cases:write` for (create a personal macro, then apply it), and a macros token
could read tickets through render. Both now also need the cases scope.
"""
import uuid
import pytest
from django.core.cache import cache
from common import scopes
from common.tests.test_org_api_key_scope import _key_client
from common.tests.test_pat_scope_enforcement import _pat_client
from macros.models import Macro
@pytest.fixture(autouse=True)
def _fresh_throttle():
cache.clear()
yield
cache.clear()
@pytest.fixture
def ticket(case_factory, contact_factory):
return case_factory(name="Printer", contact=contact_factory())
@pytest.fixture
def urgent_macro(org_a):
return Macro.objects.create(
org=org_a,
title="Escalate",
body="Hi %customer_name%, about %case_subject%.",
scope=Macro.SCOPE_ORG,
set_priority="Urgent",
)
def _apply(client, macro, case):
return client.post(
f"/api/macros/{macro.id}/apply/", {"case_id": str(case.id)}, format="json"
)
def _render(client, macro, case):
return client.post(
f"/api/macros/{macro.id}/render/", {"case_id": str(case.id)}, format="json"
)
class TestMatcher:
ID = str(uuid.uuid4())
@pytest.mark.parametrize(
"path",
[
f"/api/macros/{ID}/apply/",
# Every spelling the `<uid:pk>` converter routes to the same view.
f"/api/macros/{ID.replace('-', '')}/apply/",
f"/api/macros/{ID.upper()}/apply/",
f"/api/macros/{{{ID}}}/apply/",
],
)
def test_apply_needs_cases_write(self, path):
assert scopes.required_scopes("POST", path) == ["macros:write", "cases:write"]
def test_render_is_a_read_that_needs_cases_read(self):
path = f"/api/macros/{self.ID}/render/"
assert scopes.action_for("POST", path) == "read"
assert scopes.required_scopes("POST", path) == ["macros:read", "cases:read"]
@pytest.mark.parametrize(
"path",
[
"/api/macros/",
f"/api/macros/{ID}/",
f"/api/macros/{ID}/apply", # missing slash
f"/api/macros/{ID}/apply/extra/",
f"/api/macros/{ID}/x/apply/",
"/api/macros//apply/",
f"/api/macrosx/{ID}/apply/",
f"/api/cases/{ID}/apply/",
f"/macros/{ID}/apply/",
],
)
def test_lookalike_paths_are_plain_macro_writes(self, path):
needed = scopes.required_scopes("POST", path)
assert needed is None or needed == [f"{scopes.resource_for_path(path)}:write"]
def test_render_lookalike_stays_a_write(self):
path = f"/api/macros/{self.ID}/render/extra/"
assert scopes.action_for("POST", path) == "write"
def test_the_denial_names_the_missing_cases_scope(self):
denial = scopes.check_request(
["macros:write"], "POST", f"/api/macros/{self.ID}/apply/"
)
assert denial == (
"This token is not scoped for write access to cases. "
"It needs the cases:write scope."
)
def test_unscoped_token_is_still_unrestricted(self):
assert scopes.check_request([], "POST", f"/api/macros/{self.ID}/apply/") is None
@pytest.mark.django_db
class TestApplyOverHttp:
def test_macros_write_alone_cannot_change_a_ticket(self, admin_profile, ticket):
client = _pat_client(admin_profile, scopes=["macros:write"])
created = client.post(
"/api/macros/",
{"title": "x", "scope": "personal", "set_priority": "Urgent"},
format="json",
)
assert created.status_code == 201, created.content
macro = Macro.objects.get(id=created.json()["id"])
response = _apply(client, macro, ticket)
assert response.status_code == 403
assert "cases:write" in response.json()["detail"]
ticket.refresh_from_db()
assert ticket.priority == "Normal"
def test_macros_write_with_cases_write_applies(
self, admin_profile, ticket, urgent_macro
):
client = _pat_client(admin_profile, scopes=["macros:write", "cases:write"])
response = _apply(client, urgent_macro, ticket)
assert response.status_code == 200, response.content
ticket.refresh_from_db()
assert ticket.priority == "Urgent"
def test_cases_write_alone_cannot_apply(self, admin_profile, ticket, urgent_macro):
client = _pat_client(admin_profile, scopes=["cases:write"])
response = _apply(client, urgent_macro, ticket)
assert response.status_code == 403
assert "macros:write" in response.json()["detail"]
ticket.refresh_from_db()
assert ticket.priority == "Normal"
def test_wildcard_write_applies(self, admin_profile, ticket, urgent_macro):
client = _pat_client(admin_profile, scopes=["*:write"])
response = _apply(client, urgent_macro, ticket)
assert response.status_code == 200, response.content
ticket.refresh_from_db()
assert ticket.priority == "Urgent"
def test_org_api_key_cannot_apply(self, org_a, admin_profile, ticket, urgent_macro):
response = _apply(_key_client(org_a), urgent_macro, ticket)
assert response.status_code == 403
ticket.refresh_from_db()
assert ticket.priority == "Normal"
@pytest.mark.django_db
class TestRenderOverHttp:
def test_macros_read_alone_cannot_render(self, admin_profile, ticket, urgent_macro):
client = _pat_client(admin_profile, scopes=["macros:read"])
response = _render(client, urgent_macro, ticket)
assert response.status_code == 403
assert "cases:read" in response.json()["detail"]
assert "Printer" not in response.content.decode()
urgent_macro.refresh_from_db()
assert urgent_macro.usage_count == 0
def test_macros_read_with_cases_read_renders(
self, admin_profile, ticket, urgent_macro
):
client = _pat_client(admin_profile, scopes=["macros:read", "cases:read"])
response = _render(client, urgent_macro, ticket)
assert response.status_code == 200, response.content
assert "Printer" in response.json()["rendered_body"]
def test_macros_write_alone_cannot_render(
self, admin_profile, ticket, urgent_macro
):
# Render is a read now, and `write` never implies `read`.
client = _pat_client(admin_profile, scopes=["macros:write", "cases:write"])
assert _render(client, urgent_macro, ticket).status_code == 403
def test_org_api_key_may_render(self, org_a, admin_profile, ticket, urgent_macro):
# The key's `*:read` covers both scopes, and it can already read the
# case itself through GET /api/cases/<id>/.
response = _render(_key_client(org_a), urgent_macro, ticket)
assert response.status_code == 200, response.content
assert "Printer" in response.json()["rendered_body"]

View file

@ -8,4 +8,5 @@ urlpatterns = [
path("", views.MacroListCreateView.as_view(), name="list_create"),
path("<uid:pk>/", views.MacroDetailView.as_view(), name="detail"),
path("<uid:pk>/render/", views.MacroRenderView.as_view(), name="render"),
path("<uid:pk>/apply/", views.MacroApplyView.as_view(), name="apply"),
]

View file

@ -12,6 +12,9 @@ Routes (all under /api/macros/):
POST /<id>/render/: server-side substitute placeholders against
the requested case and return the rendered
body. Increments usage_count.
POST /<id>/apply/: apply the macro's actions (status, priority,
assignees, tags) to a case, through the
ticket PATCH's own write path.
"""
from django.db import transaction
@ -22,9 +25,10 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from cases.access import get_case_or_404
from cases.access import assert_case_write_access, get_case_or_404
from cases.updates import update_case
from common.permissions import HasOrgContext, is_org_admin
from macros.models import Macro
from macros.models import ACTION_KEYS, Macro
from macros.render import (
SUPPORTED_PLACEHOLDERS,
find_unknown_placeholders,
@ -41,6 +45,38 @@ def _visible_qs(profile):
)
def _usable_or_response(request, pk):
"""The macro ``pk`` if the requester may use it, or the refusal.
Render and apply share this. Someone else's personal macro answers 404
like a missing id, so the id space does not reveal whose private macros
exist. An inactive macro is refused with a 400.
"""
macro = get_object_or_404(Macro, pk=pk, org=request.profile.org)
if macro.scope == Macro.SCOPE_PERSONAL and macro.owner_id != request.profile.id:
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
if not macro.is_active:
return Response(
{"error": "Macro is inactive."},
status=status.HTTP_400_BAD_REQUEST,
)
return macro
def _serialize(request, macro_or_qs, **kwargs):
return MacroSerializer(
macro_or_qs, context={"org": request.profile.org}, **kwargs
).data
def _save_actions(macro, validated_data):
"""Write the two relation actions a request carried; absent means unchanged."""
if "set_assignees" in validated_data:
macro.set_assignees.set(validated_data["set_assignees"])
if "tags" in validated_data:
macro.tags.set(validated_data["tags"])
def _compute_totals(visible_qs) -> dict:
"""Summary counts over the requester's *visible* set, for the stat cards.
@ -99,10 +135,10 @@ class MacroListCreateView(APIView):
search = request.query_params.get("search")
if search:
qs = qs.filter(Q(title__icontains=search) | Q(body__icontains=search))
qs = qs.order_by("-updated_at")
qs = qs.order_by("-updated_at").prefetch_related("set_assignees__user", "tags")
return Response(
{
"results": MacroSerializer(qs, many=True).data,
"results": _serialize(request, qs, many=True),
"totals": totals,
# The supported set is server-owned (macros/render.py); the
# reference card renders exactly what the renderer expands.
@ -111,21 +147,28 @@ class MacroListCreateView(APIView):
)
def post(self, request, *args, **kwargs):
serializer = MacroSerializer(data=request.data)
serializer = MacroSerializer(
data=request.data, context={"org": request.profile.org}
)
serializer.is_valid(raise_exception=True)
try:
scope, owner = _resolve_scope_and_owner(request.profile, request.data)
except ValueError as exc:
return Response({"error": str(exc)}, status=status.HTTP_403_FORBIDDEN)
macro = Macro.objects.create(
org=request.profile.org,
scope=scope,
owner=owner,
title=serializer.validated_data["title"],
body=serializer.validated_data["body"],
is_active=serializer.validated_data.get("is_active", True),
)
return Response(MacroSerializer(macro).data, status=status.HTTP_201_CREATED)
data = serializer.validated_data
with transaction.atomic():
macro = Macro.objects.create(
org=request.profile.org,
scope=scope,
owner=owner,
title=data["title"],
body=data.get("body", ""),
is_active=data.get("is_active", True),
set_status=data.get("set_status", ""),
set_priority=data.get("set_priority", ""),
)
_save_actions(macro, data)
return Response(_serialize(request, macro), status=status.HTTP_201_CREATED)
class MacroDetailView(APIView):
@ -161,7 +204,7 @@ class MacroDetailView(APIView):
# Visibility: same rule as the list filter.
if macro.scope == Macro.SCOPE_PERSONAL and macro.owner_id != request.profile.id:
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
return Response(MacroSerializer(macro).data)
return Response(_serialize(request, macro))
def put(self, request, pk, *args, **kwargs):
return self._update(request, pk, partial=False)
@ -174,7 +217,12 @@ class MacroDetailView(APIView):
if isinstance(result, Response):
return result
macro = result
serializer = MacroSerializer(macro, data=request.data, partial=partial)
serializer = MacroSerializer(
macro,
data=request.data,
partial=partial,
context={"org": request.profile.org},
)
serializer.is_valid(raise_exception=True)
# Allow scope changes only inside the same authority bucket: an
# admin can flip personal<->org, a non-admin trying to flip their
@ -185,13 +233,16 @@ class MacroDetailView(APIView):
)
except ValueError as exc:
return Response({"error": str(exc)}, status=status.HTTP_403_FORBIDDEN)
for field in ("title", "body", "is_active"):
if field in serializer.validated_data:
setattr(macro, field, serializer.validated_data[field])
data = serializer.validated_data
for field in ("title", "body", "is_active", "set_status", "set_priority"):
if field in data:
setattr(macro, field, data[field])
macro.scope = scope
macro.owner = owner
macro.save()
return Response(MacroSerializer(macro).data)
with transaction.atomic():
macro.save()
_save_actions(macro, data)
return Response(_serialize(request, macro))
def delete(self, request, pk, *args, **kwargs):
result = self._get_writable(request, pk)
@ -212,16 +263,10 @@ class MacroRenderView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext)
def post(self, request, pk, *args, **kwargs):
macro = get_object_or_404(Macro, pk=pk, org=request.profile.org)
# Mirror visibility rules: a personal macro from another user
# should not be discoverable by id either.
if macro.scope == Macro.SCOPE_PERSONAL and macro.owner_id != request.profile.id:
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
if not macro.is_active:
return Response(
{"error": "Macro is inactive."},
status=status.HTTP_400_BAD_REQUEST,
)
result = _usable_or_response(request, pk)
if isinstance(result, Response):
return result
macro = result
case_id = request.data.get("case_id")
if not case_id:
return Response(
@ -238,3 +283,125 @@ class MacroRenderView(APIView):
with transaction.atomic():
Macro.objects.filter(pk=macro.pk).update(usage_count=F("usage_count") + 1)
return Response({"rendered_body": rendered})
class MacroApplyView(APIView):
"""POST /<id>/apply/, apply the macro's actions to a case.
Body: ``{"case_id": "<uuid>", "only": ["status", ...]}``. ``only`` is
optional and narrows the macro's actions to those listed (the ones the
agent kept); omitted, every action the macro carries is applied.
The write is `cases.updates.update_case`, the ticket PATCH's own path, so
the merged-ticket lock, the close gate and its approval rule, Duplicate
only by merge, active-only assignees and tags, and the email to whoever
is newly assigned all apply exactly as they do to a PATCH, and a refusal
comes back as the PATCH's 400. Assignees replace the ticket's; tags are
added to its own. Using a macro is counted by render, not here.
"""
permission_classes = (IsAuthenticated, HasOrgContext)
def post(self, request, pk, *args, **kwargs):
result = _usable_or_response(request, pk)
if isinstance(result, Response):
return result
macro = result
carried = macro.action_keys()
if not carried:
return Response(
{"error": "This macro has no actions to apply."},
status=status.HTTP_400_BAD_REQUEST,
)
only = request.data.get("only")
if only is None:
selected = carried
elif not isinstance(only, list) or any(key not in ACTION_KEYS for key in only):
return Response(
{"error": "only must be a list of: " + ", ".join(ACTION_KEYS) + "."},
status=status.HTTP_400_BAD_REQUEST,
)
else:
selected = [key for key in carried if key in only]
if not selected:
return Response(
{"error": "None of the chosen actions are on this macro."},
status=status.HTTP_400_BAD_REQUEST,
)
case_id = request.data.get("case_id")
if not case_id:
return Response(
{"error": "case_id is required."},
status=status.HTTP_400_BAD_REQUEST,
)
# A case the caller may not open, another org's and a malformed id all
# answer the same 404; one they may open but not change answers 403,
# exactly as the ticket PATCH does.
case = get_case_or_404(request.profile, case_id)
assert_case_write_access(request.profile, case)
org = request.profile.org
data = {}
skipped = []
if "status" in selected:
# A close is dated by `update_case`, as a PATCH's is: today in the
# org's timezone.
data["status"] = macro.set_status
if "priority" in selected:
data["priority"] = macro.set_priority
if "assignees" in selected:
ids = [
str(pk)
for pk in macro.set_assignees.filter(
org=org, is_active=True
).values_list("id", flat=True)
]
if ids:
data["assigned_to"] = ids
else:
# Replacing the assignees with nobody would unassign the
# ticket, which is never what the macro meant.
skipped.append(
{
"action": "assignees",
"reason": "Everyone this macro assigns is deactivated, "
"so the assignees were left as they were.",
}
)
if "tags" in selected:
ids = [
str(pk)
for pk in macro.tags.filter(org=org, is_active=True).values_list(
"id", flat=True
)
]
if ids:
data["tags"] = ids
else:
skipped.append(
{
"action": "tags",
"reason": "Every tag this macro adds is archived, "
"so no tags were added.",
}
)
if data:
errors = update_case(request, case, data, append_tags=True)
if errors:
return Response(
{"error": True, "errors": errors},
status=status.HTTP_400_BAD_REQUEST,
)
skipped_keys = {item["action"] for item in skipped}
return Response(
{
"error": False,
"message": "Macro applied" if data else "Nothing was applied",
"applied": [key for key in selected if key not in skipped_keys],
"skipped": skipped,
},
status=status.HTTP_200_OK,
)

View file

@ -481,13 +481,36 @@ class _MinimalAccountField(serializers.RelatedField):
return {"id": str(value.pk), "name": getattr(value, "name", "") or ""}
class _KanbanLineItemSerializer(serializers.ModelSerializer):
"""A deal's line on a board card: the mobile card counts them. Plain
columns only, so a board of cards adds one prefetch and no query per line
(the full `OpportunityLineItemSerializer` reads each line's product and
its deal)."""
class Meta:
model = OpportunityLineItem
fields = (
"id",
"name",
"quantity",
"unit_price",
"discount_type",
"discount_value",
)
read_only_fields = fields
class OpportunityKanbanCardSerializer(
DealStageFieldsMixin, serializers.ModelSerializer
):
"""Lightweight payload for kanban cards, only what the card UI renders."""
"""Lightweight payload for kanban cards, only what a card UI renders: the
web card and the mobile one, which also shows tags, a product count and
sorts a lane by `updated_at` or `stage_changed_at`."""
account = _MinimalAccountField(read_only=True)
assigned_to = ProfileSerializer(read_only=True, many=True)
tags = TagsSerializer(read_only=True, many=True)
line_items = _KanbanLineItemSerializer(read_only=True, many=True)
stage_label = serializers.SerializerMethodField()
stage_kind = serializers.SerializerMethodField()
days_in_stage = serializers.SerializerMethodField()
@ -541,11 +564,15 @@ class OpportunityKanbanCardSerializer(
"kanban_order",
"account",
"assigned_to",
"tags",
"line_items",
"days_in_stage",
"aging_status",
"stage_changed_at",
"next_activity",
"can_move",
"created_at",
"updated_at",
)

View file

@ -0,0 +1,140 @@
"""The deal board carries what the mobile board shows and filters by.
The mobile board used to build its lanes from every page of the deal list.
It now reads `GET /api/opportunities/kanban/` like the web board, so the card
carries the fields the phone's deal card shows (tags, a product count) and
sorts a lane by (`updated_at`, `stage_changed_at`), and the board takes the
same filters as the list (`deal_list_queryset`), so every filter on the phone
still narrows the board.
"""
from datetime import timedelta
from decimal import Decimal
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.utils import timezone
from common.models import Tags
from opportunity.models import Opportunity, OpportunityLineItem
URL = "/api/opportunities/kanban/"
def _deal(org, name, **kwargs):
kwargs.setdefault("stage", "PROSPECTING")
kwargs.setdefault("kanban_order", Decimal("1000"))
return Opportunity.objects.create(org=org, name=name, **kwargs)
def _cards(client, **params):
response = client.get(URL, params)
assert response.status_code == 200, response.content
return {
card["name"]: card
for column in response.json()["columns"]
for card in column["items"]
}
class TestCardFields:
def test_tags_line_items_and_timestamps_are_on_the_card(self, admin_client, org_a):
deal = _deal(org_a, "Big", amount=Decimal("500"), currency="EUR")
tag = Tags.objects.create(org=org_a, name="Hot", slug="hot")
deal.tags.add(tag)
OpportunityLineItem.objects.create(
org=org_a,
opportunity=deal,
name="Seat",
quantity=Decimal("2"),
unit_price=Decimal("100"),
)
card = _cards(admin_client)["Big"]
assert [t["name"] for t in card["tags"]] == ["Hot"]
assert [line["name"] for line in card["line_items"]] == ["Seat"]
assert set(card["line_items"][0]) == {
"id",
"name",
"quantity",
"unit_price",
"discount_type",
"discount_value",
}
assert card["updated_at"]
assert "stage_changed_at" in card
# Unchanged fields the web board reads. The line sets the amount.
assert card["amount"] == "200.00"
assert card["currency"] == "EUR"
assert card["can_move"] is True
def test_the_new_fields_add_no_query_per_card(self, admin_client, org_a):
tag = Tags.objects.create(org=org_a, name="Hot", slug="hot")
def add(n):
for i in range(n):
deal = _deal(org_a, f"d{n}-{i}")
deal.tags.add(tag)
OpportunityLineItem.objects.create(
org=org_a, opportunity=deal, name="x", quantity=1, unit_price=1
)
add(2)
with CaptureQueriesContext(connection) as few:
admin_client.get(URL)
add(6)
with CaptureQueriesContext(connection) as many:
admin_client.get(URL)
assert len(many) == len(few)
class TestBoardTakesTheListFilters:
def test_amount_range(self, admin_client, org_a):
_deal(org_a, "small", amount=Decimal("10"))
_deal(org_a, "large", amount=Decimal("5000"))
assert set(_cards(admin_client, amount__gte="100")) == {"large"}
assert set(_cards(admin_client, amount__lte="100")) == {"small"}
def test_created_range(self, admin_client, org_a):
old = _deal(org_a, "old")
Opportunity.objects.filter(pk=old.pk).update(
created_at=timezone.now() - timedelta(days=30)
)
_deal(org_a, "new")
since = (timezone.now() - timedelta(days=2)).date().isoformat()
assert set(_cards(admin_client, created_at__gte=since)) == {"new"}
def test_name_as_the_phone_sends_it(self, admin_client, org_a):
_deal(org_a, "Acme renewal")
_deal(org_a, "Globex")
assert set(_cards(admin_client, name="acme")) == {"Acme renewal"}
assert set(_cards(admin_client, search="globex")) == {"Globex"}
def test_stalled_only(self, admin_client, org_a):
stalled = _deal(org_a, "stalled")
Opportunity.objects.filter(pk=stalled.pk).update(
stage_changed_at=timezone.now() - timedelta(days=400)
)
_deal(org_a, "fresh")
assert set(_cards(admin_client, rotten="true")) == {"stalled"}
def test_a_filter_narrows_the_count_too(self, admin_client, org_a):
_deal(org_a, "small", amount=Decimal("10"))
_deal(org_a, "large", amount=Decimal("5000"))
response = admin_client.get(URL, {"amount__gte": "100"})
column = next(c for c in response.json()["columns"] if c["id"] == "PROSPECTING")
assert column["item_count"] == 1
assert response.json()["total_items"] == 1
def test_a_malformed_amount_is_a_400(self, admin_client, org_a):
response = admin_client.get(URL, {"amount__gte": "lots"})
assert response.status_code == 400
def test_the_read_rule_still_applies(
self, user_client, user_profile, admin_user, org_a
):
mine = _deal(org_a, "mine", created_by=admin_user)
mine.assigned_to.add(user_profile)
_deal(org_a, "theirs", created_by=admin_user)
assert set(_cards(user_client)) == {"mine"}
assert set(_cards(user_client, name="theirs")) == set()

View file

@ -17,7 +17,7 @@ from rest_framework.views import APIView
from common.kanban import place_in_column
from common.permissions import HasOrgContext
from common.validators import date_param, uuid_list_param, uuid_param
from common.validators import uuid_param
from opportunity.access import visible_deals_qs
from opportunity.models import DealPipeline, DealStage, Opportunity
from opportunity.next_activity import attach_next_activity
@ -26,6 +26,7 @@ from opportunity.serializer import (
OpportunityMoveSerializer,
)
from opportunity.stages import stage_index
from opportunity.views.opportunity_views import deal_list_queryset
from opportunity.workflow import CLOSED_KINDS, WON, stage_probability
# Column colour by what the stage means; the board has no per-stage colour.
@ -36,7 +37,8 @@ class OpportunityKanbanView(APIView):
"""GET /api/opportunities/kanban/?pipeline=<id>, columns grouped by stage.
Without `pipeline` the board is the org's default pipeline. A pipeline id
from another org is a 404, the same as one that does not exist.
from another org is a 404, the same as one that does not exist. Every
filter the deal list takes (`deal_list_queryset`) narrows the board too.
"""
permission_classes = (IsAuthenticated, HasOrgContext)
@ -52,6 +54,15 @@ class OpportunityKanbanView(APIView):
OpenApiParameter(name="tags", required=False, type=str),
OpenApiParameter(name="closed_on__gte", required=False, type=str),
OpenApiParameter(name="closed_on__lte", required=False, type=str),
OpenApiParameter(name="name", required=False, type=str),
OpenApiParameter(name="stage", required=False, type=str),
OpenApiParameter(name="lead_source", required=False, type=str),
OpenApiParameter(name="created_at__gte", required=False, type=str),
OpenApiParameter(name="created_at__lte", required=False, type=str),
OpenApiParameter(name="amount__gte", required=False, type=str),
OpenApiParameter(name="amount__lte", required=False, type=str),
OpenApiParameter(name="open", required=False, type=str),
OpenApiParameter(name="rotten", required=False, type=str),
],
)
def get(self, request):
@ -63,17 +74,16 @@ class OpportunityKanbanView(APIView):
else:
pipeline = DealPipeline.default_for(org)
# The list's read rule, from the one place it is defined, so the board
# never shows a deal the table would not.
# The list's read rule and its filters, from the one place they are
# defined, so the board never shows a deal the table would not and a
# filter narrows both the same way.
queryset = (
visible_deals_qs(request.profile)
deal_list_queryset(request.profile, request.query_params)
.filter(pipeline=pipeline)
.select_related("account")
.prefetch_related("assigned_to", "tags")
.prefetch_related("assigned_to", "tags", "line_items")
)
queryset = self._apply_filters(queryset, request.query_params)
# The org's stages read once and handed to every card, so no card
# queries its own stage for its label, kind or aging.
context = {"stages": stage_index(org.id), "request": request}
@ -128,26 +138,6 @@ class OpportunityKanbanView(APIView):
}
)
def _apply_filters(self, queryset, params):
if params.get("search"):
queryset = queryset.filter(name__icontains=params.get("search"))
account = uuid_param(params, "account")
if account:
queryset = queryset.filter(account_id=account)
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()
closed_on_gte = date_param(params, "closed_on__gte")
if closed_on_gte:
queryset = queryset.filter(closed_on__gte=closed_on_gte)
closed_on_lte = date_param(params, "closed_on__lte")
if closed_on_lte:
queryset = queryset.filter(closed_on__lte=closed_on_lte)
return queryset
class OpportunityMoveView(APIView):
"""PATCH /api/opportunities/<pk>/move/, change stage and/or reorder.

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "django-crm"
version = "1.12.0"
version = "1.13.0"
# The one-line summary PyPI shows under the project title and in search results,
# so it carries the terms people actually search for ("open source CRM",
# "self-hosted CRM", "Django CRM") and names the modules rather than the

View file

@ -681,7 +681,7 @@ wheels = [
[[package]]
name = "django-crm"
version = "1.12.0"
version = "1.13.0"
source = { editable = "." }
dependencies = [
{ name = "cairocffi" },

View file

@ -0,0 +1,56 @@
# Generated by Django 6.1.1 on 2026-09-26 18:49
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
# No new table, so no RLS work: `web_form` already carries its policy, and
# the M2M through table has no org_id column, like `notify_profiles`'.
# Profile comes in through 0001's dependency on common.
dependencies = [
("webforms", "0003_ticket_target"),
]
operations = [
migrations.AddField(
model_name="webform",
name="assignment_mode",
field=models.CharField(
choices=[
("person", "One person"),
("rotation", "Rotate between members"),
],
default="person",
max_length=16,
),
),
migrations.AddField(
model_name="webform",
name="rotation_cap",
field=models.PositiveIntegerField(
blank=True,
null=True,
validators=[django.core.validators.MinValueValidator(1)],
),
),
migrations.AddField(
model_name="webform",
name="rotation_last_assigned",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="common.profile",
),
),
migrations.AddField(
model_name="webform",
name="rotation_members",
field=models.ManyToManyField(
blank=True, related_name="webforms_rotated", to="common.profile"
),
),
]

View file

@ -14,6 +14,7 @@ restates `indexes = [Index(fields=["org", "-created_at"])]`.
`orders` silently lost both of its per-org indexes.
"""
from django.core.validators import MinValueValidator
from django.db import models
from common.base import BaseOrgModel
@ -97,6 +98,20 @@ class WebForm(BaseOrgModel):
)
redirect_url = models.URLField(max_length=500, blank=True, default="")
ASSIGN_PERSON = "person"
ASSIGN_ROTATION = "rotation"
ASSIGNMENT_MODE_CHOICES = [
(ASSIGN_PERSON, "One person"),
(ASSIGN_ROTATION, "Rotate between members"),
]
# How a NEW lead is assigned. `person` uses `assign_to`; `rotation` hands
# each new lead to the next eligible `rotation_members` entry
# (`webforms.service.rotation_assignee`). Lead forms only: a ticket form is
# always `person`, and its tickets are left to the case routing rules.
assignment_mode = models.CharField(
max_length=16, choices=ASSIGNMENT_MODE_CHOICES, default=ASSIGN_PERSON
)
assign_to = models.ForeignKey(
Profile,
on_delete=models.SET_NULL,
@ -104,6 +119,25 @@ class WebForm(BaseOrgModel):
blank=True,
related_name="webforms_assigned",
)
rotation_members = models.ManyToManyField(
Profile, blank=True, related_name="webforms_rotated"
)
# Most OPEN leads (active, not converted or closed) a member may hold,
# counted across the whole org rather than this form, before the rotation
# passes them over. Null means no cap.
rotation_cap = models.PositiveIntegerField(
null=True, blank=True, validators=[MinValueValidator(1)]
)
# The rotation's cursor: whoever received the last rotated lead. A profile
# rather than a list position, so adding or removing a member cannot shift
# whose turn it is. Written only by the submission path, under a row lock.
rotation_last_assigned = models.ForeignKey(
Profile,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
)
notify_profiles = models.ManyToManyField(
Profile, blank=True, related_name="webforms_notified"
)

View file

@ -32,6 +32,7 @@ from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from common.links import api_url
from common.request_meta import client_ip, referer
from common.tasks import set_rls_context
from webforms import captcha
@ -209,13 +210,14 @@ class EmbedViewMixin(PublicWebFormMixin):
except Exception:
logger.exception("Could not count a view for web form %s", form.id)
def render_context(self, request, form):
def render_context(self, form):
return {
"form": form,
"fields": list(form.fields.select_related("custom_field").all()),
"submit_url": request.build_absolute_uri(
f"/api/public/forms/{form.org_id}/{form.id}/submit/"
),
# From `DOMAIN_NAME`, like the embed snippet that loaded this page:
# behind a TLS terminator the request reads as `http://`, and an
# `http://` form action on an `https://` page is mixed content.
"submit_url": api_url(f"/api/public/forms/{form.org_id}/{form.id}/submit/"),
"honeypot": HONEYPOT_FIELD,
}
@ -241,9 +243,7 @@ class WebFormEmbedView(EmbedViewMixin, APIView):
return HttpResponseNotFound("Form not found.")
self.count_view(form)
html = render_to_string(
"webforms/form.html", self.render_context(request, form)
)
html = render_to_string("webforms/form.html", self.render_context(form))
response = HttpResponse(html, content_type="text/html; charset=utf-8")
if form.allowed_origins:
response["Content-Security-Policy"] = "frame-ancestors " + " ".join(
@ -268,7 +268,7 @@ class WebFormEmbedJsView(EmbedViewMixin, APIView):
)
self.count_view(form)
context = self.render_context(request, form)
context = self.render_context(form)
context["config_json"] = self.config_json(context)
js = render_to_string("webforms/embed.js", context)
return HttpResponse(js, content_type="application/javascript; charset=utf-8")

View file

@ -17,6 +17,7 @@ from urllib.parse import urlparse
from django.db import transaction
from rest_framework import serializers
from common.links import api_url
from common.models import CustomFieldDefinition, Profile, Tags
from webforms.constants import LEAD_FIELD_VALUES
from webforms.dynamic_serializer import CUSTOM_FIELD_TARGET
@ -133,6 +134,10 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
# deactivated assignee as an option, and a select with no matching option
# clears the field on the next save.
assign_to_details = serializers.SerializerMethodField()
# The same shape for every rotation member, active or not, for the same
# reason: a stored member deactivated since has no option in the picker.
rotation_members_details = serializers.SerializerMethodField()
rotation_last_assigned_details = serializers.SerializerMethodField()
class Meta:
model = WebForm
@ -146,7 +151,11 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
"success_mode",
"success_message",
"redirect_url",
"assignment_mode",
"assign_to",
"rotation_members",
"rotation_cap",
"rotation_last_assigned",
"notify_profiles",
"lead_source",
"tags",
@ -162,8 +171,18 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
"embed_js",
"has_captcha_secret",
"assign_to_details",
"rotation_members_details",
"rotation_last_assigned_details",
)
# `rotation_last_assigned` is the rotation's cursor. Only the
# submission path moves it, under a row lock; a writable one would let
# an admin's stale form overwrite whose turn it is.
read_only_fields = (
"id",
"created_at",
"is_published",
"rotation_last_assigned",
)
read_only_fields = ("id", "created_at", "is_published")
extra_kwargs = {
# The org pastes this into its own Cloudflare dashboard; we only
# ever send it to Cloudflare. Never read back.
@ -173,8 +192,9 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
"""Narrow every relation to the caller's own org.
`assign_to`, `notify_profiles` and `tags` are plain model relations, so
DRF builds each with a queryset of every row in the table. Left alone,
`assign_to`, `rotation_members`, `notify_profiles` and `tags` are plain
model relations, so DRF builds each with a queryset of every row in the
table. Left alone,
an admin could point a form at another tenant's profile, and that is
not confined to a wrong column: `webforms/tasks.py` mails each
submission to `notify_profiles -> user.email`, so the form would send
@ -194,6 +214,9 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
request = self.context.get("request")
org = getattr(getattr(request, "profile", None), "org", None)
self._scope("assign_to", Profile.objects.filter(org=org) if org else None)
self._scope(
"rotation_members", Profile.objects.filter(org=org) if org else None
)
self._scope("notify_profiles", Profile.objects.filter(org=org) if org else None)
self._scope("tags", Tags.objects.filter(org=org) if org else None)
@ -226,18 +249,72 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
)
return value
def validate_rotation_members(self, value):
"""No deactivated member may be ADDED to a rotation.
The rotation skips one anyway (`webforms.service.rotation_assignee`),
so adding them would save a turn that silently never comes. Members
already stored are kept, as `validate_assign_to` keeps a stored
assignee: both clients resend the whole list on every save.
"""
stored = (
set(self.instance.rotation_members.values_list("id", flat=True))
if self.instance is not None
else set()
)
for profile in value:
if not profile.is_active and profile.id not in stored:
raise serializers.ValidationError(
"This user is deactivated. Choose an active member."
)
return value
def _validate_assignment(self, attrs):
"""Rotation needs a lead form and somebody to rotate between.
Both read the value the save will leave behind, so a request that
changes only the target, or only the members, cannot slip a form into
a state its own two fields contradict.
"""
mode = attrs.get(
"assignment_mode",
getattr(self.instance, "assignment_mode", WebForm.ASSIGN_PERSON),
)
if mode != WebForm.ASSIGN_ROTATION:
return
target = attrs.get(
"target", getattr(self.instance, "target", WebForm.TARGET_LEAD)
)
if target != WebForm.TARGET_LEAD:
raise serializers.ValidationError(
{
"assignment_mode": (
"Rotation is for lead forms only. Tickets from a "
"ticket form are assigned by your routing rules."
)
}
)
if "rotation_members" in attrs:
has_members = bool(attrs["rotation_members"])
else:
has_members = (
self.instance is not None and self.instance.rotation_members.exists()
)
if not has_members:
raise serializers.ValidationError(
{"rotation_members": "Choose at least one member to rotate between."}
)
# ---- embed snippets -------------------------------------------------
#
# Built here rather than in a client because they need the API's own base
# URL. A browser knows the frontend's origin, not this one, and a relative
# URL pasted onto a customer's site would point at the customer's server.
# From `DOMAIN_NAME`, not the request: the web app asks from its own
# server, whose view of this host is internal (`common.links.api_url`).
def _public_base(self, obj):
request = self.context.get("request")
path = f"/api/public/forms/{obj.org_id}/{obj.id}/"
if request is None:
return path
return request.build_absolute_uri(path)
return api_url(f"/api/public/forms/{obj.org_id}/{obj.id}/")
def get_embed_html(self, obj):
return (
@ -249,8 +326,8 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
def get_has_captcha_secret(self, obj):
return bool(obj.captcha_secret)
def get_assign_to_details(self, obj):
profile = obj.assign_to
@staticmethod
def _profile_details(profile):
if profile is None:
return None
return {
@ -260,6 +337,16 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
"is_active": profile.is_active,
}
def get_assign_to_details(self, obj):
return self._profile_details(obj.assign_to)
def get_rotation_members_details(self, obj):
members = obj.rotation_members.select_related("user").order_by("id")
return [self._profile_details(profile) for profile in members]
def get_rotation_last_assigned_details(self, obj):
return self._profile_details(obj.rotation_last_assigned)
def get_embed_js(self, obj):
return (
f'<div id="bottlecrm-webform-{obj.id}"></div>\n'
@ -379,6 +466,7 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
def validate(self, attrs):
self._validate_target(attrs)
self._validate_assignment(attrs)
provider = attrs.get(
"captcha_provider", getattr(self.instance, "captcha_provider", "")
)
@ -426,6 +514,7 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
rows = validated_data.pop("fields", None)
tags = validated_data.pop("tags", None)
notify = validated_data.pop("notify_profiles", None)
members = validated_data.pop("rotation_members", None)
instance = WebForm.objects.create(
org=self.context["org"],
created_by=self.context["request"].profile.user,
@ -435,6 +524,8 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
instance.tags.set(tags)
if notify is not None:
instance.notify_profiles.set(notify)
if members is not None:
instance.rotation_members.set(members)
if rows is not None:
self._write_fields(instance, rows)
return instance
@ -444,6 +535,7 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
rows = validated_data.pop("fields", None)
tags = validated_data.pop("tags", None)
notify = validated_data.pop("notify_profiles", None)
members = validated_data.pop("rotation_members", None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
@ -451,6 +543,8 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
instance.tags.set(tags)
if notify is not None:
instance.notify_profiles.set(notify)
if members is not None:
instance.rotation_members.set(members)
if rows is not None:
self._write_fields(instance, rows)
return instance

View file

@ -18,6 +18,7 @@ import logging
from django.contrib.contenttypes.models import ContentType
from django.db import IntegrityError, transaction
from django.db.models import Count, Q
from django.db.models.functions import Lower
from cases.models import Case
@ -30,6 +31,10 @@ from webforms.models import WebForm, WebFormSubmission
logger = logging.getLogger(__name__)
# Lead statuses that end a lead's life as somebody's open work. What is left
# (assigned, in process, recycled) counts against a rotation member's cap.
CLOSED_LEAD_STATUSES = ("converted", "closed")
# The Lead columns the merge path may fill in on a repeat submission.
# Assignment, status, source and the pipeline columns are absent on purpose:
# those belong to whoever owns the lead now, not to a stranger who happens to
@ -80,13 +85,70 @@ def active_assignee(form):
assignee exactly as a form with no assignee. The stored row outlives the
member: deactivating somebody does not clear the forms pointing at them,
and without this they went on receiving, and being mailed, each new lead.
Also None on a rotation form. Its `assign_to` is a leftover from before the
switch, kept so switching back restores it, and it must not go on
authoring merge comments or owning legacy contacts.
"""
if form.assignment_mode == WebForm.ASSIGN_ROTATION:
return None
assignee = form.assign_to
if assignee is None or not assignee.is_active or assignee.org_id != form.org_id:
return None
return assignee
def rotation_assignee(form):
"""The member a rotation form's next new lead goes to, or None.
Call inside the transaction that inserts the lead. The form row is locked
first, so two submissions arriving together queue here instead of both
reading the same cursor and handing two leads to one person.
Eligible means an active member of the form's org holding fewer open
leads than `rotation_cap` (when one is set). Open is active and neither
converted nor closed, counted over every lead in the org, because the cap
is about the member's workload rather than about this one form.
The eligible members are taken in id order and the first whose id is
greater than the cursor wins, wrapping to the lowest. Ordering by id
rather than by a stored position is what keeps the turn fair when members
are added or removed: the cursor names a person, not a list index. When
nobody is eligible the lead is left unassigned and the cursor stays put.
"""
last_id = (
WebForm.objects.select_for_update()
.filter(pk=form.pk, org_id=form.org_id)
.values_list("rotation_last_assigned_id", flat=True)
.first()
)
members = form.rotation_members.filter(org_id=form.org_id, is_active=True)
if form.rotation_cap:
members = members.annotate(
open_leads=Count(
"lead_assigned_users",
filter=Q(
lead_assigned_users__org_id=form.org_id,
lead_assigned_users__is_active=True,
)
& ~Q(lead_assigned_users__status__in=CLOSED_LEAD_STATUSES),
distinct=True,
)
).filter(open_leads__lt=form.rotation_cap)
eligible = sorted(members, key=lambda profile: profile.id)
if not eligible:
return None
chosen = next(
(profile for profile in eligible if last_id and profile.id > last_id),
eligible[0],
)
# `update`, not `save`: this is the system moving a cursor, not somebody
# editing the form, so neither the audit stamps nor `updated_at` move.
WebForm.objects.filter(pk=form.pk).update(rotation_last_assigned=chosen)
form.rotation_last_assigned = chosen
return chosen
def _owner(form):
"""The Profile credited with the submission's comment, or None.
@ -99,35 +161,37 @@ def _owner(form):
return active_assignee(form)
def _created_by_user(form):
def _created_by_user(form, assignee):
"""The User stamped on the created Lead's or Case's `created_by`.
`created_by` points at a User while `assign_to` is a Profile, so this
reaches through. Falls back to the form's own creator when the form has no
active assignee, because a record with no creator is one nobody can be
`created_by` points at a User while the assignee is a Profile, so this
reaches through. Falls back to the form's own creator when the record has
no active assignee, because a record with no creator is one nobody can be
asked about.
"""
assignee = active_assignee(form)
if assignee is not None and assignee.user_id:
return assignee.user
return form.created_by
def _create_lead(form, values, custom_fields):
if form.assignment_mode == WebForm.ASSIGN_ROTATION:
assignee = rotation_assignee(form)
else:
assignee = active_assignee(form)
lead = Lead(
org=form.org,
# Server-derived, never from the submission.
status="assigned",
source=form.lead_source,
is_active=True,
created_by=_created_by_user(form),
created_by=_created_by_user(form, assignee),
custom_fields=dict(custom_fields or {}),
)
for key, value in values.items():
setattr(lead, key, value)
lead.save()
assignee = active_assignee(form)
if assignee is not None:
lead.assigned_to.add(assignee)
tags = list(form.tags.all())
@ -239,7 +303,7 @@ def _create_ticket(form, values, custom_fields):
case_type=form.ticket_type or None,
description=values.get("description") or "",
custom_fields=dict(custom_fields or {}),
created_by=_created_by_user(form),
created_by=_created_by_user(form, active_assignee(form)),
)
case._routing_from_domain = email.rsplit("@", 1)[-1].lower() if "@" in email else ""
with route_after_relations():

View file

@ -24,9 +24,11 @@ logger = logging.getLogger(__name__)
def send_webform_submission_email(submission_id, org_id):
"""Tell the form's recipients that a lead or ticket came in.
Deliberately REPLACES `send_lead_assigned_emails` on this path rather than
firing alongside it, so an assignee who is also a notify recipient receives
one email rather than two.
The only mail a submission sends. Recipients are the form's active
`notify_profiles` plus the record's assignee: the lead's own active
assignees on a lead form, the form's active `assign_to` on a ticket form.
One message to the union, so an assignee who is also a notify recipient
receives one email rather than two.
Rejected submissions notify nobody. Telling an org about every bot that
hits their form is how they learn to ignore the notification, and then to
@ -60,9 +62,19 @@ def send_webform_submission_email(submission_id, org_id):
profiles = set(
form.notify_profiles.filter(is_active=True).select_related("user").all()
)
assignee = active_assignee(form)
if assignee is not None:
profiles.add(assignee)
if submission.case_id:
assignee = active_assignee(form)
if assignee is not None:
profiles.add(assignee)
elif submission.lead_id:
# Whoever the lead actually belongs to, not the form's `assign_to`. A
# rotation form picks a different member each time, and a repeat
# submission merges into a lead somebody else may already own.
profiles.update(
submission.lead.assigned_to.filter(
org_id=org_id, is_active=True
).select_related("user")
)
recipients = sorted(
{

View file

@ -67,7 +67,25 @@ class TestRead:
the browser does not have. A relative URL in a snippet pasted onto a
customer's site would point at the customer's own server."""
response = admin_client.get(detail_url(form))
assert "http://testserver/api/public/forms/" in response.data["embed_html"]
assert (
"https://api.example.com/api/public/forms/" in response.data["embed_html"]
)
def test_the_snippets_ignore_the_host_that_asked(
self, admin_client, form, settings
):
"""The web app asks from its server, as `http://backend:8000` in Docker.
A snippet naming that host is dead on every customer's site, so the
base is `DOMAIN_NAME`, whatever host and scheme the request came on.
"""
settings.ALLOWED_HOSTS = ["*"]
settings.DOMAIN_NAME = "https://api.crm.example.org"
response = admin_client.get(detail_url(form), HTTP_HOST="backend:8000")
base = f"https://api.crm.example.org/api/public/forms/{form.org_id}/{form.id}/"
assert f'src="{base}embed/"' in response.data["embed_html"]
assert f'src="{base}embed.js"' in response.data["embed_js"]
assert "backend" not in response.data["embed_html"] + response.data["embed_js"]
def test_the_captcha_secret_is_never_returned(self, admin_client, form):
form.captcha_secret = "secret-value"

View file

@ -93,7 +93,10 @@ class TestIframeEmbed:
def test_carries_the_absolute_submit_url(self, unauthenticated_client, org_a, form):
body = unauthenticated_client.get(embed_url(org_a, form)).content.decode()
assert f"/api/public/forms/{org_a.id}/{form.id}/submit/" in body
assert (
f"https://api.example.com/api/public/forms/{org_a.id}/{form.id}/submit/"
in body
)
def test_an_unpublished_form_is_404(self, unauthenticated_client, org_a, form):
form.is_published = False
@ -127,7 +130,10 @@ class TestScriptEmbed:
def test_carries_the_absolute_submit_url(self, unauthenticated_client, org_a, form):
body = unauthenticated_client.get(embed_js_url(org_a, form)).content.decode()
assert f"/api/public/forms/{org_a.id}/{form.id}/submit/" in body
assert (
f"https://api.example.com/api/public/forms/{org_a.id}/{form.id}/submit/"
in body
)
def test_an_unpublished_form_is_404(self, unauthenticated_client, org_a, form):
form.is_published = False

View file

@ -0,0 +1,668 @@
"""Lead rotation on a web form (G23).
A lead form in `rotation` mode hands each NEW lead to the next eligible member
of `rotation_members`: active, in the form's org, and under `rotation_cap` open
leads when a cap is set. Members are taken in id order and the cursor
(`rotation_last_assigned`) names the last person served, so adding or removing
a member never shifts whose turn it is. Nobody eligible leaves the lead
unassigned; a repeat submission merges into the existing lead and never
reassigns it.
Profile ids are random UUIDs, so every expected order below is computed by
sorting the ids rather than written out.
"""
import threading
import time
from unittest import mock
import pytest
from django.core import mail
from django.db import connection
from django.db.models import QuerySet
from common.models import APISettings, Profile, User
from contacts.models import Contact
from leads.models import Lead
from webforms import service
from webforms.models import WebForm, WebFormField
from webforms.service import submit_form
from webforms.tasks import send_webform_submission_email
LIST_URL = "/api/webforms/"
def _member(org, email, *, active=True, role="USER"):
user = User.objects.create_user(email=email, password="testpass123")
return Profile.objects.create(user=user, org=org, role=role, is_active=active)
def _by_id(*profiles):
return sorted(profiles, key=lambda p: p.id)
def _form(org, created_by, members, *, cap=None, mode=WebForm.ASSIGN_ROTATION, **kw):
form = WebForm.objects.create(
name="Contact us",
org=org,
is_published=True,
assignment_mode=mode,
rotation_cap=cap,
created_by=created_by,
**kw,
)
form.rotation_members.set(members)
for order, name in enumerate(["email", "first_name", "description"]):
WebFormField.objects.create(
form=form,
org=org,
order=order,
source=WebFormField.SOURCE_LEAD,
lead_field=name,
label=name.title(),
)
return form
_sequence = iter(range(10_000))
def _submit(form, email=None):
"""One new lead (or a merge, given a repeat email). Returns the submission."""
email = email or f"visitor{next(_sequence)}@example.com"
form.refresh_from_db()
return submit_form(form, {"email": email})
def _assignees(submission):
return list(submission.lead.assigned_to.all())
@pytest.fixture
def three(org_a):
return _by_id(
_member(org_a, "m1@test.com"),
_member(org_a, "m2@test.com"),
_member(org_a, "m3@test.com"),
)
@pytest.mark.django_db
class TestTurnOrder:
def test_leads_go_round_in_id_order_and_wrap(self, org_a, admin_user, three):
form = _form(org_a, admin_user, three)
got = [_assignees(_submit(form)) for _ in range(4)]
assert got == [[three[0]], [three[1]], [three[2]], [three[0]]]
form.refresh_from_db()
assert form.rotation_last_assigned == three[0]
def test_a_member_added_mid_rotation_takes_their_turn_by_id(
self, org_a, admin_user, three
):
low, middle, high = three
form = _form(org_a, admin_user, [low, high])
assert _assignees(_submit(form)) == [low]
form.rotation_members.add(middle)
assert _assignees(_submit(form)) == [middle]
assert _assignees(_submit(form)) == [high]
assert _assignees(_submit(form)) == [low]
def test_removing_the_last_served_member_does_not_skip_the_next(
self, org_a, admin_user, three
):
"""A stored list index would shift here and hand `low` a second lead
before `high` had one. The cursor names a person, so it does not."""
low, middle, high = three
form = _form(org_a, admin_user, three)
_submit(form)
assert _assignees(_submit(form)) == [middle]
form.rotation_members.remove(middle)
assert _assignees(_submit(form)) == [high]
assert _assignees(_submit(form)) == [low]
def test_the_lead_is_credited_to_the_member_it_went_to(
self, org_a, admin_user, three
):
form = _form(org_a, admin_user, three)
lead = _submit(form).lead
assert lead.created_by == three[0].user
def test_the_forms_person_is_ignored_while_rotating(
self, org_a, admin_user, admin_profile, three
):
form = _form(org_a, admin_user, [three[0]], assign_to=admin_profile)
assert _assignees(_submit(form)) == [three[0]]
@pytest.mark.django_db
class TestEligibility:
def test_an_inactive_member_is_skipped(self, org_a, admin_user, three):
low, middle, high = three
middle.is_active = False
middle.save(update_fields=["is_active"])
form = _form(org_a, admin_user, three)
got = [_assignees(_submit(form)) for _ in range(3)]
assert got == [[low], [high], [low]]
def test_another_orgs_profile_is_never_assigned(
self, org_a, admin_user, profile_b, three
):
# The serializer refuses this; a row written some other way must still
# never hand one org's lead to another org's member.
form = _form(org_a, admin_user, [three[0], profile_b])
got = [_assignees(_submit(form)) for _ in range(2)]
assert got == [[three[0]], [three[0]]]
def test_a_capped_member_is_skipped_until_a_lead_converts(
self, org_a, admin_user, three
):
low, high = three[0], three[2]
form = _form(org_a, admin_user, [low, high], cap=1)
first = _submit(form).lead
assert list(first.assigned_to.all()) == [low]
assert _assignees(_submit(form)) == [high]
# Both at their cap: nobody is eligible.
full = _submit(form)
assert _assignees(full) == []
form.refresh_from_db()
assert form.rotation_last_assigned == high
first.status = "converted"
first.save(update_fields=["status"])
assert _assignees(_submit(form)) == [low]
def test_only_open_leads_count_against_the_cap(self, org_a, admin_user, three):
low, middle = three[0], three[1]
for status, active in [
("converted", True),
("closed", True),
("assigned", False),
]:
lead = Lead.objects.create(
org=org_a,
title=f"{status} {active}",
status=status,
is_active=active,
)
lead.assigned_to.add(low)
form = _form(org_a, admin_user, [low, middle], cap=1)
assert _assignees(_submit(form)) == [low]
@pytest.mark.parametrize("status", ["assigned", "in process", "recycled"])
def test_an_open_lead_counts_against_the_cap(
self, org_a, admin_user, three, status
):
low, middle = three[0], three[1]
lead = Lead.objects.create(org=org_a, title="Open", status=status)
lead.assigned_to.add(low)
form = _form(org_a, admin_user, [low, middle], cap=1)
assert _assignees(_submit(form)) == [middle]
def test_nobody_eligible_leaves_the_lead_unassigned_and_still_notifies(
self, org_a, admin_user, user_profile, three
):
for member in three:
member.is_active = False
member.save(update_fields=["is_active"])
form = _form(org_a, admin_user, three)
form.notify_profiles.add(user_profile)
submission = _submit(form)
mail.outbox.clear()
send_webform_submission_email(str(submission.id), str(org_a.id))
assert _assignees(submission) == []
assert submission.lead.created_by == admin_user
form.refresh_from_db()
assert form.rotation_last_assigned is None
assert [m.recipients() for m in mail.outbox] == [[user_profile.user.email]]
@pytest.mark.django_db
class TestOtherPathsAreUnchanged:
def test_person_mode_ignores_the_members_and_the_cursor(
self, org_a, admin_user, user_profile, three
):
form = _form(
org_a,
admin_user,
three,
mode=WebForm.ASSIGN_PERSON,
assign_to=user_profile,
)
assert _assignees(_submit(form)) == [user_profile]
form.refresh_from_db()
assert form.rotation_last_assigned is None
def test_a_repeat_email_merges_without_reassigning_or_turning(
self, org_a, admin_user, three
):
form = _form(org_a, admin_user, three)
first = _submit(form, "pat@example.com")
again = _submit(form, "pat@example.com")
assert again.lead_id == first.lead_id
assert _assignees(again) == [three[0]]
form.refresh_from_db()
assert form.rotation_last_assigned == three[0]
assert _assignees(_submit(form)) == [three[1]]
def test_the_merge_comment_is_not_credited_to_a_leftover_person(
self, org_a, admin_user, admin_profile, three
):
from django.contrib.contenttypes.models import ContentType
from common.models import Comment
form = _form(org_a, admin_user, three, assign_to=admin_profile)
_submit(form, "pat@example.com")
lead = submit_form(
form, {"email": "pat@example.com", "description": "Again"}
).lead
comment = Comment.objects.get(
content_type=ContentType.objects.get_for_model(Lead), object_id=lead.id
)
assert comment.commented_by is None
def test_the_form_row_is_locked_before_the_cursor_is_read(
self, org_a, admin_user, three
):
"""SQLite drops FOR UPDATE, so this asks which querysets were locked;
the behaviour under real concurrency is the postgres_only test below."""
form = _form(org_a, admin_user, three)
original = QuerySet.select_for_update
with mock.patch.object(
QuerySet, "select_for_update", autospec=True, side_effect=original
) as spy:
_submit(form)
assert WebForm in {call.args[0].model for call in spy.call_args_list}
@pytest.mark.django_db
class TestNotification:
def test_each_rotated_lead_mails_the_member_it_went_to(
self, org_a, admin_user, three
):
form = _form(org_a, admin_user, three[:2])
_submit(form)
second = _submit(form)
mail.outbox.clear()
send_webform_submission_email(str(second.id), str(org_a.id))
assert [m.recipients() for m in mail.outbox] == [[three[1].user.email]]
def test_a_merge_mails_the_leads_owner_not_the_forms_person(
self, org_a, admin_user, admin_profile, user_profile
):
form = _form(
org_a, admin_user, [], mode=WebForm.ASSIGN_PERSON, assign_to=admin_profile
)
lead = Lead.objects.create(
org=org_a, email="pat@example.com", status="assigned"
)
lead.assigned_to.add(user_profile)
submission = _submit(form, "pat@example.com")
mail.outbox.clear()
send_webform_submission_email(str(submission.id), str(org_a.id))
assert [m.recipients() for m in mail.outbox] == [[user_profile.user.email]]
def test_an_inactive_lead_owner_is_not_mailed(
self, org_a, admin_user, user_profile, three
):
form = _form(org_a, admin_user, [three[0]])
form.notify_profiles.add(user_profile)
submission = _submit(form)
three[0].is_active = False
three[0].save(update_fields=["is_active"])
mail.outbox.clear()
send_webform_submission_email(str(submission.id), str(org_a.id))
assert [m.recipients() for m in mail.outbox] == [[user_profile.user.email]]
@pytest.mark.django_db
class TestLegacyEndpoint:
def test_create_from_site_rotates_and_the_new_contact_follows(
self, admin_client, org_a, admin_user, three
):
setting = APISettings.objects.create(
title="Site",
website="https://example.com",
org=org_a,
created_by=admin_user,
)
_form(org_a, admin_user, three[:2], legacy_api_setting=setting)
for email in ["one@example.com", "two@example.com"]:
response = admin_client.post(
"/api/leads/create-from-site/",
{"apikey": setting.apikey, "email": email},
format="json",
)
assert response.status_code == 200, response.data
for email, member in [
("one@example.com", three[0]),
("two@example.com", three[1]),
]:
lead = Lead.objects.get(org=org_a, email=email)
assert list(lead.assigned_to.all()) == [member]
contact = Contact.objects.get(org=org_a, email=email)
assert list(contact.assigned_to.all()) == [member]
@pytest.mark.django_db
class TestAdminApi:
def _create(self, client, **body):
return client.post(LIST_URL, {"name": "Probe", **body}, format="json")
def _put(self, client, form, **body):
return client.put(f"{LIST_URL}{form.id}/", body, format="json")
def test_an_admin_creates_a_rotation_and_reads_it_back(
self, admin_client, org_a, three
):
response = self._create(
admin_client,
assignment_mode="rotation",
rotation_members=[str(p.id) for p in three],
rotation_cap=5,
)
assert response.status_code == 201, response.data
form = WebForm.objects.get(id=response.data["id"])
assert form.assignment_mode == "rotation"
assert set(form.rotation_members.all()) == set(three)
assert form.rotation_cap == 5
assert [d["id"] for d in response.data["rotation_members_details"]] == [
str(p.id) for p in three
]
assert response.data["rotation_last_assigned_details"] is None
def test_the_last_assigned_member_is_described(
self, admin_client, org_a, admin_user, three
):
form = _form(org_a, admin_user, three)
_submit(form)
data = admin_client.get(f"{LIST_URL}{form.id}/").data
assert str(data["rotation_last_assigned"]) == str(three[0].id)
assert data["rotation_last_assigned_details"] == {
"id": str(three[0].id),
"email": three[0].user.email,
"name": three[0].user.name,
"is_active": True,
}
def test_the_cursor_cannot_be_written(self, admin_client, org_a, admin_user, three):
form = _form(org_a, admin_user, three)
response = self._put(
admin_client, form, rotation_last_assigned=str(three[2].id)
)
assert response.status_code == 200, response.data
form.refresh_from_db()
assert form.rotation_last_assigned is None
def test_rotation_with_no_members_is_refused(self, admin_client):
response = self._create(admin_client, assignment_mode="rotation")
assert response.status_code == 400
assert "rotation_members" in response.data
def test_emptying_a_rotations_members_is_refused(
self, admin_client, org_a, admin_user, three
):
form = _form(org_a, admin_user, three)
response = self._put(admin_client, form, rotation_members=[])
assert response.status_code == 400
assert "rotation_members" in response.data
assert form.rotation_members.count() == 3
def test_switching_to_rotation_keeps_stored_members(
self, admin_client, org_a, admin_user, three
):
form = _form(org_a, admin_user, three, mode=WebForm.ASSIGN_PERSON)
response = self._put(admin_client, form, assignment_mode="rotation")
assert response.status_code == 200, response.data
form.refresh_from_db()
assert form.assignment_mode == "rotation"
def test_person_mode_needs_no_members(self, admin_client):
response = self._create(admin_client, assignment_mode="person")
assert response.status_code == 201, response.data
def test_another_orgs_profile_is_refused(self, admin_client, profile_b):
response = self._create(
admin_client,
assignment_mode="rotation",
rotation_members=[str(profile_b.id)],
)
assert response.status_code == 400
assert "rotation_members" in response.data
assert not WebForm.objects.filter(name="Probe").exists()
def test_adding_a_deactivated_member_is_refused(self, admin_client, org_a, three):
gone = _member(org_a, "gone@test.com", active=False)
response = self._create(
admin_client,
assignment_mode="rotation",
rotation_members=[str(three[0].id), str(gone.id)],
)
assert response.status_code == 400
assert "rotation_members" in response.data
def test_a_stored_member_deactivated_since_is_kept_on_resend(
self, admin_client, org_a, admin_user, three
):
form = _form(org_a, admin_user, three)
three[1].is_active = False
three[1].save(update_fields=["is_active"])
response = self._put(
admin_client,
form,
name="Renamed",
rotation_members=[str(p.id) for p in three],
)
assert response.status_code == 200, response.data
assert set(form.rotation_members.all()) == set(three)
details = {
d["id"]: d["is_active"] for d in response.data["rotation_members_details"]
}
assert details[str(three[1].id)] is False
def test_rotation_on_a_ticket_form_is_refused(self, admin_client, three):
response = self._create(
admin_client,
target="ticket",
assignment_mode="rotation",
rotation_members=[str(three[0].id)],
)
assert response.status_code == 400
assert "assignment_mode" in response.data
def test_turning_a_rotation_form_into_a_ticket_form_is_refused(
self, admin_client, org_a, admin_user, three
):
form = WebForm.objects.create(
name="Empty", org=org_a, assignment_mode="rotation", created_by=admin_user
)
form.rotation_members.set(three)
response = self._put(admin_client, form, target="ticket")
assert response.status_code == 400
assert "assignment_mode" in response.data
form.refresh_from_db()
assert form.target == "lead"
@pytest.mark.parametrize("cap", [0, -1, "many"])
def test_a_cap_below_one_is_refused(self, admin_client, three, cap):
response = self._create(
admin_client,
assignment_mode="rotation",
rotation_members=[str(three[0].id)],
rotation_cap=cap,
)
assert response.status_code == 400
assert "rotation_cap" in response.data
def test_a_cap_can_be_cleared(self, admin_client, org_a, admin_user, three):
form = _form(org_a, admin_user, three, cap=3)
response = self._put(admin_client, form, rotation_cap=None)
assert response.status_code == 200, response.data
form.refresh_from_db()
assert form.rotation_cap is None
def test_an_unknown_mode_is_refused(self, admin_client):
response = self._create(admin_client, assignment_mode="round_robin")
assert response.status_code == 400
assert "assignment_mode" in response.data
@pytest.mark.django_db
class TestWhoMayChangeIt:
"""Read is open to every member; changing assignment is admin only, the
rule `webforms/views.py:_may_write` applies to every write on a form."""
@pytest.fixture
def form(self, org_a, admin_user, three):
return _form(org_a, admin_user, three[:1], mode=WebForm.ASSIGN_PERSON)
def test_a_member_cannot_switch_on_rotation(self, user_client, form, three):
response = user_client.put(
f"{LIST_URL}{form.id}/",
{
"assignment_mode": "rotation",
"rotation_members": [str(p.id) for p in three],
"rotation_cap": 2,
},
format="json",
)
assert response.status_code == 403
form.refresh_from_db()
assert form.assignment_mode == "person"
assert form.rotation_cap is None
assert list(form.rotation_members.all()) == [three[0]]
def test_an_admin_can(self, admin_client, form, three):
response = admin_client.put(
f"{LIST_URL}{form.id}/",
{
"assignment_mode": "rotation",
"rotation_members": [str(p.id) for p in three],
"rotation_cap": 2,
},
format="json",
)
assert response.status_code == 200, response.data
form.refresh_from_db()
assert form.assignment_mode == "rotation"
assert form.rotation_cap == 2
assert set(form.rotation_members.all()) == set(three)
def test_a_member_can_read_the_rotation(self, user_client, form):
response = user_client.get(f"{LIST_URL}{form.id}/")
assert response.status_code == 200
assert response.data["assignment_mode"] == "person"
assert len(response.data["rotation_members_details"]) == 1
def test_another_org_cannot_read_it(self, org_b_client, form):
assert org_b_client.get(f"{LIST_URL}{form.id}/").status_code == 404
@pytest.mark.postgres_only
@pytest.mark.django_db(transaction=True)
def test_two_submissions_at_once_go_to_two_members(org_a, admin_user):
"""Both submissions start before either commits. The second waits on the
form's row lock, then reads the cursor the first one moved. Without the
lock both read "nobody yet" and the same member got both leads."""
if connection.vendor != "postgresql":
pytest.skip("row locks need PostgreSQL")
from common.tasks import set_rls_context
members = _by_id(_member(org_a, "c1@test.com"), _member(org_a, "c2@test.com"))
form = _form(org_a, admin_user, members)
barrier = threading.Barrier(2)
errors = []
original = service.rotation_assignee
def slow_pick(*args, **kwargs):
chosen = original(*args, **kwargs)
time.sleep(0.5)
return chosen
def worker(email):
try:
set_rls_context(org_a.id)
local = WebForm.objects.get(id=form.id)
barrier.wait()
submit_form(local, {"email": email})
except Exception as exc: # recorded and asserted on below
errors.append(exc)
finally:
connection.close()
with mock.patch("webforms.service.rotation_assignee", slow_pick):
threads = [
threading.Thread(target=worker, args=(f"race{i}@example.com",))
for i in range(2)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=30)
assert errors == []
set_rls_context(org_a.id)
owners = sorted(
profile.id
for lead in Lead.objects.filter(org=org_a)
for profile in lead.assigned_to.all()
)
assert owners == [m.id for m in members]

View file

@ -41,7 +41,12 @@ def on_save(sender, instance, created, raw=False, **kwargs):
def on_pre_delete(sender, instance, **kwargs):
# Built before the delete, while the assignment rows still exist.
# Built before the delete, while the assignment rows still exist. A
# snapshot already set is kept: a merge takes its own before it moves the
# merged record's assignees to the record it keeps
# (`common.duplicate_detection.merge_records`).
if getattr(instance, "_webhook_snapshot", None) is not None:
return
prefix, build = emit.SPECS[instance._meta.label_lower]
instance._webhook_snapshot = build(instance)

View file

@ -0,0 +1,119 @@
"""A merged-away record's `.deleted` payload lists the assignees it had.
`merge_records` moves the loser's assignees to the keeper when the keeper has
none, and does that before deleting the loser. The payload used to be built at
`pre_delete`, after the move, so it listed nobody. The merge now takes the
snapshot before anything moves, and the `pre_delete` receiver keeps it.
The payload carries `assigned_to` only; teams are not part of it.
"""
from unittest import mock
import pytest
from accounts.models import Account
from contacts.models import Contact
from leads.models import Lead
from webhooks import signals
from webhooks.models import WebhookDelivery, WebhookEndpoint
MAKERS = {
"leads": ("lead", lambda org, n: Lead.objects.create(org=org, first_name=n)),
"contacts": (
"contact",
lambda org, n: Contact.objects.create(org=org, first_name=n, last_name="L"),
),
"accounts": ("account", lambda org, n: Account.objects.create(org=org, name=n)),
}
@pytest.fixture(autouse=True)
def no_broker():
with mock.patch("webhooks.emit.deliver_webhook.delay") as delay:
yield delay
def _hook(org, admin_profile, prefix):
return WebhookEndpoint.objects.create(
org=org,
url="https://hooks.example.com/in",
events=[f"{prefix}.deleted", f"{prefix}.updated"],
created_by=admin_profile.user,
)
def _deleted(hook, prefix):
return WebhookDelivery.objects.get(endpoint=hook, event=f"{prefix}.deleted")
@pytest.mark.parametrize("module", sorted(MAKERS))
class TestMergedAway:
def test_the_keeper_had_nobody(
self, module, admin_client, org_a, admin_profile, user_profile
):
prefix, make = MAKERS[module]
hook = _hook(org_a, admin_profile, prefix)
keeper, loser = make(org_a, "Kay"), make(org_a, "Lou")
loser.assigned_to.add(user_profile)
response = admin_client.post(
f"/api/{module}/{keeper.id}/merge/",
{"merge_id": str(loser.id)},
format="json",
)
assert response.status_code == 200, response.content
data = _deleted(hook, prefix).payload["data"]
assert data["id"] == str(loser.id)
assert data["assigned_to"] == [str(user_profile.id)]
# The assignee moved to the keeper, which is why the snapshot had to
# be taken first.
assert list(keeper.assigned_to.all()) == [user_profile]
def test_the_keeper_had_its_own(
self, module, admin_client, org_a, admin_profile, user_profile
):
prefix, make = MAKERS[module]
hook = _hook(org_a, admin_profile, prefix)
keeper, loser = make(org_a, "Kay"), make(org_a, "Lou")
keeper.assigned_to.add(admin_profile)
loser.assigned_to.add(user_profile)
response = admin_client.post(
f"/api/{module}/{keeper.id}/merge/",
{"merge_id": str(loser.id)},
format="json",
)
assert response.status_code == 200, response.content
data = _deleted(hook, prefix).payload["data"]
assert data["assigned_to"] == [str(user_profile.id)]
assert list(keeper.assigned_to.all()) == [admin_profile]
class TestOrdinaryDelete:
def test_a_plain_delete_still_lists_its_assignees(
self, admin_client, org_a, admin_profile, user_profile
):
hook = _hook(org_a, admin_profile, "lead")
lead = Lead.objects.create(org=org_a, first_name="Ada")
lead.assigned_to.add(user_profile)
response = admin_client.delete(f"/api/leads/{lead.id}/")
assert response.status_code in (200, 204), response.content
data = _deleted(hook, "lead").payload["data"]
assert data["id"] == str(lead.id)
assert data["first_name"] == "Ada"
assert data["assigned_to"] == [str(user_profile.id)]
def test_pre_delete_builds_a_snapshot_when_none_is_set(self, org_a):
lead = Lead.objects.create(org=org_a, first_name="Ada")
signals.on_pre_delete(Lead, lead)
assert lead._webhook_snapshot["first_name"] == "Ada"
def test_pre_delete_keeps_a_snapshot_already_set(self, org_a):
lead = Lead.objects.create(org=org_a, first_name="Ada")
lead._webhook_snapshot = {"kept": True}
signals.on_pre_delete(Lead, lead)
assert lead._webhook_snapshot == {"kept": True}

View file

@ -150,8 +150,9 @@ plus `can_delete` for that account. Each hit carries only `id`, `name`, `email`,
Both search only the accounts the caller may open, so a hidden one is neither listed nor counted, and
a hidden or missing `{id}` is the same `404`. A value longer than the model allows is a `400`. The
create-form check is a `POST` that writes nothing, so that an email address and a phone number never
land in a URL and the access logs that record it; a token needs the module's `write` scope for it,
as it would to create the record. It is throttled at 120 requests a minute per user, and the
land in a URL and the access logs that record it. From django-crm 1.13.0 a token needs only the
module's `read` scope (`accounts:read`) for it, because it writes nothing; before that it needed
`accounts:write`. It is throttled at 120 requests a minute per user, and the
clients debounce well under that.
Matching: email, case-insensitive; the name once case, punctuation, a leading "The" and a trailing legal suffix ("Inc", "Ltd", "LLC", ...) are set aside, so "Acme" finds "ACME, Inc." but not "Acme Widgets"; the website host, ignoring scheme, "www." and path. Phone numbers match on their last ten digits (or every digit of a shorter number,
@ -170,7 +171,10 @@ transaction:
values; owners (`assigned_to`, `teams`) are taken only when the kept account has none; tags and linked contacts
are the union of both.
- The merged account is then deleted, which sends the usual `account.deleted` webhook, and a
`RECORD_MERGED` row naming both ids and both names is written to the audit log. There is no undo.
`RECORD_MERGED` row naming both ids and both names is written to the audit log. There is no undo. From
django-crm 1.13.0 that webhook's `assigned_to` lists the owners the merged account had before the
merge, even when they moved to the kept one; before, it was built after the move and could list
nobody.
The response is `{"error": false, "message": "...", "id": "<kept id>"}`.

View file

@ -1,7 +1,7 @@
# Security audit log
`GET /api/org/audit-log/` lets an org's admins read its security audit log: sign-ins, org switches,
refused requests, revoked memberships, paused webhooks and the like. The view is
refused requests, record merges, paused webhooks and the like. The view is
`SecurityAuditLogListView` (`backend/common/views/audit_log_views.py`), routed in
`common/urls.py`; the events are written by `AuditLogger` in `common/audit_log.py`. See
[Conventions](conventions.md#pagination) for `limit` and `offset`, and [Errors](errors.md) for the
@ -23,8 +23,8 @@ token or the organization API key is refused before the view runs, whatever scop
token cannot read what the org has noticed about it.
Only the caller's own org's rows are returned. The table has no row-level security policy on
purpose (its `org` is empty for events such as a failed login with an unknown email), so the
view's `org=request.profile.org` filter is the barrier, and a row with no org never matches it.
purpose (its `org` is empty for events such as a failed sign-in), so the view's
`org=request.profile.org` filter is the barrier, and a row with no org never matches it.
## Query parameters
@ -41,8 +41,9 @@ holds, so they are left out of the list, and out of `count`, unless the caller a
`include_token_refresh=true`, or with `event_type=TOKEN_REFRESH`, which lists only those. They are
still recorded either way.
Rows come newest first. For example, `GET /api/org/audit-log/?event_type=LOGIN_FAILURE&from=2026-09-01`
lists this month's failed sign-ins that were recorded against the org.
Rows come newest first. For example, `GET /api/org/audit-log/?event_type=LOGIN_SUCCESS&from=2026-09-01`
lists this month's sign-ins to the org. `LOGIN_FAILURE` is accepted as a filter but lists nothing:
see [Failed sign-ins](#failed-sign-ins).
```json
{"event_type": ["Unknown event type."]}
@ -94,3 +95,33 @@ What each row returns is chosen field by field rather than copied from the table
that event only; the same key on any other row is left out.
- **Paths under `/api/public/` are cut to that prefix.** Some public links carry a token in the URL
(a satisfaction-survey link, for one), and the log should not repeat it.
## Failed sign-ins
From django-crm 1.13.0 a refused staff sign-in writes a `LOGIN_FAILURE` row (`AuditLogger.login_failure`,
called from `common/views/auth_views.py`). The caller has no org yet, so the row has none, and this
endpoint never returns it: failed sign-ins are for the operator, read from the `security_audit_log`
table or the `security.audit` logger (`security_audit.log`), not from an org's viewer.
`metadata` holds the claimed `email` (cut to 254 characters, empty when the attempt named none) and a
`reason`; `user` is set when that email belongs to a user. The token or code that was tried is never
stored. The reasons:
| `reason` | When |
| --- | --- |
| `google_unreachable` | The Google token exchange could not be reached |
| `google_code_rejected` | Google refused the authorization code |
| `google_invalid_id_token` | The code flow returned no ID token, or one that would not parse |
| `google_invalid_token` | The phone's Google ID token failed verification |
| `google_no_email` | The Google token carried no email |
| `google_email_unverified` | Google does not vouch for the email |
| `account_disabled` | The user exists but is deactivated (Google or email sign-in) |
| `magic_link_invalid` | A sign-in link that is unknown, used or expired; the email is the link's own when it exists |
| `code_not_found` | No unused, unexpired sign-in code for that email |
| `code_wrong` | The code did not match |
| `code_attempts_exhausted` | The code did not match and that was the last attempt, so the code is spent |
Anonymous callers write these rows, so each client IP gets at most 20 per hour
(`LOGIN_FAILURE_ROWS_PER_IP_PER_HOUR` in `common/audit_log.py`, counted in the default cache from the
first failure). Attempts past the cap are refused exactly as before; they leave no row and no log
line.

View file

@ -39,14 +39,14 @@ with one deliberate widening for the inbox; see that section.
## List cases
`GET /api/cases/` (`CaseListView.get`, `cases/views.py:285-287`) returns one paginated list, ordered
`-created_at, -id` (`:169-174`): a comment on the queryset explains the tiebreak exists because `-id`
`GET /api/cases/` (`CaseListView.get`, `cases/views.py:310-312`) returns one paginated list, ordered
`-created_at, -id` (`case_list_queryset`, `:188-193`): a comment on the queryset explains the tiebreak exists because `-id`
alone is a random UUID and was, in practice, no ordering at all. Soft-deleted cases
(`is_active=False`) are excluded by default; an org admin can pass `?include_deleted=true` to see them
(`:176-180`). Merged duplicates (`merged_into` set, or `status="Duplicate"`) are excluded by default
too; `?show_merged=true` includes them (`:181-186`). A non-admin caller sees only cases where they are
(`:194-198`). Merged duplicates (`merged_into` set, or `status="Duplicate"`) are excluded by default
too; `?show_merged=true` includes them (`:199-202`). A non-admin caller sees only cases where they are
creator, assignee, or watcher, the same `visible_cases_qs` the detail view's read check uses
(`:190-198`), so list and detail agree by construction.
(`:203-209`), so list and detail agree by construction.
```json
{
@ -65,29 +65,29 @@ creator, assignee, or watcher, the same `visible_cases_qs` the detail view's rea
}
```
(`cases/views.py:222-263`.) `open_count`, `urgent_count` and `awaiting_first_reply` are counted over
(`cases/views.py:217-288`.) `open_count`, `urgent_count` and `awaiting_first_reply` are counted over
the whole filtered queryset, not the page. `awaiting_first_reply` counts open cases
(`status` in `New`/`Assigned`/`Pending`) with no `first_response_at` yet. It is not the same thing as
an SLA breach, which depends on the org's business calendar and is computed per-row rather than in
this aggregate. `accounts_list`/`contacts_list` feed the create form's pickers, org-wide for an admin,
but narrowed to accounts/contacts the caller created or is assigned to for a non-admin, the same split
the case queryset itself gets (`:187-206`); either way they can be heavy, and `?slim=true` omits both
(`:254-256`).
but narrowed for a non-admin by the accounts and contacts read rules (`visible_accounts_qs`,
`visible_contacts_qs`, `:224-227`), the same rules the save path accepts; either way they can be
heavy, and `?slim=true` omits both (`:279-281`).
Filters read directly from `request.query_params` (`apply_case_list_filters`, `cases/views.py:85-155`,
Filters read directly from `request.query_params` (`apply_case_list_filters`, `cases/views.py:97-177`,
shared with `GET /api/cases/watching/`): `name` (contains), `status` (repeatable. A list applies
`status__in`, a single value applies exact match), `priority` (exact), `account` (exact id),
`case_type` (exact), `assigned_to` (repeatable id list), `tags` (id list), `search` (`name` or
`description`, contains), `created_at__gte`/`created_at__lte` (date range), `sla_breached=true`
(a wall-clock approximation of the SLA badge, Postgres-only raw SQL), `cf_<key>` (custom field equals),
and `ordering`: whitelisted to `created_at`, `-created_at`, `priority`, `-priority`, `id`, `-id`,
`name`, `-name` (`:71-82,150-153`); anything else is silently ignored rather than erroring.
`name`, `-name` (`:83-94,172-175`); anything else is silently ignored rather than erroring.
## Create a case
`POST /api/cases/` (`CaseListView.post`, `cases/views.py:306-416`) accepts `multipart/form-data` (for
`POST /api/cases/` (`CaseListView.post`, `cases/views.py:331-420`) accepts `multipart/form-data` (for
an optional `case_attachment` file) or JSON, validated by `CaseCreateSerializer`
(`cases/serializer.py:139-281`; see [Fields](#fields)). On success (`200`):
(`cases/serializer.py:284-458`; see [Fields](#fields)). On success (`200`):
```json
{
@ -102,36 +102,32 @@ A validation failure returns `400` with `{"error": true, "errors": {...}}`. Two
knowing:
- **`account`, if given, must belong to the caller's org**: `validate_account` rejects a
cross-tenant id with `"No such account."` (`cases/serializer.py:151-165`); the comment on it notes
cross-tenant id with `"No such account."` (`cases/serializer.py:304-330`); the comment on it notes
this closes both a cross-tenant *write* and a cross-tenant *read*, since the create response nests
the account back through `CaseSerializer` → `AccountSerializer`.
- **`name` must be unique per org, case-insensitive** (`validate_name`, `:232-244`).
- **`name` must be unique per org, case-insensitive** (`validate_name`, `:399-413`).
`contacts`, `teams`, `assigned_to` and `tags` are **not** part of `CaseCreateSerializer`. The view
reads each from the request body after the serializer succeeds, resolved against
`org=request.profile.org` (`:332-386`). Unlike [Leads](leads.md#fields), which documents a real
`org=request.profile.org` (`cases/views.py:351-389`). Unlike [Leads](leads.md#fields), which documents a real
per-field, per-verb inconsistency in how these four are *parsed*, cases parses all four identically on
every verb: each is `json.loads`'d when it arrives as a string (which happens whenever the same
request also uploads `case_attachment` as multipart) before the id list is extracted. `case_attachment`
itself is read straight off `request.FILES` (`:388-395`): on `POST` only; see
itself is read straight off `request.FILES` (`cases/views.py:391-396`): on `POST` only; see
[Retrieve, update, delete](#retrieve-update-delete) for why `PATCH` can't set it at all, and for how
*writing* these four fields (not just parsing them) differs sharply across verbs.
## Retrieve, update, delete
`GET /api/cases/{id}/` (`CaseDetailView.get`, `cases/views.py:612-731`) looks the case up scoped to
the caller's org (`get_case_or_404`, `cases/access.py:57-71`: a malformed UUID or a case in another
org both answer `404`, never `500`, matching [Errors](errors.md#not-found-versus-forbidden)). If the
`GET /api/cases/{id}/` (`CaseDetailView.get`, `cases/views.py:579-747`) looks the case up through the
read rule first (`get_case_or_404`, `:584`: a malformed UUID, a case in another org and a same-org
case the caller may not open all answer the same `404`, never `500` or `403`, matching
[Errors](errors.md#not-found-versus-forbidden)). If the
case has been merged into another one, the response is a redirect hint instead of the case itself:
`{"redirect_to": "<uuid>", "merged_into": "<uuid>", "source_case_id": "<uuid>", "source_case_name": "..."}`,
unless the caller passes `?show_merged=true` (`:614-630`). **This merged-case branch returns before
any read-access check runs at all**; `assert_case_read_access` isn't called until `:633`, several
lines after the merged branch's own `return` at `:630`, so `source_case_name` is disclosed to any org
member who can supply a valid case id, including one with no read access to the case whatsoever, as
long as it happens to be a merged duplicate. On the ordinary, non-merged path, read access genuinely is
checked before the rest of the response body is built (`assert_case_read_access`, `:633`, the comment
on this line notes the previous order built the payload for a case the requester was about to be
refused); it's specifically the merged-redirect shortcut that skips the check entirely. The response nests
unless the caller passes `?show_merged=true` (`:589-601`). The redirect answers only after the read
check, so `source_case_name` never reaches a caller who could not open the duplicate; it used to
answer first and disclosed the name to any org member holding the id. The response nests
the record under `cases_obj`, alongside `attachments`, `comments` (public only), `internal_notes`,
`contacts`, `solutions`, `activities` (last 20), `email_messages` (last 50, inbound-email threads),
`merged_from_cases`, `custom_field_definitions`, `status`, `priority`, `type_of_case`,
@ -139,68 +135,81 @@ the record under `cases_obj`, alongside `attachments`, `comments` (public only),
`{"id", "name"}` of the rule that gates closing this case (`find_matching_rule(case, "pre_close")`,
the rule a `request-approval/` with no `rule_id` binds to), or `null` when none does; clients offer
"Request approval" only when it is set. `comment_permission` is computed with the same
`has_case_write_access` the comment-post endpoint enforces (`:642`), so the button a client shows and
`has_case_write_access` the comment-post endpoint enforces (`:612`), so the button a client shows and
the answer the server gives agree by construction, the comment on this line notes that used to not be
true (`comment_permission` was creator-or-admin while the write endpoint below also allowed assignees).
`PUT /api/cases/{id}/` (`:448-570`) and `PATCH /api/cases/{id}/` (`:823-942`) both call
`PUT /api/cases/{id}/` (`cases/views.py:452-536`) and `PATCH /api/cases/{id}/` (`:842-860`) both call
`assert_case_write_access` before touching anything. **`account` cannot be changed by either verb.**
`CaseCreateSerializer.__init__` sets `self.fields["account"].read_only = True` whenever `self.instance`
is set (`cases/serializer.py:146-149`) (true for both update calls, never for create), so on `PUT`/
is set (`cases/serializer.py:293-295`) (true for both update calls, never for create), so on `PUT`/
`PATCH` an `account` in the body is silently dropped before validation ever runs; `validate_account`
never executes and no error is returned. `PUT` is a non-partial serializer instantiation, so it still
requires `name`/`status`/`priority` the same as `POST` does; `PATCH` passes `partial=True`
(`:832`), so none of the three are required there. A `PATCH` may touch just one field.
(in `update_case`), so none of the three are required there. A `PATCH` may touch just one field. From
django-crm 1.13.0 the `PATCH` write is `update_case` in `cases/updates.py`, which a macro's actions
([Macros](macros.md#apply)) and the bulk edit also call, so all three run the same validation, the
same org and active filters on assignees and tags, and the same email to anyone newly assigned.
Closing a case is validated as a *transition*, not just a target value: `CaseCreateSerializer.validate()`
(`cases/serializer.py:167-230`) requires `closed_on` whenever `status` is being set to `"Closed"` from
anything other than `"Closed"`, and, when an active `pre_close` `ApprovalRule` matches the case's
priority/case_type/team. Requires an `Approval` row in state `approved` for that case and rule before
the close is allowed. This exists because `Case.clean()` states the same two rules
(`cases/models.py:180-249`) but `Case.save()` (`:264-283`) never calls `full_clean()`, so without the
serializer-level check a matching rule could be armed and a `PATCH {"status": "Closed"}` would still
return `200` and record zero approvals. Proven live per the comment on `validate()`. Success on either
verb, identical literal string on both (`views.py:564,936`):
`{"error": false, "message": "Case Updated Successfully"}`.
Closing a case is validated as a *transition*, not just a target value. `CaseCreateSerializer.validate()`
(`cases/serializer.py:332-397`) judges only a move into `"Closed"` from any other status, so an
already-closed case can still be edited. Two things happen on that move:
**Three of `Case.clean()`'s parent-tree guards are dead code through the API, and one field that
looks like a partial re-implementation of the model isn't a re-implementation at all.**
`Case.clean()` (`cases/models.py:180-249`) states four rules about `parent`: a case cannot be its own
parent (`:222-223`); linking cannot create a multi-level cycle, walking the whole parent chain to check
(`:226-238`); the tree is capped at `PARENT_MAX_DEPTH = 3` levels (`:178,240-243`); and a case that is
itself `status="Duplicate"` cannot be given a parent at all (`:247-250`, the self-side of the merge
guard, distinct from `parent.status == "Duplicate"`, checked separately at `:245-246`). None of the
four run through any API write, because `Case.save()` (`:264-283`) never calls `full_clean()`. Of the
four, `CaseCreateSerializer.validate_parent()` (`cases/serializer.py:246-263`) independently
re-implements exactly two: the self-parent check, and refusing a parent whose own `status` is
`Duplicate`. **The cycle walk and the depth limit are simply absent from the API, not partially
covered, not approximated, and so is the "a Duplicate case cannot receive a parent" direction.**
`validate_parent()` also rejects a cross-org `parent`, but that check has no model-side counterpart at
all: `Case.clean()` never compares `org_id`, so this is a serializer-only addition, not a partial port
of anything the model already states. Net effect: a same-org, non-self parent assignment that would
create a longer cycle (A's parent is B, then B's parent is set to A), exceed three levels, or give a
merged case a parent, is accepted by every write endpoint today: reported here, not fixed, per this
documentation task's scope.
- **The close is dated by the server.** A close that sends no `closed_on` (absent or `null`) is dated
today in the org's timezone (`Org.timezone`, which the middleware activates for the request), by
`closing_date` in `cases/approvals.py`. A `closed_on` the client sends always wins, and a malformed
one is a `400` against `closed_on` with nothing written. Clients should not compute "today"
themselves: from django-crm 1.13.0 neither the web app nor the phone sends a date for a plain close,
and an org stored under a legacy zone name such as `US/Eastern` is dated like any other. Leaving
`"Closed"` clears `closed_on` (and `resolved_at`), so a reopened case that is closed again is dated
afresh rather than keeping the old date.
- **The approval gate runs.** When an active `pre_close` `ApprovalRule` matches the case's
priority/case_type/team (the incoming values, so a request cannot re-target the case out of the
rule and close it at once), an `Approval` row in state `approved` for that case and rule is
required, or the answer is `400` with
`{"errors": {"status": ["An approval is required before this case can be closed (rule: <name>)."]}}`
and nothing is written.
`DELETE /api/cases/{id}/` (`:586-593`) calls `assert_case_delete_access`, admin or creator only, no
The same dating rule applies on every path that closes a ticket: `POST` (a case created as
`"Closed"`), `PUT`, `PATCH`, the board move (`PATCH /api/cases/{id}/move/`), the bulk edit
(`POST /api/cases/bulk/update/`), a macro's actions, and `close-with-children`. The approval gate runs
on all of them except `POST`, since an approval can only be recorded against a case that already
exists; the bulk edit reports a gated ticket as `approval_required` and carries on with the rest.
The gate exists because `Case.clean()` (`cases/models.py:193-253`)
states the rules but nothing on the save path calls `full_clean()` (`Case` defines no `save()` of its
own; it inherits `BaseModel.save()`), so without the serializer-level check a matching rule could be
armed and a `PATCH {"status": "Closed"}` would still return `200` and record zero approvals. `PUT`
and `PATCH` answer success with the identical literal string `{"error": false, "message": "Case
Updated Successfully"}`.
**Parent links.** `Case.clean()` (`cases/models.py:193-253`) states four rules about `parent`: a case
cannot be its own parent, linking cannot create a cycle, the tree is capped at
`Case.PARENT_MAX_DEPTH = 3` levels (`:191`), and nothing is linked to or from a merged (`Duplicate`)
case. Since `Case.clean()` never runs on the API path, all four are enforced by
`check_parent_link` in `cases/parent_guards.py`, which both `CaseCreateSerializer.validate()` (when
the request carries `parent`) and the link endpoint call. The depth check counts the subtree the case
brings with it. `validate_parent()` (`cases/serializer.py:415-440`) additionally refuses a parent in
another org or one the caller may not open, with the same message as an unknown id.
`DELETE /api/cases/{id}/` (`cases/views.py:552-559`) calls `assert_case_delete_access`, admin or creator only, no
assignee exception (see [above](#a-cases-read-write-and-delete-rules-differ-on-purpose)).
## Comments and attachments
Adding a comment or an attachment is the same `POST /api/cases/{id}/` route the detail endpoint uses,
just with a body (`CaseDetailView.post`, `cases/views.py:749-806`), gated by
`assert_case_write_access` (`:755`). The same rule `comment_permission` on `GET` reports. Send
just with a body (`CaseDetailView.post`, `cases/views.py:765-825`), gated by
`assert_case_write_access` (`:771`). The same rule `comment_permission` on `GET` reports. Send
`comment` (text, and optionally `is_internal`, a truthy string or boolean) and/or `case_attachment`
(multipart file), either or both. A comment created with `is_internal=true` is an internal note, not
visible to a customer-facing surface; the response splits both back out as `comments` (public) and
`internal_notes` (`:794-805`), and both are returned to any caller with read access; `is_internal` is
`internal_notes` (`:817-822`), and both are returned to any caller with read access; `is_internal` is
a display split, not an access-control split on this endpoint.
Editing or deleting an *existing* comment goes through `PUT`/`PATCH`/`DELETE /api/cases/comment/{id}/`
(`CaseCommentView`, `cases/views.py:945-1078`). Restricted to an admin, the `is_organization_admin`
flag (exposed as `.is_admin`, `common/models.py:243-246`: org-scoped, not the platform-level
superuser flag), or the comment's own author (`request.profile == obj.commented_by`, e.g. `:982`).
`Comment.commented_by` is a `Profile` foreign key (`common/models.py:275-277`), so this comparison is
(`CaseCommentView`, `cases/views.py:862-994`). Restricted to an org admin (`is_org_admin`: the
`ADMIN` role, and a superuser counts), or the comment's own author
(`request.profile == obj.commented_by`, `:900`, `:946`, `:981`).
`Comment.commented_by` is a `Profile` foreign key (`common/models.py:414-416`), so this comparison is
correct, unlike some `created_by` comparisons elsewhere in this codebase; see
[Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks).
@ -224,10 +233,11 @@ has no `Profile`, and `commented_by_contact` names them instead. That null is th
inbound email reply carries, and it is what `_evaluate_reopen` and the first-response SLA stamp both
test to tell a customer's message from a colleague's.
`DELETE /api/cases/attachment/{id}/` (`CaseAttachmentView.delete`, `cases/views.py:1098-1142`) is
org-scoped (`.filter(pk=pk, org=request.profile.org)`, `:1119-1121`) and its ownership check compares
`request.profile.user_id` to `self.object.created_by_id`, both `User` ids, correctly typed
(`:1128-1130`). The view's own docstring documents two defects that were live in this exact endpoint
`DELETE /api/cases/attachment/{id}/` (`CaseAttachmentView.delete`, `cases/views.py:1013-1055`) finds
the attachment only on a ticket the caller may open in their org (`get_on_visible_record_or_404`,
`:1036-1038`), so an attachment on a hidden ticket or on another module's record answers the same
`404` as a missing id, and its ownership check compares `request.profile.user_id` to
`self.object.created_by_id`, both `User` ids, correctly typed (`:1040-1043`). The view's own docstring documents two defects that were live in this exact endpoint
before the fix (unscoped lookup; a `Profile`-to-`User` comparison that made the endpoint silently
admin-only). Both are fixed here today. Whether the equivalent endpoint in another app still has
either defect is that app's own question; verify against that app's current source rather than this
@ -284,7 +294,7 @@ the requester are mutually exclusive for a single decision, regardless of role.
admin, so a profile that is only an admin via that flag is not automatically an approver unless
explicitly added to `rule.approvers`.
`ApprovalSerializer.can_act` and `.is_own_request` (`cases/serializer.py:1045-1062`) mirror the approve
`ApprovalSerializer.can_act` and `.is_own_request` (`cases/serializer.py:1359-1384`) mirror the approve
endpoint's checks exactly, so a client rendering the inbox from the list response gets the same answer
the action endpoints would give. Both need `context={"request": request}` to resolve; without it they
default to `False`, which the comment on the serializer notes is the safe default rather than a bug.
@ -360,7 +370,7 @@ runs the other direction.
Reading which solutions are linked to a case is not a separate endpoint. The linked set is returned
as part of `GET /api/cases/{id}/` (`solutions` key, [above](#retrieve-update-delete)). Linking is
`POST /api/cases/{id}/solutions/` and unlinking is `DELETE /api/cases/{id}/solutions/{solution_id}/`
(`CaseSolutionLinkView`, `cases/views.py:1145-1208`), gated by `assert_case_write_access` on the
(`CaseSolutionLinkView`, `cases/views.py:1058-1134`), gated by `assert_case_write_access` on the
*case*: the comment on `.post` notes this closes a read-around: before this check, a member refused a
case with `403` could still link an article to it and then read the case's name, description, account
and contacts back out through that article's own `linked_cases`. The read side of the same gap is
@ -416,7 +426,7 @@ at 5,000 data rows (`MAX_ROWS`, `:49`).
## Fields
`CaseCreateSerializer.Meta.fields` (`cases/serializer.py:265-281`) is what `POST /api/cases/`,
`CaseCreateSerializer.Meta.fields` (`cases/serializer.py:442-458`) is what `POST /api/cases/`,
`PUT /api/cases/{id}/` and `PATCH /api/cases/{id}/` accept. "Required" below means required on
`POST` and on `PUT` (which is non-partial); `PATCH` passes `partial=True`, so nothing is required
there: see [Retrieve, update, delete](#retrieve-update-delete).
@ -427,25 +437,27 @@ there: see [Retrieve, update, delete](#retrieve-update-delete).
| `status` | one of `STATUS_CHOICE` | **required** (POST/PUT) | `New`, `Assigned`, `Pending`, `Closed`, `Rejected`, `Duplicate` |
| `priority` | one of `PRIORITY_CHOICE` | **required** (POST/PUT) | `Low`, `Normal`, `High`, `Urgent` |
| `case_type` | one of `CASE_TYPE` | optional | `Question`, `Incident`, `Problem` |
| `closed_on` | date | **required when closing** | See [Retrieve, update, delete](#retrieve-update-delete); optional otherwise |
| `closed_on` | date | optional | A close that sends none is dated today in the org's timezone; a sent date wins. See [Retrieve, update, delete](#retrieve-update-delete) |
| `description` | text | optional | |
| `is_active` | boolean | optional | Defaults `true`; soft-delete flag, hidden from `GET /api/cases/` by default |
| `account` | uuid | optional on create; **write-once** | Must belong to the caller's org; silently `read_only` on `PUT`/`PATCH`. See [Retrieve, update, delete](#retrieve-update-delete) |
| `custom_fields` | object | optional | Validated against the org's `CustomFieldDefinition` rows |
| `parent` | uuid | optional | Same-org, non-self, parent not `Duplicate`; see the dead model-level guards [above](#retrieve-update-delete) |
| `parent` | uuid | optional | Same-org and readable by the caller; no self-parent, no cycle, at most 3 levels, nothing merged on either side. See [above](#retrieve-update-delete) |
| `is_problem` | boolean | optional | Defaults `false`; marks an ITIL "problem" (umbrella) ticket |
| `org` |. | **read-only** | Server-derived from `request.profile.org`; listed in `Meta.fields` but not writable |
Not part of the serializer, but accepted in the same request body and resolved by the view (each a
list of ids, org-scoped): `contacts`, `teams`, `assigned_to`, `tags`. Parsing is identical on every
verb (see [Create a case](#create-a-case)), but *writing* is not, and the difference is a real
data-loss trap: `POST` only ever adds. `PUT` calls `.clear()` on all four M2Ms **unconditionally**,
before checking whether the request even mentioned them (`views.py:485,501,515,531`), so a `PUT`
data-loss trap: `POST` only ever adds. `PUT` replaces all four M2Ms **unconditionally** (`.clear()` on
teams, assignees and tags, and `replace_visible_contacts` for contacts),
before checking whether the request even mentioned them (`views.py:496,500,506,514`), so a `PUT`
that omits `assigned_to` from the body unassigns every assignee on the case, not just leaves them
unchanged. `PATCH` clears a given M2M only when its key is present in the body at all
(`if "assigned_to" in params:`, `:903`, and identically for the other three). Omitting a key on
(`if "assigned_to" in data:` in `update_case`, `cases/updates.py`, and identically for the other
three). Omitting a key on
`PATCH` genuinely leaves it untouched. `case_attachment` (a multipart file) is accepted on `POST` and
`PUT` (`views.py:388,545`) but **not on `PATCH`**; `CaseDetailView.patch` never reads
`PUT` (`views.py:391,521`) but **not on `PATCH`**; `CaseDetailView.patch` never reads
`request.FILES` at all, so a multipart `PATCH` carrying `case_attachment` silently drops the file with
no error.
@ -454,4 +466,4 @@ no error.
(`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`), `parent_summary`, `child_count`, and
`time_summary` (`CaseSerializer.Meta.fields`, `cases/serializer.py:97-136`).
`time_summary` (`CaseSerializer.Meta.fields`, `cases/serializer.py:239-281`).

View file

@ -193,8 +193,9 @@ plus `can_delete` for that contact. Each hit carries only `id`, `name`, `email`,
Both search only the contacts the caller may open, so a hidden one is neither listed nor counted, and
a hidden or missing `{id}` is the same `404`. A value longer than the model allows is a `400`. The
create-form check is a `POST` that writes nothing, so that an email address and a phone number never
land in a URL and the access logs that record it; a token needs the module's `write` scope for it,
as it would to create the record. It is throttled at 120 requests a minute per user, and the
land in a URL and the access logs that record it. From django-crm 1.13.0 a token needs only the
module's `read` scope (`contacts:read`) for it, because it writes nothing; before that it needed
`contacts:write`. It is throttled at 120 requests a minute per user, and the
clients debounce well under that.
Matching: email, case-insensitive; first and last name together. Phone numbers match on their last ten digits (or every digit of a shorter number,
@ -213,7 +214,10 @@ transaction:
values; owners (`assigned_to`, `teams`) are taken only when the kept contact has none; tags
are the union of both. "Do not call" set on either contact stays set. Portal sign-in codes minted for the merged contact are deleted with it, and its open portal sessions stop working; when the kept contact has no email it takes the merged one's, as an edit could.
- The merged contact is then deleted, which sends the usual `contact.deleted` webhook, and a
`RECORD_MERGED` row naming both ids and both names is written to the audit log. There is no undo.
`RECORD_MERGED` row naming both ids and both names is written to the audit log. There is no undo. From
django-crm 1.13.0 that webhook's `assigned_to` lists the owners the merged contact had before the
merge, even when they moved to the kept one; before, it was built after the move and could list
nobody.
The response is `{"error": false, "message": "...", "id": "<kept id>"}`.

View file

@ -179,6 +179,7 @@ Interactive versions of the same schema are served by a running backend at
| `/api/leads/{id}/move/` | PATCH |
| `/api/macros/` | GET, POST |
| `/api/macros/{id}/` | DELETE, GET, PATCH, PUT |
| `/api/macros/{id}/apply/` | POST |
| `/api/macros/{id}/render/` | POST |
| `/api/notifications/` | GET |
| `/api/notifications/read-all/` | POST |
@ -224,8 +225,10 @@ Interactive versions of the same schema are served by a running backend at
| `/api/portal/login/{org_id}/request/` | POST |
| `/api/portal/login/{org_id}/verify/` | POST |
| `/api/profile/` | GET, PATCH |
| `/api/profile/calendar-feed/` | DELETE, GET, POST |
| `/api/profile/tokens/` | GET, POST |
| `/api/profile/tokens/{id}/` | DELETE |
| `/api/public/calendar/{token}.ics` | GET |
| `/api/public/csat/{token}/` | GET, POST |
| `/api/public/estimate/{token}/` | GET |
| `/api/public/estimate/{token}/accept/` | POST |

View file

@ -205,8 +205,9 @@ plus `can_delete` for that lead. Each hit carries only `id`, `name`, `email`, `p
Both search only the leads the caller may open, so a hidden one is neither listed nor counted, and
a hidden or missing `{id}` is the same `404`. A value longer than the model allows is a `400`. The
create-form check is a `POST` that writes nothing, so that an email address and a phone number never
land in a URL and the access logs that record it; a token needs the module's `write` scope for it,
as it would to create the record. It is throttled at 120 requests a minute per user, and the
land in a URL and the access logs that record it. From django-crm 1.13.0 a token needs only the
module's `read` scope (`leads:read`) for it, because it writes nothing; before that it needed
`leads:write`. It is throttled at 120 requests a minute per user, and the
clients debounce well under that.
Matching: email, case-insensitive; first and last name together; the company name exactly, but only when no person name is given, because two people at one company are two leads. Converted leads are never matched. Phone numbers match on their last ten digits (or every digit of a shorter number,
@ -225,7 +226,10 @@ transaction:
values; owners (`assigned_to`, `teams`) are taken only when the kept lead has none; tags and linked contacts
are the union of both. The kept lead keeps its own status, pipeline stage and board position.
- The merged lead is then deleted, which sends the usual `lead.deleted` webhook, and a
`RECORD_MERGED` row naming both ids and both names is written to the audit log. There is no undo.
`RECORD_MERGED` row naming both ids and both names is written to the audit log. There is no undo. From
django-crm 1.13.0 that webhook's `assigned_to` lists the owners the merged lead had before the
merge, even when they moved to the kept one; before, it was built after the move and could list
nobody.
The response is `{"error": false, "message": "...", "id": "<kept id>"}`.

124
docs/api/macros.md Normal file
View file

@ -0,0 +1,124 @@
# Macros
A macro (a "saved reply" in the phone app) is a canned reply for the ticket composer, optionally
carrying actions that change the ticket. Routes are in `backend/macros/urls.py`, views in
`backend/macros/views.py`, the placeholder renderer in `backend/macros/render.py`. Every route is
under `/api/macros/` and needs `(IsAuthenticated, HasOrgContext)`. See [Conventions](conventions.md)
and [Errors](errors.md) for the shapes assumed here.
## Scope and visibility
A macro is either `org` (every member of the org can see and use it; only an admin may create,
edit or delete it) or `personal` (visible to its owner only). A non-admin's `POST` with
`scope: "org"`, or an edit of an org macro, is a `403`:
```json
{"error": "Only admins can manage org-scope macros."}
```
Somebody else's personal macro answers `404` on every verb, exactly like an id that does not exist,
so the id space does not reveal whose private macros exist. An org macro a non-admin tries to edit
answers `403`, because they can already see it.
## List, create, update, delete
`GET /api/macros/` lists the macros the caller can see, newest edit first, and takes `active`
(`true` or `false`) and `search` (title or body, case-insensitive). The response is
`{"results": [...], "totals": {...}, "placeholders": [...]}`; `totals` counts the whole visible set
whatever the filters (`count`, `org`, `personal`, `inactive`, `with_unknown_placeholders`), and
`placeholders` is the list the renderer supports.
`POST /api/macros/` creates one; `GET`, `PUT`, `PATCH` and `DELETE /api/macros/{id}/` read and
change it. `DELETE` turns an org macro off (`is_active: false`) and removes a personal one.
| Field | Type | Notes |
| --- | --- | --- |
| `title` | string, max 255 | Required |
| `body` | text | May be empty when the macro carries at least one action |
| `scope` | `org` or `personal` | Defaults to `org`, which needs an admin |
| `is_active` | boolean | Defaults `true`. An inactive macro cannot be rendered or applied |
| `set_status` | a ticket status, or `""` | Any status except `Duplicate`, which only a merge sets. `""` means no change |
| `set_priority` | a ticket priority, or `""` | `""` means no change |
| `set_assignees` | list of profile ids | Replaces the ticket's assignees when applied |
| `add_tags` | list of tag ids | Added to the ticket's own tags when applied |
`owner`, `owner_name`, `usage_count`, `unknown_placeholders`, `created_at` and `updated_at` are
read-only. `set_assignees_details` and `add_tags_details` name each id (`id`, `email` and `name`,
or `id`, `name` and `color`) with its `is_active`, so a client can show an assignee deactivated
since, or a tag archived since, rather than dropping it.
Validation, each a `400`:
- **A macro has to do something.** No body and no action is refused with
`{"body": ["A macro needs a body, an action, or both."]}`.
- **`set_assignees` and `add_tags` resolve in the caller's org only.** Another org's id is refused
like an id that does not exist.
- **Only active members and active tags may be added.** A deactivated member or an archived tag the
macro already carries is kept on save, because both clients resend the whole list every time.
## Render
`POST /api/macros/{id}/render/` with `{"case_id": "<uuid>"}` expands the placeholders
(`%customer_name%`, `%customer_email%`, `%case_id%`, `%case_subject%`, `%agent_name%`,
`%agent_email%`, `%org_name%`) against the ticket and returns `{"rendered_body": "..."}`. It counts
one use of the macro. A ticket the caller may not open, another org's ticket and a malformed id are
the same `404`, because the rendered text carries the ticket's subject and its contact's name and
email. An unknown placeholder is left in the text as written.
## Apply
From django-crm 1.13.0, `POST /api/macros/{id}/apply/` applies the macro's actions to a ticket:
```json
{"case_id": "<uuid>", "only": ["status", "tags"]}
```
`only` is optional. Its keys are `status`, `priority`, `assignees` and `tags`; it narrows the macro's
actions to the ones listed, which is how a client applies only the actions the agent kept. Omitted,
every action the macro carries is applied.
The write goes through `cases.updates.update_case`, the same function the ticket `PATCH` and the
bulk edit use, so every gate those run runs here too: the merged-ticket lock, the close gate (the
approval a matching `pre_close` rule asks for), `Duplicate` only by merge,
assignees and tags limited to active rows in the org, and the email to anyone newly assigned.
Assignees replace the ticket's; tags are added to the ticket's own. A `Closed` status takes the
ticket's existing `closed_on`, or today in the org's timezone when it has none. Applying does not
count as a use; rendering does.
Refusals, in the order they are checked:
| Status | When |
| --- | --- |
| `404` | The macro is missing, in another org, or somebody else's personal macro |
| `400` | The macro is inactive: `{"error": "Macro is inactive."}` |
| `400` | The macro carries no actions: `{"error": "This macro has no actions to apply."}` |
| `400` | `only` is not a list of the four keys: `{"error": "only must be a list of: status, priority, assignees, tags."}` |
| `400` | None of the keys in `only` is on this macro: `{"error": "None of the chosen actions are on this macro."}` |
| `400` | `case_id` is missing: `{"error": "case_id is required."}` |
| `404` | The ticket is one the caller may not open, in another org, or a malformed id |
| `403` | The caller may open the ticket but not change it (only an admin, its creator or an assignee may), as on the ticket `PATCH` |
| `400` | A gate refused the change: `{"error": true, "errors": {...}}`, the `PATCH`'s own errors, with nothing written |
Success is `200`:
```json
{
"error": false,
"message": "Macro applied",
"applied": ["status", "tags"],
"skipped": []
}
```
`applied` lists the actions that were applied. `skipped` lists `{"action", "reason"}` for an action
that had nothing left to apply: `assignees` when everyone the macro assigns is now deactivated (the
ticket keeps its assignees rather than being left with nobody), and `tags` when every tag it adds is
archived. When everything was skipped, nothing is written and `message` is `"Nothing was applied"`.
### In the clients
In the ticket composer on the web and the phone, picking a macro that has a body inserts the
rendered text as before and shows its actions as chips under **Also on send**. Each chip can be
removed. The actions left on apply right after the reply posts, with `only` set to them; if the
apply is refused, the reply has still gone out and the refusal is shown. A macro with no body shows
**Apply** instead, which applies its actions at once.

View file

@ -154,9 +154,14 @@ the rotting days of the default pipeline's open stages, keyed by `stage` code.
### Board and move
`GET /api/opportunities/kanban/?pipeline={id}` (`OpportunityKanbanView.get`) returns one column per
stage of that pipeline (the default pipeline when `pipeline` is absent), applying the same org/role
scoping and a subset of the list filters (`search`, `account`, `assigned_to`, `tags`,
`closed_on__gte/lte`). Another org's pipeline id is a `404`.
stage of that pipeline (the default pipeline when `pipeline` is absent). Another org's pipeline id is
a `404`. From django-crm 1.13.0 the board starts from `deal_list_queryset`, the function behind
`GET /api/opportunities/`, its totals and its CSV export, so it takes **every** list filter described
in [List opportunities](#list-opportunities) (`name`, `search`, `account`, `stage`, `lead_source`,
`tags`, `assigned_to`, `created_at__gte/lte`, `closed_on__gte/lte`, `amount__gte/lte`, `cf_<key>`,
`open=true`, `rotten=true`) with the same visibility rule, and a filter narrows the board and the
list the same way. Before 1.13.0 it took only `search`, `account`, `assigned_to`, `tags` and
`closed_on__gte/lte`. Each column holds at most 100 cards; `item_count` is the column's full count.
```json
{
@ -169,6 +174,16 @@ scoping and a subset of the list filters (`search`, `account`, `assigned_to`, `t
}
```
Each card (`OpportunityKanbanCardSerializer`) carries `id`, `name`, `pipeline`, `stage`,
`stage_label`, `stage_kind`, `amount`, `opportunity_amount`, `currency`, `probability`, `closed_on`,
`kanban_order`, `account` (`{id, name}`), `assigned_to`, `tags`, `line_items`, `days_in_stage`,
`aging_status`, `stage_changed_at`, `next_activity`, `can_move`, `created_at` and `updated_at`.
`tags`, `line_items`, `stage_changed_at` and `updated_at` are new in 1.13.0, for the phone app's
board, which reads this endpoint too. A card's `line_items` are plain rows (`id`, `name`,
`quantity`, `unit_price`, `discount_type`, `discount_value`), not the full line item the detail
view returns. `can_move` is whether the caller may move the card, by the same rule the move endpoint
applies.
`PATCH /api/opportunities/{id}/move/` (`OpportunityMoveView.patch`) requires `column_id`, a stage
code of the deal's own pipeline (anything else is a `400`; the board never moves a deal between
pipelines, the edit form does). `above_id`/`below_id` or an explicit `kanban_order` place the card

View file

@ -246,6 +246,76 @@ overridden as a plain `SerializerMethodField()` (`:60`), not listed in `read_onl
overriding a model FK with a method field removes it from the write surface just as effectively,
per the field's own comment (`:82-84`).
## Calendar feed
From django-crm 1.13.0 each member can subscribe a calendar app (Google Calendar, Outlook, Apple
Calendar) to their open tasks. Management is `CalendarFeedView` and the feed itself
`PublicCalendarFeedView`, both in `backend/common/views/calendar_feed_views.py`; the iCalendar
writer is `backend/common/calendar_feed.py` and the token model `CalendarFeedToken` in
`backend/common/models.py`.
### Managing your feed
`/api/profile/calendar-feed/` manages the caller's own feed and nobody else's: every query filters
on `org=request.profile.org` and `profile=request.profile`. It is on the credential deny-list in
`common/scopes.py`, so a personal access token or the org API key is refused whatever its scopes
(see [Tokens and API keys](tokens-and-api-keys.md#what-no-token-may-do-whatever-its-scopes)). A feed
URL keeps working after the token that could have minted it is revoked, so only a signed-in session
may create one.
| Method | Does | Response |
| --- | --- | --- |
| `GET` | Reads the state | `200` `{"error": false, "enabled": true, "created_at": "...", "last_used_at": "..."}` |
| `POST` | Turns the feed on, or replaces it | `201`, the same fields plus `url` |
| `DELETE` | Turns it off | `200` with `enabled: false` |
The URL is returned by `POST` once and cannot be read back: only the SHA-256 of the token is stored.
`POST` on an enabled feed deletes the old token in the same transaction, so the old URL stops working
the moment the new one exists. `last_used_at` is when a calendar app last fetched the feed, moved
forward at most once an hour; it is `null` until the first fetch.
`url` is built from the `DOMAIN_NAME` setting (`common.links.api_url`), the API's public origin,
never from the request. The web app calls the API from its own server at `PUBLIC_DJANGO_API_URL`
(`http://backend:8000` in Docker), so a URL built from the request named a host no calendar service
can reach, and read `http://` behind a TLS terminator. Outside dev the backend refuses to start when
`DOMAIN_NAME` is a loopback or non-absolute URL; see
[Environment variables](../reference/environment-variables.md).
### The feed
`GET /api/public/calendar/{token}.ics` takes no credential except the token in the path and answers
`text/calendar`. It is what a calendar app polls, on its own schedule (Google refreshes a subscribed
calendar every few hours), so a change to a task reaches the calendar late.
- **Which tasks.** Status `New` or `In Progress`, with a due date from 90 days ago to 365 days ahead
in the org's timezone. Only the member's own tasks: ones whose `created_by` is their user or that
are assigned to them, for admins too (an admin's feed does not carry the rest of the org), and
never a task outside `visible_tasks_qs`. At most 1,000, keeping the tasks nearest today: tasks due
today or later, soonest first, then overdue tasks, most recent first, until the cap is reached. It
is re-read on every fetch, so an unassignment or a removal from the org applies to the next poll.
- **What an event shows.** One all-day event on the due date, marked free rather than busy, with the
task's title as the summary, `Priority: <priority>` and a link to `/tasks/{id}` in the web app as
the description, and the same link as its `URL`. Nothing else: not the task's own description,
and no contact, account or other record names, because a subscribed calendar is read by a
third-party service on the member's behalf.
- **Misses.** A malformed, unknown, replaced or disabled token, and one whose profile, user or org
is no longer active, all answer the same `404` `{"error": "Not found"}`.
- **Throttles.** `CALENDAR_FEED_THROTTLE_IP` (default `1000/hour`, per client IP; generous because
calendar services fetch from shared addresses) and `CALENDAR_FEED_THROTTLE_TOKEN` (default
`60/hour`, per feed, bucketed on the token's hash). See
[Environment variables](../reference/environment-variables.md).
- The response carries `Cache-Control: private, max-age=300` and `X-Robots-Tag: noindex`, and Sentry
events have the token cut from any URL they record.
`/api/public/calendar/` is in `RequireOrgContext.EXEMPT_PATHS`. The view finds the token row (the
`calendar_feed_token` table is not under RLS, like `personal_access_token`), takes the org from its
profile, sets the RLS context, and only then reads tasks.
The URL is a secret. Anyone holding it can read the titles and priorities of the member's open
tasks without signing in, so share it with nothing but the calendar app, and regenerate it (a new
`POST`) if it leaks. In the clients the feed is managed at `/profile/calendar-feed` on the web and
**Profile → Calendar feed** on the phone.
## Fields
`TaskCreateSerializer.Meta.fields` (`tasks/serializer.py:367-389`) is what `POST /api/tasks/`,

View file

@ -17,7 +17,22 @@ narrow that down, and are enforced.
A scope is `<resource>:<action>`. `resource` is an API root segment (the first path segment after
`/api/`, so `leads`, `contacts`, `invoices`, `cases`, …) or `*` for all of them. `action` is `read`
or `write`. `read` covers `GET`/`HEAD`/`OPTIONS`; `write` covers `POST`/`PUT`/`PATCH`/`DELETE`.
or `write`. `read` covers `GET`/`HEAD`/`OPTIONS`; `write` covers `POST`/`PUT`/`PATCH`/`DELETE`,
with one exception. From django-crm 1.13.0 a `POST` to one of the exact paths in
`READ_ONLY_POST_PATHS` counts as `read`: `/api/leads/duplicates/`, `/api/contacts/duplicates/` and
`/api/accounts/duplicates/`, the duplicate search a create form runs, which writes nothing and takes
its email, phone and name in a body only to keep them out of URLs and access logs. The match is
exact: a path with a segment more or less, or without its trailing slash, is still `write`. The
action is decided by method and path, never by anything in the body.
Two macro endpoints act on a ticket, so from django-crm 1.13.0 they need the `cases` scope as well
as their own. `POST /api/macros/{id}/apply/` changes the ticket and needs `macros:write` **and**
`cases:write`; before, a `macros:write` token could create a personal macro and apply it to a
ticket it had no `cases:write` for. `POST /api/macros/{id}/render/` writes no record and returns the
ticket's subject and its contact's name and email, so it counts as `read` and needs `macros:read`
**and** `cases:read`. A token missing either is answered `403` naming the missing scope. `*:write`
covers apply and `*:read` covers render, so the org API key can render but not apply. Both paths
are matched in full, with the id as any single segment the router accepts.
`write` does not imply `read`. A token that may create leads but not list them is a coherent thing
to want, and a scope whose name understates what it grants stops being a boundary.
@ -36,14 +51,20 @@ The vocabulary and the matcher are in `backend/common/scopes.py`; enforcement is
cannot forget to opt in. An out-of-scope request is refused with `403` before the view runs:
```json
{"detail": "This token is not scoped for write access to leads."}
{"detail": "This token is not scoped for write access to leads. It needs the leads:write scope."}
```
A path outside `/api/<resource>/` names no resource, so the message there is
`"This token is not scoped for <action> access to this endpoint."` with no scope to suggest.
### What no token may do, whatever its scopes
`/api/profile/tokens/`, `/api/org/tokens/` and `/api/org/api-key/` are refused for any personal
access token and for the organization API key, including a token with an empty (unrestricted) scope
list. Sign in to manage credentials.
`/api/profile/tokens/`, `/api/org/tokens/`, `/api/org/api-key/`, `/api/webhooks/`,
`/api/org/audit-log/` and, from django-crm 1.13.0, `/api/profile/calendar-feed/` are refused for any
personal access token and for the organization API key, including a token with an empty
(unrestricted) scope list. Sign in to manage credentials. A calendar feed URL is itself a credential
that outlives the token that minted it, so it is on the list for the same reason (see
[Tasks](tasks.md#calendar-feed)).
The reason is that both chains defeat revocation. A token that can mint another token cannot be
revoked, since revoking it leaves whatever it already created working. A token that can read the org
@ -179,7 +200,8 @@ never from anything client-supplied, so a caller can only ever read or rotate th
Two limits apply to every request made with this key, enforced in middleware before any view runs:
- **Read-only.** It is evaluated as the scope list `("*:read",)`, so an unsafe method is `403`.
- **Read-only.** It is evaluated as the scope list `("*:read",)`, so an unsafe method is `403`,
apart from the three duplicate-search `POST`s above, which count as `read`.
- **No credential access.** The deny-list above applies, so the key cannot read itself, rotate
itself, or mint a personal access token owned by the admin whose identity it borrowed.

View file

@ -57,6 +57,53 @@ validates the form's shape rather than just flipping a flag:
site: an embed you have removed from the page is not the same thing as an endpoint that has
stopped accepting posts.
## Assigning new leads
From django-crm 1.13.0 a lead form assigns each new lead in one of two ways, set by
`assignment_mode`:
- **`person`** (the default): every new lead goes to `assign_to`, as before.
- **`rotation`**: each new lead goes to the next member of `rotation_members` in turn.
| Field | Notes |
| --- | --- |
| `assignment_mode` | `person` or `rotation` |
| `rotation_members` | Profile ids in the caller's org. A deactivated member cannot be added; one already stored is kept on save, since both clients resend the whole list |
| `rotation_cap` | Optional, at least 1. The most open leads a member may hold before the rotation passes them over. `null` means no cap |
| `rotation_last_assigned` | Read-only: the member who received the last rotated lead |
| `rotation_members_details`, `rotation_last_assigned_details` | Read-only: `id`, `email`, `name` and `is_active` for each |
Like every other write here, only an admin may set these. The rules, each a `400`:
- **Lead forms only.** A ticket form with `assignment_mode: "rotation"` is refused
(`"Rotation is for lead forms only. Tickets from a ticket form are assigned by your routing
rules."`), and so is changing a rotation form's target to tickets.
- **Somebody to rotate between.** Rotation with no members is refused
(`"Choose at least one member to rotate between."`).
How the turn is chosen (`rotation_assignee` in `backend/webforms/service.py`):
- **Eligible** means an active member of the form's org who, when a cap is set, holds fewer open
leads than the cap. **Open** means an active lead whose status is neither `converted` nor
`closed`. The count covers every lead in the org, not just this form's, because the cap is about
the member's workload.
- The eligible members are taken in id order, and the first whose id comes after
`rotation_last_assigned` gets the lead, wrapping to the first. The cursor names a person rather
than a list position, so adding or removing a member does not skip or repeat anybody's turn. The
form row is locked while the choice is made, so two submissions arriving together go to two
members.
- **Nobody eligible** (everyone deactivated or at the cap) leaves the lead unassigned and the cursor
where it was. The notify list is still emailed.
- **Only new leads rotate.** A repeat address merges into the existing lead and never changes its
owner.
On a rotation form `assign_to` is kept but ignored, so switching back to `person` restores it. The
new lead's `created_by` is the chosen member, or the form's creator when nobody was chosen.
In the clients, **Assign new leads** on the form's settings page, on the web and the phone, chooses
between one person and rotating between members, with **Rotate between** for the members, **Most
open leads per member** for the cap, and the last assigned member shown beside them.
## Embedding
Both snippets are built server-side and returned on the form's detail response as `embed_html` and
@ -136,7 +183,8 @@ scripted client. The throttles and the captcha are what apply there. CORS header
`webforms/service.py` is the single write path, shared with the deprecated endpoint below.
- **A new address creates a lead**, with `status="assigned"`, `source` from the form's configured
lead source, the form's assignee, and the form's tags.
lead source, the form's assignee (or, on a rotation form, the member whose turn it is; see
[Assigning new leads](#assigning-new-leads)), and the form's tags.
- **A repeat address merges**, matched case-insensitively within the same org. The merge **fills
blank fields only and never overwrites a populated one**. Anyone who knows a prospect's email
address can post your form, so an overwrite would let a stranger rewrite that prospect's record.
@ -156,8 +204,11 @@ not sent status-change emails or a CSAT survey until an agent posts a public rep
(see [How replies go back out](inbound-email.md#how-replies-go-back-out)). Closing a spam ticket
without answering it therefore emails nobody.
The form's assignee and its notify list are emailed once per accepted submission. Rejected
submissions notify nobody, because an org told about every bot learns to ignore the notification
The form's active notify list and the record's assignee are emailed once per accepted submission,
in one message. For a lead that is whoever the lead is actually assigned to: the member a rotation
picked, or the existing owner when a repeat address merged into a lead somebody already holds (from
django-crm 1.13.0; before, it was always the form's `assign_to`). For a ticket it is the form's
active `assign_to`. Rejected submissions notify nobody, because an org told about every bot learns to ignore the notification
and then misses the real one.
`GET /api/webforms/<id>/analytics/` returns a fixed trailing 30 days of views, submissions, spam
@ -184,7 +235,8 @@ id sits after that fixed prefix rather than before it.
`POST /api/leads/create-from-site/` is the original web-to-lead endpoint. It still works and its
request and response bodies are unchanged, but it now routes through the same write path as
everything above, and its backing form is created from the API key's own configuration on first
use.
use. Set that form to rotation and this endpoint rotates too; a contact it creates takes the
lead's active assignees (the rotated member on a new lead, the owner on a merged one).
Prefer the public endpoint for anything new. `create-from-site` requires an authenticated caller
with org context, so it was never actually usable from a static page without putting a credential

View file

@ -88,7 +88,9 @@ redelivery of it, so use it to de-duplicate. Amounts are strings, to keep their
name or title, status or stage, amounts with currency, key dates, and the ids of the assigned
profiles. Nothing else is sent. In particular an invoice's public link token, any secret and any
password field never are, and an id from another org never appears. A `*.deleted` event carries
the record as it was just before it was deleted.
the record as it was just before it was deleted. For a lead, contact or account merged away into
another, that is from before the merge started: from django-crm 1.13.0 its `assigned_to` lists the
profiles it was assigned to, even when the merge moved them to the record it kept.
## Headers and signature

View file

@ -108,6 +108,8 @@ Pooling uses psycopg 3's `psycopg_pool` through Django's PostgreSQL backend. It
| `WEBFORM_THROTTLE_GLOBAL` | `200/day` | No | Rate limit for public web form submissions, per form across all clients. No number of addresses evades this one, so it is the real backstop. Raise it for a form on a high-traffic page. |
| `HELP_CENTER_THROTTLE_IP` | `600/hour` | No | Rate limit for the public help center API (`/api/public/help/`), per client IP. With `RELAY_SECRET` set on the API and the web app, the web app sends each visitor's address signed, so each reader and crawler gets its own bucket; without it they all share the web app's. The IP is `common.request_meta.client_ip`; the pages it protects are public by the org's choice. |
| `HELP_CENTER_THROTTLE_GLOBAL` | `10000/hour` | No | Rate limit for the public help center API, per help center across all clients. No number of addresses evades this one, so it is the backstop behind `HELP_CENTER_THROTTLE_IP`. It is bucketed per help center, so traffic to one org's pages never limits another's. Raise it for a busy help center. |
| `CALENDAR_FEED_THROTTLE_IP` | `1000/hour` | No | Rate limit for the task calendar feed (`/api/public/calendar/<token>.ics`), per client IP. Generous because Google, Microsoft and Apple fetch subscribed calendars from shared addresses that carry many users' feeds at once. The IP is `common.request_meta.client_ip`. |
| `CALENDAR_FEED_THROTTLE_TOKEN` | `60/hour` | No | Rate limit for the task calendar feed, per feed URL across all clients. Bucketed on the token's hash, never the raw token. A calendar app polls every few minutes at most, so this is only reached by something hammering one URL. |
### CORS and CSRF
@ -121,7 +123,7 @@ Pooling uses psycopg 3's `psycopg_pool` through Django's PostgreSQL backend. It
| Variable | Default | Required | Purpose |
| --- | --- | --- | --- |
| `DOMAIN_NAME` | `http://localhost:8000` | No. Nothing reads it any more | Base URL of the **backend**. It no longer reaches a single emitted link. The four remaining reads (`invoices/tasks.py:305,482` and `invoices/api_views.py:276,436`) pass it as the `domain=` kwarg of Celery tasks whose signatures keep that parameter only so a task queued before the change still unpacks after deploy, and whose docstrings say it is ignored. Every link now comes from `FRONTEND_URL` via `common/links.py`. It used to build the internal "assigned to you" notifications as well as the customer-facing invoice, estimate and CSAT links, and every one of those was broken: the builders wrote `f"{protocol}://{domain}/..."` around a value that already carried a scheme, so the emitted link had two, and the paths are frontend routes that do not exist on the backend host. Setting this variable changes nothing; it is kept so an existing `.env` does not have to be edited. |
| `DOMAIN_NAME` | `http://localhost:8000` | **Yes**, once self-hosted under a real domain (from django-crm 1.13.0) | The **backend's** public origin, for example `https://api.example.com`. From django-crm 1.13.0 it is the base of every URL the API hands out for use somewhere else (`common.links.api_url`): the task calendar feed URL a calendar app subscribes to, and a web form's embed snippets and the submit URL inside the embedded form. They used to be built from the incoming request, so the web app, which asks from its own server at `PUBLIC_DJANGO_API_URL` (`http://backend:8000` in Docker), showed a host nobody outside could reach, and `http://` unless `TRUST_PROXY_SSL_HEADER` was set. When `ENV_TYPE` is anything other than `dev`, `crm/settings.py` raises at import if this points at a loopback host (`localhost`, `127.0.0.1`, `0.0.0.0`, `::1`) or is not an absolute `http`/`https` URL, the same check `FRONTEND_URL` gets. A trailing slash is stripped. It no longer reaches any emailed link: those come from `FRONTEND_URL`, and the four `domain=` kwargs still passed to invoice Celery tasks (`invoices/tasks.py`, `invoices/api_views.py`) are ignored by those tasks. |
| `FRONTEND_URL` | `http://localhost:5181` | **Yes**, once self-hosted under a real domain | Base URL of the SvelteKit frontend, and the single source for every link that leaves the system in an email: the welcome email and magic-link sign-in URL (`common/tasks.py`), the emailed invoice and estimate portal links, the CSAT survey link, and the "assigned to you" invoice link (all via `common.links.frontend_url`). The customer-facing ones go to people outside the organization, so leaving the `localhost` default in a real deployment emails dead links to customers. **That is now refused rather than emailed:** when `ENV_TYPE` is anything other than `dev`, `crm/settings.py` raises at import if this points at a loopback host (`localhost`, `127.0.0.1`, `0.0.0.0`, `::1`) or is not an absolute `http`/`https` URL, so the deploy fails to start instead of failing silently on somebody else's inbox. Plain HTTP against a real host is allowed, for a deployment behind its own TLS terminator. A trailing slash is stripped, so `https://app.example.com` and `https://app.example.com/` behave the same. |
### Google OAuth

View file

@ -123,12 +123,12 @@ scope session cookies to `bottlecrm.io`, will not do what you want. See
## Reverse proxy
Nothing in `backend/crm/settings.py` terminates TLS, and `SECURE_PROXY_SSL_HEADER` is not set
anywhere in this codebase's settings modules. Concretely: if you terminate TLS at a reverse proxy
(nginx, Caddy, or similar) and forward plain HTTP to Gunicorn/Uvicorn, Django's
`request.is_secure()` will report `False` for every request unless you add
`SECURE_PROXY_SSL_HEADER` yourself (typically pointing at the `X-Forwarded-Proto` header your
proxy sets). This project does not configure that for you. `SECURE_HSTS_SECONDS = 31536000`
Nothing in `backend/crm/settings.py` terminates TLS, and `SECURE_PROXY_SSL_HEADER` is set only
when you opt in with `TRUST_PROXY_SSL_HEADER=true` (see
[Environment variables](../reference/environment-variables.md)). Concretely: if you terminate TLS at a
reverse proxy (nginx, Caddy, or similar) and forward plain HTTP to Gunicorn/Uvicorn, Django's
`request.is_secure()` will report `False` for every request unless that variable is set and your
proxy overwrites `X-Forwarded-Proto`. `SECURE_HSTS_SECONDS = 31536000`
(one year), `SECURE_HSTS_INCLUDE_SUBDOMAINS = True`, `SECURE_HSTS_PRELOAD = True` and
`SECURE_CONTENT_TYPE_NOSNIFF = True` are all set unconditionally in `backend/crm/settings.py`, but
Django's `SecurityMiddleware` only emits the `Strict-Transport-Security` header on requests it
@ -139,6 +139,17 @@ Point your proxy at whichever port your application server binds (`8000` in the
and route `/` there. There's no repo-provided proxy config (nginx site file, Caddyfile, or
similar) to point to. The specifics are yours to write.
The URLs the API hands out for use somewhere else are built from `DOMAIN_NAME`, not from the request
that reached it: the web form embed snippets and the form's submit URL and, from django-crm 1.13.0,
the task calendar feed URL (`/api/public/calendar/<token>.ics`, see
[API: Tasks](../api/tasks.md#calendar-feed)). The feed URL is pasted into Google Calendar or
Outlook, which fetch it from the internet, and the snippet onto a customer's site, so
`DOMAIN_NAME` has to be the API's public origin, for example `https://api.example.com`. The web app
asks for these from its own server at `PUBLIC_DJANGO_API_URL`, which may be an internal address such
as the compose file's `http://backend:8000`; that no longer leaks into the URLs it shows. With
`ENV_TYPE` set to anything but `dev`, the backend refuses to start when `DOMAIN_NAME` is a loopback
or non-absolute URL.
## Required settings
`backend/crm/settings.py` reads these from the environment at import time (i.e. at process
@ -159,4 +170,5 @@ and `FRONTEND_URL`, which is interpolated into every emailed link (magic-link si
customer's invoice and estimate portal, the CSAT survey, internal notifications) and so needs to
point at your real, public hostname. `FRONTEND_URL` is now checked at startup: with `ENV_TYPE`
set to anything but `dev`, a loopback or non-absolute value raises rather than mailing a dead
link to a customer. `DOMAIN_NAME` no longer reaches any emitted link and can be left alone.
link to a customer. `DOMAIN_NAME`, the API's own public origin, is checked the same way (see
above).

View file

@ -97,16 +97,21 @@ Three different credential types exist, with different blast radii if one leaks:
## What to monitor
`SecurityAuditLog` (`backend/common/audit_log.py`, table `security_audit_log`) is an RLS-protected,
per-org table of security-relevant events, with a matching `security.audit` Python logger that also
writes to `security_audit.log` (configured in `crm/settings.py`'s `LOGGING` dict). It's worth
watching, with one caveat: of the thirteen event types the model defines
(`LOGIN_SUCCESS`, `LOGIN_FAILURE`, `LOGOUT`, `ORG_SWITCH`, `TOKEN_REFRESH`, `TOKEN_REVOKED`,
`PERMISSION_DENIED`, `CROSS_ORG_ATTEMPT`, `API_KEY_USED`, `API_KEY_INVALID`, `MEMBERSHIP_REVOKED`,
`SUSPICIOUS_ACTIVITY`, `SAMPLE_DATA_CLEARED`), only `LOGIN_SUCCESS`, `ORG_SWITCH`, `TOKEN_REFRESH`,
`TOKEN_REVOKED`, `PERMISSION_DENIED` (from one call site) and `SAMPLE_DATA_CLEARED` are actually
logged anywhere in the application code as of this writing. `LOGIN_FAILURE`, `LOGOUT`,
`CROSS_ORG_ATTEMPT`, `API_KEY_USED`, `API_KEY_INVALID`, `MEMBERSHIP_REVOKED` and
`SUSPICIOUS_ACTIVITY` are defined on the model and have corresponding methods on the `AuditLogger`
helper, but nothing in the codebase currently calls them. Don't rely on this table to surface a
brute-forced login or a bad API key attempt today; it doesn't yet.
`SecurityAuditLog` (`backend/common/audit_log.py`, table `security_audit_log`) is a table of
security-relevant events, with a matching `security.audit` Python logger that also writes to
`security_audit.log` (configured in `crm/settings.py`'s `LOGGING` dict). The table has no RLS policy
on purpose, since some rows have no org; an org's admins read their own rows through
[API: Security audit log](../api/audit-log.md), filtered on their org. It's worth watching, with one
caveat: of the event types the model defines, `LOGIN_SUCCESS`, `LOGIN_FAILURE`, `LOGOUT`,
`ORG_SWITCH`, `TOKEN_REFRESH`, `TOKEN_REVOKED`, `PERMISSION_DENIED`, `SAMPLE_DATA_CLEARED`,
`WEBHOOK_PAUSED`, `WEBHOOK_REENABLED`, `WEBHOOK_CHANGED` and `RECORD_MERGED` are written by the
application code as of 1.13.0. `CROSS_ORG_ATTEMPT`, `API_KEY_USED`, `API_KEY_INVALID`,
`MEMBERSHIP_REVOKED` and `SUSPICIOUS_ACTIVITY` are defined on the model and have methods on the
`AuditLogger` helper, but nothing calls them. Don't rely on this table to surface a bad API key
attempt.
Failed staff sign-ins are recorded from django-crm 1.13.0 as `LOGIN_FAILURE`, with the claimed email
and a reason code (see [API: Security audit log](../api/audit-log.md#failed-sign-ins)). They carry no
org, so no org's audit viewer shows them: watch the table or the log file for them. Each client IP
writes at most 20 of these rows an hour, so a brute-force run shows up as a burst that stops at 20,
not as one row per attempt.

View file

@ -99,6 +99,28 @@ describe('magic-link verify (the sign-in audit row records the address)', () =>
});
});
describe('Google sign-in (the audit row, and the per-address cap on failure rows)', () => {
it('sends the signed visitor address', async () => {
const jar = {
get: vi.fn((/** @type {string} */ name) => (name === 'oauth_state' ? 's' : 'verifier')),
set: vi.fn(),
delete: vi.fn()
};
await ignoringRedirect(() =>
login.load(
/** @type {any} */ ({
url: new URL('http://app.test/login?code=c&state=s'),
cookies: jar,
getClientAddress
})
)
);
const [url, , config] = vi.mocked(axios.post).mock.calls[0];
expect(url).toMatch(/\/api\/auth\/google\/callback\/$/);
expect(config?.headers).toMatchObject(SIGNED);
});
});
describe('logout (the audit row records the address)', () => {
it('sends the signed visitor address', async () => {
await ignoringRedirect(() =>

View file

@ -0,0 +1,48 @@
/**
* Your task calendar feed: the wiring behind `/profile/calendar-feed`.
*
* Server-only, because the feed URL is a credential: anyone holding it can read
* the titles and priorities of your open tasks. `/api/profile/calendar-feed/`
* is self-scoped server-side (`profile=request.profile`) and refuses every
* API token, so this page can only ever manage the signed-in member's own feed.
*
* - `GET` `{ enabled, created_at, last_used_at }`. Never the URL: the API
* keeps only a hash of it.
* - `POST` turns the feed on, or replaces it. The old URL stops working and
* the response carries the new `url`, the one time it is shown.
* - `DELETE` turns it off. The URL stops working.
*/
import { apiRequest } from '$lib/api-helpers.js';
const ENDPOINT = '/profile/calendar-feed/';
/**
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<{ enabled: boolean, created_at: string | null, last_used_at: string | null }>}
*/
export async function getCalendarFeed({ cookies }) {
const resp = await apiRequest(ENDPOINT, {}, { cookies });
return {
enabled: Boolean(resp?.enabled),
created_at: resp?.created_at ?? null,
last_used_at: resp?.last_used_at ?? null
};
}
/**
* Issue a new feed URL. Returns it; it cannot be read back afterwards.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<string>}
*/
export async function issueCalendarFeed({ cookies }) {
const resp = await apiRequest(ENDPOINT, { method: 'POST' }, { cookies });
return resp.url;
}
/**
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
*/
export function disableCalendarFeed({ cookies }) {
return apiRequest(ENDPOINT, { method: 'DELETE' }, { cookies });
}

View file

@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { getCalendarFeed, issueCalendarFeed, disableCalendarFeed } =
await import('$lib/server/v2/calendar-feed.js');
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
describe('calendar feed', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('reads the self-scoped endpoint and keeps only the state', async () => {
apiRequest.mockResolvedValue({
error: false,
enabled: true,
created_at: '2026-09-27T10:00:00Z',
last_used_at: null,
url: 'should never be here'
});
const feed = await getCalendarFeed(event);
expect(apiRequest.mock.calls[0][0]).toBe('/profile/calendar-feed/');
expect(feed).toEqual({
enabled: true,
created_at: '2026-09-27T10:00:00Z',
last_used_at: null
});
});
it('reads a disabled feed as off', async () => {
apiRequest.mockResolvedValue({ enabled: false, created_at: null, last_used_at: null });
expect((await getCalendarFeed(event)).enabled).toBe(false);
});
it('issues with a bodiless POST and returns the one-time URL', async () => {
apiRequest.mockResolvedValue({ enabled: true, url: 'https://api.example.com/x.ics' });
const url = await issueCalendarFeed(event);
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/profile/calendar-feed/');
expect(options).toEqual({ method: 'POST' });
expect(url).toBe('https://api.example.com/x.ics');
});
it('disables with a DELETE', async () => {
apiRequest.mockResolvedValue({ enabled: false });
await disableCalendarFeed(event);
expect(apiRequest.mock.calls[0].slice(0, 2)).toEqual([
'/profile/calendar-feed/',
{ method: 'DELETE' }
]);
});
});

View file

@ -189,13 +189,16 @@ function normaliseTotals(totals, rows) {
* value for.
*/
/**
* The filter fields and presets the BOARD can actually run.
* The filter fields and presets the BOARD offers.
*
* `kanban_views.py:122-137` reads only `search`, `account`, `assigned_to`,
* `tags` and the `closed_on` range, a narrower vocabulary than the list
* endpoint's. A chip for anything else would sit above cards it did not
* filter, while the header totals (which come from the list endpoint) DID
* apply it, so a number and the cards under it would describe different deals.
* `GET /opportunities/kanban/` runs the deal list's own filters
* (`deal_list_queryset`): `search`, `name`, `account`, `assigned_to`, `tags`,
* `stage`, `lead_source`, the `closed_on`, `created_at` and `amount` ranges,
* `open` and `rotten`. The board still offers only these: it was built when
* the endpoint read a narrower set, and no board UI has been added for the
* rest. Only a param listed here is carried from the list to the board and
* sent to both the board and its header totals, so the cards and the number
* above them always describe the same deals.
*
* These live here rather than in `pipeline/+page.server.js` because SvelteKit
* allows only a fixed set of named exports from a `+page.server.js` (`load`,

View file

@ -101,9 +101,10 @@ export function invoiceListQuery(url) {
* filters, and the board always shows one pipeline (the default when none is
* picked) and open stages only, so the pipelines are needed to build it.
*
* `search` is the one non-field param the board can also run
* (`kanban_views.py`). `limit`, `open` and `rotten` are list-only presets that
* `readFilters` does not carry. `?pipeline=` is forwarded only when the org
* `search` is the one non-field param the board forwards alongside
* `BOARD_FIELDS` (the kanban endpoint runs every deal list filter, but the
* board offers only those). `limit`, `open` and `rotten` are list presets
* that `readFilters` does not carry. `?pipeline=` is forwarded only when the org
* has that pipeline.
*
* @param {URL} url

View file

@ -33,22 +33,140 @@
* server per row and passed straight through. The page never recomputes which
* tokens are broken, so its "broken placeholder" flag can't drift from the set
* the renderer actually expands.
*
* ACTIONS
* A macro may also carry actions: `set_status`, `set_priority`,
* `set_assignees` (replaces the ticket's) and `add_tags` (added to the
* ticket's). The ticket composer shows them as removable chips and applies
* the kept ones through `POST /macros/<id>/apply/` right after the reply
* posts; the API runs them through the ticket PATCH's own write, gates and
* all. The assignee and tag pickers offer active rows plus whatever the macro
* already carries, so a deactivated assignee is shown rather than silently
* dropped by a select with no matching option.
*/
import { apiRequest } from '$lib/api-helpers.js';
import { CASE_STATUSES } from '$lib/v2/enums.js';
import { viewerIsAdmin } from './organization.js';
import { myProfileId } from './leads.js';
import { getOrgPeopleAndTeams } from './org-people.js';
/** What a macro may set a ticket to: every status but Duplicate, which only a
* merge sets. The API refuses it too (`MACRO_STATUS_CHOICES`). */
export const MACRO_STATUSES = CASE_STATUSES.filter((s) => s !== 'Duplicate');
/** The action names `POST /macros/<id>/apply/` takes in `only`. */
export const MACRO_ACTION_KEYS = ['status', 'priority', 'assignees', 'tags'];
/** @param {{ name?: string, email?: string, is_active?: boolean }} p */
function personLabel(p) {
const name = p.name || p.email || 'Unknown';
return p.is_active === false ? `${name} (deactivated)` : name;
}
/** @param {{ name?: string, is_active?: boolean }} t */
function tagLabel(t) {
const name = t.name || 'Unnamed';
return t.is_active === false ? `${name} (archived)` : name;
}
/**
* One chip per action a macro carries, in `MACRO_ACTION_KEYS` order.
*
* @param {any} m a macro row from the API
* @returns {{ key: string, label: string }[]}
*/
export function macroActionChips(m) {
/** @type {{ key: string, label: string }[]} */
const chips = [];
if (m?.set_status) chips.push({ key: 'status', label: `Status: ${m.set_status}` });
if (m?.set_priority) chips.push({ key: 'priority', label: `Priority: ${m.set_priority}` });
const people = m?.set_assignees_details ?? [];
if (people.length) {
chips.push({ key: 'assignees', label: `Assign: ${people.map(personLabel).join(', ')}` });
}
const tags = m?.add_tags_details ?? [];
if (tags.length) chips.push({ key: 'tags', label: `Tag: ${tags.map(tagLabel).join(', ')}` });
return chips;
}
/**
* Picker options: the active rows, plus any the macro carries that are not
* among them (deactivated, archived, or a list that failed to load), labelled
* as such. Without the second half a select with no matching option submits
* nothing, and saving the form would drop them.
*
* @param {{ id: string, name: string }[]} active
* @param {any[]} stored the macro's `*_details`
* @param {(row: any) => string} label
*/
export function optionsWithStored(active, stored, label) {
const seen = new Set(active.map((o) => o.id));
const extra = (stored ?? [])
.filter((row) => !seen.has(row.id))
.map((row) => ({ id: row.id, name: label(row) }));
return [...active, ...extra];
}
/**
* The action names the composer kept, from its `macro_action` inputs. Anything
* else is dropped; the API refuses an unknown name anyway.
*
* @param {FormData} form
*/
export function keptActions(form) {
const kept = form.getAll('macro_action').map(String);
return MACRO_ACTION_KEYS.filter((key) => kept.includes(key));
}
/**
* A sentence for what applying did, from the API's `applied` and `skipped`.
*
* @param {{ applied?: string[], skipped?: { action: string, reason: string }[] }} resp
*/
export function applySummary(resp) {
const applied = resp?.applied ?? [];
const skipped = resp?.skipped ?? [];
const head = applied.length
? `Macro applied: ${applied.join(', ')}.`
: 'The macro changed nothing on this ticket.';
return [head, ...skipped.map((s) => s.reason)].join(' ');
}
/** Active tags in this org, for the macro tag picker. Empty on failure; the
* picker still offers the tags each macro already carries. */
async function listActiveTags(cookies) {
try {
const resp = await apiRequest('/tags/', {}, { cookies });
return (resp.tags ?? []).map((/** @type {any} */ t) => ({ id: t.id, name: t.name }));
} catch {
return [];
}
}
/**
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<{ macros: any[], totals: any, placeholders: any[], can_create_org: boolean, my_profile_id: string }>}
* @returns {Promise<{ macros: any[], people: { id: string, name: string }[], tags: { id: string, name: string }[], statuses: string[], totals: any, placeholders: any[], can_create_org: boolean, my_profile_id: string }>}
*/
export async function getMacros({ cookies }) {
const resp = await apiRequest('/macros/', {}, { cookies });
const [resp, people, tags] = await Promise.all([
apiRequest('/macros/', {}, { cookies }),
getOrgPeopleAndTeams(cookies),
listActiveTags(cookies)
]);
const activePeople = people.people.map((p) => ({ id: p.id, name: p.name }));
const results = (resp.results ?? []).map((m) => ({
...m,
owner: m.owner ? { id: m.owner, name: m.owner_name || m.owner } : null
owner: m.owner ? { id: m.owner, name: m.owner_name || m.owner } : null,
chips: macroActionChips(m),
// The edit form's picker options for this row: active rows plus its own.
assignee_options: optionsWithStored(activePeople, m.set_assignees_details, personLabel),
tag_options: optionsWithStored(tags, m.add_tags_details, tagLabel)
}));
return {
// Active members and active tags, the new-macro form's picker options.
people: activePeople,
tags,
statuses: MACRO_STATUSES,
macros: results,
totals: resp.totals ?? {
count: 0,
@ -75,13 +193,23 @@ export async function getMacros({ cookies }) {
* derived server-side by `_resolve_scope_and_owner` from `request.profile`,
* never from the body, so it can never appear here. `org` is a JWT claim
* for the same reason. */
const WRITABLE_FIELDS = ['title', 'body', 'scope', 'is_active'];
const WRITABLE_FIELDS = [
'title',
'body',
'scope',
'is_active',
'set_status',
'set_priority',
'set_assignees',
'add_tags'
];
/**
* Shape the request body from the allow-list.
*
* The three checks below are a fast fail for an obviously bad form, not the
* authority: `MacroSerializer` requires both `title` and `body`, and
* The checks below are a fast fail for an obviously bad form, not the
* authority: `MacroSerializer` requires a `title` and a body or an action,
* checks every status, priority, assignee and tag, and
* `_resolve_scope_and_owner` rejects any scope outside these two, so the
* server re-validates everything here regardless of what this lets through.
*
@ -97,8 +225,11 @@ function buildBody(values) {
body.title = String(body.title ?? '').trim();
body.body = String(body.body ?? '').trim();
const acts = Boolean(
body.set_status || body.set_priority || body.set_assignees?.length || body.add_tags?.length
);
if (!body.title) throw new Error('A macro needs a title.');
if (!body.body) throw new Error('A macro needs a body.');
if (!body.body && !acts) throw new Error('A macro needs a body, an action, or both.');
if (body.scope !== 'org' && body.scope !== 'personal') {
throw new Error("scope must be 'org' or 'personal'.");
}
@ -115,9 +246,9 @@ export async function createMacro({ cookies }, values) {
/**
* PATCH, not PUT: `MacroDetailView.put` runs the serializer with
* `partial=False` and `MacroSerializer` requires both `title` and `body`, so
* a PUT missing either 400s. `patch` is the partial verb and is what this
* form, which always submits both fields anyway, should be using regardless.
* `partial=False` and `MacroSerializer` requires a `title`, so a PUT missing
* it 400s. `patch` is the partial verb and is what this form, which always
* submits every field anyway, should be using regardless.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
*/
@ -144,8 +275,8 @@ export async function deleteMacro({ cookies }, id) {
* Turn a macro back on.
*
* A dedicated action, not a reuse of `updateMacro`: `buildBody` above
* unconditionally requires both `title` and `body` to be non-empty (a fast
* client-side fail for the create/edit form, which always submits both), so
* requires a title and a body or an action (a fast client-side fail for the
* create/edit form, which always submits them), so
* calling `updateMacro(event, id, { is_active: true })` would throw before a
* request was even made. This bypasses `buildBody` and sends exactly
* `{ is_active: true }`. PATCH, partial, so the rest of the row is untouched.
@ -165,14 +296,20 @@ export async function activateMacro({ cookies }, id) {
/**
* The saved replies this person may insert: active ones only, org-wide plus
* their own personal ones (the API's visibility rule). Title and id are all
* the ticket composer needs.
* their own personal ones (the API's visibility rule). The composer needs the
* title and id, whether there is any text to insert, and the chips for the
* macro's actions.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
*/
export async function listUsableMacros({ cookies }) {
const resp = await apiRequest('/macros/?active=true', {}, { cookies });
return (resp.results ?? []).map((/** @type {any} */ m) => ({ id: m.id, title: m.title ?? '' }));
return (resp.results ?? []).map((/** @type {any} */ m) => ({
id: m.id,
title: m.title ?? '',
has_body: Boolean(String(m.body ?? '').trim()),
chips: macroActionChips(m)
}));
}
/**
@ -194,3 +331,23 @@ export async function renderMacro({ cookies }, macroId, caseId) {
);
return resp.rendered_body ?? '';
}
/**
* Apply a macro's actions to a ticket. `only` lists the kept action names;
* omitted, every action the macro carries applies. The API refuses a ticket
* the caller may not change and runs the ticket PATCH's gates (the close
* approval, the merged-ticket lock), answering a refusal with the PATCH's
* own 400.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @param {string} macroId
* @param {string} caseId
* @param {string[] | undefined} only
* @returns {Promise<{ applied: string[], skipped: { action: string, reason: string }[] }>}
*/
export async function applyMacro({ cookies }, macroId, caseId, only) {
/** @type {Record<string, any>} */
const body = { case_id: caseId };
if (only) body.only = only;
return await apiRequest(`/macros/${macroId}/apply/`, { method: 'POST', body }, { cookies });
}

View file

@ -3,8 +3,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { getMacros, createMacro, updateMacro, deleteMacro, activateMacro } =
await import('$lib/server/v2/macros.js');
const {
getMacros,
createMacro,
updateMacro,
deleteMacro,
activateMacro,
macroActionChips,
optionsWithStored,
keptActions,
applySummary,
applyMacro,
listUsableMacros,
MACRO_STATUSES
} = await import('$lib/server/v2/macros.js');
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
@ -161,6 +173,34 @@ describe('createMacro', () => {
expect(apiRequest).not.toHaveBeenCalled();
});
it('accepts an empty body when the macro carries an action', async () => {
apiRequest.mockResolvedValue({});
await createMacro(event, { title: 't', body: '', scope: 'personal', set_status: 'Closed' });
expect(apiRequest.mock.calls[0][1].body).toMatchObject({ body: '', set_status: 'Closed' });
});
it('forwards the four actions as the serializer names them', async () => {
apiRequest.mockResolvedValue({});
await createMacro(event, {
title: 't',
body: 'b',
scope: 'org',
set_status: 'Pending',
set_priority: 'High',
set_assignees: ['p1'],
add_tags: ['t1']
});
expect(apiRequest.mock.calls[0][1].body).toEqual({
title: 't',
body: 'b',
scope: 'org',
set_status: 'Pending',
set_priority: 'High',
set_assignees: ['p1'],
add_tags: ['t1']
});
});
it('rejects an empty title or body before making a request', async () => {
await expect(createMacro(event, { title: ' ', body: 'b', scope: 'org' })).rejects.toThrow(
/title/i
@ -234,3 +274,100 @@ describe('activateMacro', () => {
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('macro actions', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('offers every status but Duplicate, which only a merge sets', () => {
expect(MACRO_STATUSES).not.toContain('Duplicate');
expect(MACRO_STATUSES).toContain('Closed');
});
it('builds one chip per action, in order, naming deactivated and archived rows', () => {
expect(
macroActionChips({
set_status: 'Closed',
set_priority: '',
set_assignees_details: [
{ id: 'a', name: 'Ann', email: 'ann@x.test', is_active: true },
{ id: 'b', name: '', email: 'bo@x.test', is_active: false }
],
add_tags_details: [{ id: 't', name: 'vip', is_active: false }]
})
).toEqual([
{ key: 'status', label: 'Status: Closed' },
{ key: 'assignees', label: 'Assign: Ann, bo@x.test (deactivated)' },
{ key: 'tags', label: 'Tag: vip (archived)' }
]);
expect(macroActionChips({})).toEqual([]);
});
it('keeps a stored row the active list no longer offers, so a save cannot drop it', () => {
const options = optionsWithStored(
[{ id: 'a', name: 'Ann' }],
[
{ id: 'a', name: 'Ann', is_active: true },
{ id: 'b', name: 'Bo', is_active: false }
],
(row) => `${row.name}!`
);
expect(options).toEqual([
{ id: 'a', name: 'Ann' },
{ id: 'b', name: 'Bo!' }
]);
});
it('reads only known action names from the composer, in API order', () => {
const form = new FormData();
form.append('macro_action', 'tags');
form.append('macro_action', 'delete');
form.append('macro_action', 'status');
expect(keptActions(form)).toEqual(['status', 'tags']);
});
it('says what applied and why anything was skipped', () => {
expect(
applySummary({
applied: ['status'],
skipped: [{ action: 'tags', reason: 'Every tag this macro adds is archived.' }]
})
).toBe('Macro applied: status. Every tag this macro adds is archived.');
expect(applySummary({ applied: [], skipped: [] })).toBe(
'The macro changed nothing on this ticket.'
);
});
it('POSTs the ticket and the kept actions to apply/', async () => {
apiRequest.mockResolvedValue({ applied: ['priority'], skipped: [] });
await applyMacro(event, 'm1', 'c1', ['priority']);
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/macros/m1/apply/');
expect(options).toEqual({ method: 'POST', body: { case_id: 'c1', only: ['priority'] } });
});
it('omits `only` to apply everything, for a macro with no text', async () => {
apiRequest.mockResolvedValue({ applied: [], skipped: [] });
await applyMacro(event, 'm1', 'c1', undefined);
expect(apiRequest.mock.calls[0][1].body).toEqual({ case_id: 'c1' });
});
it('tells the composer which macros have text and what each one does', async () => {
apiRequest.mockResolvedValue({
results: [
{ id: 'm1', title: 'Words', body: 'Hi' },
{ id: 'm2', title: 'Close', body: ' ', set_status: 'Closed' }
]
});
expect(await listUsableMacros(event)).toEqual([
{ id: 'm1', title: 'Words', has_body: true, chips: [] },
{
id: 'm2',
title: 'Close',
has_body: false,
chips: [{ key: 'status', label: 'Status: Closed' }]
}
]);
});
});

View file

@ -8,14 +8,12 @@ describe('summarizeBulk', () => {
{ id: '2', status: 'updated' },
{ id: '3', status: 'no_access' },
{ id: '4', status: 'approval_required' },
{ id: '5', status: 'closed_on_required' },
{ id: '6', status: 'invalid' },
{ id: '7', status: 'merged', detail: 'Unmerge it first.' }
{ id: '5', status: 'invalid' },
{ id: '6', status: 'merged', detail: 'Unmerge it first.' }
]);
expect(s.updated).toBe(2);
expect(s.no_access).toBe(1);
expect(s.approval_required).toBe(1);
expect(s.closed_on_required).toBe(1);
expect(s.invalid).toBe(1);
expect(s.merged).toBe(1);
});

View file

@ -17,11 +17,13 @@ const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
/**
* @param {string} action
* @param {Record<string, string>} fields
* @param {Record<string, string | string[]>} fields a list sends the key once per item
*/
function actionEvent(action, fields = {}) {
const body = new FormData();
for (const [key, value] of Object.entries(fields)) body.set(key, value);
for (const [key, value] of Object.entries(fields)) {
for (const v of [value].flat()) body.append(key, v);
}
return /** @type {any} */ ({
request: new Request(`http://test/tickets/a?/${action}`, { method: 'POST', body }),
params: { id: 'a' },
@ -41,6 +43,27 @@ beforeEach(() => {
apiRequest.mockReset();
});
/**
* Answer each API call by its path and verb (`'PATCH /cases/a/'`, or just the
* path for any verb). An Error value is thrown instead of returned.
*
* @param {Record<string, any>} table
*/
function routes(table) {
apiRequest.mockImplementation(async (path, opts = {}) => {
const key = `${opts.method ?? 'GET'} ${path}`;
const hit = key in table ? table[key] : table[path];
if (hit === undefined) throw new Error(`unexpected call: ${key}`);
if (hit instanceof Error) throw hit;
return hit;
});
}
/** The API calls made, as `'VERB /path'`. */
function calls() {
return apiRequest.mock.calls.map(([path, opts = {}]) => `${opts.method ?? 'GET'} ${path}`);
}
describe('watching', () => {
it('reads the server’s own answer about the viewer', async () => {
apiRequest.mockResolvedValue({ watchers: [{}, {}], count: 2, is_current_user_watching: true });
@ -125,15 +148,20 @@ describe('saved replies', () => {
apiRequest.mockResolvedValue({
results: [{ id: 'm1', title: 'Greeting', body: 'Hi %customer_name%', scope: 'org' }]
});
expect(await listUsableMacros(event)).toEqual([{ id: 'm1', title: 'Greeting' }]);
expect(await listUsableMacros(event)).toEqual([
{ id: 'm1', title: 'Greeting', has_body: true, chips: [] }
]);
expect(apiRequest.mock.calls[0][0]).toBe('/macros/?active=true');
});
it('renders against this ticket and returns text only, sending nothing', async () => {
apiRequest.mockResolvedValue({ rendered_body: 'Hi Liz' });
routes({
'/macros/?active=true': { results: [{ id: 'm1', title: 'Greeting', body: 'Hi' }] },
'/macros/m1/render/': { rendered_body: 'Hi Liz' }
});
const out = await actions.renderMacro(actionEvent('renderMacro', { macro_id: 'm1' }));
expect(out).toEqual({ macroText: 'Hi Liz' });
expect(apiRequest).toHaveBeenCalledTimes(1);
expect(out).toEqual({ macroText: 'Hi Liz', macroId: 'm1' });
expect(apiRequest).toHaveBeenCalledTimes(2);
expect(apiRequest).toHaveBeenCalledWith(
'/macros/m1/render/',
{ method: 'POST', body: { case_id: 'a' } },
@ -141,6 +169,20 @@ describe('saved replies', () => {
);
});
it('lists a macro’s action chips and whether it has any text', async () => {
apiRequest.mockResolvedValue({
results: [{ id: 'm2', title: 'Close it', body: ' ', set_status: 'Closed' }]
});
expect(await listUsableMacros(event)).toEqual([
{
id: 'm2',
title: 'Close it',
has_body: false,
chips: [{ key: 'status', label: 'Status: Closed' }]
}
]);
});
it('says so when the server refuses the render', async () => {
apiRequest.mockRejectedValue(apiError(404, 'Not found.'));
const out = /** @type {any} */ (
@ -155,3 +197,179 @@ describe('saved replies', () => {
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('macro actions on send', () => {
const posted = { 'POST /cases/a/': {} };
const applied = { applied: ['status', 'tags'], skipped: [] };
it('posts the reply, then applies only the kept actions', async () => {
routes({ ...posted, 'POST /macros/m1/apply/': applied });
const out = await actions.reply(
actionEvent('reply', { body: 'Done', macro_id: 'm1', macro_action: ['tags', 'status'] })
);
expect(out).toEqual({
sent: true,
internal: false,
macroNote: 'Macro applied: status, tags.'
});
expect(calls()).toEqual(['POST /cases/a/', 'POST /macros/m1/apply/']);
expect(apiRequest.mock.calls[1][1].body).toEqual({ case_id: 'a', only: ['status', 'tags'] });
});
it('applies nothing when every chip was taken off', async () => {
routes(posted);
const out = await actions.reply(actionEvent('reply', { body: 'Done', macro_id: 'm1' }));
expect(out).toEqual({ sent: true, internal: false });
expect(calls()).toEqual(['POST /cases/a/']);
});
it('keeps the reply marked sent when the apply is refused after it posted', async () => {
routes({ ...posted, 'POST /macros/m1/apply/': apiError(400, 'Approval needed to close.') });
const out = /** @type {any} */ (
await actions.reply(
actionEvent('reply', { body: 'Done', macro_id: 'm1', macro_action: 'status' })
)
);
expect(out.status).toBe(400);
expect(out.data.sent).toBe(true);
expect(out.data.error).toBe(
"Reply posted, but the macro's actions were not applied: Approval needed to close."
);
});
it('still applies the macro when the status change is refused, and names that part', async () => {
routes({
...posted,
'PATCH /cases/a/': apiError(400, 'This ticket is merged.'),
'POST /macros/m1/apply/': { applied: ['tags'], skipped: [] }
});
const out = /** @type {any} */ (
await actions.reply(
actionEvent('reply', {
body: 'Done',
status: 'Pending',
macro_id: 'm1',
macro_action: 'tags'
})
)
);
expect(calls()).toEqual(['POST /cases/a/', 'PATCH /cases/a/', 'POST /macros/m1/apply/']);
expect(out.status).toBe(400);
expect(out.data).toEqual({
sent: true,
macroNote: 'Macro applied: tags.',
error: 'Reply posted, but the status stayed put: This ticket is merged.'
});
});
it('names both parts when the status and the macro are both refused', async () => {
routes({
...posted,
'PATCH /cases/a/': apiError(400, 'No.'),
'POST /macros/m1/apply/': apiError(403, 'Not yours.')
});
const out = /** @type {any} */ (
await actions.reply(
actionEvent('reply', {
body: 'Done',
status: 'Pending',
macro_id: 'm1',
macro_action: 'tags'
})
)
);
expect(out.data.sent).toBe(true);
expect(out.data.error).toBe(
"Reply posted, but the status stayed put: No. Also, the macro's actions were not applied: Not yours."
);
});
it('never applies anything when the reply itself fails', async () => {
routes({ 'POST /cases/a/': apiError(400, 'Too long.') });
const out = /** @type {any} */ (
await actions.reply(
actionEvent('reply', { body: 'Done', macro_id: 'm1', macro_action: 'status' })
)
);
expect(out.data.sent).toBeUndefined();
expect(calls()).toEqual(['POST /cases/a/']);
});
});
describe('a macro with no text', () => {
const list = {
'/macros/?active=true': {
results: [
{ id: 'm1', title: 'Greeting', body: 'Hi' },
{ id: 'm2', title: 'Close it', body: '', set_status: 'Closed' }
]
}
};
it('Apply applies every action it carries, at once', async () => {
routes({ 'POST /macros/m2/apply/': { applied: ['status'], skipped: [] } });
const out = await actions.applyMacro(actionEvent('applyMacro', { macro_id: 'm2' }));
expect(out).toEqual({ macroApplied: 'Macro applied: status.' });
expect(apiRequest.mock.calls[0][1].body).toEqual({ case_id: 'a' });
});
it('Apply reports a refusal in the picker', async () => {
routes({ 'POST /macros/m2/apply/': apiError(403, 'Not yours.') });
const out = /** @type {any} */ (
await actions.applyMacro(actionEvent('applyMacro', { macro_id: 'm2' }))
);
expect(out.data.macroError).toBe('Not yours.');
});
it('Insert without script applies it instead of rendering nothing', async () => {
routes({ ...list, 'POST /macros/m2/apply/': { applied: ['status'], skipped: [] } });
const out = await actions.renderMacro(actionEvent('renderMacro', { macro_id: 'm2' }));
expect(out).toEqual({ macroApplied: 'Macro applied: status.' });
expect(calls()).toEqual(['GET /macros/?active=true', 'POST /macros/m2/apply/']);
});
it('Insert still renders a macro that has text', async () => {
routes({ ...list, 'POST /macros/m1/render/': { rendered_body: 'Hi Liz' } });
const out = await actions.renderMacro(actionEvent('renderMacro', { macro_id: 'm1' }));
expect(out).toEqual({ macroText: 'Hi Liz', macroId: 'm1' });
expect(calls()).not.toContain('POST /macros/m1/apply/');
});
it('Insert renders, never applies, when the list cannot be read', async () => {
routes({
'/macros/?active=true': apiError(500, 'Down.'),
'POST /macros/m2/render/': { rendered_body: '' }
});
const out = await actions.renderMacro(actionEvent('renderMacro', { macro_id: 'm2' }));
expect(out).toEqual({ macroText: '', macroId: 'm2' });
expect(calls()).not.toContain('POST /macros/m2/apply/');
});
});
describe('closing from the status buttons', () => {
it('sends no closed_on: the API dates the close in the org’s timezone', async () => {
routes({ 'PATCH /cases/a/': {} });
const out = await actions.setStatus(actionEvent('setStatus', { status: 'Closed' }));
expect(out).toEqual({ moved: 'Closed' });
expect(calls()).toEqual(['PATCH /cases/a/']);
expect(apiRequest.mock.calls[0][1].body).toEqual({ status: 'Closed' });
});
it('shows the API’s own refusal when the close is gated', async () => {
const refusal =
'An approval is required before this case can be closed (rule: Close needs sign-off).';
routes({ 'PATCH /cases/a/': apiError(400, refusal) });
const out = /** @type {any} */ (
await actions.setStatus(actionEvent('setStatus', { status: 'Closed' }))
);
expect(out.status).toBe(400);
expect(out.data.error).toContain(refusal);
});
it('sends just the status for any other status', async () => {
routes({ 'PATCH /cases/a/': {} });
await actions.setStatus(actionEvent('setStatus', { status: 'Pending' }));
expect(calls()).toEqual(['PATCH /cases/a/']);
expect(apiRequest.mock.calls[0][1].body).toEqual({ status: 'Pending' });
});
});

View file

@ -784,7 +784,6 @@ export function summarizeBulk(results) {
deleted: 0,
no_access: 0,
approval_required: 0,
closed_on_required: 0,
// A merged ticket's status changes only by unmerging it.
merged: 0,
invalid: 0

View file

@ -31,7 +31,7 @@
* Nothing here recounts the page.
*/
import { apiRequest } from '$lib/api-helpers.js';
import { missingOption } from '$lib/v2/pickers.js';
import { missingOption, missingOptions } from '$lib/v2/pickers.js';
import { builtinFor } from '$lib/v2/webform-fields.js';
import { viewerIsAdmin } from './organization.js';
@ -157,26 +157,41 @@ export async function getWebForm({ cookies }, id) {
// with no matching option submits nothing, which the save reads as "nobody".
// Labelled by email, as `listProfiles` labels everyone else.
const stored = form?.assign_to_details;
const missingAssignee = missingOption(
const missingAssignee = missingOption(profiles, stored ? asOption(stored) : null);
// The same for rotation members: one deactivated since has no option in the
// multi-select, and an option-less id is dropped from the next save.
const missingRotationMembers = missingOptions(
profiles,
stored ? { id: stored.id, name: stored.email, is_active: stored.is_active } : null
(form?.rotation_members_details ?? []).map(asOption)
);
return {
form,
profiles,
missingAssignee,
missingRotationMembers,
customFields,
tags,
canManage: viewerIsAdmin(cookies)
};
}
/**
* A `*_details` profile as a picker option, labelled by email as
* `listProfiles` labels everyone else.
*
* @param {{ id: string, email: string, is_active: boolean }} details
*/
function asOption(details) {
return { id: details.id, name: details.email, is_active: details.is_active };
}
/**
* The fields `WebFormDetailSerializer` accepts on create or update.
*
* An allow-list rather than a pass-through. `org`, `created_by` and
* `is_published` are all derived server-side and would be ignored, but sending
* one is how a reader comes to believe this page sets it. `fields` is nested
* An allow-list rather than a pass-through. `org`, `created_by`,
* `is_published` and the rotation cursor `rotation_last_assigned` are all
* derived server-side and would be ignored, but sending one is how a reader
* comes to believe this page sets it. `fields` is nested
* and handled separately below because its shape needs its own pass.
*/
const WRITABLE_FIELDS = [
@ -187,7 +202,10 @@ const WRITABLE_FIELDS = [
'success_mode',
'success_message',
'redirect_url',
'assignment_mode',
'assign_to',
'rotation_members',
'rotation_cap',
'notify_profiles',
'lead_source',
'tags',

View file

@ -139,3 +139,76 @@ describe('ticket forms', () => {
expect(opts.body.fields).toEqual([{ source: 'ticket', ticket_field: 'email', label: 'Email' }]);
});
});
describe('lead rotation', () => {
beforeEach(() => {
apiRequest.mockReset();
});
/** @param {any[]} members the form's `rotation_members_details` */
function serveMembers(members) {
apiRequest.mockImplementation(async (/** @type {string} */ url) => {
if (url.startsWith('/webforms/')) {
return {
id: 'f1',
target: 'lead',
assignment_mode: 'rotation',
rotation_members: members.map((m) => m.id),
rotation_members_details: members
};
}
if (url === '/users/get-teams-and-users/') return { profiles: [ACTIVE] };
if (url === '/custom-fields/') return { definitions: [] };
return { tags: [] };
});
}
it('returns a stored member the people list cannot offer, marked inactive', async () => {
// Without an option of their own the multi-select drops them on save.
serveMembers([
{ id: 'p1', email: 'ada@example.com', name: 'Ada', is_active: true },
{ id: 'gone', email: 'left@example.com', name: 'Left', is_active: false }
]);
const data = await getWebForm(event, 'f1');
expect(data.missingRotationMembers).toEqual([
{ id: 'gone', name: 'left@example.com', is_active: false }
]);
});
it('returns no extra options when every member is in the list', async () => {
serveMembers([{ id: 'p1', email: 'ada@example.com', name: 'Ada', is_active: true }]);
const data = await getWebForm(event, 'f1');
expect(data.missingRotationMembers).toEqual([]);
});
it('sends the mode, members and cap, and never the cursor', async () => {
apiRequest.mockResolvedValue({});
await updateWebForm(event, 'f1', {
assignment_mode: 'rotation',
rotation_members: ['p1', 'p2'],
rotation_cap: '3',
rotation_last_assigned: 'p2'
});
const [, opts] = apiRequest.mock.calls[0];
expect(opts.body).toEqual({
assignment_mode: 'rotation',
rotation_members: ['p1', 'p2'],
rotation_cap: '3'
});
});
it('sends a cleared cap as null', async () => {
apiRequest.mockResolvedValue({});
await updateWebForm(event, 'f1', { assignment_mode: 'person', rotation_cap: null });
const [, opts] = apiRequest.mock.calls[0];
expect(opts.body.rotation_cap).toBeNull();
});
});

View file

@ -58,9 +58,9 @@
/**
* Some pages run a second query engine alongside the main one, with a
* narrower filter vocabulary than the page's own descriptor. Pipeline's
* board is the first: it renders from `/opportunities/kanban/`, which reads
* only a few of the params `/opportunities/` does. `onlyFields`/
* `onlyPresets` name the subset that view can actually run; when passed,
* board is the first: it renders from `/opportunities/kanban/` and offers
* only a few of the list's filters. `onlyFields`/`onlyPresets` name the
* subset that view runs; when passed,
* the bar offers, and only offers, controls and chips for that subset. A
* chip for a param the current view cannot honour would sit above rows it
* did not filter, while a header total computed with the full param set

View file

@ -27,10 +27,11 @@ import { loadSavedViews, savedViewActions } from '$lib/server/v2/saved-views.js'
* `?rotten=true` to the stalled subset (`opportunity_views.py:183,186`), and
* neither is one of the descriptor's `fields`, only its `presets`, so
* `readFilters` does not carry them and this load reads them off the URL
* itself. Neither is offered on the board (see BOARD_PRESETS below): the
* kanban endpoint does not read either one.
* itself. Neither is offered as a board preset (see BOARD_PRESETS below),
* though the kanban endpoint reads both: `dealListQuery` sets `open=true` for
* the board itself.
*
* `BOARD_FIELDS` and `BOARD_PRESETS` say what the board can actually run, and
* `BOARD_FIELDS` and `BOARD_PRESETS` say what the board offers, and
* live in `deals.js` rather than here. A `+page.server.js` may export only a
* fixed set of names, and SvelteKit answers 500 for the whole route on any
* other one.

View file

@ -161,7 +161,7 @@
* `/pipeline?view=board`, so switching layout silently dropped every active
* filter. Board to List keeps every param and drops only `view`: the list
* can run everything the board could and more. List to Board keeps
* `view=board` plus only the params the board can actually honour
* `view=board` plus only the params the board offers
* (`data.boardFields`, always returned by `load` regardless of the current
* view, see the note in `+page.server.js`); the rest are deliberately
* dropped, and it is visible rather than silent, since the chips for them
@ -172,7 +172,8 @@
const next = new SvelteURLSearchParams();
next.set('view', 'board');
// `search` mirrors what `+page.server.js` forwards to the board itself
// (`kanban_views.py:123` reads it); it is not one of `boardFields`
// (the kanban endpoint runs the deal list's filters, `search` among
// them); it is not one of `boardFields`
// because it is not a descriptor field, just like on the list view.
for (const key of [...(data.boardFields ?? []), 'search', 'pipeline']) {
const value = page.url.searchParams.get(key);

View file

@ -11,22 +11,30 @@ import { readFilters, buildFilterQuery } from '$lib/server/v2/filter-params.js';
/**
* The board renders from `/opportunities/kanban/`, a different endpoint from
* the list's `/opportunities/`, with a narrower filter vocabulary:
* `backend/opportunity/views/kanban_views.py:122-137` (`_apply_filters`)
* reads only `search`, `account`, `assigned_to`, `tags`, `closed_on__gte` and
* `closed_on__lte`. `BOARD_FIELDS` names the subset of pipeline's OWN
* descriptor fields that also appear in that list, so the board never offers
* a chip for a param it cannot actually run. Re-check this list against the
* backend file above if either side ever changes; nothing enforces the
* agreement except this test.
* the list's `/opportunities/`, but it runs the list's own filters
* (`deal_list_queryset` in `backend/opportunity/views/opportunity_views.py`),
* so it reads every param below. `BOARD_FIELDS` names the subset of
* pipeline's OWN descriptor fields the board offers, and must stay inside
* this list so the board never offers a chip for a param it cannot run.
* Re-check this list against the backend if either side ever changes;
* nothing enforces the agreement except this test.
*/
const KANBAN_SUPPORTED_PARAMS = [
'search',
'name',
'account',
'assigned_to',
'tags',
'stage',
'lead_source',
'closed_on__gte',
'closed_on__lte'
'closed_on__lte',
'created_at__gte',
'created_at__lte',
'amount__gte',
'amount__lte',
'open',
'rotten'
];
describe('BOARD_FIELDS', () => {
@ -41,15 +49,15 @@ describe('BOARD_FIELDS', () => {
for (const key of BOARD_FIELDS) {
expect(
KANBAN_SUPPORTED_PARAMS,
`"${key}" is not read by OpportunityKanbanView._apply_filters`
`"${key}" is not read by OpportunityKanbanView (deal_list_queryset)`
).toContain(key);
}
});
it('excludes stage, lead_source and amount, which the kanban endpoint drops silently', () => {
// These three are real pipeline filters, and real list filters, but the
// kanban view does not read them at all: a chip for one of them on the
// board would sit above cards it never actually filtered.
it('does not offer stage, lead_source or amount on the board', () => {
// The kanban endpoint reads all three now, but the board has no UI for
// them yet, so they stay off it. Offering one is a deliberate change to
// the board, and this is the test to update when that happens.
expect(BOARD_FIELDS).not.toContain('stage');
expect(BOARD_FIELDS).not.toContain('lead_source');
expect(BOARD_FIELDS).not.toContain('amount');
@ -64,11 +72,11 @@ describe('BOARD_PRESETS', () => {
}
});
it('excludes "open" and "stalled", which need params the kanban endpoint ignores', () => {
// "open" writes ?open=true and "stalled" writes ?rotten=true
// (opportunity_views.py:183,186 on the LIST endpoint); the kanban view
// reads neither, so offering either preset on the board would change the
// URL without changing a single card.
it('does not offer the "open" or "stalled" preset on the board', () => {
// "open" writes ?open=true and "stalled" writes ?rotten=true. The kanban
// endpoint reads both now, but the board already shows open deals only
// (`dealListQuery` sets open=true for it) and has no UI for "stalled"
// yet, so neither preset is offered there.
expect(BOARD_PRESETS).not.toContain('open');
expect(BOARD_PRESETS).not.toContain('stalled');
});

View file

@ -20,7 +20,7 @@
import Avatar from '$lib/v2/components/Avatar.svelte';
import { relativeDays, shortDate, count } from '$lib/v2/format.js';
import { ROLE_LABEL, ROLE_TONE } from '$lib/v2/enums.js';
import { KeyRound, Lock, ArrowLeftRight } from '@lucide/svelte';
import { KeyRound, Lock, ArrowLeftRight, CalendarDays } from '@lucide/svelte';
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
@ -203,6 +203,15 @@
{count(p.active_token_count)}
</span>
</a>
<a class="v2-setting" href={resolve('/profile/calendar-feed')}>
<div class="v2-setting-body">
<b>Calendar feed</b>
<span class="v2-sub" style="font-size:11.5px">
Your open tasks in Google Calendar, Outlook or Apple Calendar.
</span>
</div>
<CalendarDays size={14} style="color:var(--v2-slate);flex:none" />
</a>
<div class="v2-setting">
<div class="v2-setting-body">
<b>Sign-in method</b>

Some files were not shown because too many files have changed in this diff Show more