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:
parent
66421bbad0
commit
2be3f51efc
146 changed files with 11861 additions and 1059 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
343
backend/cases/tests/test_close_dated_by_server.py
Normal file
343
backend/cases/tests/test_close_dated_by_server.py
Normal 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
137
backend/cases/updates.py
Normal 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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
105
backend/common/calendar_feed.py
Normal file
105
backend/common/calendar_feed.py
Normal 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/"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
92
backend/common/migrations/0047_calendar_feed_token.py
Normal file
92
backend/common/migrations/0047_calendar_feed_token.py
Normal 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",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
68
backend/common/public_tokens.py
Normal file
68
backend/common/public_tokens.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
533
backend/common/tests/test_calendar_feed.py
Normal file
533
backend/common/tests/test_calendar_feed.py
Normal 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)
|
||||
119
backend/common/tests/test_calendar_feed_ics.py
Normal file
119
backend/common/tests/test_calendar_feed_ics.py
Normal 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)
|
||||
75
backend/common/tests/test_domain_name_guard.py
Normal file
75
backend/common/tests/test_domain_name_guard.py
Normal 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/"
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
434
backend/common/tests/test_login_failure_audit.py
Normal file
434
backend/common/tests/test_login_failure_audit.py
Normal 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
|
||||
126
backend/common/tests/test_public_tokens.py
Normal file
126
backend/common/tests/test_public_tokens.py
Normal 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)
|
||||
132
backend/common/tests/test_read_only_post_scopes.py
Normal file
132
backend/common/tests/test_read_only_post_scopes.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
236
backend/common/views/calendar_feed_views.py
Normal file
236
backend/common/views/calendar_feed_views.py
Normal 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
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
68
backend/macros/migrations/0002_macro_actions.py
Normal file
68
backend/macros/migrations/0002_macro_actions.py
Normal 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=""),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
]
|
||||
|
|
|
|||
636
backend/macros/tests/test_actions.py
Normal file
636
backend/macros/tests/test_actions.py
Normal 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]
|
||||
193
backend/macros/tests/test_token_scopes.py
Normal file
193
backend/macros/tests/test_token_scopes.py
Normal 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"]
|
||||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
140
backend/opportunity/tests/test_kanban_card_mobile_fields.py
Normal file
140
backend/opportunity/tests/test_kanban_card_mobile_fields.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -681,7 +681,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "django-crm"
|
||||
version = "1.12.0"
|
||||
version = "1.13.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "cairocffi" },
|
||||
|
|
|
|||
56
backend/webforms/migrations/0004_rotation_assignment.py
Normal file
56
backend/webforms/migrations/0004_rotation_assignment.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
668
backend/webforms/tests/test_rotation.py
Normal file
668
backend/webforms/tests/test_rotation.py
Normal 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]
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
119
backend/webhooks/tests/test_merge_snapshot.py
Normal file
119
backend/webhooks/tests/test_merge_snapshot.py
Normal 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}
|
||||
|
|
@ -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>"}`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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>"}`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
124
docs/api/macros.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/`,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
48
frontend/src/lib/server/v2/calendar-feed.js
Normal file
48
frontend/src/lib/server/v2/calendar-feed.js
Normal 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 });
|
||||
}
|
||||
55
frontend/src/lib/server/v2/calendar-feed.test.js
Normal file
55
frontend/src/lib/server/v2/calendar-feed.test.js
Normal 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' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue