Refactor comments for clarity and consistency across multiple screens and services

- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
This commit is contained in:
Ashwin 2026-08-03 09:36:48 +05:30
parent d6bb754673
commit e3b5181b19
487 changed files with 3548 additions and 3548 deletions

View file

@ -25,7 +25,7 @@ POSTGRES_PASSWORD=postgres_password
CELERY_BROKER_URL=redis://redis:6379/0 CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/0
# Email (console backend in dev — emails print to stdout) # Email (console backend in dev: emails print to stdout)
DEFAULT_FROM_EMAIL=noreply@localhost DEFAULT_FROM_EMAIL=noreply@localhost
ADMIN_EMAIL=admin@localhost ADMIN_EMAIL=admin@localhost
ADMIN_PASSWORD=admin ADMIN_PASSWORD=admin

View file

@ -152,7 +152,7 @@ jobs:
# These two tests skip when mkdocs is absent, so the backend-tests job # These two tests skip when mkdocs is absent, so the backend-tests job
# (which installs no docs group) cannot run them. Without this step the # (which installs no docs group) cannot run them. Without this step the
# "no internal artifacts published" guard would silently never execute # "no internal artifacts published" guard would silently never execute
# in CI — a skip reads as a pass. # in CI. A skip reads as a pass.
- name: Check docs build guards - name: Check docs build guards
working-directory: backend working-directory: backend
env: env:

View file

@ -1,6 +1,6 @@
# Read the Docs build configuration. # Read the Docs build configuration.
# The docs dependencies live in backend/pyproject.toml's `docs` group so the # The docs dependencies live in backend/pyproject.toml's `docs` group so the
# site builds with uv, exactly as CI does — one dependency source, one lockfile. # site builds with uv, exactly as CI does, one dependency source, one lockfile.
version: 2 version: 2
build: build:

View file

@ -10,20 +10,20 @@ Thank you for helping improve BottleCRM. Contributions of all sizes are welcome,
## Development Setup ## Development Setup
Full instructions — cloning, Docker, the backend (`uv`), the frontend (`pnpm`), running the mobile app, adding dependencies, and running tests — live in the documentation site under Contributing: Full instructions (cloning, Docker, the backend (`uv`), the frontend (`pnpm`), running the mobile app, adding dependencies, and running tests) live in the documentation site under Contributing:
- [Development setup](docs/contributing/development-setup.md) — get all three projects running locally. - [Development setup](docs/contributing/development-setup.md). Get all three projects running locally.
- [Testing](docs/contributing/testing.md) — running the suite, markers, and what CI runs. - [Testing](docs/contributing/testing.md), running the suite, markers, and what CI runs.
- [Code style](docs/contributing/code-style.md) — Python, frontend, and Flutter conventions, and what CI does and doesn't enforce. - [Code style](docs/contributing/code-style.md), Python, frontend, and Flutter conventions, and what CI does and doesn't enforce.
- [Repository layout](docs/contributing/repo-layout.md) — where each part of the codebase lives. - [Repository layout](docs/contributing/repo-layout.md), where each part of the codebase lives.
- [Security rules](docs/contributing/security-rules.md) — the rules this codebase treats as non-negotiable. - [Security rules](docs/contributing/security-rules.md). The rules this codebase treats as non-negotiable.
- [Adding an org-scoped model](docs/contributing/adding-an-org-scoped-model.md) — what a new multi-tenant model needs, including its RLS policy. - [Adding an org-scoped model](docs/contributing/adding-an-org-scoped-model.md). What a new multi-tenant model needs, including its RLS policy.
## Submitting a Pull Request ## Submitting a Pull Request
1. Create a branch from `master` with a descriptive name. 1. Create a branch from `master` with a descriptive name.
2. Make focused changes and include tests where appropriate. 2. Make focused changes and include tests where appropriate.
3. Run the relevant development checks — see [Testing](docs/contributing/testing.md) and [Code style](docs/contributing/code-style.md). 3. Run the relevant development checks. See [Testing](docs/contributing/testing.md) and [Code style](docs/contributing/code-style.md).
4. Update documentation when behavior, setup, or public APIs change. 4. Update documentation when behavior, setup, or public APIs change.
5. Push your branch and open a pull request against `master`. 5. Push your branch and open a pull request against `master`.

View file

@ -1,4 +1,4 @@
# BottleCRM — Open Source Django CRM for Startups & Enterprises # BottleCRM: Open Source Django CRM for Startups & Enterprises
A free, self-hosted, multi-tenant CRM built with Django REST Framework, SvelteKit and Flutter. A free, self-hosted, multi-tenant CRM built with Django REST Framework, SvelteKit and Flutter.
@ -11,31 +11,31 @@ A free, self-hosted, multi-tenant CRM built with Django REST Framework, SvelteKi
![Coverage](./coverage-badge.svg) ![Coverage](./coverage-badge.svg)
**BottleCRM is an open source CRM you run on your own infrastructure.** It covers the full **BottleCRM is an open source CRM you run on your own infrastructure.** It covers the full
customer lifecycle — leads, accounts, contacts, opportunities, support tickets, tasks and customer lifecycle: leads, accounts, contacts, opportunities, support tickets, tasks and
invoices — through a SvelteKit web app, a native Flutter mobile app, and a documented REST API invoices: through a SvelteKit web app, a native Flutter mobile app, and a documented REST API
sharing one Django backend. PostgreSQL Row-Level Security isolates each organization's data at sharing one Django backend. PostgreSQL Row-Level Security isolates each organization's data at
the database layer, so a single deployment serves one startup or hundreds of tenants. the database layer, so a single deployment serves one startup or hundreds of tenants.
No per-seat pricing, no user caps, no feature paywall. MIT licensed — fork it, self-host it, own No per-seat pricing, no user caps, no feature paywall. MIT licensed: fork it, self-host it, own
your data. your data.
**[Try it free →](https://bottlecrm.io/)** · [Features](https://bottlecrm.io/features) · [Product docs](https://bottlecrm.io/docs) · [Pricing](https://bottlecrm.io/pricing) · [Migrate from Salesforce or HubSpot](https://bottlecrm.io/migration) **[Try it free →](https://bottlecrm.io/)** · [Features](https://bottlecrm.io/features) · [Product docs](https://bottlecrm.io/docs) · [Pricing](https://bottlecrm.io/pricing) · [Migrate from Salesforce or HubSpot](https://bottlecrm.io/migration)
(That's the marketing site's product documentation. For the technical documentation — self-hosting, architecture, the API, contributing — see [Documentation](#documentation) below.) (That's the marketing site's product documentation. For the technical documentation (self-hosting, architecture, the API, contributing) see [Documentation](#documentation) below.)
## Why BottleCRM ## Why BottleCRM
- **Free forever, MIT licensed** — unlimited users and records, no subscription. A genuinely - **Free forever, MIT licensed**: unlimited users and records, no subscription. A genuinely
self-hosted CRM alternative to Salesforce, HubSpot and Pipedrive. self-hosted CRM alternative to Salesforce, HubSpot and Pipedrive.
- **Multi-tenant by design** — PostgreSQL Row-Level Security enforces tenant isolation in the - **Multi-tenant by design**: PostgreSQL Row-Level Security enforces tenant isolation in the
database, not just in application code. Run it for a single company or as a SaaS for many. database, not just in application code. Run it for a single company or as a SaaS for many.
- **AI agents built in (MCP)** — connect Claude, Cursor, Codex, Gemini or any MCP client and let - **AI agents built in (MCP)**: connect Claude, Cursor, Codex, Gemini or any MCP client and let
it search, create and update records *as you*, inheriting your role, org and permissions. it search, create and update records *as you*, inheriting your role, org and permissions.
- **Web, native mobile and API** — one Django REST backend behind a Svelte 5 web app and a - **Web, native mobile and API**: one Django REST backend behind a Svelte 5 web app and a
Flutter app for iOS and Android. Flutter app for iOS and Android.
- **A stack you can actually hack on** — Django 6 / DRF and Svelte 5, not a bespoke in-house - **A stack you can actually hack on**: Django 6 / DRF and Svelte 5, not a bespoke in-house
framework. If your team writes Python, it can extend this on day one. framework. If your team writes Python, it can extend this on day one.
- **Real support tooling** — a full helpdesk with SLA timers, approvals, escalations, macros and - **Real support tooling**: a full helpdesk with SLA timers, approvals, escalations, macros and
a knowledge base, not a bolted-on ticket list. a knowledge base, not a bolted-on ticket list.
## How it compares ## How it compares
@ -44,9 +44,9 @@ your data.
|---|---|---|---| |---|---|---|---|
| **Cost** | Free, unlimited users | Per seat, per month | Free core, often paid tiers | | **Cost** | Free, unlimited users | Per seat, per month | Free core, often paid tiers |
| **Hosting** | Self-hosted (managed hosting available) | Vendor cloud only | Self-hosted | | **Hosting** | Self-hosted (managed hosting available) | Vendor cloud only | Self-hosted |
| **Data ownership** | Total — it is your database | Vendor-controlled | Total | | **Data ownership** | Total. It is your database | Vendor-controlled | Total |
| **Multi-tenancy** | Database-level RLS | Not applicable | Uncommon | | **Multi-tenancy** | Database-level RLS | Not applicable | Uncommon |
| **Native mobile app** | Yes — Flutter, iOS + Android | Yes | Uncommon | | **Native mobile app** | Yes, Flutter, iOS + Android | Yes | Uncommon |
| **AI agent access** | Built-in MCP server | Proprietary add-ons | Uncommon | | **AI agent access** | Built-in MCP server | Proprietary add-ons | Uncommon |
| **Stack** | Django REST + SvelteKit | Closed source | Varies | | **Stack** | Django REST + SvelteKit | Closed source | Varies |
| **License** | MIT | Proprietary | Varies, often GPL/AGPL | | **License** | MIT | Proprietary | Varies, often GPL/AGPL |
@ -65,14 +65,14 @@ login, loading demo data, and what each service does.
## Documentation ## Documentation
The full documentation — setup, self-hosting, architecture, the REST API, and contributing — is The full documentation (setup, self-hosting, architecture, the REST API, and contributing) is
built from this repository's [`docs/`](docs/) with MkDocs Material and publishes to Read the Docs built from this repository's [`docs/`](docs/) with MkDocs Material and publishes to Read the Docs
at <https://bottlecrm.readthedocs.io/>. Four starting points: at <https://bottlecrm.readthedocs.io/>. Four starting points:
- **[Getting started](docs/getting-started/docker-quick-start.md)** — Docker and manual setup, first sign-in, demo data. - **[Getting started](docs/getting-started/docker-quick-start.md)**: Docker and manual setup, first sign-in, demo data.
- **[Self-hosting](docs/self-hosting/requirements.md)** — requirements, production deployment, PostgreSQL and Row-Level Security, backups, security hardening. - **[Self-hosting](docs/self-hosting/requirements.md)**: requirements, production deployment, PostgreSQL and Row-Level Security, backups, security hardening.
- **[API reference](docs/api/conventions.md)** — authentication, conventions, and every documented endpoint. - **[API reference](docs/api/conventions.md)**: authentication, conventions, and every documented endpoint.
- **[Contributing](docs/contributing/development-setup.md)** — local development setup, testing, code style, and how to open a pull request. - **[Contributing](docs/contributing/development-setup.md)**: local development setup, testing, code style, and how to open a pull request.
## Contributing ## Contributing

View file

@ -13,5 +13,5 @@ misconfigured role (empty admin lists, data visible across organizations, and mo
This file is kept only so existing links to `RLS_SETUP.md` keep resolving; it is not maintained This file is kept only so existing links to `RLS_SETUP.md` keep resolving; it is not maintained
as a setup guide in its own right. Notably, the number of RLS-protected tables cited in earlier as a setup guide in its own right. Notably, the number of RLS-protected tables cited in earlier
versions of this file (24) was out of date — the current, verified count is documented on the versions of this file (24) was out of date. The current, verified count is documented on the
linked page rather than repeated here, precisely so this file can't drift out of sync again. linked page rather than repeated here, precisely so this file can't drift out of sync again.

View file

@ -28,7 +28,7 @@ SENTRY_DSN=""
CELERY_BROKER_URL="redis://localhost:6379/0" CELERY_BROKER_URL="redis://localhost:6379/0"
CELERY_RESULT_BACKEND="redis://localhost:6379/0" CELERY_RESULT_BACKEND="redis://localhost:6379/0"
# Google OAuth (optional — leave blank to disable) # Google OAuth (optional: leave blank to disable)
GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET="" GOOGLE_CLIENT_SECRET=""

View file

@ -38,7 +38,7 @@ The backend for BottleCRM, a multi-tenant CRM platform built with Django REST Fr
- **Python 3.10+** (uv installs a matching Python automatically if needed) - **Python 3.10+** (uv installs a matching Python automatically if needed)
- **PostgreSQL** - **PostgreSQL**
- **Redis** (for Celery) - **Redis** (for Celery)
- **[uv](https://docs.astral.sh/uv/)** — Python package & venv manager (replaces pip + virtualenv) - **[uv](https://docs.astral.sh/uv/)**: Python package & venv manager (replaces pip + virtualenv)
## Installation ## Installation
@ -61,7 +61,7 @@ cd backend
uv sync uv sync
``` ```
> Run any backend command with `uv run <cmd>` (e.g. `uv run python manage.py migrate`). uv resolves binaries from `.venv/bin/` automatically — no manual `source .venv/bin/activate` needed (though that still works if you prefer it). > Run any backend command with `uv run <cmd>` (e.g. `uv run python manage.py migrate`). uv resolves binaries from `.venv/bin/` automatically, no manual `source .venv/bin/activate` needed (though that still works if you prefer it).
### 3. Install PDF generation system dependencies ### 3. Install PDF generation system dependencies
@ -181,7 +181,7 @@ Every request operates within an organization context:
- **Organization (Org)**: Top-level tenant container - **Organization (Org)**: Top-level tenant container
- **Users**: Regular members with USER role - **Users**: Regular members with USER role
- **Admins**: Organization administrators with ADMIN role - **Admins**: Organization administrators with ADMIN role
- **Super Admin**: Users with `is_superuser` set on the user record have platform-wide access. Grant it deliberately (`manage.py createsuperuser` or the Django admin) — it is never inferred from the email address - **Super Admin**: Users with `is_superuser` set on the user record have platform-wide access. Grant it deliberately (`manage.py createsuperuser` or the Django admin). It is never inferred from the email address
### Authentication ### Authentication

View file

@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='account', model_name='account',
name='is_sample', name='is_sample',
field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively — never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'),
), ),
] ]

View file

@ -43,7 +43,7 @@ class AccountSerializer(serializers.ModelSerializer):
"""What this account is worth, owes and is complaining about. """What this account is worth, owes and is complaining about.
Computed by `accounts.views.annotate_rollups`, which is applied on the Computed by `accounts.views.annotate_rollups`, which is applied on the
list and detail endpoints. It is deliberately absent — `null` — rather list and detail endpoints. It is deliberately absent, `null`, rather
than zero-filled anywhere else: a page that was never given the numbers than zero-filled anywhere else: a page that was never given the numbers
should say nothing, not quietly claim every total is zero. should say nothing, not quietly claim every total is zero.
""" """
@ -213,7 +213,7 @@ class AccountCreateSerializer(serializers.ModelSerializer):
"""Reject negative revenue here rather than letting the database do it. """Reject negative revenue here rather than letting the database do it.
`Account` carries a `account_revenue_non_negative` CheckConstraint, so `Account` carries a `account_revenue_non_negative` CheckConstraint, so
the value could never be stored — but with nothing in front of it the the value could never be stored, but with nothing in front of it the
constraint surfaced as an IntegrityError, i.e. a 500, with no indication constraint surfaced as an IntegrityError, i.e. a 500, with no indication
of which field was at fault. `number_of_employees` is a of which field was at fault. `number_of_employees` is a
PositiveIntegerField and DRF derives `min_value=0` from it for free, PositiveIntegerField and DRF derives `min_value=0` from it for free,

View file

@ -4,26 +4,26 @@ Written while wiring `/v2/accounts` to the real API. Every case here failed
against the code as it stood, or pins a behaviour that was one edit away from against the code as it stood, or pins a behaviour that was one edit away from
breaking: breaking:
1. `get`, `put`, `patch` and comment `post` compared `request.profile` — a 1. `get`, `put`, `patch` and comment `post` compared `request.profile`, a
Profile — against `account.created_by`, a FK to `User`. Never equal, so the Profile: against `account.created_by`, a FK to `User`. Never equal, so the
creator branch was unreachable. `delete()` and the list filter compared creator branch was unreachable. `delete()` and the list filter compared
correctly, which is how you can tell: the same non-admin could see an correctly, which is how you can tell: the same non-admin could see an
account in their own list, be refused permission to open it, and delete it account in their own list, be refused permission to open it, and delete it
anyway. anyway.
2. `users_mention` read `created_by.user.email`. `created_by` *is* the User, so 2. `users_mention` read `created_by.user.email`. `created_by` *is* the User, so
that was an AttributeError — a 500 for every non-admin assignee who opened that was an AttributeError, a 500 for every non-admin assignee who opened
an account they were legitimately assigned. an account they were legitimately assigned.
3. Commenting on an id that does not exist raised `DoesNotExist` (500) where 3. Commenting on an id that does not exist raised `DoesNotExist` (500) where
`get` on the same id answered 404. `get` on the same id answered 404.
4. `AccountAttachmentView.delete` looked its row up by primary key with no org 4. `AccountAttachmentView.delete` looked its row up by primary key with no org
filter. `Attachments` is one generic table shared by every module, so that filter. `Attachments` is one generic table shared by every module, so that
endpoint deleted any attachment in the database — any org, any parent record. endpoint deleted any attachment in the database, any org, any parent record.
5. A negative `annual_revenue` reached the CheckConstraint and came back as an 5. A negative `annual_revenue` reached the CheckConstraint and came back as an
IntegrityError, i.e. a 500 naming no field. IntegrityError, i.e. a 500 naming no field.
6. The account page's money had no source at all. `won_amount`, `open_pipeline`, 6. The account page's money had no source at all. `won_amount`, `open_pipeline`,
`overdue_amount` and the counts beside them are now annotated in SQL, and `overdue_amount` and the counts beside them are now annotated in SQL, and
the interesting case is an account with several deals *and* several invoices the interesting case is an account with several deals *and* several invoices,
— a single `.annotate(Sum(...), Sum(...))` multiplies those together. a single `.annotate(Sum(...), Sum(...))` multiplies those together.
""" """
import datetime import datetime
@ -68,7 +68,7 @@ def _created_by(account, user):
"""Stage a creator. """Stage a creator.
`BaseModel.save()` overwrites `created_by` from the crum thread-local, so `BaseModel.save()` overwrites `created_by` from the crum thread-local, so
passing it to `create()` does not stick — the row comes back with whoever passing it to `create()` does not stick. The row comes back with whoever
the request belongs to, or None. `.update()` skips `save()` and is the only the request belongs to, or None. `.update()` skips `save()` and is the only
way to set it. Getting this wrong makes every creator test a false negative: way to set it. Getting this wrong makes every creator test a false negative:
the branch under test is simply never entered. the branch under test is simply never entered.
@ -167,7 +167,7 @@ class TestAccountVerbsAgree:
): ):
"""`delete` was the verb that was already correct. """`delete` was the verb that was already correct.
It stays correct — the point of the fix was to bring the other four up It stays correct. The point of the fix was to bring the other four up
to it, not to take this one away. to it, not to take this one away.
""" """
_created_by(account, user_profile.user) _created_by(account, user_profile.user)
@ -212,8 +212,8 @@ class TestAccountVerbsAgree:
"""A stranger with a malformed body is still a stranger. """A stranger with a malformed body is still a stranger.
The access check used to sit inside `is_valid()`, so an unauthorised The access check used to sit inside `is_valid()`, so an unauthorised
caller was told their payload was bad — a free validity oracle on caller was told their payload was bad, a free validity oracle on
somebody else's record — before being refused. somebody else's record, before being refused.
""" """
_created_by(account, admin_user) _created_by(account, admin_user)
response = user_client.put( response = user_client.put(
@ -315,7 +315,7 @@ class TestMalformedIds:
"""`acc-northwind` is what the v2 fixtures used for ids. """`acc-northwind` is what the v2 fixtures used for ids.
The URL pattern is `<str:pk>`, so the text reaches `UUIDField` and The URL pattern is `<str:pk>`, so the text reaches `UUIDField` and
`to_python` raises `ValidationError` — which `get_object_or_404` does `to_python` raises `ValidationError`, which `get_object_or_404` does
not catch, so every typo and every old link answered 500. not catch, so every typo and every old link answered 500.
""" """
response = admin_client.get("/api/accounts/acc-northwind/") response = admin_client.get("/api/accounts/acc-northwind/")
@ -414,7 +414,7 @@ class TestAccountRollups:
def test_nothing_is_zero_not_null(self, admin_client, account): def test_nothing_is_zero_not_null(self, admin_client, account):
"""An account with no deals, invoices or tickets. """An account with no deals, invoices or tickets.
Each subquery returns no rows at all, and an empty subquery is NULL — Each subquery returns no rows at all, and an empty subquery is NULL,
so without the Coalesce every figure on a brand new account renders as so without the Coalesce every figure on a brand new account renders as
blank rather than zero. blank rather than zero.
""" """
@ -462,8 +462,8 @@ class TestAccountRollups:
): ):
"""`status == "Overdue"` is a nightly task's opinion, and it lags. """`status == "Overdue"` is a nightly task's opinion, and it lags.
Keying off the status alone hides money on any day the task has not run Keying off the status alone hides money on any day the task has not run,
— which on the seeded database is most of them. which on the seeded database is most of them.
""" """
_invoice(org_a, account, "LATE-1", Decimal("400"), datetime.date(2020, 1, 1)) _invoice(org_a, account, "LATE-1", Decimal("400"), datetime.date(2020, 1, 1))

View file

