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:
parent
d6bb754673
commit
e3b5181b19
487 changed files with 3548 additions and 3548 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
36
README.md
36
README.md
|
|
@ -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
|
||||||

|

|
||||||
|
|
||||||
**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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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=""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()}"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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``
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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").
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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/"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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!"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 ==="))
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue