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_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
|
||||
ADMIN_EMAIL=admin@localhost
|
||||
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
|
||||
# (which installs no docs group) cannot run them. Without this step the
|
||||
# "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
|
||||
working-directory: backend
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Read the Docs build configuration.
|
||||
# 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
|
||||
|
||||
build:
|
||||
|
|
|
|||
|
|
@ -10,20 +10,20 @@ Thank you for helping improve BottleCRM. Contributions of all sizes are welcome,
|
|||
|
||||
## 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.
|
||||
- [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.
|
||||
- [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.
|
||||
- [Adding an org-scoped model](docs/contributing/adding-an-org-scoped-model.md) — what a new multi-tenant model needs, including its RLS policy.
|
||||
- [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.
|
||||
- [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.
|
||||
- [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.
|
||||
|
||||
## Submitting a Pull Request
|
||||
|
||||
1. Create a branch from `master` with a descriptive name.
|
||||
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.
|
||||
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.
|
||||
|
||||
|
|
@ -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
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
**[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
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## How it compares
|
||||
|
|
@ -44,9 +44,9 @@ your data.
|
|||
|---|---|---|---|
|
||||
| **Cost** | Free, unlimited users | Per seat, per month | Free core, often paid tiers |
|
||||
| **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 |
|
||||
| **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 |
|
||||
| **Stack** | Django REST + SvelteKit | Closed source | Varies |
|
||||
| **License** | MIT | Proprietary | Varies, often GPL/AGPL |
|
||||
|
|
@ -65,14 +65,14 @@ login, loading demo data, and what each service does.
|
|||
|
||||
## 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
|
||||
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.
|
||||
- **[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.
|
||||
- **[Contributing](docs/contributing/development-setup.md)** — local development setup, testing, code style, and how to open a pull request.
|
||||
- **[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.
|
||||
- **[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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ SENTRY_DSN=""
|
|||
CELERY_BROKER_URL="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_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)
|
||||
- **PostgreSQL**
|
||||
- **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
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ cd backend
|
|||
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
|
||||
|
||||
|
|
@ -181,7 +181,7 @@ Every request operates within an organization context:
|
|||
- **Organization (Org)**: Top-level tenant container
|
||||
- **Users**: Regular members with USER 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
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
|||
migrations.AddField(
|
||||
model_name='account',
|
||||
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.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
`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
|
||||
of which field was at fault. `number_of_employees` is a
|
||||
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
|
||||
breaking:
|
||||
|
||||
1. `get`, `put`, `patch` and comment `post` compared `request.profile` — a
|
||||
Profile — against `account.created_by`, a FK to `User`. Never equal, so the
|
||||
1. `get`, `put`, `patch` and comment `post` compared `request.profile`, a
|
||||
Profile: against `account.created_by`, a FK to `User`. Never equal, so the
|
||||
creator branch was unreachable. `delete()` and the list filter compared
|
||||
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
|
||||
anyway.
|
||||
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.
|
||||
3. Commenting on an id that does not exist raised `DoesNotExist` (500) where
|
||||
`get` on the same id answered 404.
|
||||
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
|
||||
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
|
||||
IntegrityError, i.e. a 500 naming no field.
|
||||
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
|
||||
the interesting case is an account with several deals *and* several invoices
|
||||
— a single `.annotate(Sum(...), Sum(...))` multiplies those together.
|
||||
the interesting case is an account with several deals *and* several invoices,
|
||||
a single `.annotate(Sum(...), Sum(...))` multiplies those together.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
|
@ -68,7 +68,7 @@ def _created_by(account, user):
|
|||
"""Stage a creator.
|
||||
|
||||
`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
|
||||
way to set it. Getting this wrong makes every creator test a false negative:
|
||||
the branch under test is simply never entered.
|
||||
|
|
@ -167,7 +167,7 @@ class TestAccountVerbsAgree:
|
|||
):
|
||||
"""`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.
|
||||
"""
|
||||
_created_by(account, user_profile.user)
|
||||
|
|
@ -212,8 +212,8 @@ class TestAccountVerbsAgree:
|
|||
"""A stranger with a malformed body is still a stranger.
|
||||
|
||||
The access check used to sit inside `is_valid()`, so an unauthorised
|
||||
caller was told their payload was bad — a free validity oracle on
|
||||
somebody else's record — before being refused.
|
||||
caller was told their payload was bad, a free validity oracle on
|
||||
somebody else's record, before being refused.
|
||||
"""
|
||||
_created_by(account, admin_user)
|
||||
response = user_client.put(
|
||||
|
|
@ -315,7 +315,7 @@ class TestMalformedIds:
|
|||
"""`acc-northwind` is what the v2 fixtures used for ids.
|
||||
|
||||
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.
|
||||
"""
|
||||
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):
|
||||
"""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
|
||||
blank rather than zero.
|
||||
"""
|
||||
|
|
@ -462,8 +462,8 @@ class TestAccountRollups:
|
|||
):
|
||||
"""`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
|
||||
— which on the seeded database is most of them.
|
||||
Keying off the status alone hides money on any day the task has not run,
|
||||
which on the seeded database is most of them.
|
||||
"""
|
||||
_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.
|
||||
#
|
||||
# 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.
|
||||
ROLLUP_FIELDS = (
|
||||
"won_amount",
|
||||
|
|
@ -107,7 +107,7 @@ def _per_account(model, aggregate, output_field, **filters):
|
|||
|
||||
`.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
|
||||
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
|
||||
a reader of the page, and should.
|
||||
"""
|
||||
|
|
@ -148,7 +148,7 @@ def annotate_rollups(queryset):
|
|||
}
|
||||
|
||||
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.
|
||||
won_amount=Coalesce(
|
||||
_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"
|
||||
# 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(
|
||||
_per_account(Invoice, Sum("amount_due"), MONEY, **past_due), zero
|
||||
),
|
||||
|
|
@ -407,7 +407,7 @@ class AccountDetailView(APIView):
|
|||
def get_object(self, pk):
|
||||
# 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
|
||||
# `UUIDField.to_python` raises `ValidationError` — which
|
||||
# `UUIDField.to_python` raises `ValidationError`, which
|
||||
# `get_object_or_404` does not catch, because it only knows about
|
||||
# `DoesNotExist`. "That is not an id" and "no such account" are the
|
||||
# same answer to whoever asked.
|
||||
|
|
@ -424,7 +424,7 @@ class AccountDetailView(APIView):
|
|||
"""Admins, the person who created it, and anyone assigned. Else 403.
|
||||
|
||||
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
|
||||
equal, so the creator branch could not fire and creators were locked out
|
||||
of their own accounts.
|
||||
|
|
@ -622,7 +622,7 @@ class AccountDetailView(APIView):
|
|||
elif self.request.profile.user_id != self.account.created_by_id:
|
||||
# `created_by` IS the User. Reading `created_by.user.email` raised
|
||||
# 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 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):
|
||||
data = request.data
|
||||
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
|
||||
# on a deleted account is a normal race, not a server fault.
|
||||
self.account_obj = self.get_object(pk=pk)
|
||||
|
|
@ -976,7 +976,7 @@ class AccountAttachmentView(APIView):
|
|||
@extend_schema(tags=["Accounts"], parameters=swagger_params.organization_params)
|
||||
def delete(self, request, pk, format=None):
|
||||
# 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
|
||||
# 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
|
||||
|
|
@ -1027,7 +1027,7 @@ class AccountCreateMailView(APIView):
|
|||
# Org-scoped: unscoped, this would let anyone send mail recorded as
|
||||
# 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
|
||||
# every request dies with a TypeError before reaching any of this. 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
|
||||
to 24/7 wall-clock arithmetic. Holidays attached to the calendar are
|
||||
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 hours is None:
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class BusinessHoliday(BaseModel):
|
|||
"""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
|
||||
timezone — partial-day holidays are out of scope for v1.
|
||||
timezone. Partial-day holidays are out of scope for v1.
|
||||
"""
|
||||
|
||||
calendar = models.ForeignKey(
|
||||
|
|
|
|||
|
|
@ -196,6 +196,6 @@ class TestOrgContextRequired:
|
|||
request = APIRequestFactory().get(CALENDAR_URL)
|
||||
force_authenticate(request, user=admin_user)
|
||||
# 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)
|
||||
assert response.status_code == 403
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class TestAddBusinessHours:
|
|||
assert result == datetime(2026, 5, 11, 11, 0, tzinfo=_UTC)
|
||||
|
||||
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")
|
||||
start = datetime(2026, 5, 11, 10, 0, tzinfo=_UTC)
|
||||
result = add_business_hours(start, 4, calendar_a)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
"""REST endpoints for the business-hours settings page.
|
||||
|
||||
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
|
||||
holidays.
|
||||
PUT /calendar/<pk>/ — admin update of weekday hours,
|
||||
PUT /calendar/<pk>/: admin update of weekday hours,
|
||||
timezone, name. Holidays are
|
||||
managed via the nested endpoints
|
||||
below to keep payloads small.
|
||||
POST /calendar/<pk>/holidays/ — add a single holiday.
|
||||
DELETE /calendar/<pk>/holidays/<hid>/ — remove a single holiday.
|
||||
POST /calendar/<pk>/holidays/: add a single holiday.
|
||||
DELETE /calendar/<pk>/holidays/<hid>/, remove a single holiday.
|
||||
"""
|
||||
|
||||
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
|
||||
into three different answers:
|
||||
|
|
@ -6,8 +6,8 @@ into three different answers:
|
|||
* `CaseListView` and `watcher_views._visible_cases_qs` said **admin, creator,
|
||||
assignee or watcher**.
|
||||
* `CaseDetailView.get` / `.put` / `.patch` / `.post` and
|
||||
`CaseActivityListView` said **admin, creator or assignee** — no watcher.
|
||||
* `CaseDetailView.delete` said **admin or creator** — no assignee either.
|
||||
`CaseActivityListView` said **admin, creator or assignee**, no watcher.
|
||||
* `CaseDetailView.delete` said **admin or creator**, no assignee either.
|
||||
|
||||
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
|
||||
|
|
@ -21,7 +21,7 @@ deleting are three rules on three lines, deliberately not one:
|
|||
write admin · creator · assignee
|
||||
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.
|
||||
"""
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ def is_org_admin(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
|
||||
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``.
|
||||
|
||||
``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
|
||||
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":
|
||||
# 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:
|
||||
raise ValueError("backlog drilldown requires a bucket=YYYY-MM-DD")
|
||||
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
|
||||
# fan out five requests. Elapsed times are wall-clock, consistent with 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
|
||||
# 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
|
||||
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
|
||||
|
||||
|
|
@ -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:
|
||||
bucket = per.get(priority)
|
||||
if bucket is None:
|
||||
# Unknown priority string — skip rather than invent a column.
|
||||
# Unknown priority string: skip rather than invent a column.
|
||||
continue
|
||||
_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:
|
||||
_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"] = open_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.
|
||||
|
||||
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
|
||||
params = request.query_params
|
||||
|
|
@ -140,8 +140,8 @@ class AnalyticsServiceView(_AnalyticsBaseView):
|
|||
|
||||
Returns org-wide service KPIs (totals, per-day volume, first-response
|
||||
attainment by priority, case-type mix, per-agent table). Unlike the
|
||||
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
|
||||
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
|
||||
view is an org-aggregate management surface, so it 403s non-admins outright
|
||||
(mirrors the invoices/reports policy for whole-org figures).
|
||||
"""
|
||||
|
|
@ -202,7 +202,7 @@ class AnalyticsDrilldownView(_AnalyticsBaseView):
|
|||
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 = (
|
||||
("id", "ID"),
|
||||
("name", "Name"),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Two flavors of endpoint live here:
|
|||
Each transition writes a ``common.Activity`` row using the verbs registered in
|
||||
``common/models.Activity.ACTION_CHOICES`` (``APPROVAL_REQUESTED``,
|
||||
``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
|
||||
|
|
@ -180,7 +180,7 @@ class ApprovalRuleDetailView(APIView):
|
|||
{"error": True, "errors": "Rule 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.
|
||||
if rule.requests.exists():
|
||||
# Soft-disable instead of hard-delete when there is history.
|
||||
|
|
@ -199,7 +199,7 @@ class ApprovalRuleDetailView(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)
|
||||
|
||||
|
|
@ -280,11 +280,11 @@ class CaseRequestApprovalView(APIView):
|
|||
|
||||
|
||||
class ApprovalInboxView(APIView):
|
||||
"""``GET /api/cases/approvals/`` — list approvals.
|
||||
"""``GET /api/cases/approvals/``, list approvals.
|
||||
|
||||
Query params:
|
||||
* ``state`` (default ``pending``) — filter by state, or ``all`` to drop the filter.
|
||||
* ``mine`` (``true``/``false``) — when true, restrict to approvals the
|
||||
* ``state`` (default ``pending``): filter by state, or ``all`` to drop the filter.
|
||||
* ``mine`` (``true``/``false``), when true, restrict to approvals the
|
||||
caller can act on (in rule.approvers OR matches rule.approver_role).
|
||||
"""
|
||||
|
||||
|
|
@ -322,8 +322,8 @@ class ApprovalInboxView(APIView):
|
|||
)
|
||||
rows = list(qs)
|
||||
if mine:
|
||||
# "Mine" = rows I can act on right now, which — like the approve
|
||||
# endpoint — excludes requests I filed myself (separation of duties).
|
||||
# "Mine" = rows I can act on right now, which, like the approve
|
||||
# endpoint, excludes requests I filed myself (separation of duties).
|
||||
profile = request.profile
|
||||
rows = [
|
||||
a
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ Tier 3 approval workflows.
|
|||
|
||||
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
|
||||
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``.
|
||||
|
||||
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
|
||||
# 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
|
||||
# model expands. Today, MANAGER simply matches no profiles.
|
||||
APPROVER_ROLE_CHOICES = (
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ class BulkDeleteCasesView(APIView):
|
|||
)
|
||||
org = request.profile.org
|
||||
# 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)
|
||||
target_cases = list(qs.values("id", "name"))
|
||||
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.
|
||||
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)
|
||||
if timezone.now() >= window_close:
|
||||
return Response(
|
||||
{"error": "Survey is locked — edit window has closed."},
|
||||
{"error": "Survey is locked. Edit window has closed."},
|
||||
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.
|
||||
|
||||
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
|
||||
sourced from the ``ESCALATED`` Activity log: ``scan_for_breached_cases``
|
||||
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
|
||||
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.
|
||||
"""
|
||||
now = timezone.now()
|
||||
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)
|
||||
counts = {}
|
||||
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"},
|
||||
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).
|
||||
data = {k: v for k, v in request.data.items() if k != "priority"}
|
||||
serializer = EscalationPolicySerializer(
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ def _extract_bodies(msg: StdlibEmailMessage) -> tuple[str, str]:
|
|||
continue
|
||||
try:
|
||||
content = part.get_content()
|
||||
except Exception: # pragma: no cover — corrupted MIME
|
||||
except Exception: # pragma: no cover, corrupted MIME
|
||||
continue
|
||||
if not isinstance(content, str):
|
||||
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)?
|
||||
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.
|
||||
"""
|
||||
|
||||
|
|
@ -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
|
||||
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.
|
||||
"""
|
||||
drop, reason = should_drop(parsed)
|
||||
|
|
@ -92,7 +92,7 @@ def ingest(parsed: ParsedEmail, mailbox: InboundMailbox) -> IngestResult:
|
|||
)
|
||||
|
||||
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
|
||||
# has it. Fabricate a synthetic id for the audit row.
|
||||
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`
|
||||
surfaces the redirect as an error instead of chasing it.
|
||||
* **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
|
||||
— not the signing cert's own issuer chain — is what proves the PEM really
|
||||
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
|
||||
came from AWS.
|
||||
* **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
|
||||
|
|
@ -147,8 +147,8 @@ def _fetch_signing_cert(url: str, *, timeout: float = 5.0) -> bytes:
|
|||
if not parsed.path.endswith(".pem"):
|
||||
raise SNSVerificationError(f"SigningCertURL must end in .pem: {url!r}")
|
||||
# `_OPENER` refuses redirects, so the host checked above is the host we talk
|
||||
# to — the pinning cannot be sidestepped by a 3xx.
|
||||
with _OPENER.open(url, timeout=timeout) as response: # noqa: S310 — host pinned
|
||||
# to. The pinning cannot be sidestepped by a 3xx.
|
||||
with _OPENER.open(url, timeout=timeout) as response: # noqa: S310. Host pinned
|
||||
return response.read()
|
||||
|
||||
|
||||
|
|
@ -214,7 +214,7 @@ def verify_sns_message(
|
|||
try:
|
||||
cert = load_pem_x509_certificate(cert_bytes)
|
||||
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.
|
||||
raise SNSVerificationError(
|
||||
f"Signing certificate is not valid PEM: {exc}"
|
||||
|
|
@ -236,7 +236,7 @@ def verify_sns_message(
|
|||
)
|
||||
except InvalidSignature as 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
|
||||
|
||||
|
||||
|
|
@ -259,7 +259,7 @@ def confirm_subscription(payload: dict, *, fetch=None, timeout: float = 5.0) ->
|
|||
# stays the single place redirect policy is decided.
|
||||
fetch = fetch or _OPENER.open
|
||||
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()
|
||||
except Exception:
|
||||
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
|
||||
|
||||
|
||||
# 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.
|
||||
def short_case_id(case: Case) -> str:
|
||||
"""8-char prefix used in subject-line fallback threading."""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Inbound email webhook + admin mailbox CRUD endpoints.
|
||||
|
||||
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
|
||||
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.
|
||||
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
|
||||
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
|
||||
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
|
||||
case's ``created_at``).
|
||||
- ``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).
|
||||
- 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).
|
||||
|
|
@ -102,7 +102,7 @@ def _admin_required():
|
|||
|
||||
|
||||
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."""
|
||||
return Response(
|
||||
{"error": True, "errors": "Signature verification failed"},
|
||||
|
|
@ -114,7 +114,7 @@ class InboundMailboxWebhookView(APIView):
|
|||
"""Public endpoint where AWS SNS POSTs for one configured mailbox.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ class InboundMailboxWebhookView(APIView):
|
|||
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:
|
||||
payload = (
|
||||
request.data
|
||||
|
|
@ -235,7 +235,7 @@ class InboundMailboxWebhookView(APIView):
|
|||
)
|
||||
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)
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
**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
|
||||
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
|
||||
|
|
@ -23,8 +23,8 @@ hypothetical shape of bug for this codebase.
|
|||
|
||||
`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
|
||||
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
|
||||
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
|
||||
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
|
||||
Django's ``ValidationError`` instead of returning nothing. The views
|
||||
caught ``Solution.DoesNotExist`` and nothing else, so every verb — read,
|
||||
update, delete, publish, unpublish — answered 500 for any id that was not
|
||||
caught ``Solution.DoesNotExist`` and nothing else, so every verb. Read,
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Agent-facing knowledge-base helpers.
|
||||
|
||||
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
|
||||
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=`.
|
||||
|
||||
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
|
||||
useful on first focus.
|
||||
"""
|
||||
|
|
@ -93,8 +93,8 @@ class SolutionSuggestionsView(APIView):
|
|||
else:
|
||||
# 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
|
||||
# matches, fall back to the most-recent published solutions —
|
||||
# the agent should always see *something* on first focus.
|
||||
# matches, fall back to the most-recent published solutions.
|
||||
# The agent should always see *something* on first focus.
|
||||
terms = _seed_terms(case)
|
||||
results = []
|
||||
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
|
||||
# (org, -created_at) index doesn't cover queries that filter by resolved_at.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
|||
migrations.AddField(
|
||||
model_name='case',
|
||||
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."""
|
||||
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
|
||||
# before the first INSERT.
|
||||
if self._state.adding:
|
||||
|
|
@ -299,7 +299,7 @@ class Case(AssignableMixin, BaseModel):
|
|||
return None
|
||||
deadline = add_business_hours(self.created_at, hours, self._sla_calendar())
|
||||
# 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,
|
||||
# not a target that respects the calendar.
|
||||
paused = self.sla_paused_seconds or 0
|
||||
|
|
@ -344,7 +344,7 @@ class CaseWatcher(BaseModel):
|
|||
"""A profile subscribed to updates on a case.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
|
@ -947,7 +947,7 @@ class TimeEntry(BaseModel):
|
|||
Per `docs/cases/COORDINATION_DECISIONS.md` D2 we inherit BaseModel and
|
||||
declare our own org FK; RLS is enforced via the migration policy. The
|
||||
`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
|
||||
check.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -42,14 +42,14 @@ def case_link(case_id) -> str:
|
|||
|
||||
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
|
||||
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
|
||||
`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
|
||||
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
|
||||
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/`.
|
||||
"""
|
||||
return f"/tickets/{case_id}"
|
||||
|
|
@ -156,7 +156,7 @@ def dispatch_for_comment(comment, case, actor: Profile | None = None) -> dict:
|
|||
)
|
||||
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
|
||||
# same comment).
|
||||
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:
|
||||
|
||||
* ``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>/close-with-children/`` — close parent and (optionally) cascade-close descendants.
|
||||
* ``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>/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
|
||||
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.
|
||||
|
||||
`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.
|
||||
"""
|
||||
|
||||
|
|
@ -80,10 +80,10 @@ def _match_condition(case_data: dict, condition: dict) -> bool:
|
|||
op = condition.get("op", "eq")
|
||||
value = condition.get("value")
|
||||
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
|
||||
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
|
||||
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:
|
||||
"""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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
``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.
|
||||
|
||||
Anchored on ``created_at`` (routing runs once, at case creation, so a case's
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ class CaseSerializer(serializers.ModelSerializer):
|
|||
"custom_fields",
|
||||
"escalation_count",
|
||||
"last_escalation_fired_at",
|
||||
# SLA — Tier 2 business-hours-sla
|
||||
# SLA: Tier 2 business-hours-sla
|
||||
"sla_first_response_hours",
|
||||
"sla_resolution_hours",
|
||||
"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
|
||||
*every* Account row. Posting a stranger's account UUID stored the
|
||||
link and — because the create response echoes `cases_obj` through
|
||||
`CaseSerializer`, which nests `AccountSerializer` — handed the caller
|
||||
link and, because the create response echoes `cases_obj` through
|
||||
`CaseSerializer`, which nests `AccountSerializer`: handed the caller
|
||||
that account's name, email, phone and website back. So the same hole
|
||||
was both a cross-tenant write and a cross-tenant read. `validate_parent`
|
||||
below already guarded the other FK on this serializer; this one had
|
||||
|
|
@ -173,7 +173,7 @@ class CaseCreateSerializer(serializers.ModelSerializer):
|
|||
API, because DRF does not call `Model.clean()` and nothing on the save
|
||||
path calls `full_clean()`. Proven live: with a matching rule armed,
|
||||
`PATCH {"status": "Closed"}` returned 200, closed the case and
|
||||
recorded zero approvals — the whole approval feature, its inbox and
|
||||
recorded zero approvals, the whole approval feature, its inbox and
|
||||
its settings page were decoration.
|
||||
|
||||
Validating the *transition* rather than the target matters here: a
|
||||
|
|
@ -200,7 +200,7 @@ class CaseCreateSerializer(serializers.ModelSerializer):
|
|||
if self.instance is not None:
|
||||
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,
|
||||
# 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
|
||||
|
|
@ -594,7 +594,7 @@ class InboundMailboxSerializer(serializers.ModelSerializer):
|
|||
return value
|
||||
|
||||
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
|
||||
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
|
||||
|
|
@ -830,7 +830,7 @@ class TimeEntryUpdateSerializer(serializers.ModelSerializer):
|
|||
|
||||
Lets the owner (or an admin) revise the start/end window, description,
|
||||
billable flag, and hourly rate. `case`, `profile`, and `invoice` stay
|
||||
read-only — moving an entry between cases or stealing someone else's
|
||||
read-only, moving an entry between cases or stealing someone else's
|
||||
timer is intentionally not supported.
|
||||
"""
|
||||
|
||||
|
|
@ -955,7 +955,7 @@ class ApprovalRuleSerializer(serializers.ModelSerializer):
|
|||
if org is None:
|
||||
org = self.context.get("org") if hasattr(self, "context") else None
|
||||
if org is not None:
|
||||
# Scope the relational querysets to the caller's org — the same
|
||||
# Scope the relational querysets to the caller's org, the same
|
||||
# __init__ scoping RoutingRule/Escalation serializers do. Without it
|
||||
# approver_ids/match_team_id accept ANY org's rows, so a PUT (whose
|
||||
# view does not re-run the POST's manual org check) could attach a
|
||||
|
|
@ -971,7 +971,7 @@ class ApprovalSerializer(serializers.ModelSerializer):
|
|||
|
||||
``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)
|
||||
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.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
draft :class:`invoices.models.InvoiceLineItem` shapes.
|
||||
|
||||
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
|
||||
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`):
|
||||
|
||||
* Empty input.
|
||||
* Mixed currencies — single invoices stay single-currency.
|
||||
* Mixed currencies: single invoices stay single-currency.
|
||||
* Already-invoiced entries.
|
||||
* Unstopped (running) 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")
|
||||
case_name = entry.case.name if getattr(entry, "case", None) else "Time entry"
|
||||
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(
|
||||
{
|
||||
"name": full[:255],
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ reported as row errors rather than silently dropped. Tags are auto-created at
|
|||
commit if not present.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
Audit-log signal handlers for the Cases app.
|
||||
|
||||
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,
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
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):
|
||||
"""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
|
||||
inbound-email path — while four things read it: the breach property, 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
|
||||
"first reply" column, the escalation scan and the FRT dashboard. So every
|
||||
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
|
||||
|
|
@ -229,7 +229,7 @@ def _maybe_stamp_first_response(case, comment):
|
|||
|
||||
"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
|
||||
`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.
|
||||
"""
|
||||
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
|
||||
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.
|
||||
"""
|
||||
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)],
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -440,7 +440,7 @@ def _evaluate_reopen(case, comment):
|
|||
case.closed_on = None
|
||||
# `.update()` skips signals, so the resolved_at reset that
|
||||
# `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.objects.filter(pk=case.pk).update(
|
||||
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
|
||||
# `.update()` skips signals, so the resolved_at reset that
|
||||
# `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.objects.filter(pk=case.pk).update(
|
||||
status=case.status, closed_on=None, resolved_at=None
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ from common.serializer import OrganizationSerializer
|
|||
def _author_name(obj):
|
||||
"""A name to print, not a foreign key to resolve.
|
||||
|
||||
`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
|
||||
`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
|
||||
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.
|
||||
"""
|
||||
|
|
@ -60,7 +60,7 @@ class SolutionSerializer(serializers.ModelSerializer):
|
|||
"""Get number of cases using this solution"""
|
||||
# `SolutionListView` annotates this so a list of N articles costs one
|
||||
# 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)
|
||||
if annotated is not None:
|
||||
return annotated
|
||||
|
|
@ -91,13 +91,13 @@ class SolutionCreateUpdateSerializer(serializers.ModelSerializer):
|
|||
only half did:
|
||||
|
||||
* `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
|
||||
with a published draft: the review workflow skipped in one request.
|
||||
Proven against the running server.
|
||||
* 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
|
||||
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
|
||||
rule about validating the *source* state of a transition rather than
|
||||
only its target, in miniature.
|
||||
|
|
@ -111,7 +111,7 @@ class SolutionCreateUpdateSerializer(serializers.ModelSerializer):
|
|||
# Absent means unchanged, for validation as much as for saving. An
|
||||
# article created *before* this rule existed can already be a
|
||||
# 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
|
||||
# `is_published`, which is both baffling and the opposite of helpful,
|
||||
# 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):
|
||||
"""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
|
||||
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
|
||||
|
|
@ -194,7 +194,7 @@ class SolutionDetailSerializer(serializers.ModelSerializer):
|
|||
`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
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
|
|
@ -109,8 +109,8 @@ class SolutionListView(APIView, LimitOffsetPagination):
|
|||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
if is_published is not None:
|
||||
# `is_published.lower() == "true"` meant every other spelling —
|
||||
# `1`, `yes`, `on` — silently filtered to *unpublished*, which is
|
||||
# `is_published.lower() == "true"` meant every other spelling:
|
||||
# `1`, `yes`, `on`: silently filtered to *unpublished*, which is
|
||||
# the opposite of what the caller asked for.
|
||||
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.
|
||||
# These four are a partition of the KB — "12 total, 3 of them drafts"
|
||||
# — so recomputing them inside a `?status=draft` filter would leave
|
||||
# These four are a partition of the KB, "12 total, 3 of them drafts"
|
||||
#, so recomputing them inside a `?status=draft` filter would leave
|
||||
# the other three cards reading zero and say nothing.
|
||||
counts = base.aggregate(
|
||||
count=Count("id"),
|
||||
|
|
@ -182,7 +182,7 @@ class SolutionDetailView(APIView):
|
|||
def get(self, request, pk):
|
||||
"""Get solution details"""
|
||||
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`.
|
||||
serializer = SolutionDetailSerializer(
|
||||
solution, context={"profile": request.profile}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ CSAT_SEND_DELAY_MINUTES = 30
|
|||
# elsewhere in the codebase.
|
||||
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
|
||||
# Minimum gap between escalation attempts on the same case (prevents storming).
|
||||
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))
|
||||
except (
|
||||
Exception
|
||||
): # pragma: no cover — broker glitches shouldn't lose the escalation
|
||||
): # pragma: no cover. Broker glitches shouldn't lose the escalation
|
||||
logger.exception(
|
||||
"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:
|
||||
"""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()
|
||||
|
||||
|
||||
|
|
@ -257,7 +257,7 @@ def send_csat_survey(case_id, org_id):
|
|||
- The org has flipped `csat_enabled` off.
|
||||
- The case has no contact with an email (logged, not raised).
|
||||
- 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).
|
||||
"""
|
||||
set_rls_context(org_id)
|
||||
|
|
@ -267,7 +267,7 @@ def send_csat_survey(case_id, org_id):
|
|||
return None
|
||||
if case.status != "Closed":
|
||||
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.status,
|
||||
)
|
||||
|
|
@ -313,7 +313,7 @@ def send_csat_survey(case_id, org_id):
|
|||
try:
|
||||
html = render_to_string("csat/survey_email.html", context=context)
|
||||
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.
|
||||
html = (
|
||||
f'<p>Hi {contact.first_name or "there"},</p>'
|
||||
|
|
@ -322,7 +322,7 @@ def send_csat_survey(case_id, org_id):
|
|||
)
|
||||
|
||||
msg = EmailMessage(
|
||||
subject=f"How did we do? — {case.name}",
|
||||
subject=f"How did we do?, {case.name}",
|
||||
body=html,
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# meets its SLA exactly (1h against a 1h target) just over the line.
|
||||
_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:
|
||||
|
|
@ -92,7 +92,7 @@ class TestServiceAuthorization:
|
|||
assert resp.status_code in (401, 403)
|
||||
|
||||
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/")
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ class TestStateTransitions:
|
|||
def test_approve_records_activity(
|
||||
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.
|
||||
case, rule, approval = self._setup(org_a, user_profile, regular_user)
|
||||
res = admin_client.post(
|
||||
|
|
@ -528,7 +528,7 @@ class TestRuleCRUD:
|
|||
assert rule.match_team_id is None
|
||||
|
||||
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."""
|
||||
rule = _make_rule(org_a, match_priority="Urgent")
|
||||
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").
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`watcher_views` granted it with a citation; the detail view dropped it.
|
||||
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.
|
||||
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
|
||||
and attachment endpoints, because a UUID column raises rather than
|
||||
returning nothing.
|
||||
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
|
||||
person who uploaded a file could not delete it.
|
||||
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
|
||||
nested serializer.
|
||||
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,
|
||||
`PATCH {"status": "Closed"}` returned 200 and recorded zero approvals.
|
||||
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
|
||||
"resolution breached" and invisible to MTTR.
|
||||
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.
|
||||
|
||||
`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()`
|
||||
writes the column directly and skips that.
|
||||
"""
|
||||
|
|
@ -92,7 +92,7 @@ def assigned_case(case_a, user_profile):
|
|||
|
||||
@pytest.fixture
|
||||
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(
|
||||
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)
|
||||
|
||||
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))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
|
@ -132,7 +132,7 @@ class TestWhoMayOpenACase:
|
|||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
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))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
|
@ -368,7 +368,7 @@ class TestClosingACase:
|
|||
self, admin_client, case_a, org_a
|
||||
):
|
||||
"""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.save()
|
||||
ApprovalRule.objects.create(
|
||||
|
|
@ -494,7 +494,7 @@ class TestFirstResponseIsRecorded:
|
|||
@pytest.mark.django_db
|
||||
class TestListOrderAndMentions:
|
||||
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."""
|
||||
Case.objects.filter(pk=case_a.id).update(
|
||||
created_at=timezone.now() - timezone.timedelta(days=3)
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ def _make_case(
|
|||
"""Create a Case with `created_by` set deterministically.
|
||||
|
||||
`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
|
||||
the create.
|
||||
"""
|
||||
|
|
@ -206,8 +206,8 @@ class TestCaseMergePermissions:
|
|||
def test_creator_of_only_one_forbidden(
|
||||
self, user_client, regular_user, admin_user, org_a
|
||||
):
|
||||
a = _make_case(org_a, regular_user, name="A — user-created")
|
||||
b = _make_case(org_a, admin_user, name="B — admin-created")
|
||||
a = _make_case(org_a, regular_user, name="A. User-created")
|
||||
b = _make_case(org_a, admin_user, name="B, admin-created")
|
||||
response = user_client.post(f"/api/cases/{a.id}/merge/{b.id}/")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@ class TestCaseListView:
|
|||
|
||||
def test_create_case_unauthenticated(self, unauthenticated_client):
|
||||
"""Asserted `pytest.raises(PermissionDenied)`, which can never hold
|
||||
through an APIClient — DRF renders an APIException as a response
|
||||
rather than letting it escape — so this had always failed. It now
|
||||
through an APIClient, DRF renders an APIException as a response
|
||||
rather than letting it escape, so this had always failed. It now
|
||||
checks the response the client actually receives."""
|
||||
response = unauthenticated_client.post(
|
||||
CASES_LIST_URL,
|
||||
|
|
@ -1390,7 +1390,7 @@ class TestCaseModelMethods:
|
|||
def test_save_applies_priority_specific_sla_defaults(self, admin_user, org_a):
|
||||
"""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
|
||||
because BaseModel populates pk via uuid4 default; we now use
|
||||
_state.adding so the auto-set actually applies.
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ class TestPublicCsatPost:
|
|||
|
||||
def test_edit_after_24h_locked(self, client, 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(
|
||||
f"/api/public/csat/{token}/",
|
||||
data={"rating": 3},
|
||||
|
|
@ -259,7 +259,7 @@ class TestCsatAggregate:
|
|||
rating=rating,
|
||||
responded_at=timezone.now(),
|
||||
)
|
||||
# One un-responded survey — must not count.
|
||||
# One un-responded survey, must not count.
|
||||
case = Case.objects.create(
|
||||
org=org_a, name="Cx", status="Closed", priority="Normal",
|
||||
closed_on=timezone.now().date(),
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ class TestEscalationPolicyAPI:
|
|||
def test_cross_org_isolation(self, admin_client, org_a, org_b):
|
||||
target_b = _make_target_profile(org_b, "b@x.com")
|
||||
_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"] == []
|
||||
|
||||
|
||||
|
|
@ -237,7 +237,7 @@ class TestScanForBreachedCases:
|
|||
_create_breached_case(admin_user, org_a)
|
||||
|
||||
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 Activity.objects.filter(action="ESCALATED").count() == 1
|
||||
|
||||
|
|
@ -284,7 +284,7 @@ class TestScanForBreachedCases:
|
|||
)
|
||||
_create_breached_case(admin_user, org_a)
|
||||
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 = []
|
||||
for call in mock_email.delay.call_args_list:
|
||||
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).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
|
@ -419,7 +419,7 @@ class TestEscalationBreachCounts:
|
|||
|
||||
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
|
||||
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)
|
||||
_make_policy(org_a, target, priority="Urgent", is_active=False)
|
||||
_case_with_sla(admin_user, org_a, created_ago=timedelta(hours=5))
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ class TestImportPreview:
|
|||
def test_cross_org_account_rejected(
|
||||
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)
|
||||
csv_file = _csv(
|
||||
["name", "status", "priority", "account_name"],
|
||||
|
|
@ -199,7 +199,7 @@ class TestImportCommit:
|
|||
assert case.tags.filter(name="auth").exists()
|
||||
|
||||
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(
|
||||
["name", "status", "priority"],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ class TestThreading:
|
|||
org=org_a,
|
||||
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"))
|
||||
assert find_existing_case(parsed, org_a) is None
|
||||
|
||||
|
|
@ -563,7 +563,7 @@ class TestMailboxAPI:
|
|||
assert response.status_code == 403
|
||||
|
||||
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)."""
|
||||
mailbox = _make_mailbox(org_a, webhook_secret="s3cr3t-value")
|
||||
|
||||
|
|
@ -693,7 +693,7 @@ class TestWebhook:
|
|||
# TopicArn pinning
|
||||
#
|
||||
# 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
|
||||
# webhook therefore also pins the TopicArn to the mailbox.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -787,7 +787,7 @@ class TestTopicArnPinning:
|
|||
self, admin_client, org_a
|
||||
):
|
||||
"""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)
|
||||
with (
|
||||
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
|
||||
# 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`)
|
||||
on `cases/inbound/sns.py`:
|
||||
|
||||
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
|
||||
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
|
||||
would otherwise land us on an unvalidated host.
|
||||
2. The signing certificate is validated (validity window + subject CN pinned
|
||||
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")
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ def _signed_payload(key, *, topic_arn="arn:aws:sns:us-east-1:1:t", **overrides):
|
|||
|
||||
class TestSigningCertValidation:
|
||||
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)
|
||||
payload = _signed_payload(signing_key)
|
||||
|
||||
|
|
@ -188,7 +188,7 @@ class _RedirectHandler(http.server.BaseHTTPRequestHandler):
|
|||
|
||||
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)
|
||||
if self.path == "/start":
|
||||
self.send_response(302)
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ def published_solutions(org_a):
|
|||
s3 = Solution.objects.create(
|
||||
org=org_a,
|
||||
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",
|
||||
is_published=False,
|
||||
)
|
||||
|
|
@ -122,7 +122,7 @@ class TestSolutionSuggestionsSeed:
|
|||
def test_empty_q_falls_back_to_recent_when_no_seed_match(
|
||||
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.
|
||||
Solution.objects.create(
|
||||
org=org_a,
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ class TestReopenPolicyAPI:
|
|||
|
||||
@pytest.mark.django_db
|
||||
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)."""
|
||||
|
||||
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
|
||||
# activity log the engine writes.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -599,7 +599,7 @@ class TestRoutingAnalytics:
|
|||
self, admin_client, admin_profile, org_a
|
||||
):
|
||||
"""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'."""
|
||||
_rule(org_a, "Empty pool", strategy="direct", target_assignees=[])
|
||||
_case(org_a, name="c1")
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ class TestSLADeadlineWithCalendar:
|
|||
assert deadline == datetime(2026, 5, 11, 14, 0, tzinfo=_UTC)
|
||||
|
||||
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.sla_first_response_hours = 4
|
||||
case_a.status = "Pending"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Every case below was driven against the running API before the fix and
|
||||
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
|
||||
(200), publish it to customers (200) and hard-delete it (204). The
|
||||
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":
|
||||
true}` returned 201 with a published draft.
|
||||
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
|
||||
published. The invariant the model asserts ("published implies approved")
|
||||
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
|
||||
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.
|
||||
5. A malformed id was a **500** on every verb — read, update, delete,
|
||||
publish, unpublish — because a UUID column raises `ValidationError`
|
||||
5. A malformed id was a **500** on every verb. Read, update, delete,
|
||||
publish, unpublish, because a UUID column raises `ValidationError`
|
||||
rather than returning nothing, and the views caught only
|
||||
`Solution.DoesNotExist`.
|
||||
6. `?is_published=1` silently meant *unpublished*: the parse was
|
||||
`value.lower() == "true"`, so every other spelling of true inverted the
|
||||
filter.
|
||||
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.
|
||||
"""
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ def member_article(org_a, regular_user):
|
|||
|
||||
@pytest.mark.django_db
|
||||
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):
|
||||
response = user_client.patch(
|
||||
|
|
@ -138,7 +138,7 @@ class TestWhoMayEditAnArticle:
|
|||
assert Solution.objects.filter(pk=admin_article.pk).exists()
|
||||
|
||||
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))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["title"] == "Admin's article"
|
||||
|
|
@ -298,7 +298,7 @@ class TestPublishedImpliesApproved:
|
|||
):
|
||||
"""Rows written before the rule existed can be published drafts.
|
||||
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."""
|
||||
article = _article(org_a, regular_user, status="draft", is_published=True)
|
||||
response = user_client.patch(
|
||||
|
|
@ -373,7 +373,7 @@ class TestLinkedCasesIsNotAWayIntoCases:
|
|||
|
||||
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
|
||||
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."""
|
||||
hidden_case.solutions.add(member_article)
|
||||
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
|
||||
):
|
||||
"""`case_count` and the nested `org` were both computed per row: 13
|
||||
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."""
|
||||
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."""
|
||||
for index in range(12):
|
||||
_article(org_a, admin_user, title=f"Article {index}")
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ class TestModelConstraints:
|
|||
TimeEntry.objects.create(
|
||||
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(
|
||||
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"]]
|
||||
assert len(entries) == 1
|
||||
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.
|
||||
assert entry["case"] == {"id": str(case.id), "name": "SSO login loop"}
|
||||
# An unbilled entry carries invoice: null, so the page never links to an
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ class TestWatchAPI:
|
|||
assert r.status_code == 204
|
||||
|
||||
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/")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
|
@ -162,7 +162,7 @@ class TestWatchingListAPI:
|
|||
CaseWatcher.objects.create(
|
||||
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(
|
||||
case=case_b_same_org,
|
||||
profile=user_profile,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class TestParseMentions:
|
|||
assert out == ["bob", "alice"]
|
||||
|
||||
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") == []
|
||||
|
||||
def test_punctuation_chars_in_username(self):
|
||||
|
|
@ -52,7 +52,7 @@ class TestParseMentions:
|
|||
]
|
||||
|
||||
def test_no_match_inside_word(self):
|
||||
# `\u`@x` — preceded by alphanumeric, skip
|
||||
# `\u`@x`. Preceded by alphanumeric, skip
|
||||
assert parse_mentions("u@x") == []
|
||||
|
||||
def test_empty_body(self):
|
||||
|
|
@ -126,7 +126,7 @@ class TestDispatchForComment:
|
|||
_comment(
|
||||
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(
|
||||
recipient=bob, verb="case.mentioned"
|
||||
).exists()
|
||||
|
|
@ -171,7 +171,7 @@ class TestDispatchForComment:
|
|||
def test_actor_does_not_get_their_own_comment_notification(
|
||||
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(
|
||||
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.
|
||||
|
||||
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
|
||||
`/tickets/<id>` — and its notification panel assigns `link` straight to
|
||||
client has ever had a `/cases` route, the SvelteKit app serves tickets at
|
||||
`/tickets/<id>`, and its notification panel assigns `link` straight to
|
||||
`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:
|
||||
|
||||
* ``GET /api/cases/<pk>/time-entries/`` — list (visible to actor)
|
||||
* ``POST /api/cases/<pk>/time-entries/`` — manual entry
|
||||
* ``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-entries/``: list (visible to actor)
|
||||
* ``POST /api/cases/<pk>/time-entries/``: manual entry
|
||||
* ``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
|
||||
|
||||
Entry-scoped (registered at the project root under ``/api/time-entries/``):
|
||||
|
||||
* ``POST /api/time-entries/<pk>/stop/`` — stop a running timer
|
||||
* ``PUT /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
|
||||
* ``POST /api/time-entries/<pk>/stop/``: stop a running timer
|
||||
* ``PUT /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
|
||||
"""
|
||||
|
||||
from collections import OrderedDict
|
||||
|
|
@ -123,7 +123,7 @@ class TimeEntryStartView(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
|
||||
want to refetch the full case envelope.
|
||||
|
|
@ -218,7 +218,7 @@ class TimeEntryDetailView(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)
|
||||
|
||||
|
|
@ -249,7 +249,7 @@ class TimeEntryStopView(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
|
||||
can show them. Org-scoped; admins see all entries, agents only their own.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ urlpatterns = [
|
|||
routing_views.RoutingRuleTestView.as_view(),
|
||||
name="routing_rule_test",
|
||||
),
|
||||
# Inbound email — public webhook (SNS-signed) + admin mailbox CRUD.
|
||||
# Inbound email: public webhook (SNS-signed) + admin mailbox CRUD.
|
||||
path(
|
||||
"inbound/<str:mailbox_id>/",
|
||||
inbound_views.InboundMailboxWebhookView.as_view(),
|
||||
|
|
@ -197,7 +197,7 @@ urlpatterns = [
|
|||
import_views.CaseImportCommitView.as_view(),
|
||||
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(
|
||||
"<str:pk>/solutions/",
|
||||
views.CaseSolutionLinkView.as_view(),
|
||||
|
|
@ -274,7 +274,7 @@ urlpatterns = [
|
|||
time_views.TimeSummaryView.as_view(),
|
||||
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(
|
||||
"approval-rules/",
|
||||
approval_views.ApprovalRuleListCreateView.as_view(),
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ from contacts.models import Contact
|
|||
from contacts.serializer import ContactSerializer
|
||||
|
||||
# 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.
|
||||
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
|
||||
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.
|
||||
"""
|
||||
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"))
|
||||
if params.get("sla_breached") == "true":
|
||||
# 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
|
||||
# created_at + hours, so this filter mirrors what the user actually
|
||||
# sees on the row. Postgres-specific (INTERVAL).
|
||||
|
|
@ -147,7 +147,7 @@ def apply_case_list_filters(queryset, params):
|
|||
cf_key = raw_key[3:]
|
||||
if cf_key:
|
||||
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")
|
||||
if ordering in _ALLOWED_CASE_ORDERINGS:
|
||||
queryset = queryset.order_by(ordering, "-id")
|
||||
|
|
@ -162,7 +162,7 @@ class CaseListView(APIView, LimitOffsetPagination):
|
|||
def get_context_data(self, **kwargs):
|
||||
params = self.request.query_params
|
||||
# `-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;
|
||||
# `-id` stays as a tiebreak so pagination is stable when a batch of
|
||||
# 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):
|
||||
# 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
|
||||
# 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
|
||||
# somebody a ticket that answered 403 when they clicked it.
|
||||
queryset = queryset.filter(
|
||||
|
|
@ -246,7 +246,7 @@ class CaseListView(APIView, LimitOffsetPagination):
|
|||
context["priority"] = PRIORITY_CHOICE
|
||||
context["type_of_case"] = CASE_TYPE
|
||||
# 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
|
||||
# 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
|
||||
|
|
@ -254,8 +254,8 @@ class CaseListView(APIView, LimitOffsetPagination):
|
|||
if params.get("slim") != "true":
|
||||
context["accounts_list"] = AccountSerializer(accounts, many=True).data
|
||||
context["contacts_list"] = ContactSerializer(contacts, many=True).data
|
||||
# `profiles` was computed a few lines up — narrowed to admins for
|
||||
# non-admins, even — and then dropped on the floor. So a ticket form
|
||||
# `profiles` was computed a few lines up, narrowed to admins for
|
||||
# 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
|
||||
# already knew the answer. Same `id` / `user__email` shape the
|
||||
# contacts and accounts lists publish.
|
||||
|
|
@ -650,7 +650,7 @@ class CaseDetailView(APIView):
|
|||
elif self.request.profile.user_id != self.cases.created_by_id:
|
||||
# `created_by` is a `User`; `Profile.user` is the FK pointing at
|
||||
# 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
|
||||
# Profile to a User and was always true, so nothing shielded it.
|
||||
# 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):
|
||||
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
|
||||
# 404, not a crash.
|
||||
self.cases_obj = self.get_object(pk)
|
||||
|
|
@ -947,7 +947,7 @@ class CaseCommentView(APIView):
|
|||
permission_classes = (IsAuthenticated, HasOrgContext)
|
||||
|
||||
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."""
|
||||
try:
|
||||
comment = self.model.objects.filter(
|
||||
|
|
@ -1104,7 +1104,7 @@ class CaseAttachmentView(APIView):
|
|||
generic table shared by leads, accounts, contacts, deals, cases and
|
||||
tasks, so this endpoint deleted any attachment in the database
|
||||
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
|
||||
here is a superuser, so the probe went through.
|
||||
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
|
||||
# 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
|
||||
# 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
|
||||
# is not enough on its own: writing to a ticket you cannot open is a
|
||||
# defect whichever way the data comes back.
|
||||
|
|
@ -1255,15 +1255,15 @@ def _reopen_analytics(org):
|
|||
persists:
|
||||
- `reopened_last_30d`: one REOPENED Activity is written per reopen.
|
||||
- `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
|
||||
force at the time (which a recompute here could not reproduce).
|
||||
- `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
|
||||
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
|
||||
`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.
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"""Watch / unwatch / list-watchers REST endpoints.
|
||||
|
||||
URL surface (mounted under /api/cases/):
|
||||
POST /<id>/watch/ — subscribe the requesting profile (idempotent)
|
||||
DELETE /<id>/watch/ — unsubscribe
|
||||
GET /<id>/watchers/ — list everyone watching the case
|
||||
GET /watching/ — cases the current profile watches
|
||||
POST /<id>/watch/: subscribe the requesting profile (idempotent)
|
||||
DELETE /<id>/watch/: unsubscribe
|
||||
GET /<id>/watchers/: list everyone watching the case
|
||||
GET /watching/: cases the current profile watches
|
||||
|
||||
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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ urlpatterns = [
|
|||
include("business_hours.urls", namespace="api_business_hours"),
|
||||
),
|
||||
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
|
||||
# link with no auth context.
|
||||
path(
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ class AuditLogger:
|
|||
"""Log a vertical-pack sample-data clear.
|
||||
|
||||
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
|
||||
this, recorded nothing. Any org admin can trigger it, so an audit
|
||||
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.
|
||||
SAMPLE_DATA_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 "
|
||||
"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."
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ def validate_payload(
|
|||
|
||||
Returns (cleaned_dict, errors_dict). Non-empty errors -> caller should 400.
|
||||
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.
|
||||
|
||||
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
|
||||
# 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():
|
||||
if key in all_keys and key not in value_dict:
|
||||
cleaned[key] = value
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class Command(BaseCommand):
|
|||
|
||||
def handle(self, *args, **options):
|
||||
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
|
||||
|
||||
email = os.environ.get("ADMIN_EMAIL", "admin@localhost")
|
||||
|
|
@ -20,7 +20,7 @@ class Command(BaseCommand):
|
|||
if not password:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"WARNING: ADMIN_PASSWORD not set — using default 'admin'. "
|
||||
"WARNING: ADMIN_PASSWORD not set, using default 'admin'. "
|
||||
"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
|
||||
a production environment.
|
||||
|
|
@ -90,7 +90,7 @@ class Command(BaseCommand):
|
|||
self.stdout.write(f"bound to org: {org.name} ({org.id})")
|
||||
else:
|
||||
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.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`.
|
||||
|
||||
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.
|
||||
"""
|
||||
name = "MicroPyramid" if index == 0 else self._demo_company()
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class GetProfileAndOrg:
|
|||
request.profile = 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).
|
||||
# MUST come before the JWT branch so a PAT bearer is never handed to
|
||||
# the JWT decoder.
|
||||
|
|
@ -166,7 +166,7 @@ class GetProfileAndOrg:
|
|||
|
||||
if not profile:
|
||||
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.
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -118,10 +118,10 @@ class RequireOrgContext:
|
|||
"/admin/",
|
||||
"/swagger-ui/",
|
||||
"/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.
|
||||
"/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
|
||||
# (see invoices/public_views.py) and never reads request.user or
|
||||
# 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`
|
||||
# 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(
|
||||
# 'app.current_org', true), '')`, which matches nothing when empty.
|
||||
# Verified as `crm_user` (non-superuser, no BYPASSRLS): invoice,
|
||||
|
|
@ -143,12 +143,12 @@ class RequireOrgContext:
|
|||
"/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
|
||||
# common.middleware.get_company.GetProfileAndOrg.process_request
|
||||
# (`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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# `Case` Activity rows by (entity_type, action, created_at) inside a single
|
||||
# org without re-walking the whole table.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Drop the unused `session_token` table.
|
||||
|
||||
`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
|
||||
permanently empty table, which read as a working session-revocation feature
|
||||
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,
|
||||
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
|
||||
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.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
# 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
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
# org filters. Vertical packs write to all six on every apply, which is what
|
||||
# made the gap load-bearing rather than theoretical.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
# 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).
|
||||
if self._state.adding and not self.name and self.email:
|
||||
self.name = self.email.split("@", 1)[0][:255]
|
||||
|
|
@ -129,7 +129,7 @@ class Org(BaseModel):
|
|||
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.
|
||||
csat_enabled = models.BooleanField(default=True)
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ class Org(BaseModel):
|
|||
# explicit confirmation; this flag only controls the default state.
|
||||
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.
|
||||
vertical = models.CharField(
|
||||
max_length=64,
|
||||
|
|
@ -576,7 +576,7 @@ class Activity(BaseModel):
|
|||
("VIEW", "Viewed"),
|
||||
("COMMENT", "Commented"),
|
||||
("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"),
|
||||
("PRIORITY_CHANGED", "Priority Changed"),
|
||||
("ROUTED", "Routed"),
|
||||
|
|
@ -659,7 +659,7 @@ class Notification(BaseModel):
|
|||
"""In-app notification delivered to a single recipient.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
|
@ -869,7 +869,7 @@ class PersonalAccessToken(BaseOrgModel):
|
|||
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
|
||||
token_prefix = models.CharField(max_length=20)
|
||||
# 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.
|
||||
scopes = models.JSONField(default=list, blank=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
|
@ -910,15 +910,15 @@ class PersonalAccessToken(BaseOrgModel):
|
|||
class PortalAccessToken(models.Model):
|
||||
"""Unscoped ``token_hash → org`` lookup for anonymous portal requests.
|
||||
|
||||
The public portal endpoints — the invoice and estimate links emailed to
|
||||
customers, and the CSAT survey link — arrive with no auth and no org
|
||||
The public portal endpoints: the invoice and estimate links emailed to
|
||||
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
|
||||
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:
|
||||
you cannot set the context until you know the org, and you cannot read the
|
||||
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
|
||||
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
|
||||
|
|
@ -927,7 +927,7 @@ class PortalAccessToken(models.Model):
|
|||
|
||||
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 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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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".
|
||||
"""
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ _redis_lock = threading.Lock()
|
|||
def _get_redis():
|
||||
"""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.
|
||||
"""
|
||||
global _redis_client
|
||||
|
|
@ -64,7 +64,7 @@ def _publish(channel: str, payload: str) -> None:
|
|||
try:
|
||||
client.publish(channel, payload)
|
||||
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.
|
||||
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:
|
||||
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():
|
||||
report.add_skipped("product", product["name"])
|
||||
continue
|
||||
|
|
@ -147,12 +147,12 @@ def _apply_products(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
|
||||
# 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).
|
||||
# Deciding "already set?" from that snapshot and then saving it back
|
||||
# would overwrite whatever the DB row actually holds. Re-read the 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)
|
||||
|
||||
terminology = pack.get("terminology") or {}
|
||||
|
|
@ -169,7 +169,7 @@ def _apply_terminology(org, pack: dict, report: _Report) -> None:
|
|||
current.update(added)
|
||||
locked.terminology = current
|
||||
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
|
||||
# field that records that.
|
||||
if not locked.vertical:
|
||||
|
|
@ -188,7 +188,7 @@ def _sample_models():
|
|||
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
|
||||
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.
|
||||
"""
|
||||
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() /
|
||||
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
|
||||
outside is what stops this clause from misreporting an unrelated integrity
|
||||
error as a collision. ValidationError is caught alongside because Task.save
|
||||
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".
|
||||
"""
|
||||
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):
|
||||
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
|
||||
# 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
|
||||
|
|
@ -264,9 +264,9 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
|
|||
report.add_skipped("sample_data", pack["id"])
|
||||
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
|
||||
# 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.
|
||||
sample_tag, _ = Tags.objects.get_or_create(
|
||||
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
|
||||
obj.tags.add(sample_tag)
|
||||
# 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
|
||||
# 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,
|
||||
|
|
@ -286,7 +286,7 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
|
|||
if actor is not None:
|
||||
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
|
||||
# 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
|
||||
|
|
@ -343,7 +343,7 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
|
|||
finish(obj)
|
||||
# Account.contacts is the M2M the account page actually reads; the
|
||||
# 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.
|
||||
if obj.account_id:
|
||||
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"),
|
||||
description=s.get("description"),
|
||||
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.
|
||||
account=accounts.get(s.get("account")),
|
||||
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
|
||||
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
|
||||
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
|
||||
|
|
@ -507,8 +507,8 @@ def _ids_with_foreign_children(model, ids: list, retained_pks: set) -> set:
|
|||
def clear_sample_data(org, actor=None) -> dict:
|
||||
"""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
|
||||
all six models — never on the "Sample data" tag. `Tags.save()` recomputes its
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
would raise ProtectedError and abort the entire clear. Any sample row
|
||||
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.
|
||||
|
||||
`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.
|
||||
"""
|
||||
from common.audit_log import audit_log
|
||||
|
||||
deleted: 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.
|
||||
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
|
||||
# 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
|
||||
# second, racy count() query.
|
||||
count = by_model.get(model._meta.label, 0)
|
||||
if 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
|
||||
# the odd one out. See AuditLogger.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
|
||||
# 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
|
||||
# — 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
|
||||
# that profile can then read those leads through an ordinary "my leads"
|
||||
# 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:
|
||||
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)
|
||||
# are conventions rather than DB constraints, so without this two racing
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# function and would raise OperationalError. Guard on connection.vendor,
|
||||
# matching the existing pattern in common/tasks.py's set_rls_context and
|
||||
# common/management/commands/manage_rls.py: skip the Postgres-only
|
||||
# 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.
|
||||
from django.db import connection
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""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.
|
||||
"""
|
||||
|
||||
|
|
@ -35,5 +35,5 @@ def get_registry() -> dict[str, dict]:
|
|||
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""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.
|
||||
"""
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ TOP_LEVEL_KEYS = {
|
|||
"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
|
||||
# genuinely unknown keys. See _reject_is_default for why this matters.
|
||||
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
|
||||
# — 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
|
||||
# other five. common.packs.applier imports this rather than restating it.
|
||||
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
|
||||
# a generic unknown-key error. is_default is deliberately INCLUDED in
|
||||
# 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
|
||||
# "unknown key(s) [...]" message, which also contains the substring
|
||||
# "is_default" and would let a loose regex match pass for the wrong
|
||||
# reason.
|
||||
if isinstance(obj, dict) and "is_default" in obj:
|
||||
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),
|
||||
TaskStage = open/in_progress/completed (tasks/models.py:240).
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
"""maps_to_status choices also differ per pipeline model. LeadStage maps
|
||||
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
|
||||
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
|
||||
skips the check rather than inventing a restriction the model doesn't
|
||||
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:
|
||||
"""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
|
||||
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
|
||||
|
|
@ -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.
|
||||
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
if name in seen:
|
||||
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"
|
||||
)
|
||||
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.
|
||||
|
||||
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
|
||||
never attach itself to a tenant's real account (see _apply_sample_data),
|
||||
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)
|
||||
# Lead.source and Lead.status carry `choices` the applier never checks.
|
||||
# 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
|
||||
# row looked fine in the database, which is precisely why this needs a
|
||||
# schema check rather than trust.
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ class PATAuthentication(BaseAuthentication):
|
|||
def authenticate(self, request):
|
||||
raw = _extract_raw(request)
|
||||
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
|
||||
# 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
|
||||
(``User.is_superuser``), never inferred from the email address. Deriving it
|
||||
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
|
||||
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
|
||||
ordinary signup into vertical privilege escalation.
|
||||
|
||||
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."
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ it.
|
|||
|
||||
The lookup key is always ``sha256(url_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``
|
||||
* invoice / estimate. The URL token is the raw ``public_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``.
|
||||
|
||||
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
|
||||
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
|
||||
|
|
@ -24,7 +24,7 @@ from common.models import PortalAccessToken
|
|||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -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:
|
||||
"""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:
|
||||
return
|
||||
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):
|
||||
"""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,
|
||||
so an estimate token cannot resolve on the invoice endpoint. The caller is
|
||||
responsible for ``set_rls_context`` and for the resource query that
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ ORG_SCOPED_TABLES = [
|
|||
"accounts", # Note: plural
|
||||
"contacts", # Note: plural
|
||||
"opportunity",
|
||||
"sales_goal", # SalesGoal quota/target — org-scoped, see opportunity/0012
|
||||
# Both org-scoped opportunity children were live but unregistered — the
|
||||
"sales_goal", # SalesGoal quota/target: org-scoped, see opportunity/0012
|
||||
# Both org-scoped opportunity children were live but unregistered, the
|
||||
# policy tooling (manage_rls --status, audits) could not see them. RLS is
|
||||
# stamped from source by common/0011 (line items) and opportunity/0013
|
||||
# (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_calendar",
|
||||
"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
|
||||
# tooling (manage_rls --status, audit verifications).
|
||||
"macro",
|
||||
|
|
@ -119,7 +119,7 @@ ORG_SCOPED_TABLES = [
|
|||
"approval_rule",
|
||||
"approval",
|
||||
# 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
|
||||
# exists), mirroring the Org table. Isolation for token management is enforced
|
||||
# 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`)
|
||||
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
|
||||
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
|
||||
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.
|
||||
* tickets — reuse ``visible_cases_qs`` (adds the watcher clause).
|
||||
* knowledge-base articles — org-wide, every member reads them.
|
||||
* tickets. Reuse ``visible_cases_qs`` (adds the watcher clause).
|
||||
* knowledge-base articles. Org-wide, every member reads them.
|
||||
|
||||
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 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.
|
||||
PER_TYPE = 6
|
||||
# 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.
|
||||
|
||||
``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.
|
||||
"""
|
||||
return Q(created_by=profile.user) | Q(assigned_to=profile)
|
||||
|
|
@ -64,7 +64,7 @@ def _scope_superuser(qs, profile, user):
|
|||
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -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 = (
|
||||
visible_cases_qs(profile)
|
||||
.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(
|
||||
Q(title__icontains=q) | Q(description__icontains=q)
|
||||
)[:PER_TYPE]
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ class CommentCreateSerializer(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)
|
||||
|
||||
|
|
@ -318,7 +318,7 @@ class LeadCommentSerializer(serializers.ModelSerializer):
|
|||
|
||||
class CustomFieldDefinitionSerializer(serializers.ModelSerializer):
|
||||
"""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:
|
||||
model = CustomFieldDefinition
|
||||
|
|
@ -549,7 +549,7 @@ class CreateProfileSerializer(serializers.ModelSerializer):
|
|||
# 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
|
||||
# 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
|
||||
# (so they are silently ignored) when the caller may not grant them.
|
||||
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:
|
||||
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
|
||||
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.
|
||||
|
||||
``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 allowed and clears it — ``allow_blank`` short-circuits before the regex,
|
||||
which requires 7–25 characters.
|
||||
is allowed and clears it, ``allow_blank`` short-circuits before the regex,
|
||||
which requires 7-25 characters.
|
||||
"""
|
||||
|
||||
name = serializers.CharField(required=False, allow_blank=True, max_length=255)
|
||||
|
|
@ -685,7 +685,7 @@ class DocumentSerializer(serializers.ModelSerializer):
|
|||
|
||||
@extend_schema_field(list)
|
||||
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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ def opportunity_post_delete(sender, instance, **kwargs):
|
|||
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.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ def set_rls_context(org_id):
|
|||
Args:
|
||||
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":
|
||||
return
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
rotation. An expired token is rejected on its `exp` claim regardless of
|
||||
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
|
||||
entry.
|
||||
|
||||
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.
|
||||
|
||||
Returns the number of outstanding-token rows removed.
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ class TestValidatePayload:
|
|||
|
||||
def test_inactive_definition_preserves_existing_value(self, org_a):
|
||||
"""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)
|
||||
cleaned, errors = validate_payload(
|
||||
"Case", {}, org_a, existing={"severity": "S1"}
|
||||
|
|
@ -365,7 +365,7 @@ class TestCustomFieldDefinitionAPI:
|
|||
|
||||
@pytest.mark.django_db
|
||||
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/"
|
||||
|
||||
|
|
@ -447,7 +447,7 @@ class TestCustomFieldAnalytics:
|
|||
}
|
||||
|
||||
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."""
|
||||
CustomFieldDefinition.objects.create(
|
||||
org=org_a,
|
||||
|
|
|
|||
|
|
@ -79,10 +79,10 @@ def _find_missing(
|
|||
if f"{method} {path}" in allowlist:
|
||||
continue
|
||||
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]:
|
||||
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]))})"
|
||||
)
|
||||
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
|
||||
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.
|
||||
"""
|
||||
|
||||
|
|
@ -168,7 +168,7 @@ class TestDocumentDetailAccess:
|
|||
assert user_client.get(_detail_url(doc.id)).status_code == 200
|
||||
|
||||
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)
|
||||
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
|
||||
# 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".)
|
||||
assert not MagicLinkToken.objects.filter(
|
||||
email="linktok@example.com", is_used=True
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class _StubPubSub:
|
|||
|
||||
class TestStreamEvents(TransactionTestCase):
|
||||
"""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."""
|
||||
|
||||
def setUp(self):
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ class TestDispatcher(NotificationModelBase):
|
|||
(),
|
||||
{"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")
|
||||
assert n is not None
|
||||
assert Notification.objects.filter(pk=n.pk).exists()
|
||||
|
|
@ -214,7 +214,7 @@ class TestPurgeTask(NotificationModelBase):
|
|||
def test_purge_respects_custom_days_arg(self):
|
||||
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(
|
||||
org=self.org_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