@ -84,7 +84,7 @@ from tasks.serializer import TaskSerializer
# worth, what is still in play, what it owes, and what is on fire. # worth, what is still in play, what it owes, and what is on fire.
# #
# These are annotations, never stored columns. A stored total is a total that # These are annotations, never stored columns. A stored total is a total that
# can be wrong — it goes stale the moment a deal moves or an invoice is paid, # can be wrong. It goes stale the moment a deal moves or an invoice is paid,
# and then the header disagrees with the very rows printed underneath it. # and then the header disagrees with the very rows printed underneath it.
ROLLUP_FIELDS = ( ROLLUP_FIELDS = (
"won_amount", "won_amount",
@ -107,7 +107,7 @@ def _per_account(model, aggregate, output_field, **filters):
`.values("account").annotate(...)` groups inside the subquery so it yields `.values("account").annotate(...)` groups inside the subquery so it yields
at most one row. When an account has no matching rows it yields *none*, and at most one row. When an account has no matching rows it yields *none*, and
an empty subquery is NULL rather than zero — which is why every caller an empty subquery is NULL rather than zero, which is why every caller
wraps this in a `Coalesce`. Zero and "no invoices at all" look identical to wraps this in a `Coalesce`. Zero and "no invoices at all" look identical to
a reader of the page, and should. a reader of the page, and should.
""" """
@ -148,7 +148,7 @@ def annotate_rollups(queryset):
} }
return queryset.annotate( return queryset.annotate(
# Booked revenue — deals actually won. Not cash collected; the invoices # Booked revenue: deals actually won. Not cash collected; the invoices
# tell that story and are counted separately below. # tell that story and are counted separately below.
won_amount=Coalesce( won_amount=Coalesce(
_per_account(Opportunity, Sum("amount"), MONEY, **won), zero _per_account(Opportunity, Sum("amount"), MONEY, **won), zero
@ -164,7 +164,7 @@ def annotate_rollups(queryset):
), ),
# Past due and still owed. The due date is the fact; the "Overdue" # Past due and still owed. The due date is the fact; the "Overdue"
# status is a nightly task's opinion about that fact, and can be a day # status is a nightly task's opinion about that fact, and can be a day
# behind it — see UNPAID_STATUSES. # behind it. See UNPAID_STATUSES.
overdue_amount=Coalesce( overdue_amount=Coalesce(
_per_account(Invoice, Sum("amount_due"), MONEY, **past_due), zero _per_account(Invoice, Sum("amount_due"), MONEY, **past_due), zero
), ),
@ -407,7 +407,7 @@ class AccountDetailView(APIView):
def get_object(self, pk): def get_object(self, pk):
# A malformed id is 404, not 500. The URL pattern is `<str:pk>`, so an # A malformed id is 404, not 500. The URL pattern is `<str:pk>`, so an
# old bookmark or a typo reaches the UUID field as text, and # old bookmark or a typo reaches the UUID field as text, and
# `UUIDField.to_python` raises `ValidationError` — which # `UUIDField.to_python` raises `ValidationError`, which
# `get_object_or_404` does not catch, because it only knows about # `get_object_or_404` does not catch, because it only knows about
# `DoesNotExist`. "That is not an id" and "no such account" are the # `DoesNotExist`. "That is not an id" and "no such account" are the
# same answer to whoever asked. # same answer to whoever asked.
@ -424,7 +424,7 @@ class AccountDetailView(APIView):
"""Admins, the person who created it, and anyone assigned. Else 403. """Admins, the person who created it, and anyone assigned. Else 403.
One check, because there were four and they disagreed. `get`, `put`, One check, because there were four and they disagreed. `get`, `put`,
`patch` and comment `post` each compared `request.profile` — a Profile — `patch` and comment `post` each compared `request.profile`, a Profile,
against `account.created_by`, which is a FK to `User`. Those are never against `account.created_by`, which is a FK to `User`. Those are never
equal, so the creator branch could not fire and creators were locked out equal, so the creator branch could not fire and creators were locked out
of their own accounts. of their own accounts.
@ -622,7 +622,7 @@ class AccountDetailView(APIView):
elif self.request.profile.user_id != self.account.created_by_id: elif self.request.profile.user_id != self.account.created_by_id:
# `created_by` IS the User. Reading `created_by.user.email` raised # `created_by` IS the User. Reading `created_by.user.email` raised
# AttributeError and returned a 500 to every non-admin assignee who # AttributeError and returned a 500 to every non-admin assignee who
# opened an account — and the branch was reached every time, because # opened an account, and the branch was reached every time, because
# the Profile-vs-User comparison above it was never equal. # the Profile-vs-User comparison above it was never equal.
# #
# The key is `user__email`, matching the admin branch. It used to be # The key is `user__email`, matching the admin branch. It used to be
@ -712,7 +712,7 @@ class AccountDetailView(APIView):
def post(self, request, pk, **kwargs): def post(self, request, pk, **kwargs):
data = request.data data = request.data
context = {} context = {}
# `Account.objects.get()` raised DoesNotExist — a 500 — for an id that # `Account.objects.get()` raised DoesNotExist, a 500, for an id that
# simply is not there, while GET on the same id answered 404. Commenting # simply is not there, while GET on the same id answered 404. Commenting
# on a deleted account is a normal race, not a server fault. # on a deleted account is a normal race, not a server fault.
self.account_obj = self.get_object(pk=pk) self.account_obj = self.get_object(pk=pk)
@ -976,7 +976,7 @@ class AccountAttachmentView(APIView):
@extend_schema(tags=["Accounts"], parameters=swagger_params.organization_params) @extend_schema(tags=["Accounts"], parameters=swagger_params.organization_params)
def delete(self, request, pk, format=None): def delete(self, request, pk, format=None):
# Scoped to the caller's org. `Attachments` is one generic table shared # Scoped to the caller's org. `Attachments` is one generic table shared
# by every module, and this looked the row up by primary key alone — so # by every module, and this looked the row up by primary key alone, so
# the endpoint would delete any attachment in the database, belonging to # the endpoint would delete any attachment in the database, belonging to
# any organisation, hanging off any kind of record. RLS is the only # any organisation, hanging off any kind of record. RLS is the only
# thing that stood between that and a cross-tenant delete, and RLS is # thing that stood between that and a cross-tenant delete, and RLS is
@ -1027,7 +1027,7 @@ class AccountCreateMailView(APIView):
# Org-scoped: unscoped, this would let anyone send mail recorded as # Org-scoped: unscoped, this would let anyone send mail recorded as
# coming *from* another organisation's account. # coming *from* another organisation's account.
# #
# Note this endpoint cannot currently succeed at all — `EmailSerializer` # Note this endpoint cannot currently succeed at all; `EmailSerializer`
# does not accept the `request_obj` kwarg the line below passes it, so # does not accept the `request_obj` kwarg the line below passes it, so
# every request dies with a TypeError before reaching any of this. The # every request dies with a TypeError before reaching any of this. The
# scoping is here so it is not missing when somebody revives the # scoping is here so it is not missing when somebody revives the

View file

@ -47,7 +47,7 @@ def add_business_hours(start_dt: datetime, hours: float, calendar) -> datetime:
``calendar`` is a ``BusinessCalendar`` instance, or ``None`` to fall back ``calendar`` is a ``BusinessCalendar`` instance, or ``None`` to fall back
to 24/7 wall-clock arithmetic. Holidays attached to the calendar are to 24/7 wall-clock arithmetic. Holidays attached to the calendar are
skipped (full days off, in the calendar's timezone). The result preserves skipped (full days off, in the calendar's timezone). The result preserves
the input timezone — a UTC ``start_dt`` returns a UTC-aware datetime even the input timezone. A UTC ``start_dt`` returns a UTC-aware datetime even
if the calendar lives in another zone. if the calendar lives in another zone.
""" """
if hours is None: if hours is None:

View file

@ -84,7 +84,7 @@ class BusinessHoliday(BaseModel):
"""A single full-day off on a calendar (national holiday, company day off). """A single full-day off on a calendar (national holiday, company day off).
Holidays are date-based and apply for the entire day in the calendar's Holidays are date-based and apply for the entire day in the calendar's
timezone — partial-day holidays are out of scope for v1. timezone. Partial-day holidays are out of scope for v1.
""" """
calendar = models.ForeignKey( calendar = models.ForeignKey(

View file

@ -196,6 +196,6 @@ class TestOrgContextRequired:
request = APIRequestFactory().get(CALENDAR_URL) request = APIRequestFactory().get(CALENDAR_URL)
force_authenticate(request, user=admin_user) force_authenticate(request, user=admin_user)
# This direct-call path skips the middleware that sets request.profile, # This direct-call path skips the middleware that sets request.profile,
# so the request has no org context — exactly what HasOrgContext guards. # so the request has no org context, exactly what HasOrgContext guards.
response = BusinessCalendarView.as_view()(request) response = BusinessCalendarView.as_view()(request)
assert response.status_code == 403 assert response.status_code == 403

View file

@ -60,7 +60,7 @@ class TestAddBusinessHours:
assert result == datetime(2026, 5, 11, 11, 0, tzinfo=_UTC) assert result == datetime(2026, 5, 11, 11, 0, tzinfo=_UTC)
def test_skip_holiday(self, calendar_a, holiday_factory): def test_skip_holiday(self, calendar_a, holiday_factory):
# Make Mon 5/11 a holiday — Mon's 8 hours move to Tue # Make Mon 5/11 a holiday, Mon's 8 hours move to Tue
holiday_factory(calendar_a, date=date(2026, 5, 11), name="Founders Day") holiday_factory(calendar_a, date=date(2026, 5, 11), name="Founders Day")
start = datetime(2026, 5, 11, 10, 0, tzinfo=_UTC) start = datetime(2026, 5, 11, 10, 0, tzinfo=_UTC)
result = add_business_hours(start, 4, calendar_a) result = add_business_hours(start, 4, calendar_a)

View file

@ -1,15 +1,15 @@
"""REST endpoints for the business-hours settings page. """REST endpoints for the business-hours settings page.
Routes (all under /api/business-hours/): Routes (all under /api/business-hours/):
GET /calendar/ — fetch (and create-on-demand) the GET /calendar/: fetch (and create-on-demand) the
org's default calendar with its org's default calendar with its
holidays. holidays.
PUT /calendar/<pk>/ — admin update of weekday hours, PUT /calendar/<pk>/: admin update of weekday hours,
timezone, name. Holidays are timezone, name. Holidays are
managed via the nested endpoints managed via the nested endpoints
below to keep payloads small. below to keep payloads small.
POST /calendar/<pk>/holidays/ — add a single holiday. POST /calendar/<pk>/holidays/: add a single holiday.
DELETE /calendar/<pk>/holidays/<hid>/ — remove a single holiday. DELETE /calendar/<pk>/holidays/<hid>/, remove a single holiday.
""" """
from datetime import time from datetime import time

View file

@ -1,4 +1,4 @@
"""Who may do what to a case — one place, three rules. """Who may do what to a case, one place, three rules.
Before this module the question was answered in five places and had drifted Before this module the question was answered in five places and had drifted
into three different answers: into three different answers:
@ -6,8 +6,8 @@ into three different answers:
* `CaseListView` and `watcher_views._visible_cases_qs` said **admin, creator, * `CaseListView` and `watcher_views._visible_cases_qs` said **admin, creator,
assignee or watcher**. assignee or watcher**.
* `CaseDetailView.get` / `.put` / `.patch` / `.post` and * `CaseDetailView.get` / `.put` / `.patch` / `.post` and
`CaseActivityListView` said **admin, creator or assignee** — no watcher. `CaseActivityListView` said **admin, creator or assignee**, no watcher.
* `CaseDetailView.delete` said **admin or creator** — no assignee either. * `CaseDetailView.delete` said **admin or creator**, no assignee either.
The visible consequence was a queue that lied. A watcher's ticket list showed The visible consequence was a queue that lied. A watcher's ticket list showed
the one case they follow; opening it answered 403. That is the same shape of the one case they follow; opening it answered 403. That is the same shape of
@ -21,7 +21,7 @@ deleting are three rules on three lines, deliberately not one:
write admin · creator · assignee write admin · creator · assignee
delete admin · creator delete admin · creator
Watching is subscribing to a ticket, not being handed the keys to it — so the Watching is subscribing to a ticket, not being handed the keys to it, so the
watcher clause that was missing from reads is not quietly added to writes. watcher clause that was missing from reads is not quietly added to writes.
""" """
@ -41,7 +41,7 @@ def is_org_admin(profile):
def visible_cases_qs(profile): def visible_cases_qs(profile):
"""Cases ``profile`` is allowed to open — the queryset form of `read`. """Cases ``profile`` is allowed to open. The queryset form of `read`.
The watcher clause is what lets somebody keep following a ticket after The watcher clause is what lets somebody keep following a ticket after
they are unassigned, and it is the clause the detail view was missing. they are unassigned, and it is the clause the detail view was missing.
@ -58,7 +58,7 @@ def get_case_or_404(profile, pk):
"""Fetch a case in the requester's org, or raise ``Http404``. """Fetch a case in the requester's org, or raise ``Http404``.
``Case.id`` is a UUID column, so ``filter(id="nobody")`` raises Django's ``Case.id`` is a UUID column, so ``filter(id="nobody")`` raises Django's
``ValidationError`` rather than returning nothing — which surfaced as a ``ValidationError`` rather than returning nothing, which surfaced as a
500 on every verb for any id that was not a well-formed UUID. A bad id is 500 on every verb for any id that was not a well-formed UUID. A bad id is
a request for a case that does not exist; that is a 404. a request for a case that does not exist; that is a 404.
""" """

View file

@ -491,7 +491,7 @@ def case_ids_for_metric(
) )
if metric == "backlog": if metric == "backlog":
# Bucket is a YYYY-MM-DD date — return cases open at end of that day. # Bucket is a YYYY-MM-DD date. Return cases open at end of that day.
if not bucket: if not bucket:
raise ValueError("backlog drilldown requires a bucket=YYYY-MM-DD") raise ValueError("backlog drilldown requires a bucket=YYYY-MM-DD")
target = date.fromisoformat(bucket) target = date.fromisoformat(bucket)
@ -529,7 +529,7 @@ def case_ids_for_metric(
# whole "service health" page in one admin-only call so the frontend does not # whole "service health" page in one admin-only call so the frontend does not
# fan out five requests. Elapsed times are wall-clock, consistent with the # fan out five requests. Elapsed times are wall-clock, consistent with the
# compute_* functions above (business hours inform SLA *deadlines*, not the # compute_* functions above (business hours inform SLA *deadlines*, not the
# elapsed math here — the same caveat the rest of this module already lives # elapsed math here, the same caveat the rest of this module already lives
# with). The caller admin-gates this, so `qs` is the full org queryset with no # with). The caller admin-gates this, so `qs` is the full org queryset with no
# per-user visibility narrowing. # per-user visibility narrowing.
@ -543,7 +543,7 @@ def _business_hours_state(org_id) -> tuple[Optional[str], bool]:
Mirrors the SLA engine's own fallback (`business_hours.calendar`): no Mirrors the SLA engine's own fallback (`business_hours.calendar`): no
calendar, or a calendar with no open weekday window, means 24/7 calendar, or a calendar with no open weekday window, means 24/7
wall-clock — reported here as not-applied with a null name. wall-clock, reported here as not-applied with a null name.
""" """
from business_hours.calendar import get_default_calendar from business_hours.calendar import get_default_calendar
@ -593,7 +593,7 @@ def _first_response_by_priority(created_rows, now) -> list[dict]:
for _id, created_at, first_response_at, sla_hours, priority, _ctype in created_rows: for _id, created_at, first_response_at, sla_hours, priority, _ctype in created_rows:
bucket = per.get(priority) bucket = per.get(priority)
if bucket is None: if bucket is None:
# Unknown priority string — skip rather than invent a column. # Unknown priority string: skip rather than invent a column.
continue continue
_accumulate_frt(bucket, created_at, first_response_at, sla_hours, now) _accumulate_frt(bucket, created_at, first_response_at, sla_hours, now)
@ -661,7 +661,7 @@ def _agent_table(qs, from_dt, to_dt, now) -> list[dict]:
if pid is not None: if pid is not None:
_accumulate_frt(bucket(pid), created_at, fra, sla_hours, now) _accumulate_frt(bucket(pid), created_at, fra, sla_hours, now)
# Unassigned bucket — explicit isnull filters (see docstring). # Unassigned bucket: explicit isnull filters (see docstring).
un = {"open": 0, "closed_this_week": 0, "frt_minutes": [], "breached": 0, "met": 0} un = {"open": 0, "closed_this_week": 0, "frt_minutes": [], "breached": 0, "met": 0}
un["open"] = open_qs.filter(assigned_to__isnull=True).count() un["open"] = open_qs.filter(assigned_to__isnull=True).count()
un["closed_this_week"] = closed_week_qs.filter(assigned_to__isnull=True).count() un["closed_this_week"] = closed_week_qs.filter(assigned_to__isnull=True).count()

View file

@ -51,7 +51,7 @@ def _filtered_qs(request) -> tuple[object, datetime, datetime]:
"""Build the org-scoped Case queryset + (from, to) window from query params. """Build the org-scoped Case queryset + (from, to) window from query params.
Visibility: admins see every case; non-admins see only those they Visibility: admins see every case; non-admins see only those they
created, are assigned to, or watch — mirrors `CaseListView.get_queryset`. created, are assigned to, or watch. Mirrors `CaseListView.get_queryset`.
""" """
profile = request.profile profile = request.profile
params = request.query_params params = request.query_params
@ -140,8 +140,8 @@ class AnalyticsServiceView(_AnalyticsBaseView):
Returns org-wide service KPIs (totals, per-day volume, first-response Returns org-wide service KPIs (totals, per-day volume, first-response
attainment by priority, case-type mix, per-agent table). Unlike the attainment by priority, case-type mix, per-agent table). Unlike the
per-metric endpoints above — which narrow a non-admin to their own cases per-metric endpoints above, which narrow a non-admin to their own cases
and would therefore show a personal slice under org-wide headings — this and would therefore show a personal slice under org-wide headings. This
view is an org-aggregate management surface, so it 403s non-admins outright view is an org-aggregate management surface, so it 403s non-admins outright
(mirrors the invoices/reports policy for whole-org figures). (mirrors the invoices/reports policy for whole-org figures).
""" """
@ -202,7 +202,7 @@ class AnalyticsDrilldownView(_AnalyticsBaseView):
return Response({"count": len(data), "results": data}) return Response({"count": len(data), "results": data})
# CSV columns kept tight on purpose — analyst pivots externally. # CSV columns kept tight on purpose: analyst pivots externally.
_CSV_COLUMNS = ( _CSV_COLUMNS = (
("id", "ID"), ("id", "ID"),
("name", "Name"), ("name", "Name"),

View file

@ -11,7 +11,7 @@ Two flavors of endpoint live here:
Each transition writes a ``common.Activity`` row using the verbs registered in Each transition writes a ``common.Activity`` row using the verbs registered in
``common/models.Activity.ACTION_CHOICES`` (``APPROVAL_REQUESTED``, ``common/models.Activity.ACTION_CHOICES`` (``APPROVAL_REQUESTED``,
``APPROVED``, ``REJECTED``, ``APPROVAL_CANCELLED``). Two approvers cannot ``APPROVED``, ``REJECTED``, ``APPROVAL_CANCELLED``). Two approvers cannot
double-approve the same row — every transition takes a row lock first. double-approve the same row. Every transition takes a row lock first.
""" """
from __future__ import annotations from __future__ import annotations
@ -180,7 +180,7 @@ class ApprovalRuleDetailView(APIView):
{"error": True, "errors": "Rule not found"}, {"error": True, "errors": "Rule not found"},
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_404_NOT_FOUND,
) )
# Hard delete is fine — Approval.rule is on_delete=PROTECT, so any # Hard delete is fine. Approval.rule is on_delete=PROTECT, so any
# historical request keeps the rule alive automatically. # historical request keeps the rule alive automatically.
if rule.requests.exists(): if rule.requests.exists():
# Soft-disable instead of hard-delete when there is history. # Soft-disable instead of hard-delete when there is history.
@ -199,7 +199,7 @@ class ApprovalRuleDetailView(APIView):
class CaseRequestApprovalView(APIView): class CaseRequestApprovalView(APIView):
"""``POST /api/cases/<pk>/request-approval/`` — agent fires a new request.""" """``POST /api/cases/<pk>/request-approval/``, agent fires a new request."""
permission_classes = (IsAuthenticated, HasOrgContext) permission_classes = (IsAuthenticated, HasOrgContext)
@ -280,11 +280,11 @@ class CaseRequestApprovalView(APIView):
class ApprovalInboxView(APIView): class ApprovalInboxView(APIView):
"""``GET /api/cases/approvals/`` — list approvals. """``GET /api/cases/approvals/``, list approvals.
Query params: Query params:
* ``state`` (default ``pending``) — filter by state, or ``all`` to drop the filter. * ``state`` (default ``pending``): filter by state, or ``all`` to drop the filter.
* ``mine`` (``true``/``false``) — when true, restrict to approvals the * ``mine`` (``true``/``false``), when true, restrict to approvals the
caller can act on (in rule.approvers OR matches rule.approver_role). caller can act on (in rule.approvers OR matches rule.approver_role).
""" """
@ -322,8 +322,8 @@ class ApprovalInboxView(APIView):
) )
rows = list(qs) rows = list(qs)
if mine: if mine:
# "Mine" = rows I can act on right now, which — like the approve # "Mine" = rows I can act on right now, which, like the approve
# endpoint — excludes requests I filed myself (separation of duties). # endpoint, excludes requests I filed myself (separation of duties).
profile = request.profile profile = request.profile
rows = [ rows = [
a a

View file

@ -3,10 +3,10 @@ Tier 3 approval workflows.
Two org-scoped models live here: Two org-scoped models live here:
* ``ApprovalRule`` — admin-configurable predicate. When an active rule matches * ``ApprovalRule``: admin-configurable predicate. When an active rule matches
a case (priority + case_type + team filters), the close transition is gated a case (priority + case_type + team filters), the close transition is gated
until an ``Approval`` row in state ``approved`` exists. until an ``Approval`` row in state ``approved`` exists.
* ``Approval`` — one row per request. State machine: * ``Approval``: one row per request. State machine:
``pending`` -> ``approved`` | ``rejected`` | ``cancelled``. ``pending`` -> ``approved`` | ``rejected`` | ``cancelled``.
Both models follow ``COORDINATION_DECISIONS.md`` D2: inherit ``BaseModel`` and Both models follow ``COORDINATION_DECISIONS.md`` D2: inherit ``BaseModel`` and
@ -24,7 +24,7 @@ from common.utils import CASE_TYPE, PRIORITY_CHOICE
# Approver roles. Mirrors the spec's ``ADMIN``/``MANAGER`` choices even though # Approver roles. Mirrors the spec's ``ADMIN``/``MANAGER`` choices even though
# the project's ``Profile.role`` only knows ``ADMIN``/``USER`` today — keeping # the project's ``Profile.role`` only knows ``ADMIN``/``USER`` today, keeping
# ``MANAGER`` as a reserved value avoids a future schema change when the role # ``MANAGER`` as a reserved value avoids a future schema change when the role
# model expands. Today, MANAGER simply matches no profiles. # model expands. Today, MANAGER simply matches no profiles.
APPROVER_ROLE_CHOICES = ( APPROVER_ROLE_CHOICES = (

View file

@ -91,7 +91,7 @@ class BulkDeleteCasesView(APIView):
) )
org = request.profile.org org = request.profile.org
# Snapshot the cases so we can emit Activity rows after the bulk update # Snapshot the cases so we can emit Activity rows after the bulk update
# — queryset.update() bypasses signals. #; queryset.update() bypasses signals.
qs = Case.objects.filter(pk__in=ids, org=org, is_active=True) qs = Case.objects.filter(pk__in=ids, org=org, is_active=True)
target_cases = list(qs.values("id", "name")) target_cases = list(qs.values("id", "name"))
deleted_count = qs.update(is_active=False) deleted_count = qs.update(is_active=False)

View file

@ -1,4 +1,4 @@
"""CSAT endpoints — public (token-scoped) + internal (aggregate). """CSAT endpoints, public (token-scoped) + internal (aggregate).
The public GET/POST pair is hit anonymously from a customer's email link. The public GET/POST pair is hit anonymously from a customer's email link.
There is no agent JWT, no portal session, and we deliberately do not There is no agent JWT, no portal session, and we deliberately do not
@ -110,7 +110,7 @@ class PublicCsatView(APIView):
window_close = survey.responded_at + timedelta(hours=EDIT_WINDOW_HOURS) window_close = survey.responded_at + timedelta(hours=EDIT_WINDOW_HOURS)
if timezone.now() >= window_close: if timezone.now() >= window_close:
return Response( return Response(
{"error": "Survey is locked — edit window has closed."}, {"error": "Survey is locked. Edit window has closed."},
status=drf_status.HTTP_409_CONFLICT, status=drf_status.HTTP_409_CONFLICT,
) )

View file

@ -48,7 +48,7 @@ def _breach_counts_last_30d(org):
"""Count SLA breaches per priority among cases OPENED in the last 30 days. """Count SLA breaches per priority among cases OPENED in the last 30 days.
The escalation page shows these under each priority's policy AND, crucially, The escalation page shows these under each priority's policy AND, crucially,
keeps showing them when the policy is dead (off, or no recipient) — that is keeps showing them when the policy is dead (off, or no recipient). That is
the entire point of the screen ("breaches told nobody"). So this cannot be the entire point of the screen ("breaches told nobody"). So this cannot be
sourced from the ``ESCALATED`` Activity log: ``scan_for_breached_cases`` sourced from the ``ESCALATED`` Activity log: ``scan_for_breached_cases``
records an activity only when a policy actually acts (active + a target set), records an activity only when a policy actually acts (active + a target set),
@ -63,12 +63,12 @@ def _breach_counts_last_30d(org):
Anchored on ``created_at`` (a standard SLA cohort framing) so the scan stays Anchored on ``created_at`` (a standard SLA cohort framing) so the scan stays
bounded and index-friendly. Not captured: a case opened before the window bounded and index-friendly. Not captured: a case opened before the window
whose deadline slipped inside it via a long Pending pause — rare; noted, not whose deadline slipped inside it via a long Pending pause, rare; noted, not
counted. counted.
""" """
now = timezone.now() now = timezone.now()
cutoff = now - timedelta(days=BREACH_WINDOW_DAYS) cutoff = now - timedelta(days=BREACH_WINDOW_DAYS)
# One calendar for the whole org — resolved once, not per case. # One calendar for the whole org: resolved once, not per case.
calendar = get_default_calendar(org.id) calendar = get_default_calendar(org.id)
counts = {} counts = {}
qs = Case.objects.filter(org=org, is_active=True, created_at__gte=cutoff) qs = Case.objects.filter(org=org, is_active=True, created_at__gte=cutoff)
@ -194,7 +194,7 @@ class EscalationPolicyDetailView(APIView):
{"error": True, "errors": "Escalation policy not found"}, {"error": True, "errors": "Escalation policy not found"},
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_404_NOT_FOUND,
) )
# `priority` is the natural key — disallow changing it post-create # `priority` is the natural key. Disallow changing it post-create
# (would collide with the (org, priority) unique constraint anyway). # (would collide with the (org, priority) unique constraint anyway).
data = {k: v for k, v in request.data.items() if k != "priority"} data = {k: v for k, v in request.data.items() if k != "priority"}
serializer = EscalationPolicySerializer( serializer = EscalationPolicySerializer(

View file

@ -108,7 +108,7 @@ def _extract_bodies(msg: StdlibEmailMessage) -> tuple[str, str]:
continue continue
try: try:
content = part.get_content() content = part.get_content()
except Exception: # pragma: no cover — corrupted MIME except Exception: # pragma: no cover, corrupted MIME
continue continue
if not isinstance(content, str): if not isinstance(content, str):
continue continue

View file

@ -5,7 +5,7 @@ Given a parsed email and the mailbox it arrived on, decide:
2. Does it belong to an existing Case (threading match)? 2. Does it belong to an existing Case (threading match)?
3. Otherwise: who's the contact, and we create a new Case. 3. Otherwise: who's the contact, and we create a new Case.
Always records exactly one `EmailMessage` row — even drops — so admins have a Always records exactly one `EmailMessage` row, even drops, so admins have a
forensic trail. forensic trail.
""" """
@ -74,7 +74,7 @@ def ingest(parsed: ParsedEmail, mailbox: InboundMailbox) -> IngestResult:
Wraps DB writes in a transaction so a partial run leaves no half-written Wraps DB writes in a transaction so a partial run leaves no half-written
Case behind. Webhooks should ack with 200 even when this returns Case behind. Webhooks should ack with 200 even when this returns
`dropped=True` — the provider already accepted the message and a 4xx `dropped=True`: the provider already accepted the message and a 4xx
would just trigger pointless retries. would just trigger pointless retries.
""" """
drop, reason = should_drop(parsed) drop, reason = should_drop(parsed)
@ -92,7 +92,7 @@ def ingest(parsed: ParsedEmail, mailbox: InboundMailbox) -> IngestResult:
) )
if not parsed.message_id: if not parsed.message_id:
# Treat a missing Message-ID as an auto_submitted-style drop — without # Treat a missing Message-ID as an auto_submitted-style drop. Without
# one we can't thread or de-dupe, and legitimate human-sent mail always # one we can't thread or de-dupe, and legitimate human-sent mail always
# has it. Fabricate a synthetic id for the audit row. # has it. Fabricate a synthetic id for the audit row.
synthetic_id = f"missing-msgid-{mailbox.id}-{parsed.received_at.isoformat()}" synthetic_id = f"missing-msgid-{mailbox.id}-{parsed.received_at.isoformat()}"

View file

@ -26,8 +26,8 @@ Three controls keep that pinning honest (CodeQL `py/full-ssrf` #27/#28):
fetch; following a 3xx would land us somewhere never checked. `_OPENER` fetch; following a 3xx would land us somewhere never checked. `_OPENER`
surfaces the redirect as an error instead of chasing it. surfaces the redirect as an error instead of chasing it.
* **Strict TLS.** The fetch uses an explicit default-verifying SSL context, so * **Strict TLS.** The fetch uses an explicit default-verifying SSL context, so
transport trust is anchored to the CA store and can't silently degrade. This transport trust is anchored to the CA store and can't silently degrade. This,
— not the signing cert's own issuer chain — is what proves the PEM really not the signing cert's own issuer chain, is what proves the PEM really
came from AWS. came from AWS.
* **Certificate validation.** The fetched cert must be inside its validity * **Certificate validation.** The fetched cert must be inside its validity
window and carry a subject CN in the same SNS host family, so a stale or window and carry a subject CN in the same SNS host family, so a stale or
@ -147,8 +147,8 @@ def _fetch_signing_cert(url: str, *, timeout: float = 5.0) -> bytes:
if not parsed.path.endswith(".pem"): if not parsed.path.endswith(".pem"):
raise SNSVerificationError(f"SigningCertURL must end in .pem: {url!r}") raise SNSVerificationError(f"SigningCertURL must end in .pem: {url!r}")
# `_OPENER` refuses redirects, so the host checked above is the host we talk # `_OPENER` refuses redirects, so the host checked above is the host we talk
# to — the pinning cannot be sidestepped by a 3xx. # to. The pinning cannot be sidestepped by a 3xx.
with _OPENER.open(url, timeout=timeout) as response: # noqa: S310 — host pinned with _OPENER.open(url, timeout=timeout) as response: # noqa: S310. Host pinned
return response.read() return response.read()
@ -214,7 +214,7 @@ def verify_sns_message(
try: try:
cert = load_pem_x509_certificate(cert_bytes) cert = load_pem_x509_certificate(cert_bytes)
except ValueError as exc: except ValueError as exc:
# Malformed PEM is an attacker-shaped input, not a server fault — a 403 # Malformed PEM is an attacker-shaped input, not a server fault. A 403
# from the caller beats a 500. # from the caller beats a 500.
raise SNSVerificationError( raise SNSVerificationError(
f"Signing certificate is not valid PEM: {exc}" f"Signing certificate is not valid PEM: {exc}"
@ -236,7 +236,7 @@ def verify_sns_message(
) )
except InvalidSignature as exc: except InvalidSignature as exc:
raise SNSVerificationError("Signature does not match") from exc raise SNSVerificationError("Signature does not match") from exc
except Exception as exc: # pragma: no cover — non-RSA cert is impossible in practice except Exception as exc: # pragma: no cover. Non-RSA cert is impossible in practice
raise SNSVerificationError(f"Verification failed: {exc}") from exc raise SNSVerificationError(f"Verification failed: {exc}") from exc
@ -259,7 +259,7 @@ def confirm_subscription(payload: dict, *, fetch=None, timeout: float = 5.0) ->
# stays the single place redirect policy is decided. # stays the single place redirect policy is decided.
fetch = fetch or _OPENER.open fetch = fetch or _OPENER.open
try: try:
with fetch(url, timeout=timeout) as response: # noqa: S310 — host pinned with fetch(url, timeout=timeout) as response: # noqa: S310. Host pinned
response.read() response.read()
except Exception: except Exception:
logger.exception("Failed to confirm SNS subscription at %s", url) logger.exception("Failed to confirm SNS subscription at %s", url)

View file

@ -106,7 +106,7 @@ def find_existing_case(parsed: ParsedEmail, org) -> Optional[Case]:
return None return None
# A tiny helper used by the pipeline when a brand-new Case is created — the # A tiny helper used by the pipeline when a brand-new Case is created. The
# Case.external_thread_id should be the Message-ID of the email that birthed it. # Case.external_thread_id should be the Message-ID of the email that birthed it.
def short_case_id(case: Case) -> str: def short_case_id(case: Case) -> str:
"""8-char prefix used in subject-line fallback threading.""" """8-char prefix used in subject-line fallback threading."""

View file

@ -1,7 +1,7 @@
"""Inbound email webhook + admin mailbox CRUD endpoints. """Inbound email webhook + admin mailbox CRUD endpoints.
The webhook is intentionally public (no auth). Trust rests on two checks, and The webhook is intentionally public (no auth). Trust rests on two checks, and
it takes both — the signature alone is not enough: it takes both. The signature alone is not enough:
1. `verify_sns_message` proves the payload was signed by AWS SNS. That rules 1. `verify_sns_message` proves the payload was signed by AWS SNS. That rules
out arbitrary JSON posted at the URL, but note what it does *not* prove: out arbitrary JSON posted at the URL, but note what it does *not* prove:
@ -9,7 +9,7 @@ it takes both — the signature alone is not enough:
says the message came from *some* topic in *some* account. says the message came from *some* topic in *some* account.
2. The TopicArn pin (`InboundMailbox.topic_arn`) proves it came from *this 2. The TopicArn pin (`InboundMailbox.topic_arn`) proves it came from *this
mailbox's* topic. Without it, anyone who learned a mailbox UUID could point mailbox's* topic. Without it, anyone who learned a mailbox UUID could point
their own SNS topic here and have AWS sign forged mail for them — which, their own SNS topic here and have AWS sign forged mail for them, which,
because the pipeline threads replies onto existing cases, means injecting because the pipeline threads replies onto existing cases, means injecting
messages into live customer conversations, not just spam tickets. messages into live customer conversations, not just spam tickets.
@ -66,7 +66,7 @@ def _mailbox_analytics(org):
at the address for an older case is not a new ticket, so we anchor on the at the address for an older case is not a new ticket, so we anchor on the
case's ``created_at``). case's ``created_at``).
- ``last_received_at`` per mailbox = the newest inbound message's - ``last_received_at`` per mailbox = the newest inbound message's
``received_at`` (any message, including dropped ones — the address still ``received_at`` (any message, including dropped ones, the address still
received mail). received mail).
- org ``cases_last_30d`` = distinct such cases across all mailboxes (not a - org ``cases_last_30d`` = distinct such cases across all mailboxes (not a
sum, so a case cross-posted to two addresses is not double counted). sum, so a case cross-posted to two addresses is not double counted).
@ -102,7 +102,7 @@ def _admin_required():
def _topic_rejected(): def _topic_rejected():
"""Deliberately as opaque as the signature failure — a caller probing the """Deliberately as opaque as the signature failure. A caller probing the
webhook shouldn't learn whether a mailbox is pinned or to what.""" webhook shouldn't learn whether a mailbox is pinned or to what."""
return Response( return Response(
{"error": True, "errors": "Signature verification failed"}, {"error": True, "errors": "Signature verification failed"},
@ -114,7 +114,7 @@ class InboundMailboxWebhookView(APIView):
"""Public endpoint where AWS SNS POSTs for one configured mailbox. """Public endpoint where AWS SNS POSTs for one configured mailbox.
URL: `/api/cases/inbound/<mailbox_id>/`. The mailbox lookup also acts as URL: `/api/cases/inbound/<mailbox_id>/`. The mailbox lookup also acts as
the org boundary — the URL embeds the per-mailbox UUID so the webhook the org boundary, the URL embeds the per-mailbox UUID so the webhook
can't be confused for one belonging to a different tenant. can't be confused for one belonging to a different tenant.
""" """
@ -171,7 +171,7 @@ class InboundMailboxWebhookView(APIView):
status=status.HTTP_501_NOT_IMPLEMENTED, status=status.HTTP_501_NOT_IMPLEMENTED,
) )
# SNS posts the JSON body in `request.body` — DRF may have parsed it. # SNS posts the JSON body in `request.body`. DRF may have parsed it.
try: try:
payload = ( payload = (
request.data request.data
@ -235,7 +235,7 @@ class InboundMailboxWebhookView(APIView):
) )
return Response({"ok": True, "subscribed": True}) return Response({"ok": True, "subscribed": True})
if msg_type == "UnsubscribeConfirmation": # pragma: no cover — informational if msg_type == "UnsubscribeConfirmation": # pragma: no cover: informational
logger.info("SNS unsubscribe for mailbox=%s", mailbox.id) logger.info("SNS unsubscribe for mailbox=%s", mailbox.id)
return Response({"ok": True, "unsubscribed": True}) return Response({"ok": True, "unsubscribed": True})

View file

@ -1,4 +1,4 @@
"""Who may do what to a knowledge-base article — three rules, beside the """Who may do what to a knowledge-base article, three rules, beside the
three in `cases.access` and for the same reason. three in `cases.access` and for the same reason.
Before this module the Solution endpoints had **no authorization at all**. Before this module the Solution endpoints had **no authorization at all**.
@ -13,7 +13,7 @@ profile: `PATCH` a colleague's article 200, `POST .../publish/` 200,
delete author · admin delete author · admin
**Why `release` is narrower than `write`.** The model already carries a review **Why `release` is narrower than `write`.** The model already carries a review
workflow — draft → reviewed → approved, plus a separate `is_published` — and workflow (draft → reviewed → approved, plus a separate `is_published`), and
the whole point of it is that somebody other than the writer says the answer is the whole point of it is that somebody other than the writer says the answer is
right before customers are shown it. If the author can approve their own right before customers are shown it. If the author can approve their own
article the workflow is theatre; the rules, the states and the UI would all article the workflow is theatre; the rules, the states and the UI would all
@ -23,8 +23,8 @@ hypothetical shape of bug for this codebase.
`read` is deliberately absent: every member of the org may read every article. `read` is deliberately absent: every member of the org may read every article.
A knowledge base whose articles are hidden from the agents answering the A knowledge base whose articles are hidden from the agents answering the
tickets is not a knowledge base. `is_published` is not an access rule either — tickets is not a knowledge base. `is_published` is not an access rule either.
it is the customer-visibility switch, and the customer-facing site it gates was It is the customer-visibility switch, and the customer-facing site it gates was
explicitly cut (see `kb_views`), so today it gates the agent suggester only. explicitly cut (see `kb_views`), so today it gates the agent suggester only.
""" """
@ -43,8 +43,8 @@ def get_solution_or_404(profile, pk):
``Solution.id`` is a UUID column, so ``filter(pk="nobody")`` raises ``Solution.id`` is a UUID column, so ``filter(pk="nobody")`` raises
Django's ``ValidationError`` instead of returning nothing. The views Django's ``ValidationError`` instead of returning nothing. The views
caught ``Solution.DoesNotExist`` and nothing else, so every verb — read, caught ``Solution.DoesNotExist`` and nothing else, so every verb. Read,
update, delete, publish, unpublish — answered 500 for any id that was not update, delete, publish, unpublish. Answered 500 for any id that was not
a well-formed UUID. A bad id names an article that does not exist. a well-formed UUID. A bad id names an article that does not exist.
""" """
try: try:

View file

@ -1,7 +1,7 @@
"""Agent-facing knowledge-base helpers. """Agent-facing knowledge-base helpers.
The full kb-frontend spec calls for a customer-facing KB site too. We The full kb-frontend spec calls for a customer-facing KB site too. We
ship only the agent-side suggester here — see ship only the agent-side suggester here. See
docs/cases/tier2/IMPLEMENTATION_STATUS.md "kb-frontend" section for the docs/cases/tier2/IMPLEMENTATION_STATUS.md "kb-frontend" section for the
deliberate cut. The endpoint feeds the comment composer's typeahead. deliberate cut. The endpoint feeds the comment composer's typeahead.
""" """
@ -54,7 +54,7 @@ class SolutionSuggestionsView(APIView):
"""`GET /api/cases/<pk>/solution-suggestions/?q=&limit=`. """`GET /api/cases/<pk>/solution-suggestions/?q=&limit=`.
Returns the top N published solutions in the same org whose title or Returns the top N published solutions in the same org whose title or
description matches the search term. `?q=` is optional — when blank, description matches the search term. `?q=` is optional, when blank,
the case's own name + description seed the search so the panel is the case's own name + description seed the search so the panel is
useful on first focus. useful on first focus.
""" """
@ -93,8 +93,8 @@ class SolutionSuggestionsView(APIView):
else: else:
# Seed from the case so the panel is useful on first focus. If # Seed from the case so the panel is useful on first focus. If
# there are no seed terms, or the seed-term filter produces zero # there are no seed terms, or the seed-term filter produces zero
# matches, fall back to the most-recent published solutions — # matches, fall back to the most-recent published solutions.
# the agent should always see *something* on first focus. # The agent should always see *something* on first focus.
terms = _seed_terms(case) terms = _seed_terms(case)
results = [] results = []
if terms: if terms:

View file

@ -1,4 +1,4 @@
# Resolution-window scans for MTTR / SLA breach aggregations — see # Resolution-window scans for MTTR / SLA breach aggregations. See
# docs/cases/tier2/reporting.md "Data model changes". The existing # docs/cases/tier2/reporting.md "Data model changes". The existing
# (org, -created_at) index doesn't cover queries that filter by resolved_at. # (org, -created_at) index doesn't cover queries that filter by resolved_at.

View file

@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='case', model_name='case',
name='is_sample', name='is_sample',
field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively — never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'),
), ),
] ]

View file

@ -265,7 +265,7 @@ class Case(AssignableMixin, BaseModel):
"""Auto-set SLA values based on priority for new cases.""" """Auto-set SLA values based on priority for new cases."""
from .workflow import DEFAULT_FIRST_RESPONSE_SLA, DEFAULT_RESOLUTION_SLA from .workflow import DEFAULT_FIRST_RESPONSE_SLA, DEFAULT_RESOLUTION_SLA
# Use `_state.adding` rather than `not self.pk` — BaseModel populates # Use `_state.adding` rather than `not self.pk`, BaseModel populates
# `id` with a UUID default at __init__, so `self.pk` is truthy long # `id` with a UUID default at __init__, so `self.pk` is truthy long
# before the first INSERT. # before the first INSERT.
if self._state.adding: if self._state.adding:
@ -299,7 +299,7 @@ class Case(AssignableMixin, BaseModel):
return None return None
deadline = add_business_hours(self.created_at, hours, self._sla_calendar()) deadline = add_business_hours(self.created_at, hours, self._sla_calendar())
# Customer wait time accumulated while Pending shifts the deadline # Customer wait time accumulated while Pending shifts the deadline
# forward verbatim — we don't try to walk this through business hours # forward verbatim. We don't try to walk this through business hours
# again because it represents real elapsed time the agent had to wait, # again because it represents real elapsed time the agent had to wait,
# not a target that respects the calendar. # not a target that respects the calendar.
paused = self.sla_paused_seconds or 0 paused = self.sla_paused_seconds or 0
@ -344,7 +344,7 @@ class CaseWatcher(BaseModel):
"""A profile subscribed to updates on a case. """A profile subscribed to updates on a case.
Per `docs/cases/COORDINATION_DECISIONS.md` D2 we inherit BaseModel and Per `docs/cases/COORDINATION_DECISIONS.md` D2 we inherit BaseModel and
declare our own org FK rather than using BaseOrgModel — RLS still declare our own org FK rather than using BaseOrgModel. RLS still
applies via the migration that adds the `case_watcher` table. applies via the migration that adds the `case_watcher` table.
""" """
@ -947,7 +947,7 @@ class TimeEntry(BaseModel):
Per `docs/cases/COORDINATION_DECISIONS.md` D2 we inherit BaseModel and Per `docs/cases/COORDINATION_DECISIONS.md` D2 we inherit BaseModel and
declare our own org FK; RLS is enforced via the migration policy. The declare our own org FK; RLS is enforced via the migration policy. The
`one_active_timer_per_profile` partial unique index guarantees a profile `one_active_timer_per_profile` partial unique index guarantees a profile
cannot have two running timers concurrently — the start endpoint relies cannot have two running timers concurrently. The start endpoint relies
on this constraint as a race-defense in addition to its own existence on this constraint as a race-defense in addition to its own existence
check. check.
""" """

View file

@ -42,14 +42,14 @@ def case_link(case_id) -> str:
The model and this app call them cases; every client calls them tickets The model and this app call them cases; every client calls them tickets
and routes them at `/tickets/<id>`. This used to emit `/cases/<id>`, which and routes them at `/tickets/<id>`. This used to emit `/cases/<id>`, which
no client has ever served — the SvelteKit app has no `/cases` route at no client has ever served. The SvelteKit app has no `/cases` route at
all, and its notification panel assigns `link` straight to all, and its notification panel assigns `link` straight to
`window.location.href`, so every notification ever sent landed on a 404. `window.location.href`, so every notification ever sent landed on a 404.
It lives in one function because the fix belongs here rather than in the It lives in one function because the fix belongs here rather than in the
readers: the same rows are consumed by the web panel, the notifications readers: the same rows are consumed by the web panel, the notifications
page and the Flutter client, and a rewrite in each of them is three places page and the Flutter client, and a rewrite in each of them is three places
to forget. `Notification.link` is a client path, not an API path — do not to forget. `Notification.link` is a client path, not an API path. Do not
prefix it with `/api/`. prefix it with `/api/`.
""" """
return f"/tickets/{case_id}" return f"/tickets/{case_id}"
@ -156,7 +156,7 @@ def dispatch_for_comment(comment, case, actor: Profile | None = None) -> dict:
) )
mentioned.append(p) mentioned.append(p)
# Fan out `case.commented` to remaining watchers — exclude actor and # Fan out `case.commented` to remaining watchers, exclude actor and
# anyone who just got `case.mentioned` (they don't need both for the # anyone who just got `case.mentioned` (they don't need both for the
# same comment). # same comment).
skip_ids = {p.id for p in mentioned} skip_ids = {p.id for p in mentioned}

View file

@ -3,9 +3,9 @@ Parent/child case endpoints (Tier 3 parent-child).
Three endpoints over and above what `CaseDetailView` already exposes: Three endpoints over and above what `CaseDetailView` already exposes:
* ``GET /api/cases/<pk>/tree/`` — descendant tree, max depth 3. * ``GET /api/cases/<pk>/tree/``: descendant tree, max depth 3.
* ``POST /api/cases/<pk>/link/`` — set/clear parent with explicit audit row. * ``POST /api/cases/<pk>/link/``: set/clear parent with explicit audit row.
* ``POST /api/cases/<pk>/close-with-children/`` — close parent and (optionally) cascade-close descendants. * ``POST /api/cases/<pk>/close-with-children/``: close parent and (optionally) cascade-close descendants.
The link endpoint takes a row lock on both rows so two concurrent agents cannot The link endpoint takes a row lock on both rows so two concurrent agents cannot
build a cycle. Cascade close honours ``Org.auto_close_children_on_parent_close`` build a cycle. Cascade close honours ``Org.auto_close_children_on_parent_close``

View file

@ -11,7 +11,7 @@ creates a Case. It runs inside the caller's transaction, so the assignment is
committed atomically with the Case row. committed atomically with the Case row.
`dry_run=True` runs the same matching/strategy logic but does NOT mutate `dry_run=True` runs the same matching/strategy logic but does NOT mutate
state — used by the `test/` API endpoint to preview which agent a sample Case state, used by the `test/` API endpoint to preview which agent a sample Case
would route to. would route to.
""" """
@ -80,10 +80,10 @@ def _match_condition(case_data: dict, condition: dict) -> bool:
op = condition.get("op", "eq") op = condition.get("op", "eq")
value = condition.get("value") value = condition.get("value")
if op not in SUPPORTED_OPS: if op not in SUPPORTED_OPS:
logger.warning("RoutingRule unknown op %r — skipping condition", op) logger.warning("RoutingRule unknown op %r. Skipping condition", op)
return False return False
if not (f in SUPPORTED_FIELDS or f.startswith("custom_fields.")): if not (f in SUPPORTED_FIELDS or f.startswith("custom_fields.")):
logger.warning("RoutingRule unknown field %r — skipping condition", f) logger.warning("RoutingRule unknown field %r. Skipping condition", f)
return False return False
actual = _resolve_field(case_data, f) actual = _resolve_field(case_data, f)
@ -201,7 +201,7 @@ def _round_robin(rule: RoutingRule, pool: list[Profile], dry_run: bool) -> Profi
def _case_to_dict(case) -> dict: def _case_to_dict(case) -> dict:
"""Snapshot the fields the engine reads — keeps real Case + synthetic """Snapshot the fields the engine reads. Keeps real Case + synthetic
dicts behind the same shape. dicts behind the same shape.
The inbound pipeline attaches `_routing_mailbox_id` and The inbound pipeline attaches `_routing_mailbox_id` and

View file

@ -30,12 +30,12 @@ def _routing_analytics(org):
"""Per-rule match counts + the org's unrouted count over the last 30 days. """Per-rule match counts + the org's unrouted count over the last 30 days.
Both read from the ``ROUTED`` Activity log the engine writes on every match Both read from the ``ROUTED`` Activity log the engine writes on every match
(``cases.routing._apply``) — one row per rule that fired on a case, written (``cases.routing._apply``), one row per rule that fired on a case, written
even when the matched rule had an empty pool or a missing team. That log even when the matched rule had an empty pool or a missing team. That log
records the whole population the page needs (unlike escalation's ESCALATED records the whole population the page needs (unlike escalation's ESCALATED
log), so there is no fork: ``matched_last_30d`` is a straight count per log), so there is no fork: ``matched_last_30d`` is a straight count per
``rule_id`` and ``unrouted_last_30d`` is the cases that got no ROUTED row at ``rule_id`` and ``unrouted_last_30d`` is the cases that got no ROUTED row at
all — i.e. literally no rule matched, which is what "nobody was assigned" all, i.e. literally no rule matched, which is what "nobody was assigned"
on the page means. A rule that matched but could not assign is NOT unrouted. on the page means. A rule that matched but could not assign is NOT unrouted.
Anchored on ``created_at`` (routing runs once, at case creation, so a case's Anchored on ``created_at`` (routing runs once, at case creation, so a case's

View file

@ -116,7 +116,7 @@ class CaseSerializer(serializers.ModelSerializer):
"custom_fields", "custom_fields",
"escalation_count", "escalation_count",
"last_escalation_fired_at", "last_escalation_fired_at",
# SLA — Tier 2 business-hours-sla # SLA: Tier 2 business-hours-sla
"sla_first_response_hours", "sla_first_response_hours",
"sla_resolution_hours", "sla_resolution_hours",
"first_response_at", "first_response_at",
@ -153,8 +153,8 @@ class CaseCreateSerializer(serializers.ModelSerializer):
`account` is a plain model FK, so DRF built it with a queryset of `account` is a plain model FK, so DRF built it with a queryset of
*every* Account row. Posting a stranger's account UUID stored the *every* Account row. Posting a stranger's account UUID stored the
link and — because the create response echoes `cases_obj` through link and, because the create response echoes `cases_obj` through
`CaseSerializer`, which nests `AccountSerializer` — handed the caller `CaseSerializer`, which nests `AccountSerializer`: handed the caller
that account's name, email, phone and website back. So the same hole that account's name, email, phone and website back. So the same hole
was both a cross-tenant write and a cross-tenant read. `validate_parent` was both a cross-tenant write and a cross-tenant read. `validate_parent`
below already guarded the other FK on this serializer; this one had below already guarded the other FK on this serializer; this one had
@ -173,7 +173,7 @@ class CaseCreateSerializer(serializers.ModelSerializer):
API, because DRF does not call `Model.clean()` and nothing on the save API, because DRF does not call `Model.clean()` and nothing on the save
path calls `full_clean()`. Proven live: with a matching rule armed, path calls `full_clean()`. Proven live: with a matching rule armed,
`PATCH {"status": "Closed"}` returned 200, closed the case and `PATCH {"status": "Closed"}` returned 200, closed the case and
recorded zero approvals — the whole approval feature, its inbox and recorded zero approvals, the whole approval feature, its inbox and
its settings page were decoration. its settings page were decoration.
Validating the *transition* rather than the target matters here: a Validating the *transition* rather than the target matters here: a
@ -200,7 +200,7 @@ class CaseCreateSerializer(serializers.ModelSerializer):
if self.instance is not None: if self.instance is not None:
from cases.approvals import Approval, find_matching_rule from cases.approvals import Approval, find_matching_rule
# A rule matches on priority, case_type and team — so evaluate it # A rule matches on priority, case_type and team, so evaluate it
# against the values this request is setting, not the stored ones, # against the values this request is setting, not the stored ones,
# or a caller could re-target the case out of the rule and close it # or a caller could re-target the case out of the rule and close it
# in the same PATCH. The instance is restored either way; nothing # in the same PATCH. The instance is restored either way; nothing
@ -594,7 +594,7 @@ class InboundMailboxSerializer(serializers.ModelSerializer):
return value return value
def to_representation(self, instance): def to_representation(self, instance):
"""`webhook_secret` is a per-org shared secret — the credential a sender """`webhook_secret` is a per-org shared secret, the credential a sender
signs with. It is settable/rotatable by an admin, but it must never be signs with. It is settable/rotatable by an admin, but it must never be
read back by a regular member, or every agent in the org can forge read back by a regular member, or every agent in the org can forge
inbound mail. `topic_arn` is the webhook's TopicArn pin and embeds the inbound mail. `topic_arn` is the webhook's TopicArn pin and embeds the
@ -830,7 +830,7 @@ class TimeEntryUpdateSerializer(serializers.ModelSerializer):
Lets the owner (or an admin) revise the start/end window, description, Lets the owner (or an admin) revise the start/end window, description,
billable flag, and hourly rate. `case`, `profile`, and `invoice` stay billable flag, and hourly rate. `case`, `profile`, and `invoice` stay
read-only — moving an entry between cases or stealing someone else's read-only, moving an entry between cases or stealing someone else's
timer is intentionally not supported. timer is intentionally not supported.
""" """
@ -955,7 +955,7 @@ class ApprovalRuleSerializer(serializers.ModelSerializer):
if org is None: if org is None:
org = self.context.get("org") if hasattr(self, "context") else None org = self.context.get("org") if hasattr(self, "context") else None
if org is not None: if org is not None:
# Scope the relational querysets to the caller's org — the same # Scope the relational querysets to the caller's org, the same
# __init__ scoping RoutingRule/Escalation serializers do. Without it # __init__ scoping RoutingRule/Escalation serializers do. Without it
# approver_ids/match_team_id accept ANY org's rows, so a PUT (whose # approver_ids/match_team_id accept ANY org's rows, so a PUT (whose
# view does not re-run the POST's manual org check) could attach a # view does not re-run the POST's manual org check) could attach a
@ -971,7 +971,7 @@ class ApprovalSerializer(serializers.ModelSerializer):
``can_act`` and ``is_own_request`` are viewer-relative and need the request ``can_act`` and ``is_own_request`` are viewer-relative and need the request
in serializer context; without it (e.g. the single-object action responses) in serializer context; without it (e.g. the single-object action responses)
they default to False, which is safe — the queue re-reads the list after any they default to False, which is safe. The queue re-reads the list after any
action, and that read carries the context. action, and that read carries the context.
""" """

View file

@ -2,7 +2,7 @@
draft :class:`invoices.models.InvoiceLineItem` shapes. draft :class:`invoices.models.InvoiceLineItem` shapes.
Kept in ``cases/services/`` so the cross-app glue lives on the cases side Kept in ``cases/services/`` so the cross-app glue lives on the cases side
of the boundary — the invoices app only consumes these dicts. Reach over of the boundary, the invoices app only consumes these dicts. Reach over
to ``invoices/api_views.py`` :class:`InvoiceFromTimeEntriesView` for the to ``invoices/api_views.py`` :class:`InvoiceFromTimeEntriesView` for the
actual endpoint that calls this helper. actual endpoint that calls this helper.
""" """
@ -26,7 +26,7 @@ def build_invoice_lines_from_entries(entries) -> Tuple[str, List[dict]]:
Rejects (raising :class:`TimeEntryInvoicingError`): Rejects (raising :class:`TimeEntryInvoicingError`):
* Empty input. * Empty input.
* Mixed currencies — single invoices stay single-currency. * Mixed currencies: single invoices stay single-currency.
* Already-invoiced entries. * Already-invoiced entries.
* Unstopped (running) entries. * Unstopped (running) entries.
* Non-billable entries. * Non-billable entries.
@ -68,7 +68,7 @@ def build_invoice_lines_from_entries(entries) -> Tuple[str, List[dict]]:
rate = entry.hourly_rate if entry.hourly_rate is not None else Decimal("0") rate = entry.hourly_rate if entry.hourly_rate is not None else Decimal("0")
case_name = entry.case.name if getattr(entry, "case", None) else "Time entry" case_name = entry.case.name if getattr(entry, "case", None) else "Time entry"
body = entry.description.strip() if entry.description else "" body = entry.description.strip() if entry.description else ""
full = f"{case_name} — {body}" if body else case_name full = f"{case_name}, {body}" if body else case_name
lines.append( lines.append(
{ {
"name": full[:255], "name": full[:255],

View file

@ -10,7 +10,7 @@ reported as row errors rather than silently dropped. Tags are auto-created at
commit if not present. commit if not present.
All reference lookups are bulk-prefetched once per call (one SELECT per All reference lookups are bulk-prefetched once per call (one SELECT per
reference type), not per-row — a 5000-row file with five reference columns reference type), not per-row, a 5000-row file with five reference columns
runs ~6 queries during validation instead of ~25k. runs ~6 queries during validation instead of ~25k.
""" """

View file

@ -2,7 +2,7 @@
Audit-log signal handlers for the Cases app. Audit-log signal handlers for the Cases app.
Replaces the generic Case CREATE/UPDATE/DELETE handlers in `common/signals.py` Replaces the generic Case CREATE/UPDATE/DELETE handlers in `common/signals.py`
with finer-grained Activity rows — see `docs/cases/COORDINATION_DECISIONS.md` D1. with finer-grained Activity rows. See `docs/cases/COORDINATION_DECISIONS.md` D1.
Verbs emitted from here: CREATE, UPDATE, DELETE, ASSIGN, STATUS_CHANGED, Verbs emitted from here: CREATE, UPDATE, DELETE, ASSIGN, STATUS_CHANGED,
PRIORITY_CHANGED, COMMENT, LINKED_SOLUTION, UNLINKED_SOLUTION. PRIORITY_CHANGED, COMMENT, LINKED_SOLUTION, UNLINKED_SOLUTION.
@ -187,7 +187,7 @@ def case_pre_save_stamp_resolved_at(sender, instance, **kwargs):
`resolved_at` drives `is_sla_resolution_breached`, the MTTR dashboard and `resolved_at` drives `is_sla_resolution_breached`, the MTTR dashboard and
the resolution half of the escalation scan. The only code in the repo that the resolution half of the escalation scan. The only code in the repo that
ever wrote it was `parent_views.close_with_children`; a case closed the ever wrote it was `parent_views.close_with_children`; a case closed the
ordinary way — the API, the v1 UI, a bulk update, a kanban drag — kept ordinary way (the API, the v1 UI, a bulk update, a kanban drag) kept
`resolved_at = NULL` forever. Both seeded Closed cases show it. `resolved_at = NULL` forever. Both seeded Closed cases show it.
The consequences ran one way: a resolved case stayed permanently The consequences ran one way: a resolved case stayed permanently
@ -219,8 +219,8 @@ def case_pre_save_stamp_resolved_at(sender, instance, **kwargs):
def _maybe_stamp_first_response(case, comment): def _maybe_stamp_first_response(case, comment):
"""Stamp `first_response_at` on the first agent reply to a ticket. """Stamp `first_response_at` on the first agent reply to a ticket.
Nothing wrote this field either — not the API, not the v1 UI, not the Nothing wrote this field either, not the API, not the v1 UI, not the
inbound-email path — while four things read it: the breach property, the inbound-email path, while four things read it: the breach property, the
"first reply" column, the escalation scan and the FRT dashboard. So every "first reply" column, the escalation scan and the FRT dashboard. So every
case in every org was permanently first-response-breached, the escalation case in every org was permanently first-response-breached, the escalation
task re-fired on tickets that had been answered hours ago, and no ticket task re-fired on tickets that had been answered hours ago, and no ticket
@ -229,7 +229,7 @@ def _maybe_stamp_first_response(case, comment):
"First response" is the first *public* comment from somebody on our side. "First response" is the first *public* comment from somebody on our side.
An internal note is not a reply to the customer, and a comment with no An internal note is not a reply to the customer, and a comment with no
`commented_by` is the customer themselves — the same test `commented_by` is the customer themselves. The same test
`_evaluate_reopen` already uses to tell the two apart. `_evaluate_reopen` already uses to tell the two apart.
""" """
if case.first_response_at is not None: if case.first_response_at is not None:
@ -321,7 +321,7 @@ def _maybe_schedule_csat(case, old_status):
Uses a delayed Celery task so a near-immediate reopen short-circuits Uses a delayed Celery task so a near-immediate reopen short-circuits
inside the task body (status re-check). The task itself is also inside the task body (status re-check). The task itself is also
idempotent — a second STATUS_CHANGED → Closed on the same case won't idempotent, a second STATUS_CHANGED → Closed on the same case won't
create a second survey because of the `OneToOneField` on Case. create a second survey because of the `OneToOneField` on Case.
""" """
if case.status != "Closed" or old_status == "Closed": if case.status != "Closed" or old_status == "Closed":
@ -335,7 +335,7 @@ def _maybe_schedule_csat(case, old_status):
args=[str(case.id), str(case.org_id)], args=[str(case.id), str(case.org_id)],
countdown=CSAT_SEND_DELAY_MINUTES * 60, countdown=CSAT_SEND_DELAY_MINUTES * 60,
) )
except Exception: # pragma: no cover — broker outage shouldn't block close except Exception: # pragma: no cover. Broker outage shouldn't block close
logger.exception("Failed to enqueue CSAT survey for case=%s", case.pk) logger.exception("Failed to enqueue CSAT survey for case=%s", case.pk)
@ -440,7 +440,7 @@ def _evaluate_reopen(case, comment):
case.closed_on = None case.closed_on = None
# `.update()` skips signals, so the resolved_at reset that # `.update()` skips signals, so the resolved_at reset that
# `case_pre_save_stamp_resolved_at` does on a normal save has to be # `case_pre_save_stamp_resolved_at` does on a normal save has to be
# written out by hand here — a reopened ticket is not a resolved one. # written out by hand here. A reopened ticket is not a resolved one.
case.resolved_at = None case.resolved_at = None
Case.objects.filter(pk=case.pk).update( Case.objects.filter(pk=case.pk).update(
status=case.status, closed_on=None, resolved_at=None status=case.status, closed_on=None, resolved_at=None
@ -500,7 +500,7 @@ def maybe_reopen_for_inbound_email(case, email_message):
case.closed_on = None case.closed_on = None
# `.update()` skips signals, so the resolved_at reset that # `.update()` skips signals, so the resolved_at reset that
# `case_pre_save_stamp_resolved_at` does on a normal save has to be # `case_pre_save_stamp_resolved_at` does on a normal save has to be
# written out by hand here — a reopened ticket is not a resolved one. # written out by hand here. A reopened ticket is not a resolved one.
case.resolved_at = None case.resolved_at = None
Case.objects.filter(pk=case.pk).update( Case.objects.filter(pk=case.pk).update(
status=case.status, closed_on=None, resolved_at=None status=case.status, closed_on=None, resolved_at=None

View file

@ -12,8 +12,8 @@ from common.serializer import OrganizationSerializer
def _author_name(obj): def _author_name(obj):
"""A name to print, not a foreign key to resolve. """A name to print, not a foreign key to resolve.
`created_by` serialized as a bare `User` UUID, which no client can render `created_by` serialized as a bare `User` UUID, which no client can render.
— the list page has an Author column and the only honest thing it could The list page has an Author column and the only honest thing it could
have shown was a UUID. `User.name` falls back to the email local part on have shown was a UUID. `User.name` falls back to the email local part on
first save, so it is populated for every real account. first save, so it is populated for every real account.
""" """
@ -60,7 +60,7 @@ class SolutionSerializer(serializers.ModelSerializer):
"""Get number of cases using this solution""" """Get number of cases using this solution"""
# `SolutionListView` annotates this so a list of N articles costs one # `SolutionListView` annotates this so a list of N articles costs one
# query rather than N. The fallback keeps the serializer usable on a # query rather than N. The fallback keeps the serializer usable on a
# single un-annotated instance — the create and publish responses. # single un-annotated instance: the create and publish responses.
annotated = getattr(obj, "linked_case_count", None) annotated = getattr(obj, "linked_case_count", None)
if annotated is not None: if annotated is not None:
return annotated return annotated
@ -91,13 +91,13 @@ class SolutionCreateUpdateSerializer(serializers.ModelSerializer):
only half did: only half did:
* `validate_is_published` was guarded by `if value and self.instance`, * `validate_is_published` was guarded by `if value and self.instance`,
so on **create** — where `self.instance` is None — it did not run at so on **create**, where `self.instance` is None, it did not run at
all. `POST {"status": "draft", "is_published": true}` returned 201 all. `POST {"status": "draft", "is_published": true}` returned 201
with a published draft: the review workflow skipped in one request. with a published draft: the review workflow skipped in one request.
Proven against the running server. Proven against the running server.
* On update it read the **stored** status, never the incoming one, and * On update it read the **stored** status, never the incoming one, and
it only ran when `is_published` was in the body. So the demotion was it only ran when `is_published` was in the body. So the demotion was
free — `PATCH {"status": "draft"}` on an approved, published article free, `PATCH {"status": "draft"}` on an approved, published article
left `is_published` true. Proven the same way. That is the CLAUDE.md left `is_published` true. Proven the same way. That is the CLAUDE.md
rule about validating the *source* state of a transition rather than rule about validating the *source* state of a transition rather than
only its target, in miniature. only its target, in miniature.
@ -111,7 +111,7 @@ class SolutionCreateUpdateSerializer(serializers.ModelSerializer):
# Absent means unchanged, for validation as much as for saving. An # Absent means unchanged, for validation as much as for saving. An
# article created *before* this rule existed can already be a # article created *before* this rule existed can already be a
# published draft, and judging the stored combination on a request # published draft, and judging the stored combination on a request
# that touches neither field would make those rows uneditable — a # that touches neither field would make those rows uneditable: a
# `PATCH {"description": ...}` refused with an error about # `PATCH {"description": ...}` refused with an error about
# `is_published`, which is both baffling and the opposite of helpful, # `is_published`, which is both baffling and the opposite of helpful,
# since editing is how somebody would fix it. Any request that does # since editing is how somebody would fix it. Any request that does
@ -180,7 +180,7 @@ class SolutionDetailSerializer(serializers.ModelSerializer):
def get_linked_cases(self, obj): def get_linked_cases(self, obj):
"""Linked tickets the *requester* is allowed to open. """Linked tickets the *requester* is allowed to open.
This used to be `CaseSerializer(obj.cases.all()[:10], many=True)` — This used to be `CaseSerializer(obj.cases.all()[:10], many=True)`,
the full 33-field case payload, with no access check of any kind. A the full 33-field case payload, with no access check of any kind. A
member of the org refused a case with a 403 could link an article to member of the org refused a case with a 403 could link an article to
that very case (the link endpoint checked org and nothing else) and that very case (the link endpoint checked org and nothing else) and
@ -194,7 +194,7 @@ class SolutionDetailSerializer(serializers.ModelSerializer):
`case_count` stays the *total*. It is the honest usage figure for an `case_count` stays the *total*. It is the honest usage figure for an
article and is already public on the list endpoint, so a reader seeing article and is already public on the list endpoint, so a reader seeing
fewer rows than the count is being told plainly that some of those fewer rows than the count is being told plainly that some of those
tickets are not theirs — rather than shown a number that quietly means tickets are not theirs, rather than shown a number that quietly means
something different for each reader. something different for each reader.
""" """
from cases.access import visible_cases_qs from cases.access import visible_cases_qs

View file

@ -29,7 +29,7 @@ def _wants_release(data, instance=None):
"""True when this payload would approve or publish the article. """True when this payload would approve or publish the article.
Both are admin-only acts (see `cases.kb_access`), and both can arrive as Both are admin-only acts (see `cases.kb_access`), and both can arrive as
ordinary fields on an ordinary create or update — which is exactly how ordinary fields on an ordinary create or update, which is exactly how
they were being performed. Read as a transition, not as a value: a PATCH they were being performed. Read as a transition, not as a value: a PATCH
that repeats the `approved` an article already has is not an approval, so that repeats the `approved` an article already has is not an approval, so
an author fixing a typo on their own approved draft is not stopped by a an author fixing a typo on their own approved draft is not stopped by a
@ -109,8 +109,8 @@ class SolutionListView(APIView, LimitOffsetPagination):
queryset = queryset.filter(status=status_filter) queryset = queryset.filter(status=status_filter)
if is_published is not None: if is_published is not None:
# `is_published.lower() == "true"` meant every other spelling — # `is_published.lower() == "true"` meant every other spelling:
# `1`, `yes`, `on` — silently filtered to *unpublished*, which is # `1`, `yes`, `on`: silently filtered to *unpublished*, which is
# the opposite of what the caller asked for. # the opposite of what the caller asked for.
queryset = queryset.filter(is_published=_as_bool(is_published)) queryset = queryset.filter(is_published=_as_bool(is_published))
@ -120,8 +120,8 @@ class SolutionListView(APIView, LimitOffsetPagination):
) )
# Counted over the whole knowledge base, not over the filtered page. # Counted over the whole knowledge base, not over the filtered page.
# These four are a partition of the KB — "12 total, 3 of them drafts" # These four are a partition of the KB, "12 total, 3 of them drafts"
# — so recomputing them inside a `?status=draft` filter would leave #, so recomputing them inside a `?status=draft` filter would leave
# the other three cards reading zero and say nothing. # the other three cards reading zero and say nothing.
counts = base.aggregate( counts = base.aggregate(
count=Count("id"), count=Count("id"),
@ -182,7 +182,7 @@ class SolutionDetailView(APIView):
def get(self, request, pk): def get(self, request, pk):
"""Get solution details""" """Get solution details"""
solution = get_solution_or_404(request.profile, pk) solution = get_solution_or_404(request.profile, pk)
# The requester's profile decides which linked cases come back — see # The requester's profile decides which linked cases come back. See
# `SolutionDetailSerializer.get_linked_cases`. # `SolutionDetailSerializer.get_linked_cases`.
serializer = SolutionDetailSerializer( serializer = SolutionDetailSerializer(
solution, context={"profile": request.profile} solution, context={"profile": request.profile}

View file

@ -27,7 +27,7 @@ CSAT_SEND_DELAY_MINUTES = 30
# elsewhere in the codebase. # elsewhere in the codebase.
CSAT_SIGNER_SALT = "cases.csat_survey" CSAT_SIGNER_SALT = "cases.csat_survey"
# Cap a single case at 3 escalations total — past that, a human needs to step in. # Cap a single case at 3 escalations total. Past that, a human needs to step in.
ESCALATION_COUNT_CAP = 3 ESCALATION_COUNT_CAP = 3
# Minimum gap between escalation attempts on the same case (prevents storming). # Minimum gap between escalation attempts on the same case (prevents storming).
ESCALATION_COOLDOWN_MINUTES = 60 ESCALATION_COOLDOWN_MINUTES = 60
@ -87,7 +87,7 @@ def _dispatch_breach(case, action, target_profile, team, org_id):
send_email_to_assigned_user.delay(recipients, str(case.id), str(org_id)) send_email_to_assigned_user.delay(recipients, str(case.id), str(org_id))
except ( except (
Exception Exception
): # pragma: no cover — broker glitches shouldn't lose the escalation ): # pragma: no cover. Broker glitches shouldn't lose the escalation
logger.exception( logger.exception(
"Failed to enqueue escalation email for case=%s", case.pk "Failed to enqueue escalation email for case=%s", case.pk
) )
@ -230,7 +230,7 @@ def csat_signer() -> TimestampSigner:
def hash_csat_token(token: str) -> str: def hash_csat_token(token: str) -> str:
"""SHA-256 hex digest. We never store raw tokens — only their hash.""" """SHA-256 hex digest. We never store raw tokens, only their hash."""
return hashlib.sha256(token.encode("utf-8")).hexdigest() return hashlib.sha256(token.encode("utf-8")).hexdigest()
@ -257,7 +257,7 @@ def send_csat_survey(case_id, org_id):
- The org has flipped `csat_enabled` off. - The org has flipped `csat_enabled` off.
- The case has no contact with an email (logged, not raised). - The case has no contact with an email (logged, not raised).
- The case has been reopened in the meantime (status no longer - The case has been reopened in the meantime (status no longer
Closed) — the spec's reopen-protection clause. Closed): the spec's reopen-protection clause.
- A survey row already exists for this case (don't double-send). - A survey row already exists for this case (don't double-send).
""" """
set_rls_context(org_id) set_rls_context(org_id)
@ -267,7 +267,7 @@ def send_csat_survey(case_id, org_id):
return None return None
if case.status != "Closed": if case.status != "Closed":
logger.info( logger.info(
"send_csat_survey: case=%s status=%s — likely reopened, skipping", "send_csat_survey: case=%s status=%s, likely reopened, skipping",
case_id, case_id,
case.status, case.status,
) )
@ -313,7 +313,7 @@ def send_csat_survey(case_id, org_id):
try: try:
html = render_to_string("csat/survey_email.html", context=context) html = render_to_string("csat/survey_email.html", context=context)
except Exception: except Exception:
# Template missing in dev — fall back to a plain link so the task # Template missing in dev: fall back to a plain link so the task
# still records the survey row + token. Production has the template. # still records the survey row + token. Production has the template.
html = ( html = (
f'<p>Hi {contact.first_name or "there"},</p>' f'<p>Hi {contact.first_name or "there"},</p>'
@ -322,7 +322,7 @@ def send_csat_survey(case_id, org_id):
) )
msg = EmailMessage( msg = EmailMessage(
subject=f"How did we do? — {case.name}", subject=f"How did we do?, {case.name}",
body=html, body=html,
to=[contact.email], to=[contact.email],
) )
@ -342,7 +342,7 @@ def send_csat_survey(case_id, org_id):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tier 3 time-tracking — auto-stop forgotten timers. # Tier 3 time-tracking: auto-stop forgotten timers.
# A running timer this old gets killed by the Celery beat. Hand-tuned: 12h # A running timer this old gets killed by the Celery beat. Hand-tuned: 12h
# covers an overnight forgotten timer without clobbering a mid-day session # covers an overnight forgotten timer without clobbering a mid-day session

View file

@ -18,7 +18,7 @@ from django.utils import timezone
from cases.models import Case from cases.models import Case
# A single reference instant for the whole fixture. Anchoring every timestamp # A single reference instant for the whole fixture. Anchoring every timestamp
# to one `now` keeps the FRT deltas exact — deriving each from its own # to one `now` keeps the FRT deltas exact, deriving each from its own
# timezone.now() call drifts by microseconds, which would nudge a case that # timezone.now() call drifts by microseconds, which would nudge a case that
# meets its SLA exactly (1h against a 1h target) just over the line. # meets its SLA exactly (1h against a 1h target) just over the line.
_BASE = timezone.now() _BASE = timezone.now()
@ -83,7 +83,7 @@ def service_cases(org_a, admin_profile):
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Authorization — the admin-only gate (both directions) # Authorization: the admin-only gate (both directions)
class TestServiceAuthorization: class TestServiceAuthorization:
@ -92,7 +92,7 @@ class TestServiceAuthorization:
assert resp.status_code in (401, 403) assert resp.status_code in (401, 403)
def test_non_admin_forbidden(self, user_client, service_cases): def test_non_admin_forbidden(self, user_client, service_cases):
"""A USER-role member gets 403 — never a personal slice.""" """A USER-role member gets 403, never a personal slice."""
resp = user_client.get("/api/cases/analytics/service/") resp = user_client.get("/api/cases/analytics/service/")
assert resp.status_code == 403 assert resp.status_code == 403
@ -226,7 +226,7 @@ class TestServiceByAgent:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Empty org — the shape holds so the SSR page never divides by an absent array # Empty org: the shape holds so the SSR page never divides by an absent array
class TestServiceEmpty: class TestServiceEmpty:

View file

@ -216,7 +216,7 @@ class TestStateTransitions:
def test_approve_records_activity( def test_approve_records_activity(
self, admin_client, admin_profile, user_profile, regular_user, org_a self, admin_client, admin_profile, user_profile, regular_user, org_a
): ):
# Requested by the USER, approved by the admin — a requester and an # Requested by the USER, approved by the admin. A requester and an
# approver who are different people, which the rule now requires. # approver who are different people, which the rule now requires.
case, rule, approval = self._setup(org_a, user_profile, regular_user) case, rule, approval = self._setup(org_a, user_profile, regular_user)
res = admin_client.post( res = admin_client.post(
@ -528,7 +528,7 @@ class TestRuleCRUD:
assert rule.match_team_id is None assert rule.match_team_id is None
def test_put_accepts_same_org_approver(self, admin_client, org_a, user_profile): def test_put_accepts_same_org_approver(self, admin_client, org_a, user_profile):
"""The allowed side — a same-org approver PUTs fine, proving the two """The allowed side, a same-org approver PUTs fine, proving the two
rejections above are org-scoping and not a blanket block on the field.""" rejections above are org-scoping and not a blanket block on the field."""
rule = _make_rule(org_a, match_priority="Urgent") rule = _make_rule(org_a, match_priority="Urgent")
res = admin_client.put( res = admin_client.put(
@ -560,7 +560,7 @@ class TestRuleCRUD:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Rule-list analytics — pending_count per rule + org totals, read from the # Rule-list analytics: pending_count per rule + org totals, read from the
# Approval log (state="pending"). # Approval log (state="pending").
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -12,25 +12,25 @@ answered the wrong thing. What was broken, in the order the classes appear:
2. The watcher allowance was half-built. `CaseListView` and 2. The watcher allowance was half-built. `CaseListView` and
`watcher_views` granted it with a citation; the detail view dropped it. `watcher_views` granted it with a citation; the detail view dropped it.
A watcher's queue showed the ticket and opening it answered 403. A watcher's queue showed the ticket and opening it answered 403.
3. The verbs disagreed by accident — read, write and delete each had a 3. The verbs disagreed by accident: read, write and delete each had a
different idea of who was allowed, spread over five copies. different idea of who was allowed, spread over five copies.
4. A missing case was a **500** on PUT / PATCH / DELETE (only GET checked 4. A missing case was a **500** on PUT / PATCH / DELETE (only GET checked
for `None`), and a malformed id was a 500 on all four plus the comment for `None`), and a malformed id was a 500 on all four plus the comment
and attachment endpoints, because a UUID column raises rather than and attachment endpoints, because a UUID column raises rather than
returning nothing. returning nothing.
5. `CaseAttachmentView.delete` looked its row up by pk with **no org 5. `CaseAttachmentView.delete` looked its row up by pk with **no org
filter** — one endpoint that deleted any attachment in the database — filter**, one endpoint that deleted any attachment in the database,
and its permission branch compared a `Profile` to a `User` FK, so the and its permission branch compared a `Profile` to a `User` FK, so the
person who uploaded a file could not delete it. person who uploaded a file could not delete it.
6. `account` accepted **another org's account** on create, stored the link, 6. `account` accepted **another org's account** on create, stored the link,
and echoed that account's name, email, phone and website back through the and echoed that account's name, email, phone and website back through the
nested serializer. nested serializer.
7. The close gate did not exist in practice. `Case.clean()` requires a 7. The close gate did not exist in practice. `Case.clean()` requires a
`closed_on` and, where an active rule matches, a recorded approval — but `closed_on` and, where an active rule matches, a recorded approval, but
DRF never calls `Model.clean()`. With a matching rule armed, DRF never calls `Model.clean()`. With a matching rule armed,
`PATCH {"status": "Closed"}` returned 200 and recorded zero approvals. `PATCH {"status": "Closed"}` returned 200 and recorded zero approvals.
8. `resolved_at` was written by exactly one code path in the repo 8. `resolved_at` was written by exactly one code path in the repo
(`close_with_children`), so an ordinarily-closed case stayed NULL — and (`close_with_children`), so an ordinarily-closed case stayed NULL, and
the property reads NULL as *not resolved*, leaving it permanently the property reads NULL as *not resolved*, leaving it permanently
"resolution breached" and invisible to MTTR. "resolution breached" and invisible to MTTR.
9. `first_response_at` was written by **no** code path at all, while four 9. `first_response_at` was written by **no** code path at all, while four
@ -64,7 +64,7 @@ def _created_by(instance, user):
"""Set `created_by` after the fact. """Set `created_by` after the fact.
`BaseModel.save()` takes `created_by` from the crum thread-local and sets `BaseModel.save()` takes `created_by` from the crum thread-local and sets
it to **None** when there is no request in flight — so passing it to it to **None** when there is no request in flight, so passing it to
`objects.create()` inside a test silently stores nothing. `.update()` `objects.create()` inside a test silently stores nothing. `.update()`
writes the column directly and skips that. writes the column directly and skips that.
""" """
@ -92,7 +92,7 @@ def assigned_case(case_a, user_profile):
@pytest.fixture @pytest.fixture
def watched_case(case_a, user_profile, org_a): def watched_case(case_a, user_profile, org_a):
"""A case the non-admin only watches — not creator, not assignee.""" """A case the non-admin only watches, not creator, not assignee."""
CaseWatcher.objects.create( CaseWatcher.objects.create(
case=case_a, profile=user_profile, org=org_a, subscribed_via="manual" case=case_a, profile=user_profile, org=org_a, subscribed_via="manual"
) )
@ -113,7 +113,7 @@ class TestWhoMayOpenACase:
assert response.json()["cases_obj"]["id"] == str(case_a.id) assert response.json()["cases_obj"]["id"] == str(case_a.id)
def test_assignee_opens_a_case_they_did_not_raise(self, user_client, assigned_case): def test_assignee_opens_a_case_they_did_not_raise(self, user_client, assigned_case):
"""Also a 500 — every non-admin hit the same line.""" """Also a 500. Every non-admin hit the same line."""
response = user_client.get(_detail(assigned_case.id)) response = user_client.get(_detail(assigned_case.id))
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@ -132,7 +132,7 @@ class TestWhoMayOpenACase:
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
def test_another_org_gets_404_not_403(self, org_b_client, case_a): def test_another_org_gets_404_not_403(self, org_b_client, case_a):
"""404, not 403 — confirming a record exists is itself a disclosure.""" """404, not 403. Confirming a record exists is itself a disclosure."""
response = org_b_client.get(_detail(case_a.id)) response = org_b_client.get(_detail(case_a.id))
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
@ -368,7 +368,7 @@ class TestClosingACase:
self, admin_client, case_a, org_a self, admin_client, case_a, org_a
): ):
"""The rule matches on priority, so it is evaluated against the """The rule matches on priority, so it is evaluated against the
incoming values — not the stored ones a caller is about to replace.""" incoming values, not the stored ones a caller is about to replace."""
case_a.priority = "High" case_a.priority = "High"
case_a.save() case_a.save()
ApprovalRule.objects.create( ApprovalRule.objects.create(
@ -494,7 +494,7 @@ class TestFirstResponseIsRecorded:
@pytest.mark.django_db @pytest.mark.django_db
class TestListOrderAndMentions: class TestListOrderAndMentions:
def test_newest_first(self, admin_client, case_a, case_b_same_org): def test_newest_first(self, admin_client, case_a, case_b_same_org):
"""Ordering was `-id` — a random UUID — under a header that said """Ordering was `-id`, a random UUID, under a header that said
the newest were on top.""" the newest were on top."""
Case.objects.filter(pk=case_a.id).update( Case.objects.filter(pk=case_a.id).update(
created_at=timezone.now() - timezone.timedelta(days=3) created_at=timezone.now() - timezone.timedelta(days=3)

View file

@ -50,7 +50,7 @@ def _make_case(
"""Create a Case with `created_by` set deterministically. """Create a Case with `created_by` set deterministically.
`BaseModel.save()` reads `crum.get_current_user()` to populate `BaseModel.save()` reads `crum.get_current_user()` to populate
`created_by` — outside a request that's None, which would break the `created_by`: outside a request that's None, which would break the
permission tests. Set the current user explicitly for the duration of permission tests. Set the current user explicitly for the duration of
the create. the create.
""" """
@ -206,8 +206,8 @@ class TestCaseMergePermissions:
def test_creator_of_only_one_forbidden( def test_creator_of_only_one_forbidden(
self, user_client, regular_user, admin_user, org_a self, user_client, regular_user, admin_user, org_a
): ):
a = _make_case(org_a, regular_user, name="A — user-created") a = _make_case(org_a, regular_user, name="A. User-created")
b = _make_case(org_a, admin_user, name="B — admin-created") b = _make_case(org_a, admin_user, name="B, admin-created")
response = user_client.post(f"/api/cases/{a.id}/merge/{b.id}/") response = user_client.post(f"/api/cases/{a.id}/merge/{b.id}/")
assert response.status_code == 403 assert response.status_code == 403

View file

@ -72,8 +72,8 @@ class TestCaseListView:
def test_create_case_unauthenticated(self, unauthenticated_client): def test_create_case_unauthenticated(self, unauthenticated_client):
"""Asserted `pytest.raises(PermissionDenied)`, which can never hold """Asserted `pytest.raises(PermissionDenied)`, which can never hold
through an APIClient — DRF renders an APIException as a response through an APIClient, DRF renders an APIException as a response
rather than letting it escape — so this had always failed. It now rather than letting it escape, so this had always failed. It now
checks the response the client actually receives.""" checks the response the client actually receives."""
response = unauthenticated_client.post( response = unauthenticated_client.post(
CASES_LIST_URL, CASES_LIST_URL,
@ -1390,7 +1390,7 @@ class TestCaseModelMethods:
def test_save_applies_priority_specific_sla_defaults(self, admin_user, org_a): def test_save_applies_priority_specific_sla_defaults(self, admin_user, org_a):
"""New case picks up the priority-specific SLA defaults via save(). """New case picks up the priority-specific SLA defaults via save().
See cases/workflow.py — Urgent gets 1h first-response / 4h resolution. See cases/workflow.py: Urgent gets 1h first-response / 4h resolution.
Previously this logic was guarded by `not self.pk`, which never fired Previously this logic was guarded by `not self.pk`, which never fired
because BaseModel populates pk via uuid4 default; we now use because BaseModel populates pk via uuid4 default; we now use
_state.adding so the auto-set actually applies. _state.adding so the auto-set actually applies.

View file

@ -211,7 +211,7 @@ class TestPublicCsatPost:
def test_edit_after_24h_locked(self, client, closed_case): def test_edit_after_24h_locked(self, client, closed_case):
survey, token = self._seed(closed_case) survey, token = self._seed(closed_case)
# First submit — rewind responded_at past the edit window. # First submit: rewind responded_at past the edit window.
client.post( client.post(
f"/api/public/csat/{token}/", f"/api/public/csat/{token}/",
data={"rating": 3}, data={"rating": 3},
@ -259,7 +259,7 @@ class TestCsatAggregate:
rating=rating, rating=rating,
responded_at=timezone.now(), responded_at=timezone.now(),
) )
# One un-responded survey — must not count. # One un-responded survey, must not count.
case = Case.objects.create( case = Case.objects.create(
org=org_a, name="Cx", status="Closed", priority="Normal", org=org_a, name="Cx", status="Closed", priority="Normal",
closed_on=timezone.now().date(), closed_on=timezone.now().date(),

View file

@ -171,7 +171,7 @@ class TestEscalationPolicyAPI:
def test_cross_org_isolation(self, admin_client, org_a, org_b): def test_cross_org_isolation(self, admin_client, org_a, org_b):
target_b = _make_target_profile(org_b, "b@x.com") target_b = _make_target_profile(org_b, "b@x.com")
_make_policy(org_b, target_b, priority="Urgent") _make_policy(org_b, target_b, priority="Urgent")
# admin_client has org_a — should see no policies # admin_client has org_a, should see no policies
assert admin_client.get(POLICIES_URL).json()["policies"] == [] assert admin_client.get(POLICIES_URL).json()["policies"] == []
@ -237,7 +237,7 @@ class TestScanForBreachedCases:
_create_breached_case(admin_user, org_a) _create_breached_case(admin_user, org_a)
scan_for_breached_cases() scan_for_breached_cases()
# Second pass within cooldown — no new activity # Second pass within cooldown, no new activity
assert scan_for_breached_cases() == 0 assert scan_for_breached_cases() == 0
assert Activity.objects.filter(action="ESCALATED").count() == 1 assert Activity.objects.filter(action="ESCALATED").count() == 1
@ -284,7 +284,7 @@ class TestScanForBreachedCases:
) )
_create_breached_case(admin_user, org_a) _create_breached_case(admin_user, org_a)
scan_for_breached_cases() scan_for_breached_cases()
# Look at any of the .delay calls — recipients should include both ids # Look at any of the .delay calls. Recipients should include both ids
called_recipients = [] called_recipients = []
for call in mock_email.delay.call_args_list: for call in mock_email.delay.call_args_list:
called_recipients.extend(call.args[0]) called_recipients.extend(call.args[0])
@ -320,7 +320,7 @@ class TestScanForBreachedCases:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# breaches_last_30d — the analytics the escalation page reads (Urgent SLAs: # breaches_last_30d. The analytics the escalation page reads (Urgent SLAs:
# 1h first response, 4h resolution). # 1h first response, 4h resolution).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -419,7 +419,7 @@ class TestEscalationBreachCounts:
def test_dead_policy_still_reports_breaches(self, admin_client, admin_user, org_a): def test_dead_policy_still_reports_breaches(self, admin_client, admin_user, org_a):
"""A disabled policy fires no ESCALATED activity, but its breaches must """A disabled policy fires no ESCALATED activity, but its breaches must
still surface — that is the whole reason the page exists.""" still surface. That is the whole reason the page exists."""
target = _make_target_profile(org_a) target = _make_target_profile(org_a)
_make_policy(org_a, target, priority="Urgent", is_active=False) _make_policy(org_a, target, priority="Urgent", is_active=False)
_case_with_sla(admin_user, org_a, created_ago=timedelta(hours=5)) _case_with_sla(admin_user, org_a, created_ago=timedelta(hours=5))

View file

@ -134,7 +134,7 @@ class TestImportPreview:
def test_cross_org_account_rejected( def test_cross_org_account_rejected(
self, admin_client, admin_profile, org_b, user_b self, admin_client, admin_profile, org_b, user_b
): ):
# Account belongs to org_b but admin is in org_a — must not resolve. # Account belongs to org_b but admin is in org_a, must not resolve.
Account.objects.create(name="Secret Co", org=org_b, created_by=user_b) Account.objects.create(name="Secret Co", org=org_b, created_by=user_b)
csv_file = _csv( csv_file = _csv(
["name", "status", "priority", "account_name"], ["name", "status", "priority", "account_name"],
@ -199,7 +199,7 @@ class TestImportCommit:
assert case.tags.filter(name="auth").exists() assert case.tags.filter(name="auth").exists()
def test_rolls_back_on_any_error(self, admin_client, org_a, admin_profile): def test_rolls_back_on_any_error(self, admin_client, org_a, admin_profile):
# Mix one good row with one invalid row — neither should be created. # Mix one good row with one invalid row. Neither should be created.
csv_file = _csv( csv_file = _csv(
["name", "status", "priority"], ["name", "status", "priority"],
[ [

View file

@ -258,7 +258,7 @@ class TestThreading:
org=org_a, org=org_a,
created_by=admin_user, created_by=admin_user,
) )
# Subject identical but no `[Case #...]` marker — must not match. # Subject identical but no `[Case #...]` marker, must not match.
parsed = parse_raw_email(_raw_email(message_id="<reply@x>", subject="Help")) parsed = parse_raw_email(_raw_email(message_id="<reply@x>", subject="Help"))
assert find_existing_case(parsed, org_a) is None assert find_existing_case(parsed, org_a) is None
@ -563,7 +563,7 @@ class TestMailboxAPI:
assert response.status_code == 403 assert response.status_code == 403
def test_non_admin_cannot_read_webhook_secret(self, user_client, org_a): def test_non_admin_cannot_read_webhook_secret(self, user_client, org_a):
"""webhook_secret is the credential a sender signs with — a non-admin """webhook_secret is the credential a sender signs with. A non-admin
member may see the mailbox config but never the secret (list + detail).""" member may see the mailbox config but never the secret (list + detail)."""
mailbox = _make_mailbox(org_a, webhook_secret="s3cr3t-value") mailbox = _make_mailbox(org_a, webhook_secret="s3cr3t-value")
@ -693,7 +693,7 @@ class TestWebhook:
# TopicArn pinning # TopicArn pinning
# #
# A valid SNS signature only proves a message came from *some* SNS topic in # A valid SNS signature only proves a message came from *some* SNS topic in
# *some* AWS account — anyone who learns a mailbox UUID could otherwise point # *some* AWS account: anyone who learns a mailbox UUID could otherwise point
# their own topic at the webhook and have AWS sign forged mail for them. The # their own topic at the webhook and have AWS sign forged mail for them. The
# webhook therefore also pins the TopicArn to the mailbox. # webhook therefore also pins the TopicArn to the mailbox.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -787,7 +787,7 @@ class TestTopicArnPinning:
self, admin_client, org_a self, admin_client, org_a
): ):
"""Once pinned, a confirmation from another topic must not steal the """Once pinned, a confirmation from another topic must not steal the
mailbox — otherwise the pin is trivially resettable by an attacker.""" mailbox. Otherwise the pin is trivially resettable by an attacker."""
mailbox = _make_mailbox(org_a, topic_arn=SNS_TOPIC) mailbox = _make_mailbox(org_a, topic_arn=SNS_TOPIC)
with ( with (
patch("cases.inbound_views.verify_sns_message"), patch("cases.inbound_views.verify_sns_message"),
@ -841,7 +841,7 @@ class TestTopicArnPinning:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Mailbox list analytics — cases_last_30d / last_received_at, attributed to a # Mailbox list analytics: cases_last_30d / last_received_at, attributed to a
# mailbox via the EmailMessage.mailbox FK (set at ingest). Driven through the # mailbox via the EmailMessage.mailbox FK (set at ingest). Driven through the
# real pipeline so the FK is populated the way production populates it. # real pipeline so the FK is populated the way production populates it.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -3,8 +3,8 @@
Covers the three controls added after CodeQL alerts #27/#28 (`py/full-ssrf`) Covers the three controls added after CodeQL alerts #27/#28 (`py/full-ssrf`)
on `cases/inbound/sns.py`: on `cases/inbound/sns.py`:
1. Neither outbound fetch (SigningCertURL, SubscribeURL) follows redirects — 1. Neither outbound fetch (SigningCertURL, SubscribeURL) follows redirects.
the host allow-list is applied to the URL we are *about* to fetch, so a 3xx The host allow-list is applied to the URL we are *about* to fetch, so a 3xx
would otherwise land us on an unvalidated host. would otherwise land us on an unvalidated host.
2. The signing certificate is validated (validity window + subject CN pinned 2. The signing certificate is validated (validity window + subject CN pinned
to the AWS SNS host family) instead of being trusted blindly. to the AWS SNS host family) instead of being trusted blindly.
@ -37,7 +37,7 @@ from cases.inbound.sns import SNSVerificationError, verify_sns_message
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def signing_key(): def signing_key():
"""One RSA key for the whole module — keygen is the slow part.""" """One RSA key for the whole module. Keygen is the slow part."""
return rsa.generate_private_key(public_exponent=65537, key_size=2048) return rsa.generate_private_key(public_exponent=65537, key_size=2048)
@ -92,7 +92,7 @@ def _signed_payload(key, *, topic_arn="arn:aws:sns:us-east-1:1:t", **overrides):
class TestSigningCertValidation: class TestSigningCertValidation:
def test_valid_cert_and_signature_verifies(self, signing_key): def test_valid_cert_and_signature_verifies(self, signing_key):
"""Happy path — proves the new checks can return True, not just False.""" """Happy path. Proves the new checks can return True, not just False."""
cert = _make_cert(signing_key) cert = _make_cert(signing_key)
payload = _signed_payload(signing_key) payload = _signed_payload(signing_key)
@ -188,7 +188,7 @@ class _RedirectHandler(http.server.BaseHTTPRequestHandler):
paths_seen: list = [] paths_seen: list = []
def do_GET(self): # noqa: N802 — BaseHTTPRequestHandler API def do_GET(self): # noqa: N802. BaseHTTPRequestHandler API
type(self).paths_seen.append(self.path) type(self).paths_seen.append(self.path)
if self.path == "/start": if self.path == "/start":
self.send_response(302) self.send_response(302)

View file

@ -31,7 +31,7 @@ def published_solutions(org_a):
s3 = Solution.objects.create( s3 = Solution.objects.create(
org=org_a, org=org_a,
title="Internal draft about passwords", title="Internal draft about passwords",
description="Don't show me — I'm a draft.", description="Don't show me: I'm a draft.",
status="draft", status="draft",
is_published=False, is_published=False,
) )
@ -122,7 +122,7 @@ class TestSolutionSuggestionsSeed:
def test_empty_q_falls_back_to_recent_when_no_seed_match( def test_empty_q_falls_back_to_recent_when_no_seed_match(
self, admin_client, case_a, org_a self, admin_client, case_a, org_a
): ):
# No matches against "Bug in login page" terms — endpoint falls back # No matches against "Bug in login page" terms, endpoint falls back
# to the most-recent published solutions. # to the most-recent published solutions.
Solution.objects.create( Solution.objects.create(
org=org_a, org=org_a,

View file

@ -220,7 +220,7 @@ class TestReopenPolicyAPI:
@pytest.mark.django_db @pytest.mark.django_db
class TestReopenAnalytics: class TestReopenAnalytics:
"""The three settings-page metrics returned alongside the policy — all built """The three settings-page metrics returned alongside the policy, all built
from the real reopen path (create a comment, let the signal fire).""" from the real reopen path (create a comment, let the signal fire)."""
URL = "/api/cases/reopen-policy/" URL = "/api/cases/reopen-policy/"

View file

@ -556,7 +556,7 @@ class TestRoutingRuleAPI:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# List analytics — matched_last_30d / unrouted_last_30d / round_robin state. # List analytics: matched_last_30d / unrouted_last_30d / round_robin state.
# Driven through the real create-signal path so counts come from the ROUTED # Driven through the real create-signal path so counts come from the ROUTED
# activity log the engine writes. # activity log the engine writes.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -599,7 +599,7 @@ class TestRoutingAnalytics:
self, admin_client, admin_profile, org_a self, admin_client, admin_profile, org_a
): ):
"""A rule that matches but has nobody to assign still logs a ROUTED """A rule that matches but has nobody to assign still logs a ROUTED
row, so its case counts as matched, NOT unrouted — 'nobody assigned' row, so its case counts as matched, NOT unrouted; 'nobody assigned'
is not the same as 'no rule matched'.""" is not the same as 'no rule matched'."""
_rule(org_a, "Empty pool", strategy="direct", target_assignees=[]) _rule(org_a, "Empty pool", strategy="direct", target_assignees=[])
_case(org_a, name="c1") _case(org_a, name="c1")

View file

@ -142,7 +142,7 @@ class TestSLADeadlineWithCalendar:
assert deadline == datetime(2026, 5, 11, 14, 0, tzinfo=_UTC) assert deadline == datetime(2026, 5, 11, 14, 0, tzinfo=_UTC)
def test_currently_pending_extends_deadline(self, case_a, calendar_a): def test_currently_pending_extends_deadline(self, case_a, calendar_a):
# Case is currently pending — paused_at set 30 minutes ago. # Case is currently pending; paused_at set 30 minutes ago.
case_a.created_at = datetime(2026, 5, 11, 9, 0, tzinfo=_UTC) case_a.created_at = datetime(2026, 5, 11, 9, 0, tzinfo=_UTC)
case_a.sla_first_response_hours = 4 case_a.sla_first_response_hours = 4
case_a.status = "Pending" case_a.status = "Pending"

View file

@ -3,7 +3,7 @@
Every case below was driven against the running API before the fix and Every case below was driven against the running API before the fix and
answered the wrong thing. What was broken: answered the wrong thing. What was broken:
1. The Solution endpoints had **no authorization at all** — `IsAuthenticated` 1. The Solution endpoints had **no authorization at all**; `IsAuthenticated`
was the whole of it. A plain `USER` could rewrite an admin's article was the whole of it. A plain `USER` could rewrite an admin's article
(200), publish it to customers (200) and hard-delete it (204). The (200), publish it to customers (200) and hard-delete it (204). The
draft → reviewed → approved workflow, the publish and unpublish draft → reviewed → approved workflow, the publish and unpublish
@ -13,7 +13,7 @@ answered the wrong thing. What was broken:
**create** it did not run. `POST {"status": "draft", "is_published": **create** it did not run. `POST {"status": "draft", "is_published":
true}` returned 201 with a published draft. true}` returned 201 with a published draft.
3. On update the same check read the **stored** status, never the incoming 3. On update the same check read the **stored** status, never the incoming
one, and only ran when `is_published` was in the body — so one, and only ran when `is_published` was in the body, so
`PATCH {"status": "draft"}` on an approved, published article left it `PATCH {"status": "draft"}` on an approved, published article left it
published. The invariant the model asserts ("published implies approved") published. The invariant the model asserts ("published implies approved")
held on one path in three. held on one path in three.
@ -22,15 +22,15 @@ answered the wrong thing. What was broken:
`CaseSolutionLinkView` checked org and nothing else. So a member refused `CaseSolutionLinkView` checked org and nothing else. So a member refused
a case with a 403 could link an article to that very case and read its a case with a 403 could link an article to that very case and read its
name, description, account and contacts back out of the article. name, description, account and contacts back out of the article.
5. A malformed id was a **500** on every verb — read, update, delete, 5. A malformed id was a **500** on every verb. Read, update, delete,
publish, unpublish — because a UUID column raises `ValidationError` publish, unpublish, because a UUID column raises `ValidationError`
rather than returning nothing, and the views caught only rather than returning nothing, and the views caught only
`Solution.DoesNotExist`. `Solution.DoesNotExist`.
6. `?is_published=1` silently meant *unpublished*: the parse was 6. `?is_published=1` silently meant *unpublished*: the parse was
`value.lower() == "true"`, so every other spelling of true inverted the `value.lower() == "true"`, so every other spelling of true inverted the
filter. filter.
7. The list served `case_count` and a nested `org` per row without fetching 7. The list served `case_count` and a nested `org` per row without fetching
either — 13 articles cost 27 queries — and `created_by` came back as a either, 13 articles cost 27 queries, and `created_by` came back as a
bare `User` UUID, so no client could name the author. bare `User` UUID, so no client could name the author.
""" """
@ -99,7 +99,7 @@ def member_article(org_a, regular_user):
@pytest.mark.django_db @pytest.mark.django_db
class TestWhoMayEditAnArticle: class TestWhoMayEditAnArticle:
"""`write` — author or admin. Proven both ways, per rule.""" """`write`, author or admin. Proven both ways, per rule."""
def test_author_may_edit_their_own(self, user_client, member_article): def test_author_may_edit_their_own(self, user_client, member_article):
response = user_client.patch( response = user_client.patch(
@ -138,7 +138,7 @@ class TestWhoMayEditAnArticle:
assert Solution.objects.filter(pk=admin_article.pk).exists() assert Solution.objects.filter(pk=admin_article.pk).exists()
def test_every_member_may_read_every_article(self, user_client, admin_article): def test_every_member_may_read_every_article(self, user_client, admin_article):
"""`read` is deliberately not restricted — see `cases.kb_access`.""" """`read` is deliberately not restricted. See `cases.kb_access`."""
response = user_client.get(_detail(admin_article.pk)) response = user_client.get(_detail(admin_article.pk))
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert response.data["title"] == "Admin's article" assert response.data["title"] == "Admin's article"
@ -298,7 +298,7 @@ class TestPublishedImpliesApproved:
): ):
"""Rows written before the rule existed can be published drafts. """Rows written before the rule existed can be published drafts.
Judging the stored combination on a request that touches neither Judging the stored combination on a request that touches neither
field would make them uneditable — and editing is how somebody fixes field would make them uneditable, and editing is how somebody fixes
one.""" one."""
article = _article(org_a, regular_user, status="draft", is_published=True) article = _article(org_a, regular_user, status="draft", is_published=True)
response = user_client.patch( response = user_client.patch(
@ -373,7 +373,7 @@ class TestLinkedCasesIsNotAWayIntoCases:
def test_the_count_stays_honest(self, user_client, hidden_case, member_article): def test_the_count_stays_honest(self, user_client, hidden_case, member_article):
"""Zero rows and a count of one is the page saying "there is a ticket """Zero rows and a count of one is the page saying "there is a ticket
here and it is not yours" — better than a number that quietly means here and it is not yours". Better than a number that quietly means
something different for each reader.""" something different for each reader."""
hidden_case.solutions.add(member_article) hidden_case.solutions.add(member_article)
response = user_client.get(_detail(member_article.pk)) response = user_client.get(_detail(member_article.pk))
@ -507,8 +507,8 @@ class TestTheListPayload:
self, admin_client, org_a, admin_user, django_assert_max_num_queries self, admin_client, org_a, admin_user, django_assert_max_num_queries
): ):
"""`case_count` and the nested `org` were both computed per row: 13 """`case_count` and the nested `org` were both computed per row: 13
articles, 27 queries. The ceiling here is the page's fixed cost — articles, 27 queries. The ceiling here is the page's fixed cost.
auth, the count, the page — and does not move with the row count.""" Auth, the count, the page, and does not move with the row count."""
for index in range(12): for index in range(12):
_article(org_a, admin_user, title=f"Article {index}") _article(org_a, admin_user, title=f"Article {index}")

View file

@ -85,7 +85,7 @@ class TestModelConstraints:
TimeEntry.objects.create( TimeEntry.objects.create(
org=org_a, case=case, profile=admin_profile, started_at=timezone.now() org=org_a, case=case, profile=admin_profile, started_at=timezone.now()
) )
# Different profile — should NOT collide. # Different profile, should NOT collide.
TimeEntry.objects.create( TimeEntry.objects.create(
org=org_a, case=case, profile=user_profile, started_at=timezone.now() org=org_a, case=case, profile=user_profile, started_at=timezone.now()
) )
@ -404,7 +404,7 @@ class TestTimesheetEndpoint:
entries = [e for d in body["days"] for e in d["entries"]] entries = [e for d in body["days"] for e in d["entries"]]
assert len(entries) == 1 assert len(entries) == 1
entry = entries[0] entry = entries[0]
# case is expanded to {id, name} — not the bare id the serializer emits — # case is expanded to {id, name}, not the bare id the serializer emits,
# so the page can show the ticket name and link to it. # so the page can show the ticket name and link to it.
assert entry["case"] == {"id": str(case.id), "name": "SSO login loop"} assert entry["case"] == {"id": str(case.id), "name": "SSO login loop"}
# An unbilled entry carries invoice: null, so the page never links to an # An unbilled entry carries invoice: null, so the page never links to an

View file

@ -104,7 +104,7 @@ class TestWatchAPI:
assert r.status_code == 204 assert r.status_code == 204
def test_cross_org_case_returns_404(self, admin_client, case_b): def test_cross_org_case_returns_404(self, admin_client, case_b):
# admin_client is in org_a; case_b is in org_b — should never be visible # admin_client is in org_a; case_b is in org_b, should never be visible
r = admin_client.post(f"/api/cases/{case_b.id}/watch/") r = admin_client.post(f"/api/cases/{case_b.id}/watch/")
assert r.status_code == 404 assert r.status_code == 404
@ -162,7 +162,7 @@ class TestWatchingListAPI:
CaseWatcher.objects.create( CaseWatcher.objects.create(
case=case_a, profile=admin_profile, org=case_a.org case=case_a, profile=admin_profile, org=case_a.org
) )
# Another user watches a different case — must not leak. # Another user watches a different case, must not leak.
CaseWatcher.objects.create( CaseWatcher.objects.create(
case=case_b_same_org, case=case_b_same_org,
profile=user_profile, profile=user_profile,

View file

@ -42,7 +42,7 @@ class TestParseMentions:
assert out == ["bob", "alice"] assert out == ["bob", "alice"]
def test_does_not_match_email_addresses(self): def test_does_not_match_email_addresses(self):
# The @ in 'foo@bar.com' is preceded by an alphanumeric — should be skipped. # The @ in 'foo@bar.com' is preceded by an alphanumeric, should be skipped.
assert parse_mentions("send mail to alice@example.com") == [] assert parse_mentions("send mail to alice@example.com") == []
def test_punctuation_chars_in_username(self): def test_punctuation_chars_in_username(self):
@ -52,7 +52,7 @@ class TestParseMentions:
] ]
def test_no_match_inside_word(self): def test_no_match_inside_word(self):
# `\u`@x` — preceded by alphanumeric, skip # `\u`@x`. Preceded by alphanumeric, skip
assert parse_mentions("u@x") == [] assert parse_mentions("u@x") == []
def test_empty_body(self): def test_empty_body(self):
@ -126,7 +126,7 @@ class TestDispatchForComment:
_comment( _comment(
case_a, "Sent to bob@org.com about this", by=admin_profile case_a, "Sent to bob@org.com about this", by=admin_profile
) )
# @ preceded by alphanumeric — must NOT be parsed as a mention. # @ preceded by alphanumeric, must NOT be parsed as a mention.
assert not Notification.objects.filter( assert not Notification.objects.filter(
recipient=bob, verb="case.mentioned" recipient=bob, verb="case.mentioned"
).exists() ).exists()
@ -171,7 +171,7 @@ class TestDispatchForComment:
def test_actor_does_not_get_their_own_comment_notification( def test_actor_does_not_get_their_own_comment_notification(
self, case_a, admin_profile, org_a self, case_a, admin_profile, org_a
): ):
# Actor is also a watcher — should still NOT receive case.commented. # Actor is also a watcher, should still NOT receive case.commented.
CaseWatcher.objects.create( CaseWatcher.objects.create(
case=case_a, profile=admin_profile, org=org_a case=case_a, profile=admin_profile, org=org_a
) )
@ -200,8 +200,8 @@ class TestNotificationLinkIsReachable:
"""`Notification.link` is a client path, and it has to be one a client serves. """`Notification.link` is a client path, and it has to be one a client serves.
The producer wrote `/cases/<id>` for the entire life of the feature. No The producer wrote `/cases/<id>` for the entire life of the feature. No
client has ever had a `/cases` route — the SvelteKit app serves tickets at client has ever had a `/cases` route, the SvelteKit app serves tickets at
`/tickets/<id>` — and its notification panel assigns `link` straight to `/tickets/<id>`, and its notification panel assigns `link` straight to
`window.location.href`, so every notification ever sent landed on a 404. `window.location.href`, so every notification ever sent landed on a 404.
""" """

View file

@ -3,17 +3,17 @@ Time-tracking endpoints (Tier 3 time-tracking).
Case-scoped: Case-scoped:
* ``GET /api/cases/<pk>/time-entries/`` — list (visible to actor) * ``GET /api/cases/<pk>/time-entries/``: list (visible to actor)
* ``POST /api/cases/<pk>/time-entries/`` — manual entry * ``POST /api/cases/<pk>/time-entries/``: manual entry
* ``POST /api/cases/<pk>/time-entries/start/`` — start a running timer (409 if one active) * ``POST /api/cases/<pk>/time-entries/start/``: start a running timer (409 if one active)
* ``GET /api/cases/<pk>/time-summary/`` — totals + by-profile breakdown * ``GET /api/cases/<pk>/time-summary/``: totals + by-profile breakdown
Entry-scoped (registered at the project root under ``/api/time-entries/``): Entry-scoped (registered at the project root under ``/api/time-entries/``):
* ``POST /api/time-entries/<pk>/stop/`` — stop a running timer * ``POST /api/time-entries/<pk>/stop/``: stop a running timer
* ``PUT /api/time-entries/<pk>/`` — owner or admin * ``PUT /api/time-entries/<pk>/``: owner or admin
* ``DELETE /api/time-entries/<pk>/`` — owner or admin * ``DELETE /api/time-entries/<pk>/``: owner or admin
* ``GET /api/time-entries/timesheet/`` — week view, grouped by day * ``GET /api/time-entries/timesheet/``: week view, grouped by day
""" """
from collections import OrderedDict from collections import OrderedDict
@ -123,7 +123,7 @@ class TimeEntryStartView(APIView):
class TimeSummaryView(APIView): class TimeSummaryView(APIView):
"""``GET /api/cases/<pk>/time-summary/`` — totals + per-profile breakdown. """``GET /api/cases/<pk>/time-summary/``, totals + per-profile breakdown.
Same shape as ``CaseSerializer.time_summary`` for clients that don't Same shape as ``CaseSerializer.time_summary`` for clients that don't
want to refetch the full case envelope. want to refetch the full case envelope.
@ -218,7 +218,7 @@ class TimeEntryDetailView(APIView):
class TimeEntryStopView(APIView): class TimeEntryStopView(APIView):
"""``POST /api/time-entries/<pk>/stop/`` — stop a running timer.""" """``POST /api/time-entries/<pk>/stop/``. Stop a running timer."""
permission_classes = (IsAuthenticated, HasOrgContext) permission_classes = (IsAuthenticated, HasOrgContext)
@ -249,7 +249,7 @@ class TimeEntryStopView(APIView):
class UnbilledEntriesView(APIView): class UnbilledEntriesView(APIView):
"""``GET /api/time-entries/unbilled/?account=<uuid>`` — list billable, """``GET /api/time-entries/unbilled/?account=<uuid>``: list billable,
stopped, not-yet-invoiced entries for an account so the invoice picker stopped, not-yet-invoiced entries for an account so the invoice picker
can show them. Org-scoped; admins see all entries, agents only their own. can show them. Org-scoped; admins see all entries, agents only their own.
""" """

View file

@ -106,7 +106,7 @@ urlpatterns = [
routing_views.RoutingRuleTestView.as_view(), routing_views.RoutingRuleTestView.as_view(),
name="routing_rule_test", name="routing_rule_test",
), ),
# Inbound email — public webhook (SNS-signed) + admin mailbox CRUD. # Inbound email: public webhook (SNS-signed) + admin mailbox CRUD.
path( path(
"inbound/<str:mailbox_id>/", "inbound/<str:mailbox_id>/",
inbound_views.InboundMailboxWebhookView.as_view(), inbound_views.InboundMailboxWebhookView.as_view(),
@ -197,7 +197,7 @@ urlpatterns = [
import_views.CaseImportCommitView.as_view(), import_views.CaseImportCommitView.as_view(),
name="cases_import_commit", name="cases_import_commit",
), ),
# Case ↔ Solution linking (M2M endpoints — must be before <str:pk>/ patterns) # Case ↔ Solution linking (M2M endpoints, must be before <str:pk>/ patterns)
path( path(
"<str:pk>/solutions/", "<str:pk>/solutions/",
views.CaseSolutionLinkView.as_view(), views.CaseSolutionLinkView.as_view(),
@ -274,7 +274,7 @@ urlpatterns = [
time_views.TimeSummaryView.as_view(), time_views.TimeSummaryView.as_view(),
name="case_time_summary", name="case_time_summary",
), ),
# Approval workflows (must be before <str:pk>/ catchall) — Tier 3 approvals. # Approval workflows (must be before <str:pk>/ catchall), Tier 3 approvals.
path( path(
"approval-rules/", "approval-rules/",
approval_views.ApprovalRuleListCreateView.as_view(), approval_views.ApprovalRuleListCreateView.as_view(),

View file

@ -64,7 +64,7 @@ from contacts.models import Contact
from contacts.serializer import ContactSerializer from contacts.serializer import ContactSerializer
# A ticket is "open" while somebody still owes the customer something. The # A ticket is "open" while somebody still owes the customer something. The
# other three values in STATUS_CHOICE — Closed, Rejected, Duplicate — are all # other three values in STATUS_CHOICE (Closed, Rejected, Duplicate) are all
# ways of being finished with it. # ways of being finished with it.
OPEN_STATUSES = ("New", "Assigned", "Pending") OPEN_STATUSES = ("New", "Assigned", "Pending")
@ -87,7 +87,7 @@ def apply_case_list_filters(queryset, params):
Shared between CaseListView and WatchingListView so both endpoints accept Shared between CaseListView and WatchingListView so both endpoints accept
the same filter chips from the mobile client. Caller is responsible for the same filter chips from the mobile client. Caller is responsible for
the base scope (org membership, watcher allowance, etc.) — this only the base scope (org membership, watcher allowance, etc.). This only
layers in user-supplied filters. layers in user-supplied filters.
""" """
if not params: if not params:
@ -125,7 +125,7 @@ def apply_case_list_filters(queryset, params):
queryset = queryset.filter(created_at__lte=params.get("created_at__lte")) queryset = queryset.filter(created_at__lte=params.get("created_at__lte"))
if params.get("sla_breached") == "true": if params.get("sla_breached") == "true":
# Wall-clock approximation matching the mobile card's # Wall-clock approximation matching the mobile card's
# `isFirstResponseSlaBreached` getter — `Case.is_sla_*_breached` uses # `isFirstResponseSlaBreached` getter; `Case.is_sla_*_breached` uses
# business hours, but the badges on both clients compute from # business hours, but the badges on both clients compute from
# created_at + hours, so this filter mirrors what the user actually # created_at + hours, so this filter mirrors what the user actually
# sees on the row. Postgres-specific (INTERVAL). # sees on the row. Postgres-specific (INTERVAL).
@ -147,7 +147,7 @@ def apply_case_list_filters(queryset, params):
cf_key = raw_key[3:] cf_key = raw_key[3:]
if cf_key: if cf_key:
queryset = queryset.filter(custom_fields__contains={cf_key: raw_value}) queryset = queryset.filter(custom_fields__contains={cf_key: raw_value})
# Ordering — whitelisted so callers can't sort on arbitrary cols. # Ordering: whitelisted so callers can't sort on arbitrary cols.
ordering = params.get("ordering") ordering = params.get("ordering")
if ordering in _ALLOWED_CASE_ORDERINGS: if ordering in _ALLOWED_CASE_ORDERINGS:
queryset = queryset.order_by(ordering, "-id") queryset = queryset.order_by(ordering, "-id")
@ -162,7 +162,7 @@ class CaseListView(APIView, LimitOffsetPagination):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
params = self.request.query_params params = self.request.query_params
# `-id` is a random UUID, so the default "newest first" was in fact no # `-id` is a random UUID, so the default "newest first" was in fact no
# order at all — the queue came back shuffled and the page still said # order at all, the queue came back shuffled and the page still said
# it was sorted. `-created_at` is the order the header promises; # it was sorted. `-created_at` is the order the header promises;
# `-id` stays as a tiebreak so pagination is stable when a batch of # `-id` stays as a tiebreak so pagination is stable when a batch of
# cases shares a timestamp (which the seeded data does exactly). # cases shares a timestamp (which the seeded data does exactly).
@ -190,7 +190,7 @@ class CaseListView(APIView, LimitOffsetPagination):
if not is_org_admin(self.request.profile): if not is_org_admin(self.request.profile):
# Watcher allowance: a non-admin who is a watcher must still be # Watcher allowance: a non-admin who is a watcher must still be
# able to see the case even when un-assigned. The rule now lives # able to see the case even when un-assigned. The rule now lives
# in `cases.access` so the detail view enforces the same one — it # in `cases.access` so the detail view enforces the same one. It
# used to drop the watcher clause, which meant this list handed # used to drop the watcher clause, which meant this list handed
# somebody a ticket that answered 403 when they clicked it. # somebody a ticket that answered 403 when they clicked it.
queryset = queryset.filter( queryset = queryset.filter(
@ -246,7 +246,7 @@ class CaseListView(APIView, LimitOffsetPagination):
context["priority"] = PRIORITY_CHOICE context["priority"] = PRIORITY_CHOICE
context["type_of_case"] = CASE_TYPE context["type_of_case"] = CASE_TYPE
# The account and contact catalogues exist for the case *form*, but # The account and contact catalogues exist for the case *form*, but
# they were serialized in full on every list call — 190 KB of response # they were serialized in full on every list call, 190 KB of response
# for a queue of five tickets in the seeded org, and it grows with the # for a queue of five tickets in the seeded org, and it grows with the
# org rather than with the page. `?slim=true` omits them for callers # org rather than with the page. `?slim=true` omits them for callers
# that only want the queue. The default is unchanged, so v1 and the # that only want the queue. The default is unchanged, so v1 and the
@ -254,8 +254,8 @@ class CaseListView(APIView, LimitOffsetPagination):
if params.get("slim") != "true": if params.get("slim") != "true":
context["accounts_list"] = AccountSerializer(accounts, many=True).data context["accounts_list"] = AccountSerializer(accounts, many=True).data
context["contacts_list"] = ContactSerializer(contacts, many=True).data context["contacts_list"] = ContactSerializer(contacts, many=True).data
# `profiles` was computed a few lines up — narrowed to admins for # `profiles` was computed a few lines up, narrowed to admins for
# non-admins, even — and then dropped on the floor. So a ticket form # non-admins, even, and then dropped on the floor. So a ticket form
# had no way to populate an assignee picker from the endpoint that # had no way to populate an assignee picker from the endpoint that
# already knew the answer. Same `id` / `user__email` shape the # already knew the answer. Same `id` / `user__email` shape the
# contacts and accounts lists publish. # contacts and accounts lists publish.
@ -650,7 +650,7 @@ class CaseDetailView(APIView):
elif self.request.profile.user_id != self.cases.created_by_id: elif self.request.profile.user_id != self.cases.created_by_id:
# `created_by` is a `User`; `Profile.user` is the FK pointing at # `created_by` is a `User`; `Profile.user` is the FK pointing at
# one, so `User.user` does not exist and this line raised # one, so `User.user` does not exist and this line raised
# AttributeError — a 500 for *every* non-admin on *every* ticket # AttributeError, a 500 for *every* non-admin on *every* ticket
# they were entitled to open. The guard above it compared a # they were entitled to open. The guard above it compared a
# Profile to a User and was always true, so nothing shielded it. # Profile to a User and was always true, so nothing shielded it.
# The admin branch above emits `user__email` keys, so this one does # The admin branch above emits `user__email` keys, so this one does
@ -748,7 +748,7 @@ class CaseDetailView(APIView):
) )
def post(self, request, pk, **kwargs): def post(self, request, pk, **kwargs):
params = request.data params = request.data
# `.get()` raised DoesNotExist — a 500 — when the case was missing or # `.get()` raised DoesNotExist, a 500, when the case was missing or
# belonged to another org. Replying to a ticket that is not there is a # belonged to another org. Replying to a ticket that is not there is a
# 404, not a crash. # 404, not a crash.
self.cases_obj = self.get_object(pk) self.cases_obj = self.get_object(pk)
@ -947,7 +947,7 @@ class CaseCommentView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext) permission_classes = (IsAuthenticated, HasOrgContext)
def get_object(self, pk): def get_object(self, pk):
"""Org-scoped already; `.get()` was the problem — a comment id that """Org-scoped already; `.get()` was the problem. A comment id that
does not exist raised DoesNotExist and answered 500 instead of 404.""" does not exist raised DoesNotExist and answered 500 instead of 404."""
try: try:
comment = self.model.objects.filter( comment = self.model.objects.filter(
@ -1104,7 +1104,7 @@ class CaseAttachmentView(APIView):
generic table shared by leads, accounts, contacts, deals, cases and generic table shared by leads, accounts, contacts, deals, cases and
tasks, so this endpoint deleted any attachment in the database tasks, so this endpoint deleted any attachment in the database
belonging to any organisation, given only its UUID. RLS blocks that belonging to any organisation, given only its UUID. RLS blocks that
in a correctly-configured deployment — but per CLAUDE.md the ORM in a correctly-configured deployment, but per CLAUDE.md the ORM
filter is the contract and RLS is the safety net, and the dev role filter is the contract and RLS is the safety net, and the dev role
here is a superuser, so the probe went through. here is a superuser, so the probe went through.
2. `request.profile == self.object.created_by` compares a `Profile` to 2. `request.profile == self.object.created_by` compares a `Profile` to
@ -1166,7 +1166,7 @@ class CaseSolutionLinkView(APIView):
# Attaching an article to a ticket changes the ticket, so it takes the # Attaching an article to a ticket changes the ticket, so it takes the
# same permission as any other change to it. Org alone was the whole # same permission as any other change to it. Org alone was the whole
# check, which meant somebody refused the case with a 403 could still # check, which meant somebody refused the case with a 403 could still
# link to it — and then read the case straight back out of the # link to it, and then read the case straight back out of the
# article's `linked_cases`. Closing that read-around at the serializer # article's `linked_cases`. Closing that read-around at the serializer
# is not enough on its own: writing to a ticket you cannot open is a # is not enough on its own: writing to a ticket you cannot open is a
# defect whichever way the data comes back. # defect whichever way the data comes back.
@ -1255,15 +1255,15 @@ def _reopen_analytics(org):
persists: persists:
- `reopened_last_30d`: one REOPENED Activity is written per reopen. - `reopened_last_30d`: one REOPENED Activity is written per reopen.
- `replies_after_window_30d`: when an external reply lands too late the - `replies_after_window_30d`: when an external reply lands too late the
signal flags the COMMENT Activity `out_of_reopen_window=True` — the signal flags the COMMENT Activity `out_of_reopen_window=True`, the
authoritative "reopened nothing" record, judged against the window in authoritative "reopened nothing" record, judged against the window in
force at the time (which a recompute here could not reproduce). force at the time (which a recompute here could not reproduce).
- `median_days_to_reply`: days-from-close-to-reply across every post-close - `median_days_to_reply`: days-from-close-to-reply across every post-close
external reply we can date. A reply that reopened nulled `closed_on`, so external reply we can date. A reply that reopened nulled `closed_on`, so
its delta survives only in the REOPENED Activity's `days_since_close`; a its delta survives only in the REOPENED Activity's `days_since_close`; a
reply that did not reopen left its case Closed, so its delta is reply that did not reopen left its case Closed, so its delta is
`commented_on − closed_on`. The median unions both, with no double count — `commented_on − closed_on`. The median unions both, with no double count.
a reopened case is no longer Closed, so it drops out of the comment side. A reopened case is no longer Closed, so it drops out of the comment side.
Every query is `org=`-scoped explicitly (RLS is inert for the app's DB role Every query is `org=`-scoped explicitly (RLS is inert for the app's DB role
in dev/test), so another org's cases, comments and activities cannot leak in. in dev/test), so another org's cases, comments and activities cannot leak in.

View file

@ -1,13 +1,13 @@
"""Watch / unwatch / list-watchers REST endpoints. """Watch / unwatch / list-watchers REST endpoints.
URL surface (mounted under /api/cases/): URL surface (mounted under /api/cases/):
POST /<id>/watch/ — subscribe the requesting profile (idempotent) POST /<id>/watch/: subscribe the requesting profile (idempotent)
DELETE /<id>/watch/ — unsubscribe DELETE /<id>/watch/: unsubscribe
GET /<id>/watchers/ — list everyone watching the case GET /<id>/watchers/: list everyone watching the case
GET /watching/ — cases the current profile watches GET /watching/: cases the current profile watches
Authorisation: a user who can see the case can also watch it, using the one Authorisation: a user who can see the case can also watch it, using the one
rule in `cases.access.visible_cases_qs` — admins see everything; non-admins rule in `cases.access.visible_cases_qs`, admins see everything; non-admins
see cases they created OR are assigned to OR already watch (so a watcher who see cases they created OR are assigned to OR already watch (so a watcher who
was un-assigned can still unsubscribe). This module used to keep its own copy was un-assigned can still unsubscribe). This module used to keep its own copy
of that query, which is how the case detail view came to disagree with it. of that query, which is how the case detail view came to disagree with it.

View file

@ -30,7 +30,7 @@ urlpatterns = [
include("business_hours.urls", namespace="api_business_hours"), include("business_hours.urls", namespace="api_business_hours"),
), ),
path("macros/", include("macros.urls", namespace="api_macros")), path("macros/", include("macros.urls", namespace="api_macros")),
# Public CSAT (Tier 2 csat) — anonymous, token-scoped. Lives outside # Public CSAT (Tier 2 csat): anonymous, token-scoped. Lives outside
# any app namespace because the customer reaches it from an emailed # any app namespace because the customer reaches it from an emailed
# link with no auth context. # link with no auth context.
path( path(

View file

@ -298,7 +298,7 @@ class AuditLogger:
"""Log a vertical-pack sample-data clear. """Log a vertical-pack sample-data clear.
This is the only destructive operation in the vertical-packs This is the only destructive operation in the vertical-packs
feature — apply_pack only ever creates and records itself via feature; apply_pack only ever creates and records itself via
PackApplication, but clear_sample_data deletes rows and, before PackApplication, but clear_sample_data deletes rows and, before
this, recorded nothing. Any org admin can trigger it, so an audit this, recorded nothing. Any org admin can trigger it, so an audit
trail of who cleared what, and how many rows, matters for incident trail of who cleared what, and how many rows, matters for incident

View file

@ -17,7 +17,7 @@ from common.mixins import AuditModel
# sixth copy from quietly dropping the warning. # sixth copy from quietly dropping the warning.
SAMPLE_DATA_HELP_TEXT = ( SAMPLE_DATA_HELP_TEXT = (
"True only for demo rows created by common.packs.applier._apply_sample_data. " "True only for demo rows created by common.packs.applier._apply_sample_data. "
"Server-set exclusively — never expose this as a writable field on any " "Server-set exclusively, never expose this as a writable field on any "
"serializer. It is the sole key common.packs.applier.clear_sample_data uses " "serializer. It is the sole key common.packs.applier.clear_sample_data uses "
"to decide what to delete, so a client-writable path here would let a user " "to decide what to delete, so a client-writable path here would let a user "
"mark an arbitrary real record as sample and have it deleted." "mark an arbitrary real record as sample and have it deleted."

View file

@ -129,7 +129,7 @@ def validate_payload(
Returns (cleaned_dict, errors_dict). Non-empty errors -> caller should 400. Returns (cleaned_dict, errors_dict). Non-empty errors -> caller should 400.
Unknown keys are dropped silently and logged. `existing` is the value Unknown keys are dropped silently and logged. `existing` is the value
currently stored on the entity — required fields already set on the entity currently stored on the entity. Required fields already set on the entity
are preserved across PATCHes that omit them. are preserved across PATCHes that omit them.
Values tied to soft-deleted (is_active=False) definitions are carried Values tied to soft-deleted (is_active=False) definitions are carried
@ -156,7 +156,7 @@ def validate_payload(
# Carry forward existing recognized values (active OR soft-deleted) that # Carry forward existing recognized values (active OR soft-deleted) that
# the caller didn't touch. Values whose definition was hard-deleted are # the caller didn't touch. Values whose definition was hard-deleted are
# dropped — the schema is gone. # dropped. The schema is gone.
for key, value in existing.items(): for key, value in existing.items():
if key in all_keys and key not in value_dict: if key in all_keys and key not in value_dict:
cleaned[key] = value cleaned[key] = value

View file

@ -11,7 +11,7 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
if User.objects.filter(is_superuser=True).exists(): if User.objects.filter(is_superuser=True).exists():
self.stdout.write(self.style.SUCCESS("Superuser already exists — skipping.")) self.stdout.write(self.style.SUCCESS("Superuser already exists; skipping."))
return return
email = os.environ.get("ADMIN_EMAIL", "admin@localhost") email = os.environ.get("ADMIN_EMAIL", "admin@localhost")
@ -20,7 +20,7 @@ class Command(BaseCommand):
if not password: if not password:
self.stdout.write( self.stdout.write(
self.style.WARNING( self.style.WARNING(
"WARNING: ADMIN_PASSWORD not set — using default 'admin'. " "WARNING: ADMIN_PASSWORD not set, using default 'admin'. "
"Change it immediately in production!" "Change it immediately in production!"
) )
) )

View file

@ -1,4 +1,4 @@
"""Mint a JWT for a given user — local-dev only. """Mint a JWT for a given user. Local-dev only.
Refuses to run unless DEBUG is true so this can never produce a token in Refuses to run unless DEBUG is true so this can never produce a token in
a production environment. a production environment.
@ -90,7 +90,7 @@ class Command(BaseCommand):
self.stdout.write(f"bound to org: {org.name} ({org.id})") self.stdout.write(f"bound to org: {org.name} ({org.id})")
else: else:
self.stdout.write( self.stdout.write(
"bound to org: (none — call /api/auth/orgswitch/ from the UI)" "bound to org: (none. Call /api/auth/orgswitch/ from the UI)"
) )
self.stdout.write("") self.stdout.write("")
self.stdout.write(self.style.SUCCESS("=== Paste into browser devtools ===")) self.stdout.write(self.style.SUCCESS("=== Paste into browser devtools ==="))

View file

@ -684,7 +684,7 @@ class Command(BaseCommand):
local-dev workflows have a known name to log into via `manage.py devlogin`. local-dev workflows have a known name to log into via `manage.py devlogin`.
Uses get_or_create on name so repeated seed runs reuse the same org rather Uses get_or_create on name so repeated seed runs reuse the same org rather
than piling up duplicates — duplicate names break `devlogin --org NAME` than piling up duplicates. Duplicate names break `devlogin --org NAME`
because it can't disambiguate. because it can't disambiguate.
""" """
name = "MicroPyramid" if index == 0 else self._demo_company() name = "MicroPyramid" if index == 0 else self._demo_company()

View file

@ -41,7 +41,7 @@ class GetProfileAndOrg:
request.profile = None request.profile = None
request.org = None request.org = None
# Personal Access Token (agent / MCP) — resolve here so org context is # Personal Access Token (agent / MCP). Resolve here so org context is
# set before RequireOrgContext runs (DRF auth runs too late for that). # set before RequireOrgContext runs (DRF auth runs too late for that).
# MUST come before the JWT branch so a PAT bearer is never handed to # MUST come before the JWT branch so a PAT bearer is never handed to
# the JWT decoder. # the JWT decoder.
@ -166,7 +166,7 @@ class GetProfileAndOrg:
if not profile: if not profile:
logger.error("No active admin profile found for org %s", organization.id) logger.error("No active admin profile found for org %s", organization.id)
# Let DRF authentication reject this — raising AuthenticationFailed # Let DRF authentication reject this, raising AuthenticationFailed
# here escapes the middleware stack as a 500 instead of a 401. # here escapes the middleware stack as a 500 instead of a 401.
return return

View file

@ -118,10 +118,10 @@ class RequireOrgContext:
"/admin/", "/admin/",
"/swagger-ui/", "/swagger-ui/",
"/api/schema/", "/api/schema/",
# Public CSAT survey link (Tier 2 csat) — anonymous, sets RLS # Public CSAT survey link (Tier 2 csat). Anonymous, sets RLS
# context manually inside the view from the survey's own org_id. # context manually inside the view from the survey's own org_id.
"/api/public/csat/", "/api/public/csat/",
# Client portal — the invoice and estimate links emailed to customers. # Client portal: the invoice and estimate links emailed to customers.
# Anonymous by design: the view authorises on `public_token` alone # Anonymous by design: the view authorises on `public_token` alone
# (see invoices/public_views.py) and never reads request.user or # (see invoices/public_views.py) and never reads request.user or
# request.org, so requiring org context here rejected every customer # request.org, so requiring org context here rejected every customer
@ -131,7 +131,7 @@ class RequireOrgContext:
# #
# NOTE: this restores reachability, not readability. `_set_org_context` # NOTE: this restores reachability, not readability. `_set_org_context`
# returns early when request.org is None, so these requests run with # returns early when request.org is None, so these requests run with
# `app.current_org` empty — and the isolation policy from # `app.current_org` empty, and the isolation policy from
# `get_enable_policy_sql` is `org_id::text = NULLIF(current_setting( # `get_enable_policy_sql` is `org_id::text = NULLIF(current_setting(
# 'app.current_org', true), '')`, which matches nothing when empty. # 'app.current_org', true), '')`, which matches nothing when empty.
# Verified as `crm_user` (non-superuser, no BYPASSRLS): invoice, # Verified as `crm_user` (non-superuser, no BYPASSRLS): invoice,
@ -143,12 +143,12 @@ class RequireOrgContext:
"/api/public/estimate/", "/api/public/estimate/",
] ]
# Paths exempt on an EXACT match only — never prefix-matched like # Paths exempt on an EXACT match only, never prefix-matched like
# EXEMPT_PATHS above. Mirrors the exact-match pattern already used in # EXEMPT_PATHS above. Mirrors the exact-match pattern already used in
# common.middleware.get_company.GetProfileAndOrg.process_request # common.middleware.get_company.GetProfileAndOrg.process_request
# (`if request.path in auth_skip_paths`). # (`if request.path in auth_skip_paths`).
# #
# GET /api/packs/ is static content read from JSON files in the repo — # GET /api/packs/ is static content read from JSON files in the repo,
# byte-identical for every tenant, no tenant data whatsoever. It backs # byte-identical for every tenant, no tenant data whatsoever. It backs
# the pack chooser on the org-creation page, where a user creating their # the pack chooser on the org-creation page, where a user creating their
# *first* org has no org_id claim yet, so RequireOrgContext would 403 the # *first* org has no org_id claim yet, so RequireOrgContext would 403 the

View file

@ -1,4 +1,4 @@
# Composite index used by the cases analytics aggregation queries — see # Composite index used by the cases analytics aggregation queries. See
# docs/cases/tier2/reporting.md "Data model changes". Lets us scan # docs/cases/tier2/reporting.md "Data model changes". Lets us scan
# `Case` Activity rows by (entity_type, action, created_at) inside a single # `Case` Activity rows by (entity_type, action, created_at) inside a single
# org without re-walking the whole table. # org without re-walking the whole table.

View file

@ -1,7 +1,7 @@
"""Drop the unused `session_token` table. """Drop the unused `session_token` table.
`SessionToken` was added as "Phase 3: JWT Token Tracking" but was never wired `SessionToken` was added as "Phase 3: JWT Token Tracking" but was never wired
up — nothing in any view, middleware, serializer, or task ever created, read, up, nothing in any view, middleware, serializer, or task ever created, read,
or revoked a row. Its only consumer was a Django admin page listing a or revoked a row. Its only consumer was a Django admin page listing a
permanently empty table, which read as a working session-revocation feature permanently empty table, which read as a working session-revocation feature
that did not exist. that did not exist.

View file

@ -11,10 +11,10 @@ the strategy only decides *which rows are visible*:
* If an empty context still exposes rows (a superuser / RLS-disabled dev DB, * If an empty context still exposes rows (a superuser / RLS-disabled dev DB,
where one deployment can have thousands of orgs), a single unscoped pass sees where one deployment can have thousands of orgs), a single unscoped pass sees
everything — O(rows), not O(orgs × rows). everything, O(rows), not O(orgs × rows).
* If an empty context hides rows (a correctly-configured non-superuser prod * If an empty context hides rows (a correctly-configured non-superuser prod
role), the pass sees nothing, so it falls back to iterating orgs and setting role), the pass sees nothing, so it falls back to iterating orgs and setting
``app.current_org`` for each — O(orgs) cheap lookups, each seeing only its own ``app.current_org`` for each: O(orgs) cheap lookups, each seeing only its own
rows. rows.
""" """

View file

@ -5,11 +5,11 @@
# task_stage all carry org_id directly, but they were absent from # task_stage all carry org_id directly, but they were absent from
# ORG_SCOPED_TABLES and no migration ever created their policies. The apps that # ORG_SCOPED_TABLES and no migration ever created their policies. The apps that
# created the tables (leads/0012, cases/0009, tasks/0009) contain no RLS SQL, # created the tables (leads/0012, cases/0009, tasks/0009) contain no RLS SQL,
# and common/0028 only iterates RLS_CONFIG["tables"] — which did not list them. # and common/0028 only iterates RLS_CONFIG["tables"], which did not list them.
# #
# Deployed databases had the policies from an out-of-band step, so the gap was # Deployed databases had the policies from an out-of-band step, so the gap was
# invisible there. A database built purely from migrations — CI, and any fresh # invisible there. A database built purely from migrations: CI, and any fresh
# deploy — got none, leaving these tables with no safety net beneath the ORM's # deploy, got none, leaving these tables with no safety net beneath the ORM's
# org filters. Vertical packs write to all six on every apply, which is what # org filters. Vertical packs write to all six on every apply, which is what
# made the gap load-bearing rather than theoretical. # made the gap load-bearing rather than theoretical.
# #

View file

@ -56,7 +56,7 @@ class User(AbstractBaseUser, PermissionsMixin):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# On first save only, fall back to the email local-part when no name # On first save only, fall back to the email local-part when no name
# was supplied — keeps `name` non-empty without overwriting later # was supplied. Keeps `name` non-empty without overwriting later
# edits (PATCHing name to "" leaves it empty by user intent). # edits (PATCHing name to "" leaves it empty by user intent).
if self._state.adding and not self.name and self.email: if self._state.adding and not self.name and self.email:
self.name = self.email.split("@", 1)[0][:255] self.name = self.email.split("@", 1)[0][:255]
@ -129,7 +129,7 @@ class Org(BaseModel):
max_length=2, choices=COUNTRIES, blank=True, null=True max_length=2, choices=COUNTRIES, blank=True, null=True
) )
# CSAT (Tier 2 csat). Org-level kill switch — when False, the # CSAT (Tier 2 csat). Org-level kill switch, when False, the
# post-close signal short-circuits before any survey email is sent. # post-close signal short-circuits before any survey email is sent.
csat_enabled = models.BooleanField(default=True) csat_enabled = models.BooleanField(default=True)
@ -138,7 +138,7 @@ class Org(BaseModel):
# explicit confirmation; this flag only controls the default state. # explicit confirmation; this flag only controls the default state.
auto_close_children_on_parent_close = models.BooleanField(default=False) auto_close_children_on_parent_close = models.BooleanField(default=False)
# Vertical packs. Descriptive only — records which pack was applied at # Vertical packs. Descriptive only: records which pack was applied at
# signup. NEVER used in a permission check or a queryset filter. # signup. NEVER used in a permission check or a queryset filter.
vertical = models.CharField( vertical = models.CharField(
max_length=64, max_length=64,
@ -576,7 +576,7 @@ class Activity(BaseModel):
("VIEW", "Viewed"), ("VIEW", "Viewed"),
("COMMENT", "Commented"), ("COMMENT", "Commented"),
("ASSIGN", "Assigned"), ("ASSIGN", "Assigned"),
# Cases roadmap verb registry — see docs/cases/COORDINATION_DECISIONS.md D1. # Cases roadmap verb registry: see docs/cases/COORDINATION_DECISIONS.md D1.
("STATUS_CHANGED", "Status Changed"), ("STATUS_CHANGED", "Status Changed"),
("PRIORITY_CHANGED", "Priority Changed"), ("PRIORITY_CHANGED", "Priority Changed"),
("ROUTED", "Routed"), ("ROUTED", "Routed"),
@ -659,7 +659,7 @@ class Notification(BaseModel):
"""In-app notification delivered to a single recipient. """In-app notification delivered to a single recipient.
Per `docs/cases/COORDINATION_DECISIONS.md` D2 we inherit BaseModel and Per `docs/cases/COORDINATION_DECISIONS.md` D2 we inherit BaseModel and
declare our own `org` FK rather than using BaseOrgModel — RLS still declare our own `org` FK rather than using BaseOrgModel. RLS still
applies via the migration in 0018. applies via the migration in 0018.
""" """
@ -869,7 +869,7 @@ class PersonalAccessToken(BaseOrgModel):
token_hash = models.CharField(max_length=64, unique=True, db_index=True) token_hash = models.CharField(max_length=64, unique=True, db_index=True)
token_prefix = models.CharField(max_length=20) token_prefix = models.CharField(max_length=20)
# NOTE: scopes are stored for forward-compatibility but are NOT enforced in # NOTE: scopes are stored for forward-compatibility but are NOT enforced in
# Phase 1 — a token always inherits the owning profile's full role/permissions. # Phase 1: a token always inherits the owning profile's full role/permissions.
# Do not treat `scopes` as a trust boundary until enforcement lands. # Do not treat `scopes` as a trust boundary until enforcement lands.
scopes = models.JSONField(default=list, blank=True) scopes = models.JSONField(default=list, blank=True)
expires_at = models.DateTimeField(null=True, blank=True) expires_at = models.DateTimeField(null=True, blank=True)
@ -910,15 +910,15 @@ class PersonalAccessToken(BaseOrgModel):
class PortalAccessToken(models.Model): class PortalAccessToken(models.Model):
"""Unscoped ``token_hash → org`` lookup for anonymous portal requests. """Unscoped ``token_hash → org`` lookup for anonymous portal requests.
The public portal endpoints — the invoice and estimate links emailed to The public portal endpoints: the invoice and estimate links emailed to
customers, and the CSAT survey link — arrive with no auth and no org customers, and the CSAT survey link, arrive with no auth and no org
context. RLS is keyed on ``app.current_org``, so with an empty context the context. RLS is keyed on ``app.current_org``, so with an empty context the
isolation policy hides the very row (invoice / estimate / csat_survey) that isolation policy hides the very row (invoice / estimate / csat_survey) that
would tell us which org the token belongs to. That is a chicken-and-egg: would tell us which org the token belongs to. That is a chicken-and-egg:
you cannot set the context until you know the org, and you cannot read the you cannot set the context until you know the org, and you cannot read the
org until the context is set. org until the context is set.
This table breaks it. It is deliberately **not** org-scoped — it is absent This table breaks it. It is deliberately **not** org-scoped. It is absent
from ``common.rls.ORG_SCOPED_TABLES`` and carries no RLS policy, so it is from ``common.rls.ORG_SCOPED_TABLES`` and carries no RLS policy, so it is
readable with an empty context. A public view hashes the URL token, looks readable with an empty context. A public view hashes the URL token, looks
up the org here, calls ``set_rls_context(org_id)``, and only then reads the up the org here, calls ``set_rls_context(org_id)``, and only then reads the
@ -927,7 +927,7 @@ class PortalAccessToken(models.Model):
The key is ``sha256(url_token)``. For invoices and estimates the URL token The key is ``sha256(url_token)``. For invoices and estimates the URL token
is the raw ``public_token``; for CSAT it is the signed token whose SHA-256 is the raw ``public_token``; for CSAT it is the signed token whose SHA-256
is already the stored ``csat_survey.token_hash`` — so a single hash resolves is already the stored ``csat_survey.token_hash``, so a single hash resolves
all three. The row leaks only the *existence* of a token to somebody who all three. The row leaks only the *existence* of a token to somebody who
already holds it; the resource contents stay behind RLS, and a disabled or already holds it; the resource contents stay behind RLS, and a disabled or
deleted resource still 404s because the view's own scoped query re-checks deleted resource still 404s because the view's own scoped query re-checks

View file

@ -5,7 +5,7 @@ future apps). Writes a `Notification` row, then best-effort publishes the
new row's id on a Redis pub/sub channel so the SSE consumer can fan it out new row's id on a Redis pub/sub channel so the SSE consumer can fan it out
to any open browser tabs for that recipient. to any open browser tabs for that recipient.
Channel naming: ``notif:<org_id>:<profile_id>`` — see Channel naming: ``notif:<org_id>:<profile_id>``. See
``docs/cases/tier2/in-app-notifications.md`` "Cross-org leak". ``docs/cases/tier2/in-app-notifications.md`` "Cross-org leak".
""" """
@ -29,7 +29,7 @@ _redis_lock = threading.Lock()
def _get_redis(): def _get_redis():
"""Lazy redis-py client keyed off CELERY_BROKER_URL. """Lazy redis-py client keyed off CELERY_BROKER_URL.
Returns None if redis is unavailable (e.g. test environments) — callers Returns None if redis is unavailable (e.g. test environments). Callers
must treat publish as best-effort. must treat publish as best-effort.
""" """
global _redis_client global _redis_client
@ -64,7 +64,7 @@ def _publish(channel: str, payload: str) -> None:
try: try:
client.publish(channel, payload) client.publish(channel, payload)
except Exception as exc: except Exception as exc:
# Publish failure must never break the originating request — the # Publish failure must never break the originating request. The
# row is already persisted and the user sees it on next poll/load. # row is already persisted and the user sees it on next poll/load.
logger.warning("notifications publish failed on %s: %s", channel, exc) logger.warning("notifications publish failed on %s: %s", channel, exc)

View file

@ -129,7 +129,7 @@ def _apply_tags(org, pack: dict, report: _Report) -> None:
def _apply_products(org, pack: dict, report: _Report) -> None: def _apply_products(org, pack: dict, report: _Report) -> None:
for product in pack.get("products") or []: for product in pack.get("products") or []:
# sku is mandatory in a manifest — see schema._validate_product. # sku is mandatory in a manifest. See schema._validate_product.
if Product.objects.filter(org=org, sku=product["sku"]).exists(): if Product.objects.filter(org=org, sku=product["sku"]).exists():
report.add_skipped("product", product["name"]) report.add_skipped("product", product["name"])
continue continue
@ -147,12 +147,12 @@ def _apply_products(org, pack: dict, report: _Report) -> None:
def _apply_terminology(org, pack: dict, report: _Report) -> None: def _apply_terminology(org, pack: dict, report: _Report) -> None:
# `org` is the caller's in-memory object, loaded before apply_pack opened # `org` is the caller's in-memory object, loaded before apply_pack opened
# its transaction — it can already be stale (e.g. a second apply of a # its transaction. It can already be stale (e.g. a second apply of a
# different pack applied via the same object, or genuine concurrency). # different pack applied via the same object, or genuine concurrency).
# Deciding "already set?" from that snapshot and then saving it back # Deciding "already set?" from that snapshot and then saving it back
# would overwrite whatever the DB row actually holds. Re-read the row # would overwrite whatever the DB row actually holds. Re-read the row
# under a lock, INSIDE this transaction, and decide from the locked row # under a lock, INSIDE this transaction, and decide from the locked row
# instead — that is the only copy of the truth we write from. # instead. That is the only copy of the truth we write from.
locked = Org.objects.select_for_update().get(pk=org.pk) locked = Org.objects.select_for_update().get(pk=org.pk)
terminology = pack.get("terminology") or {} terminology = pack.get("terminology") or {}
@ -169,7 +169,7 @@ def _apply_terminology(org, pack: dict, report: _Report) -> None:
current.update(added) current.update(added)
locked.terminology = current locked.terminology = current
changed_fields.append("terminology") changed_fields.append("terminology")
# Set regardless of whether this pack carries any terminology — a # Set regardless of whether this pack carries any terminology, a
# pipeline-only or products-only pack still applied, and vertical is the # pipeline-only or products-only pack still applied, and vertical is the
# field that records that. # field that records that.
if not locked.vertical: if not locked.vertical:
@ -188,7 +188,7 @@ def _sample_models():
Ordered as the applier creates them and, reversed, as clear_sample_data Ordered as the applier creates them and, reversed, as clear_sample_data
deletes them: a referenced row exists before the row referencing it, and is deletes them: a referenced row exists before the row referencing it, and is
removed after. Defined once so the apply guard, the applier and the clear removed after. Defined once so the apply guard, the applier and the clear
can never disagree about what "sample data" covers — a seventh entity added can never disagree about what "sample data" covers, a seventh entity added
to one list but not the others is the failure mode this shape prevents. to one list but not the others is the failure mode this shape prevents.
""" """
from accounts.models import Account from accounts.models import Account
@ -219,12 +219,12 @@ def _create_sample(report, type_: str, name: str, build):
The except covers *only* `build()`. It used to wrap the tags.add() / The except covers *only* `build()`. It used to wrap the tags.add() /
assigned_to.add() follow-up too, and blamed every IntegrityError on a assigned_to.add() follow-up too, and blamed every IntegrityError on a
duplicate email — which was wrong, and silent, for the one it actually duplicate email, which was wrong, and silent, for the one it actually
produced: assigned_to.add(None) when actor is None. Keeping the M2M work produced: assigned_to.add(None) when actor is None. Keeping the M2M work
outside is what stops this clause from misreporting an unrelated integrity outside is what stops this clause from misreporting an unrelated integrity
error as a collision. ValidationError is caught alongside because Task.save error as a collision. ValidationError is caught alongside because Task.save
calls full_clean, so a bad sample task raises Django's ValidationError calls full_clean, so a bad sample task raises Django's ValidationError
rather than an IntegrityError — uncaught, that is a 500 out of an endpoint rather than an IntegrityError. Uncaught, that is a 500 out of an endpoint
whose whole contract is "additive, never fatal". whose whole contract is "additive, never fatal".
""" """
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -253,7 +253,7 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
if not any(sample.get(key) for key in SAMPLE_ENTITY_KEYS): if not any(sample.get(key) for key in SAMPLE_ENTITY_KEYS):
return return
# is_sample, not the tag, is the re-apply guard — a tag is just a label a # is_sample, not the tag, is the re-apply guard. A tag is just a label a
# user can freely rename/delete/attach, so it must never gate anything this # user can freely rename/delete/attach, so it must never gate anything this
# module decides from. See clear_sample_data for the matching deletion-side # module decides from. See clear_sample_data for the matching deletion-side
# rule. The guard spans every sample model, not just Lead: a pack whose # rule. The guard spans every sample model, not just Lead: a pack whose
@ -264,9 +264,9 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
report.add_skipped("sample_data", pack["id"]) report.add_skipped("sample_data", pack["id"])
return return
# The tag is attached purely for visibility/filtering in the UI — it # The tag is attached purely for visibility/filtering in the UI. It
# carries no authority over what apply/clear do. Adopting this tag onto a # carries no authority over what apply/clear do. Adopting this tag onto a
# real record (or renaming another tag onto this slug) is harmless — # real record (or renaming another tag onto this slug) is harmless,
# nothing destructive keys off it. # nothing destructive keys off it.
sample_tag, _ = Tags.objects.get_or_create( sample_tag, _ = Tags.objects.get_or_create(
org=org, slug=SAMPLE_TAG_SLUG, defaults={"name": SAMPLE_TAG_NAME, "color": "gray"} org=org, slug=SAMPLE_TAG_SLUG, defaults={"name": SAMPLE_TAG_NAME, "color": "gray"}
@ -278,7 +278,7 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
return return
obj.tags.add(sample_tag) obj.tags.add(sample_tag)
# actor is optional (PackApplication.applied_by is nullable for the # actor is optional (PackApplication.applied_by is nullable for the
# same reason — a caller may apply without an assigning admin, e.g. a # same reason. A caller may apply without an assigning admin, e.g. a
# future management command). Without an assignee the admin who applied # future management command). Without an assignee the admin who applied
# the pack has no ordinary "my records" query that reaches these rows, # the pack has no ordinary "my records" query that reaches these rows,
# but that is a lesser loss than silently mis-skipping every sample row, # but that is a lesser loss than silently mis-skipping every sample row,
@ -286,7 +286,7 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
if actor is not None: if actor is not None:
obj.assigned_to.add(actor) obj.assigned_to.add(actor)
# Every reference below resolves against these maps alone — rows this apply # Every reference below resolves against these maps alone. Rows this apply
# just created. Never against a queryset of the org's existing records: a # just created. Never against a queryset of the org's existing records: a
# sample contact that latched onto a tenant's real "Acme Corp" would put demo # sample contact that latched onto a tenant's real "Acme Corp" would put demo
# rows inside real customer data, and would then make that real account a # rows inside real customer data, and would then make that real account a
@ -343,7 +343,7 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
finish(obj) finish(obj)
# Account.contacts is the M2M the account page actually reads; the # Account.contacts is the M2M the account page actually reads; the
# Contact.account FK alone leaves the account's contact list empty. # Contact.account FK alone leaves the account's contact list empty.
# See the "two account links on Contact" split — both are populated # See the "two account links on Contact" split. Both are populated
# here so the demo looks right from either side. # here so the demo looks right from either side.
if obj.account_id: if obj.account_id:
obj.account.contacts.add(obj) obj.account.contacts.add(obj)
@ -434,7 +434,7 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
due_date=s.get("due_date"), due_date=s.get("due_date"),
description=s.get("description"), description=s.get("description"),
stage=task_stages.get(s.get("stage")), stage=task_stages.get(s.get("stage")),
# At most one of these is set — schema._validate_sample_data # At most one of these is set; schema._validate_sample_data
# rejects a pack that names more, matching Task.clean. # rejects a pack that names more, matching Task.clean.
account=accounts.get(s.get("account")), account=accounts.get(s.get("account")),
opportunity=deals.get(s.get("deal")), opportunity=deals.get(s.get("deal")),
@ -484,7 +484,7 @@ def _ids_with_foreign_children(model, ids: list, retained_pks: set) -> set:
`retained_pks` is the exception, and it is what makes retention hold. A `retained_pks` is the exception, and it is what makes retention hold. A
sample ticket kept because it carries billable time is no longer going sample ticket kept because it carries billable time is no longer going
anywhere — so its sample account must be kept too, or deleting the account anywhere, so its sample account must be kept too, or deleting the account
would cascade the ticket away and take that billable time with it, which is would cascade the ticket away and take that billable time with it, which is
exactly the loss the retention rule exists to prevent. Because the loop runs exactly the loss the retention rule exists to prevent. Because the loop runs
children first, every retained child is already known by the time its parent children first, every retained child is already known by the time its parent
@ -507,8 +507,8 @@ def _ids_with_foreign_children(model, ids: list, retained_pks: set) -> set:
def clear_sample_data(org, actor=None) -> dict: def clear_sample_data(org, actor=None) -> dict:
"""Delete the demo records the applier created for THIS org, and nothing else. """Delete the demo records the applier created for THIS org, and nothing else.
Keyed on `is_sample` alone — a server-set, non-serializer-writable boolean on Keyed on `is_sample` alone. A server-set, non-serializer-writable boolean on
all six models — never on the "Sample data" tag. `Tags.save()` recomputes its all six models, never on the "Sample data" tag. `Tags.save()` recomputes its
slug on every save and the tag CRUD endpoints have no reserved-slug guard, so slug on every save and the tag CRUD endpoints have no reserved-slug guard, so
the tag can be renamed onto this slug, adopted from an existing tag, or the tag can be renamed onto this slug, adopted from an existing tag, or
attached to any real record through the ordinary tag picker. None of that is attached to any real record through the ordinary tag picker. None of that is
@ -525,23 +525,23 @@ def clear_sample_data(org, actor=None) -> dict:
2. **Never take real data with it, and never fail because of it.** A user who 2. **Never take real data with it, and never fail because of it.** A user who
logs a real deal against a demo account, bills hours against a demo logs a real deal against a demo account, bills hours against a demo
ticket, or raises a real invoice on a demo account must lose none of it — ticket, or raises a real invoice on a demo account must lose none of it,
and in the invoice case a naive delete would not merely lose data, it and in the invoice case a naive delete would not merely lose data, it
would raise ProtectedError and abort the entire clear. Any sample row would raise ProtectedError and abort the entire clear. Any sample row
still referenced by a non-sample row is kept and reported as retained. still referenced by a non-sample row is kept and reported as retained.
Returns {"deleted": {type: n}, "retained": {type: n}} — retained is a normal Returns {"deleted": {type: n}, "retained": {type: n}}. Retained is a normal
outcome, not an error, and the UI is expected to say so. outcome, not an error, and the UI is expected to say so.
`actor` is optional (mirrors apply_pack's PackApplication.applied_by) and is `actor` is optional (mirrors apply_pack's PackApplication.applied_by) and is
used only to attribute the audit record — it plays no part in the delete, used only to attribute the audit record. It plays no part in the delete,
which is already org-scoped. which is already org-scoped.
""" """
from common.audit_log import audit_log from common.audit_log import audit_log
deleted: dict[str, int] = {} deleted: dict[str, int] = {}
retained: dict[str, int] = {} retained: dict[str, int] = {}
# Accumulated across models so a kept child keeps its parent — see # Accumulated across models so a kept child keeps its parent. See
# _ids_with_foreign_children. # _ids_with_foreign_children.
retained_pks: set = set() retained_pks: set = set()
@ -561,14 +561,14 @@ def clear_sample_data(org, actor=None) -> dict:
# `_total` sums every row the collector removed, including the M2M # `_total` sums every row the collector removed, including the M2M
# through-table rows (tags/contacts/assigned_to/teams) cascaded off # through-table rows (tags/contacts/assigned_to/teams) cascaded off
# each deleted record — not the number of records deleted. Pull the # each deleted record, not the number of records deleted. Pull the
# model-specific count from the per-model breakdown instead of a # model-specific count from the per-model breakdown instead of a
# second, racy count() query. # second, racy count() query.
count = by_model.get(model._meta.label, 0) count = by_model.get(model._meta.label, 0)
if count: if count:
deleted[type_] = count deleted[type_] = count
# This is the only destructive operation in the feature — apply_pack records # This is the only destructive operation in the feature; apply_pack records
# itself via PackApplication, so a delete leaving no trace at all would be # itself via PackApplication, so a delete leaving no trace at all would be
# the odd one out. See AuditLogger.sample_data_cleared. # the odd one out. See AuditLogger.sample_data_cleared.
audit_log.sample_data_cleared( audit_log.sample_data_cleared(
@ -584,7 +584,7 @@ def apply_pack(org, pack: dict, actor) -> dict:
# actor must belong to org. _apply_sample_data does # actor must belong to org. _apply_sample_data does
# lead.assigned_to.add(actor) with no further org check of its own, so # lead.assigned_to.add(actor) with no further org check of its own, so
# without this guard a caller that passes a mismatched (org, actor) pair # without this guard a caller that passes a mismatched (org, actor) pair
# — e.g. an org-B apply given an org-A profile — creates cross-org #; e.g. an org-B apply given an org-A profile: creates cross-org
# assignments: an org-A profile ends up assigned to org-B's leads, and # assignments: an org-A profile ends up assigned to org-B's leads, and
# that profile can then read those leads through an ordinary "my leads" # that profile can then read those leads through an ordinary "my leads"
# query, which is a tenant-isolation break. This is enforced here, not # query, which is a tenant-isolation break. This is enforced here, not
@ -594,7 +594,7 @@ def apply_pack(org, pack: dict, actor) -> dict:
if actor is not None and actor.org_id != org.id: if actor is not None and actor.org_id != org.id:
raise ValueError("actor must belong to the org the pack is being applied to") raise ValueError("actor must belong to the org the pack is being applied to")
# Serialize concurrent applies to the same org — keyed on org alone, not # Serialize concurrent applies to the same org, keyed on org alone, not
# org+pack. Two of our match keys (pipeline name, invoice template name) # org+pack. Two of our match keys (pipeline name, invoice template name)
# are conventions rather than DB constraints, so without this two racing # are conventions rather than DB constraints, so without this two racing
# applies of the same pack could both pass the existence check and both # applies of the same pack could both pass the existence check and both
@ -605,13 +605,13 @@ def apply_pack(org, pack: dict, actor) -> dict:
# both cases. # both cases.
# #
# pg_advisory_xact_lock is Postgres-only. Production and every real # pg_advisory_xact_lock is Postgres-only. Production and every real
# deployment run Postgres (RLS requires it — see RLS_SETUP.md), but the # deployment run Postgres (RLS requires it: see RLS_SETUP.md), but the
# test suite runs on SQLite (crm/test_settings.py), which has no such # test suite runs on SQLite (crm/test_settings.py), which has no such
# function and would raise OperationalError. Guard on connection.vendor, # function and would raise OperationalError. Guard on connection.vendor,
# matching the existing pattern in common/tasks.py's set_rls_context and # matching the existing pattern in common/tasks.py's set_rls_context and
# common/management/commands/manage_rls.py: skip the Postgres-only # common/management/commands/manage_rls.py: skip the Postgres-only
# statement on other backends instead of trying to emulate it. This is a # statement on other backends instead of trying to emulate it. This is a
# real no-op only on SQLite, which is test-only in this codebase — on # real no-op only on SQLite, which is test-only in this codebase. On
# every backend that ships to production, the lock is taken. # every backend that ships to production, the lock is taken.
from django.db import connection from django.db import connection

View file

@ -1,6 +1,6 @@
"""Registry of vertical packs, built once from backend/packs/*.json. """Registry of vertical packs, built once from backend/packs/*.json.
Pack ids are dict keys, never filesystem paths — a caller-supplied id can only Pack ids are dict keys, never filesystem paths. A caller-supplied id can only
hit or miss this dict, so there is no traversal or SSRF surface. hit or miss this dict, so there is no traversal or SSRF surface.
""" """
@ -35,5 +35,5 @@ def get_registry() -> dict[str, dict]:
def get_pack(pack_id: str) -> dict | None: def get_pack(pack_id: str) -> dict | None:
"""Look up a pack by id. Unknown ids — including anything path-shaped — return None.""" """Look up a pack by id. Unknown ids, including anything path-shaped, return None."""
return get_registry().get(pack_id) return get_registry().get(pack_id)

View file

@ -1,6 +1,6 @@
"""Strict validation for vertical-pack manifests. """Strict validation for vertical-pack manifests.
Unknown keys are errors, not ignored — a typo in a pack file must fail in CI, Unknown keys are errors, not ignored. A typo in a pack file must fail in CI,
not silently drop a stage. See docs/superpowers/specs/2026-08-01-vertical-packs-design.md §6. not silently drop a stage. See docs/superpowers/specs/2026-08-01-vertical-packs-design.md §6.
""" """
@ -16,7 +16,7 @@ TOP_LEVEL_KEYS = {
"custom_fields", "tags", "products", "sample_data", "custom_fields", "tags", "products", "sample_data",
} }
# is_default is a recognised key on both — not a typo — but is rejected by # is_default is a recognised key on both, not a typo, but is rejected by
# _reject_is_default with a specific message rather than lumped in with # _reject_is_default with a specific message rather than lumped in with
# genuinely unknown keys. See _reject_is_default for why this matters. # genuinely unknown keys. See _reject_is_default for why this matters.
PIPELINE_KEYS = {"name", "description", "stages", "is_default"} PIPELINE_KEYS = {"name", "description", "stages", "is_default"}
@ -63,7 +63,7 @@ SAMPLE_TASK_KEYS = {
} }
# Every entity `sample_data` understands, in the order the applier creates them # Every entity `sample_data` understands, in the order the applier creates them
# — a later entity may reference an earlier one by name, never the reverse. #. A later entity may reference an earlier one by name, never the reverse.
# Tasks are last because a task is the only row that can point at any of the # Tasks are last because a task is the only row that can point at any of the
# other five. common.packs.applier imports this rather than restating it. # other five. common.packs.applier imports this rather than restating it.
SAMPLE_ENTITY_KEYS = ("accounts", "contacts", "deals", "tickets", "leads", "tasks") SAMPLE_ENTITY_KEYS = ("accounts", "contacts", "deals", "tickets", "leads", "tasks")
@ -96,14 +96,14 @@ def _reject_is_default(where: str, obj: dict) -> None:
# containing is_default gets this specific, actionable message instead of # containing is_default gets this specific, actionable message instead of
# a generic unknown-key error. is_default is deliberately INCLUDED in # a generic unknown-key error. is_default is deliberately INCLUDED in
# PIPELINE_KEYS/STAGE_KEYS (a recognised key, not a typo) precisely so # PIPELINE_KEYS/STAGE_KEYS (a recognised key, not a typo) precisely so
# _reject_unknown does not also flag it — this guard must be the sole # _reject_unknown does not also flag it. This guard must be the sole
# gatekeeper, or a stub-to-no-op could hide behind _reject_unknown's # gatekeeper, or a stub-to-no-op could hide behind _reject_unknown's
# "unknown key(s) [...]" message, which also contains the substring # "unknown key(s) [...]" message, which also contains the substring
# "is_default" and would let a loose regex match pass for the wrong # "is_default" and would let a loose regex match pass for the wrong
# reason. # reason.
if isinstance(obj, dict) and "is_default" in obj: if isinstance(obj, dict) and "is_default" in obj:
raise PackValidationError( raise PackValidationError(
f"{where}: is_default is not allowed in a pack — the admin promotes a default" f"{where}: is_default is not allowed in a pack, the admin promotes a default"
) )
@ -113,7 +113,7 @@ def _stage_type_choices(pipeline_key: str) -> set[str]:
CaseStage = open/closed/rejected (cases/models.py:531), CaseStage = open/closed/rejected (cases/models.py:531),
TaskStage = open/in_progress/completed (tasks/models.py:240). TaskStage = open/in_progress/completed (tasks/models.py:240).
Lazy-imported so schema.py stays importable before the Django app Lazy-imported so schema.py stays importable before the Django app
registry is configured — same reasoning as _validate_tag's lazy import registry is configured, same reasoning as _validate_tag's lazy import
of Tags. of Tags.
""" """
from cases.models import CaseStage from cases.models import CaseStage
@ -131,10 +131,10 @@ def _stage_type_choices(pipeline_key: str) -> set[str]:
def _maps_to_status_choices(pipeline_key: str) -> set[str] | None: def _maps_to_status_choices(pipeline_key: str) -> set[str] | None:
"""maps_to_status choices also differ per pipeline model. LeadStage maps """maps_to_status choices also differ per pipeline model. LeadStage maps
to LEAD_STATUS and CaseStage maps to STATUS_CHOICE (leads/models.py:293, to LEAD_STATUS and CaseStage maps to STATUS_CHOICE (leads/models.py:293,
cases/models.py:551) — both plain tuples in common/utils.py, lazy-imported cases/models.py:551), both plain tuples in common/utils.py, lazy-imported
for consistency with _stage_type_choices even though they don't touch the for consistency with _stage_type_choices even though they don't touch the
app registry. TaskStage.maps_to_status has no `choices` at all app registry. TaskStage.maps_to_status has no `choices` at all
(tasks/models.py:257 — a freeform CharField), so there is no vocabulary to (tasks/models.py:257. A freeform CharField), so there is no vocabulary to
validate task-pipeline stages against: this returns None and the caller validate task-pipeline stages against: this returns None and the caller
skips the check rather than inventing a restriction the model doesn't skips the check rather than inventing a restriction the model doesn't
have. have.
@ -252,7 +252,7 @@ def _require(where: str, obj: dict, *keys: str) -> None:
def _validate_choice(where: str, value, field: str, allowed: set[str]) -> None: def _validate_choice(where: str, value, field: str, allowed: set[str]) -> None:
"""Reject a value the model's `choices` would reject. """Reject a value the model's `choices` would reject.
Django does not enforce `choices` on .create() — only full_clean() does, and Django does not enforce `choices` on .create(), only full_clean() does, and
of the six sample models only Task calls it. So without this check a pack of the six sample models only Task calls it. So without this check a pack
could write a stage or status no UI can render and no filter can find, and could write a stage or status no UI can render and no filter can find, and
the row would look fine in the DB. Skips None/absent: every one of these the row would look fine in the DB. Skips None/absent: every one of these
@ -283,7 +283,7 @@ def _reference_index(where: str, rows: list, key_of, label: str) -> set[str]:
"""Build the set of names later entities may reference, rejecting duplicates. """Build the set of names later entities may reference, rejecting duplicates.
A reference is resolved by name, so two sample accounts called "Northwind" A reference is resolved by name, so two sample accounts called "Northwind"
would make `"account": "Northwind"` ambiguous — and the applier would pick would make `"account": "Northwind"` ambiguous, and the applier would pick
one arbitrarily. Catching it here keeps the reference scheme honest. one arbitrarily. Catching it here keeps the reference scheme honest.
""" """
seen: set[str] = set() seen: set[str] = set()
@ -291,7 +291,7 @@ def _reference_index(where: str, rows: list, key_of, label: str) -> set[str]:
name = key_of(row) name = key_of(row)
if name in seen: if name in seen:
raise PackValidationError( raise PackValidationError(
f"{where}[{i}]: duplicate {label} {name!r} — sample references are by name, " f"{where}[{i}]: duplicate {label} {name!r}. Sample references are by name, "
"so each must be unique within the pack" "so each must be unique within the pack"
) )
seen.add(name) seen.add(name)
@ -316,7 +316,7 @@ def _validate_sample_data(where: str, sample: dict, raw: dict) -> None:
"""Validate sample_data, including every cross-entity name reference. """Validate sample_data, including every cross-entity name reference.
References are checked against the pack's own lists, not the database, References are checked against the pack's own lists, not the database,
because that is the only thing true at authoring time — and because the because that is the only thing true at authoring time, and because the
applier deliberately resolves them the same way. A sample contact must applier deliberately resolves them the same way. A sample contact must
never attach itself to a tenant's real account (see _apply_sample_data), never attach itself to a tenant's real account (see _apply_sample_data),
so "resolvable" here means "declared in this pack" and nothing wider. so "resolvable" here means "declared in this pack" and nothing wider.
@ -436,7 +436,7 @@ def _validate_sample_data(where: str, sample: dict, raw: dict) -> None:
_validate_choice(at, row.get("stage"), "stage", lead_stages) _validate_choice(at, row.get("stage"), "stage", lead_stages)
# Lead.source and Lead.status carry `choices` the applier never checks. # Lead.source and Lead.status carry `choices` the applier never checks.
# Both were unvalidated here until three shipped packs were found writing # Both were unvalidated here until three shipped packs were found writing
# source="website" — a value LEAD_SOURCE does not contain, so the demo row # source="website". A value LEAD_SOURCE does not contain, so the demo row
# rendered as a blank dropdown and no source filter could reach it. The # rendered as a blank dropdown and no source filter could reach it. The
# row looked fine in the database, which is precisely why this needs a # row looked fine in the database, which is precisely why this needs a
# schema check rather than trust. # schema check rather than trust.

View file

@ -53,7 +53,7 @@ class PATAuthentication(BaseAuthentication):
def authenticate(self, request): def authenticate(self, request):
raw = _extract_raw(request) raw = _extract_raw(request)
if not raw: if not raw:
return None # Not a PAT — let JWT / org-key auth handle it. return None # Not a PAT. Let JWT / org-key auth handle it.
# The GetProfileAndOrg middleware resolves the PAT first (so org # The GetProfileAndOrg middleware resolves the PAT first (so org
# context is set before RequireOrgContext runs) and stashes it on the # context is set before RequireOrgContext runs) and stashes it on the

View file

@ -65,12 +65,12 @@ class IsSuperAdmin(permissions.BasePermission):
Super admin is an explicit, deliberately granted flag on the user record Super admin is an explicit, deliberately granted flag on the user record
(``User.is_superuser``), never inferred from the email address. Deriving it (``User.is_superuser``), never inferred from the email address. Deriving it
from an email domain would hand platform-wide access — every org, every from an email domain would hand platform-wide access: every org, every
user — to anyone who can obtain an account at that domain, turning an user. To anyone who can obtain an account at that domain, turning an
ordinary signup into vertical privilege escalation. ordinary signup into vertical privilege escalation.
Grant it with ``manage.py createsuperuser``, the Django admin, or another Grant it with ``manage.py createsuperuser``, the Django admin, or another
audited path — not by handing out an email address. audited path, not by handing out an email address.
""" """
message = "Super admin access required." message = "Super admin access required."

View file

@ -9,13 +9,13 @@ it.
The lookup key is always ``sha256(url_token)``: The lookup key is always ``sha256(url_token)``:
* invoice / estimate — the URL token is the raw ``public_token``; * invoice / estimate. The URL token is the raw ``public_token``;
* CSAT — the URL token is the signed token, and ``cases.tasks.hash_csat_token`` * CSAT: the URL token is the signed token, and ``cases.tasks.hash_csat_token``
already stores its SHA-256 as ``csat_survey.token_hash``. already stores its SHA-256 as ``csat_survey.token_hash``.
Registration is called from the same place each token is minted, so the lookup Registration is called from the same place each token is minted, so the lookup
never drifts from the source of truth. Resolution is deliberately side-effect never drifts from the source of truth. Resolution is deliberately side-effect
free apart from returning the org id — the caller sets the RLS context. free apart from returning the org id. The caller sets the RLS context.
""" """
import hashlib import hashlib
@ -24,7 +24,7 @@ from common.models import PortalAccessToken
def portal_token_hash(url_token: str) -> str: def portal_token_hash(url_token: str) -> str:
"""SHA-256 hex digest of a raw URL token — the lookup key.""" """SHA-256 hex digest of a raw URL token. The lookup key."""
return hashlib.sha256(url_token.encode("utf-8")).hexdigest() return hashlib.sha256(url_token.encode("utf-8")).hexdigest()
@ -42,7 +42,7 @@ def register_portal_token(url_token, org_id, resource_type, resource_id) -> None
def register_portal_token_hash(token_hash, org_id, resource_type, resource_id) -> None: def register_portal_token_hash(token_hash, org_id, resource_type, resource_id) -> None:
"""Upsert by pre-computed hash — for callers that only store the hash (CSAT).""" """Upsert by pre-computed hash, for callers that only store the hash (CSAT)."""
if not token_hash or not org_id: if not token_hash or not org_id:
return return
PortalAccessToken.objects.update_or_create( PortalAccessToken.objects.update_or_create(
@ -58,7 +58,7 @@ def register_portal_token_hash(token_hash, org_id, resource_type, resource_id) -
def resolve_portal_org(url_token, resource_type=None): def resolve_portal_org(url_token, resource_type=None):
"""Resolve the org id for a raw URL token, or ``None`` if unknown. """Resolve the org id for a raw URL token, or ``None`` if unknown.
Reads the unscoped lookup only — no org-scoped table is touched, so it works Reads the unscoped lookup only, no org-scoped table is touched, so it works
with an empty RLS context. When ``resource_type`` is given it must match, with an empty RLS context. When ``resource_type`` is given it must match,
so an estimate token cannot resolve on the invoice endpoint. The caller is so an estimate token cannot resolve on the invoice endpoint. The caller is
responsible for ``set_rls_context`` and for the resource query that responsible for ``set_rls_context`` and for the resource query that

View file

@ -37,8 +37,8 @@ ORG_SCOPED_TABLES = [
"accounts", # Note: plural "accounts", # Note: plural
"contacts", # Note: plural "contacts", # Note: plural
"opportunity", "opportunity",
"sales_goal", # SalesGoal quota/target — org-scoped, see opportunity/0012 "sales_goal", # SalesGoal quota/target: org-scoped, see opportunity/0012
# Both org-scoped opportunity children were live but unregistered — the # Both org-scoped opportunity children were live but unregistered, the
# policy tooling (manage_rls --status, audits) could not see them. RLS is # policy tooling (manage_rls --status, audits) could not see them. RLS is
# stamped from source by common/0011 (line items) and opportunity/0013 # stamped from source by common/0011 (line items) and opportunity/0013
# (stage aging); these entries put them back under central governance. # (stage aging); these entries put them back under central governance.
@ -107,7 +107,7 @@ ORG_SCOPED_TABLES = [
# Business hours (Tier 2 business-hours-sla) # Business hours (Tier 2 business-hours-sla)
"business_calendar", "business_calendar",
"business_holiday", "business_holiday",
# Macros / canned responses (Tier 2 macros) — RLS is enabled by the # Macros / canned responses (Tier 2 macros). RLS is enabled by the
# migration; this entry registers the table with the central status # migration; this entry registers the table with the central status
# tooling (manage_rls --status, audit verifications). # tooling (manage_rls --status, audit verifications).
"macro", "macro",
@ -119,7 +119,7 @@ ORG_SCOPED_TABLES = [
"approval_rule", "approval_rule",
"approval", "approval",
# MCP / programmatic access # MCP / programmatic access
# NOTE: personal_access_token is intentionally NOT RLS-protected — it is an # NOTE: personal_access_token is intentionally NOT RLS-protected. It is an
# auth-bootstrap table (looked up by token_hash before any tenant context # auth-bootstrap table (looked up by token_hash before any tenant context
# exists), mirroring the Org table. Isolation for token management is enforced # exists), mirroring the Org table. Isolation for token management is enforced
# by explicit org+profile filters in common/views/pat_views.py. # by explicit org+profile filters in common/views/pat_views.py.

View file

@ -1,4 +1,4 @@
"""Org-scoped global search behind ``GET /api/search/?q=`` — the ⌘K palette. """Org-scoped global search behind ``GET /api/search/?q=``, the ⌘K palette.
ONE endpoint, deliberately. The org comes from the JWT (`request.profile.org`) ONE endpoint, deliberately. The org comes from the JWT (`request.profile.org`)
exactly once and every queryset is filtered by it; a per-model fan-out from the exactly once and every queryset is filtered by it; a per-model fan-out from the
@ -7,13 +7,13 @@ browser would be as many chances to read another tenant's rows.
Each type also honours the SAME read visibility as its own list view, so search Each type also honours the SAME read visibility as its own list view, so search
can never surface a record the caller could not open from the list: can never surface a record the caller could not open from the list:
* leads / opportunities / invoices — admin (role ``ADMIN`` or a Django * leads / opportunities / invoices, admin (role ``ADMIN`` or a Django
superuser) sees the whole org; everyone else sees only what they created or superuser) sees the whole org; everyone else sees only what they created or
were assigned. were assigned.
* accounts / contacts — admin (role ``ADMIN`` or the org-admin flag) sees the * accounts / contacts, admin (role ``ADMIN`` or the org-admin flag) sees the
whole org; everyone else sees created-or-assigned. whole org; everyone else sees created-or-assigned.
* tickets — reuse ``visible_cases_qs`` (adds the watcher clause). * tickets. Reuse ``visible_cases_qs`` (adds the watcher clause).
* knowledge-base articles — org-wide, every member reads them. * knowledge-base articles. Org-wide, every member reads them.
Matching stays on the server; the whole record set never reaches the browser. Matching stays on the server; the whole record set never reaches the browser.
""" """
@ -32,7 +32,7 @@ from invoices.models import Invoice
from leads.models import Lead from leads.models import Lead
from opportunity.models import Opportunity from opportunity.models import Opportunity
# Rows per type. Small on purpose — the palette shows a handful per group and # Rows per type. Small on purpose. The palette shows a handful per group and
# the point is the fastest match, not an exhaustive report. # the point is the fastest match, not an exhaustive report.
PER_TYPE = 6 PER_TYPE = 6
# One-character queries match almost everything; wait for a second character. # One-character queries match almost everything; wait for a second character.
@ -43,7 +43,7 @@ def _own_filter(profile):
"""The created-or-assigned clause every assignable list view uses. """The created-or-assigned clause every assignable list view uses.
``created_by`` is a FK to ``User`` (not Profile), so it is compared against ``created_by`` is a FK to ``User`` (not Profile), so it is compared against
``profile.user`` — comparing it to the Profile is the silent always-False ``profile.user``: comparing it to the Profile is the silent always-False
bug this codebase has hit before. bug this codebase has hit before.
""" """
return Q(created_by=profile.user) | Q(assigned_to=profile) return Q(created_by=profile.user) | Q(assigned_to=profile)
@ -64,7 +64,7 @@ def _scope_superuser(qs, profile, user):
class GlobalSearchView(APIView): class GlobalSearchView(APIView):
"""``GET /api/search/?q=<query>`` — a handful of matches per record type.""" """``GET /api/search/?q=<query>``. A handful of matches per record type."""
permission_classes = (IsAuthenticated, HasOrgContext) permission_classes = (IsAuthenticated, HasOrgContext)
@ -158,7 +158,7 @@ class GlobalSearchView(APIView):
} }
) )
# Tickets (Case) — the module's own read-visibility helper # Tickets (Case): the module's own read-visibility helper
cases = ( cases = (
visible_cases_qs(profile) visible_cases_qs(profile)
.select_related("account") .select_related("account")
@ -204,7 +204,7 @@ class GlobalSearchView(APIView):
} }
) )
# Knowledge base (Solution) — org-wide, every member reads # Knowledge base (Solution): org-wide, every member reads
solutions = Solution.objects.filter(org=org).filter( solutions = Solution.objects.filter(org=org).filter(
Q(title__icontains=q) | Q(description__icontains=q) Q(title__icontains=q) | Q(description__icontains=q)
)[:PER_TYPE] )[:PER_TYPE]

View file

@ -280,7 +280,7 @@ class CommentCreateSerializer(serializers.ModelSerializer):
class NotificationSerializer(serializers.ModelSerializer): class NotificationSerializer(serializers.ModelSerializer):
"""In-app notification — list/feed shape returned by GET and SSE.""" """In-app notification. List/feed shape returned by GET and SSE."""
actor = CommentUserSerializer(read_only=True) actor = CommentUserSerializer(read_only=True)
@ -318,7 +318,7 @@ class LeadCommentSerializer(serializers.ModelSerializer):
class CustomFieldDefinitionSerializer(serializers.ModelSerializer): class CustomFieldDefinitionSerializer(serializers.ModelSerializer):
"""Per-org custom-field definition. key/target_model/field_type are immutable """Per-org custom-field definition. key/target_model/field_type are immutable
after creation — admins must create a new definition to change shape.""" after creation. Admins must create a new definition to change shape."""
class Meta: class Meta:
model = CustomFieldDefinition model = CustomFieldDefinition
@ -549,7 +549,7 @@ class CreateProfileSerializer(serializers.ModelSerializer):
# a caller that forgets to pass it fails closed: the worst outcome of a # a caller that forgets to pass it fails closed: the worst outcome of a
# missing flag is that a privileged field is ignored, never that an # missing flag is that a privileged field is ignored, never that an
# unprivileged caller gets to set one. A plain member editing their own # unprivileged caller gets to set one. A plain member editing their own
# profile could otherwise PATCH themselves to role="ADMIN" — a live # profile could otherwise PATCH themselves to role="ADMIN". A live
# privilege escalation this closes by making these fields read_only # privilege escalation this closes by making these fields read_only
# (so they are silently ignored) when the caller may not grant them. # (so they are silently ignored) when the caller may not grant them.
can_grant = kwargs.pop("can_grant_privileges", False) can_grant = kwargs.pop("can_grant_privileges", False)
@ -602,13 +602,13 @@ class ProfileSelfUpdateSerializer(serializers.Serializer):
and the ``has_*_access`` / ``is_organization_admin`` flags are NOT here: and the ``has_*_access`` / ``is_organization_admin`` flags are NOT here:
those decide permissions and are an admin's to set. Leaving them out is what those decide permissions and are an admin's to set. Leaving them out is what
keeps this self-edit endpoint from becoming the same self-serve privilege keeps this self-edit endpoint from becoming the same self-serve privilege
escalation ``CreateProfileSerializer`` once was — a field the serializer does escalation ``CreateProfileSerializer`` once was. A field the serializer does
not name cannot be written, no matter what the request body contains. not name cannot be written, no matter what the request body contains.
``phone`` is validated (not written raw as the view used to), so a bad value ``phone`` is validated (not written raw as the view used to), so a bad value
is a clean 400 instead of an unvalidated string in the column. A blank phone is a clean 400 instead of an unvalidated string in the column. A blank phone
is allowed and clears it — ``allow_blank`` short-circuits before the regex, is allowed and clears it, ``allow_blank`` short-circuits before the regex,
which requires 7–25 characters. which requires 7-25 characters.
""" """
name = serializers.CharField(required=False, allow_blank=True, max_length=255) name = serializers.CharField(required=False, allow_blank=True, max_length=255)
@ -685,7 +685,7 @@ class DocumentSerializer(serializers.ModelSerializer):
@extend_schema_field(list) @extend_schema_field(list)
def get_teams(self, obj): def get_teams(self, obj):
# `member_count` is what a reader needs to understand a team share — # `member_count` is what a reader needs to understand a team share;
# "Support (3)" says how many people the pick actually reaches, which a # "Support (3)" says how many people the pick actually reaches, which a
# bare team name does not. Naming the team rather than flattening it to # bare team name does not. Naming the team rather than flattening it to
# its members keeps the share meaning what was chosen as people join and # its members keeps the share meaning what was chosen as people join and

View file

@ -110,7 +110,7 @@ def opportunity_post_delete(sender, instance, **kwargs):
create_activity(instance, "DELETE", "Opportunity") create_activity(instance, "DELETE", "Opportunity")
# Cases use a richer signal handler module — see cases/signals.py for # Cases use a richer signal handler module. See cases/signals.py for
# STATUS_CHANGED / PRIORITY_CHANGED / ASSIGN / COMMENT / LINKED_SOLUTION etc. # STATUS_CHANGED / PRIORITY_CHANGED / ASSIGN / COMMENT / LINKED_SOLUTION etc.

View file

@ -26,7 +26,7 @@ def set_rls_context(org_id):
Args: Args:
org_id: Organization UUID (string or UUID object) org_id: Organization UUID (string or UUID object)
""" """
# SQLite (test backend) has no set_config() — RLS is a Postgres feature. # SQLite (test backend) has no set_config(). RLS is a Postgres feature.
if connection.vendor != "postgresql": if connection.vendor != "postgresql":
return return
if org_id: if org_id:
@ -72,7 +72,7 @@ def send_magic_link_email(token_id, raw_code=None):
"""Send a magic-link or OTP-code email for passwordless authentication. """Send a magic-link or OTP-code email for passwordless authentication.
For `delivery == "code"` rows, the caller passes `raw_code` (the plaintext For `delivery == "code"` rows, the caller passes `raw_code` (the plaintext
OTP) — only the hash is stored on the token row, so the plaintext can't be OTP), only the hash is stored on the token row, so the plaintext can't be
recovered from the DB by this task. recovered from the DB by this task.
""" """
magic_token = MagicLinkToken.objects.filter(id=token_id).first() magic_token = MagicLinkToken.objects.filter(id=token_id).first()
@ -383,7 +383,7 @@ def purge_read_notifications(days=NOTIFICATION_PURGE_DAYS):
"""Delete already-read notifications older than ``days`` days. """Delete already-read notifications older than ``days`` days.
Schedule via celery-beat (recommended cadence: nightly). Runs once across Schedule via celery-beat (recommended cadence: nightly). Runs once across
all orgs — RLS does not need a per-org context here because the query all orgs. RLS does not need a per-org context here because the query
targets `read_at`, which is intrinsic to the row, not org-scoped logic. targets `read_at`, which is intrinsic to the row, not org-scoped logic.
""" """
cutoff = timezone.now() - timedelta(days=days) cutoff = timezone.now() - timedelta(days=days)
@ -405,12 +405,12 @@ def flush_expired_refresh_tokens():
`OutstandingToken` row per token minted and one `BlacklistedToken` row per `OutstandingToken` row per token minted and one `BlacklistedToken` row per
rotation. An expired token is rejected on its `exp` claim regardless of rotation. An expired token is rejected on its `exp` claim regardless of
blacklist state, so those rows stop carrying security value once blacklist state, so those rows stop carrying security value once
`expires_at` passes — and would otherwise grow unbounded, one row per login `expires_at` passes, and would otherwise grow unbounded, one row per login
and per refresh. Deleting the outstanding row cascades to its blacklist and per refresh. Deleting the outstanding row cascades to its blacklist
entry. entry.
Equivalent to simplejwt's `manage.py flushexpiredtokens`; schedule via Equivalent to simplejwt's `manage.py flushexpiredtokens`; schedule via
celery-beat (nightly is plenty). Not org-scoped — `expires_at` is intrinsic celery-beat (nightly is plenty). Not org-scoped; `expires_at` is intrinsic
to the row, so no RLS context is needed. to the row, so no RLS context is needed.
Returns the number of outstanding-token rows removed. Returns the number of outstanding-token rows removed.

View file

@ -120,7 +120,7 @@ class TestValidatePayload:
def test_inactive_definition_preserves_existing_value(self, org_a): def test_inactive_definition_preserves_existing_value(self, org_a):
"""Soft-deleting a definition must NOT wipe historical values on """Soft-deleting a definition must NOT wipe historical values on
the next save — admins can deactivate a field without data loss.""" the next save. Admins can deactivate a field without data loss."""
self._defn(org_a, is_active=False) self._defn(org_a, is_active=False)
cleaned, errors = validate_payload( cleaned, errors = validate_payload(
"Case", {}, org_a, existing={"severity": "S1"} "Case", {}, org_a, existing={"severity": "S1"}
@ -365,7 +365,7 @@ class TestCustomFieldDefinitionAPI:
@pytest.mark.django_db @pytest.mark.django_db
class TestCustomFieldAnalytics: class TestCustomFieldAnalytics:
"""records_missing_value + totals — the settings/custom-fields stat cards.""" """records_missing_value + totals, the settings/custom-fields stat cards."""
URL = "/api/custom-fields/" URL = "/api/custom-fields/"
@ -447,7 +447,7 @@ class TestCustomFieldAnalytics:
} }
def test_records_missing_value_is_org_scoped(self, admin_client, org_a, org_b): def test_records_missing_value_is_org_scoped(self, admin_client, org_a, org_b):
"""The record count must not include another org's rows — proven on """The record count must not include another org's rows. Proven on
SQLite where RLS is inert, so only the explicit org filter scopes it.""" SQLite where RLS is inert, so only the explicit org filter scopes it."""
CustomFieldDefinition.objects.create( CustomFieldDefinition.objects.create(
org=org_a, org=org_a,

View file

@ -79,10 +79,10 @@ def _find_missing(
if f"{method} {path}" in allowlist: if f"{method} {path}" in allowlist:
continue continue
if path not in operations: if path not in operations:
missing.append(f"{filename}: {method} {path} — no such path in the API") missing.append(f"{filename}: {method} {path}, no such path in the API")
elif method not in operations[path]: elif method not in operations[path]:
missing.append( missing.append(
f"{filename}: {method} {path} — path exists but does not accept " f"{filename}: {method} {path}. Path exists but does not accept "
f"{method} (accepts {', '.join(sorted(operations[path]))})" f"{method} (accepts {', '.join(sorted(operations[path]))})"
) )
return missing return missing

View file

@ -5,7 +5,7 @@ defects survived in it: the list crashed for non-admins on an AttributeError,
the object-level checks compared a `Profile` to a `User` FK and were therefore the object-level checks compared a `Profile` to a `User` FK and were therefore
always False, and `Document.teams` was written but never read. always False, and `Document.teams` was written but never read.
Every check below asserts BOTH directions — a permission test that only ever Every check below asserts BOTH directions, a permission test that only ever
sees one answer cannot tell a working check from a constant. sees one answer cannot tell a working check from a constant.
""" """
@ -168,7 +168,7 @@ class TestDocumentDetailAccess:
assert user_client.get(_detail_url(doc.id)).status_code == 200 assert user_client.get(_detail_url(doc.id)).status_code == 200
def test_stranger_is_forbidden(self, user_client, admin_user, org_a): def test_stranger_is_forbidden(self, user_client, admin_user, org_a):
"""The other direction — the check must still be able to say no.""" """The other direction. The check must still be able to say no."""
doc = _doc(org_a, admin_user) doc = _doc(org_a, admin_user)
assert user_client.get(_detail_url(doc.id)).status_code == 403 assert user_client.get(_detail_url(doc.id)).status_code == 403

View file

@ -435,7 +435,7 @@ class TestMagicLinkVerifyCode:
) )
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
# Also confirm that even a code that hashes to *something* on a link # Also confirm that even a code that hashes to *something* on a link
# token can't be used — there's no code_hash on a link row. # token can't be used. There's no code_hash on a link row.
# (Defensive: ensure the lookup filters by delivery="code".) # (Defensive: ensure the lookup filters by delivery="code".)
assert not MagicLinkToken.objects.filter( assert not MagicLinkToken.objects.filter(
email="linktok@example.com", is_used=True email="linktok@example.com", is_used=True

View file

@ -66,7 +66,7 @@ class _StubPubSub:
class TestStreamEvents(TransactionTestCase): class TestStreamEvents(TransactionTestCase):
"""Uses TransactionTestCase because the SSE generator's `sync_to_async` """Uses TransactionTestCase because the SSE generator's `sync_to_async`
DB hit opens a separate SQLite connection — incompatible with the DB hit opens a separate SQLite connection, incompatible with the
transaction-wrapped TestCase isolation.""" transaction-wrapped TestCase isolation."""
def setUp(self): def setUp(self):

View file

@ -172,7 +172,7 @@ class TestDispatcher(NotificationModelBase):
(), (),
{"publish": lambda self, *a, **kw: (_ for _ in ()).throw(RuntimeError("boom"))}, {"publish": lambda self, *a, **kw: (_ for _ in ()).throw(RuntimeError("boom"))},
)() )()
# Should NOT raise — publish is best-effort. # Should NOT raise. Publish is best-effort.
n = notifications.create(self.profile_a, "case.commented") n = notifications.create(self.profile_a, "case.commented")
assert n is not None assert n is not None
assert Notification.objects.filter(pk=n.pk).exists() assert Notification.objects.filter(pk=n.pk).exists()
@ -214,7 +214,7 @@ class TestPurgeTask(NotificationModelBase):
def test_purge_respects_custom_days_arg(self): def test_purge_respects_custom_days_arg(self):
from common.tasks import purge_read_notifications from common.tasks import purge_read_notifications
# Read 5 days ago — survives default 90, but not when days=1 # Read 5 days ago, survives default 90, but not when days=1
n = Notification.objects.create( n = Notification.objects.create(
org=self.org_a, org=self.org_a,
recipient=self.profile_a, recipient=self.profile_a,

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