diff --git a/.env.docker b/.env.docker index 98ca9d5a..5f274a91 100644 --- a/.env.docker +++ b/.env.docker @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 778face3..02a14f18 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 9e05b615..0b8cf867 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13cad521..091a5556 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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`. diff --git a/README.md b/README.md index 68fd1fd8..da46e4f4 100644 --- a/README.md +++ b/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 ![Coverage](./coverage-badge.svg) **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 . 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 diff --git a/RLS_SETUP.md b/RLS_SETUP.md index d73f27cf..aa586f53 100644 --- a/RLS_SETUP.md +++ b/RLS_SETUP.md @@ -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. diff --git a/backend/.env.example b/backend/.env.example index 367929d0..0b6f6976 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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="" diff --git a/backend/README.md b/backend/README.md index d9394d79..8b2ea0ee 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 ` (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 ` (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 diff --git a/backend/accounts/migrations/0008_account_is_sample.py b/backend/accounts/migrations/0008_account_is_sample.py index cb0102f6..13d8db1e 100644 --- a/backend/accounts/migrations/0008_account_is_sample.py +++ b/backend/accounts/migrations/0008_account_is_sample.py @@ -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.'), ), ] diff --git a/backend/accounts/serializer.py b/backend/accounts/serializer.py index 35cfbc24..866ad6be 100644 --- a/backend/accounts/serializer.py +++ b/backend/accounts/serializer.py @@ -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, diff --git a/backend/accounts/tests/test_account_access_and_rollups.py b/backend/accounts/tests/test_account_access_and_rollups.py index f52e3c6f..4b8ccc1c 100644 --- a/backend/accounts/tests/test_account_access_and_rollups.py +++ b/backend/accounts/tests/test_account_access_and_rollups.py @@ -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 ``, 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)) diff --git a/backend/accounts/views.py b/backend/accounts/views.py index 22109e7e..aad4c77c 100644 --- a/backend/accounts/views.py +++ b/backend/accounts/views.py @@ -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 ``, 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 diff --git a/backend/business_hours/calendar.py b/backend/business_hours/calendar.py index 746d4634..401f03a2 100644 --- a/backend/business_hours/calendar.py +++ b/backend/business_hours/calendar.py @@ -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: diff --git a/backend/business_hours/models.py b/backend/business_hours/models.py index a34ab469..79bebe07 100644 --- a/backend/business_hours/models.py +++ b/backend/business_hours/models.py @@ -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( diff --git a/backend/business_hours/tests/test_api.py b/backend/business_hours/tests/test_api.py index e3612278..b8b649b6 100644 --- a/backend/business_hours/tests/test_api.py +++ b/backend/business_hours/tests/test_api.py @@ -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 diff --git a/backend/business_hours/tests/test_calendar.py b/backend/business_hours/tests/test_calendar.py index cdabc830..174ddf6e 100644 --- a/backend/business_hours/tests/test_calendar.py +++ b/backend/business_hours/tests/test_calendar.py @@ -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) diff --git a/backend/business_hours/views.py b/backend/business_hours/views.py index e6e042ce..b215ebdb 100644 --- a/backend/business_hours/views.py +++ b/backend/business_hours/views.py @@ -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// — admin update of weekday hours, + PUT /calendar//: admin update of weekday hours, timezone, name. Holidays are managed via the nested endpoints below to keep payloads small. - POST /calendar//holidays/ — add a single holiday. - DELETE /calendar//holidays// — remove a single holiday. + POST /calendar//holidays/: add a single holiday. + DELETE /calendar//holidays//, remove a single holiday. """ from datetime import time diff --git a/backend/cases/access.py b/backend/cases/access.py index 71440936..e04cba71 100644 --- a/backend/cases/access.py +++ b/backend/cases/access.py @@ -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. """ diff --git a/backend/cases/analytics.py b/backend/cases/analytics.py index fb658d9e..f5661794 100644 --- a/backend/cases/analytics.py +++ b/backend/cases/analytics.py @@ -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() diff --git a/backend/cases/analytics_views.py b/backend/cases/analytics_views.py index bc591549..772350ac 100644 --- a/backend/cases/analytics_views.py +++ b/backend/cases/analytics_views.py @@ -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"), diff --git a/backend/cases/approval_views.py b/backend/cases/approval_views.py index 4612029e..b77ea5fe 100644 --- a/backend/cases/approval_views.py +++ b/backend/cases/approval_views.py @@ -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//request-approval/`` — agent fires a new request.""" + """``POST /api/cases//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 diff --git a/backend/cases/approvals.py b/backend/cases/approvals.py index 58526bf7..2b889143 100644 --- a/backend/cases/approvals.py +++ b/backend/cases/approvals.py @@ -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 = ( diff --git a/backend/cases/bulk_views.py b/backend/cases/bulk_views.py index 9ed0741e..17e949ab 100644 --- a/backend/cases/bulk_views.py +++ b/backend/cases/bulk_views.py @@ -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) diff --git a/backend/cases/csat_views.py b/backend/cases/csat_views.py index 93c73366..d4f2181a 100644 --- a/backend/cases/csat_views.py +++ b/backend/cases/csat_views.py @@ -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, ) diff --git a/backend/cases/escalation_views.py b/backend/cases/escalation_views.py index 09918a9e..ca33a7b2 100644 --- a/backend/cases/escalation_views.py +++ b/backend/cases/escalation_views.py @@ -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( diff --git a/backend/cases/inbound/parser.py b/backend/cases/inbound/parser.py index 2a5b829c..c68f6e41 100644 --- a/backend/cases/inbound/parser.py +++ b/backend/cases/inbound/parser.py @@ -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 diff --git a/backend/cases/inbound/pipeline.py b/backend/cases/inbound/pipeline.py index f3182a05..b5fc90a6 100644 --- a/backend/cases/inbound/pipeline.py +++ b/backend/cases/inbound/pipeline.py @@ -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()}" diff --git a/backend/cases/inbound/sns.py b/backend/cases/inbound/sns.py index 4e3bcc40..56b978fc 100644 --- a/backend/cases/inbound/sns.py +++ b/backend/cases/inbound/sns.py @@ -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) diff --git a/backend/cases/inbound/threading.py b/backend/cases/inbound/threading.py index cd9406e5..9ed0649b 100644 --- a/backend/cases/inbound/threading.py +++ b/backend/cases/inbound/threading.py @@ -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.""" diff --git a/backend/cases/inbound_views.py b/backend/cases/inbound_views.py index 029ee360..bf2b0c79 100644 --- a/backend/cases/inbound_views.py +++ b/backend/cases/inbound_views.py @@ -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//`. 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}) diff --git a/backend/cases/kb_access.py b/backend/cases/kb_access.py index 77512a21..81eeaa4f 100644 --- a/backend/cases/kb_access.py +++ b/backend/cases/kb_access.py @@ -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: diff --git a/backend/cases/kb_views.py b/backend/cases/kb_views.py index 605c4491..61f61e4e 100644 --- a/backend/cases/kb_views.py +++ b/backend/cases/kb_views.py @@ -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//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: diff --git a/backend/cases/migrations/0018_case_resolved_idx.py b/backend/cases/migrations/0018_case_resolved_idx.py index b5d16327..a63b3f90 100644 --- a/backend/cases/migrations/0018_case_resolved_idx.py +++ b/backend/cases/migrations/0018_case_resolved_idx.py @@ -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. diff --git a/backend/cases/migrations/0026_case_is_sample.py b/backend/cases/migrations/0026_case_is_sample.py index cccd6ed4..e16e8cc4 100644 --- a/backend/cases/migrations/0026_case_is_sample.py +++ b/backend/cases/migrations/0026_case_is_sample.py @@ -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.'), ), ] diff --git a/backend/cases/models.py b/backend/cases/models.py index d6ee3c4a..eceef06d 100644 --- a/backend/cases/models.py +++ b/backend/cases/models.py @@ -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. """ diff --git a/backend/cases/notifications.py b/backend/cases/notifications.py index 752e7274..0634a335 100644 --- a/backend/cases/notifications.py +++ b/backend/cases/notifications.py @@ -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/`. This used to emit `/cases/`, 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} diff --git a/backend/cases/parent_views.py b/backend/cases/parent_views.py index eea0cfd5..aa331733 100644 --- a/backend/cases/parent_views.py +++ b/backend/cases/parent_views.py @@ -3,9 +3,9 @@ Parent/child case endpoints (Tier 3 parent-child). Three endpoints over and above what `CaseDetailView` already exposes: -* ``GET /api/cases//tree/`` — descendant tree, max depth 3. -* ``POST /api/cases//link/`` — set/clear parent with explicit audit row. -* ``POST /api/cases//close-with-children/`` — close parent and (optionally) cascade-close descendants. +* ``GET /api/cases//tree/``: descendant tree, max depth 3. +* ``POST /api/cases//link/``: set/clear parent with explicit audit row. +* ``POST /api/cases//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`` diff --git a/backend/cases/routing.py b/backend/cases/routing.py index d9c679a9..75b4ebaa 100644 --- a/backend/cases/routing.py +++ b/backend/cases/routing.py @@ -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 diff --git a/backend/cases/routing_views.py b/backend/cases/routing_views.py index b0b0ce08..95eaa518 100644 --- a/backend/cases/routing_views.py +++ b/backend/cases/routing_views.py @@ -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 diff --git a/backend/cases/serializer.py b/backend/cases/serializer.py index e9de9adf..cc2a9f4f 100644 --- a/backend/cases/serializer.py +++ b/backend/cases/serializer.py @@ -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. """ diff --git a/backend/cases/services/billing.py b/backend/cases/services/billing.py index 112df24e..8b2d1286 100644 --- a/backend/cases/services/billing.py +++ b/backend/cases/services/billing.py @@ -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], diff --git a/backend/cases/services/csv_import.py b/backend/cases/services/csv_import.py index 89b279dc..2b42cc2b 100644 --- a/backend/cases/services/csv_import.py +++ b/backend/cases/services/csv_import.py @@ -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. """ diff --git a/backend/cases/signals.py b/backend/cases/signals.py index 82d78483..18a16c1d 100644 --- a/backend/cases/signals.py +++ b/backend/cases/signals.py @@ -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 diff --git a/backend/cases/solution_serializers.py b/backend/cases/solution_serializers.py index be8b461d..2e94a101 100644 --- a/backend/cases/solution_serializers.py +++ b/backend/cases/solution_serializers.py @@ -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 diff --git a/backend/cases/solution_views.py b/backend/cases/solution_views.py index e8a94cf0..c5ffa20d 100644 --- a/backend/cases/solution_views.py +++ b/backend/cases/solution_views.py @@ -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} diff --git a/backend/cases/tasks.py b/backend/cases/tasks.py index ba71c515..323816fc 100644 --- a/backend/cases/tasks.py +++ b/backend/cases/tasks.py @@ -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'

Hi {contact.first_name or "there"},

' @@ -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 diff --git a/backend/cases/tests/test_analytics_service.py b/backend/cases/tests/test_analytics_service.py index 6f513409..63f5dcdb 100644 --- a/backend/cases/tests/test_analytics_service.py +++ b/backend/cases/tests/test_analytics_service.py @@ -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: diff --git a/backend/cases/tests/test_approvals.py b/backend/cases/tests/test_approvals.py index b03c54b3..6036fc3d 100644 --- a/backend/cases/tests/test_approvals.py +++ b/backend/cases/tests/test_approvals.py @@ -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"). # --------------------------------------------------------------------------- diff --git a/backend/cases/tests/test_case_access_and_sla.py b/backend/cases/tests/test_case_access_and_sla.py index 9ada4e05..380648a0 100644 --- a/backend/cases/tests/test_case_access_and_sla.py +++ b/backend/cases/tests/test_case_access_and_sla.py @@ -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) diff --git a/backend/cases/tests/test_case_merge.py b/backend/cases/tests/test_case_merge.py index d5355dc7..ab127e9d 100644 --- a/backend/cases/tests/test_case_merge.py +++ b/backend/cases/tests/test_case_merge.py @@ -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 diff --git a/backend/cases/tests/test_cases_api.py b/backend/cases/tests/test_cases_api.py index 36d874d9..6e5a0f6a 100644 --- a/backend/cases/tests/test_cases_api.py +++ b/backend/cases/tests/test_cases_api.py @@ -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. diff --git a/backend/cases/tests/test_csat.py b/backend/cases/tests/test_csat.py index b0e0a9e2..e89401de 100644 --- a/backend/cases/tests/test_csat.py +++ b/backend/cases/tests/test_csat.py @@ -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(), diff --git a/backend/cases/tests/test_escalation.py b/backend/cases/tests/test_escalation.py index 9fdce185..ffd04b51 100644 --- a/backend/cases/tests/test_escalation.py +++ b/backend/cases/tests/test_escalation.py @@ -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)) diff --git a/backend/cases/tests/test_import.py b/backend/cases/tests/test_import.py index 9da12a5f..871bf043 100644 --- a/backend/cases/tests/test_import.py +++ b/backend/cases/tests/test_import.py @@ -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"], [ diff --git a/backend/cases/tests/test_inbound_email.py b/backend/cases/tests/test_inbound_email.py index ff35869e..e369d50f 100644 --- a/backend/cases/tests/test_inbound_email.py +++ b/backend/cases/tests/test_inbound_email.py @@ -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="", 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. # --------------------------------------------------------------------------- diff --git a/backend/cases/tests/test_inbound_sns.py b/backend/cases/tests/test_inbound_sns.py index 8b83f1d6..c8901919 100644 --- a/backend/cases/tests/test_inbound_sns.py +++ b/backend/cases/tests/test_inbound_sns.py @@ -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) diff --git a/backend/cases/tests/test_kb_suggestions.py b/backend/cases/tests/test_kb_suggestions.py index d928687c..6b73ef7b 100644 --- a/backend/cases/tests/test_kb_suggestions.py +++ b/backend/cases/tests/test_kb_suggestions.py @@ -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, diff --git a/backend/cases/tests/test_reopen.py b/backend/cases/tests/test_reopen.py index f99ae593..4c72c065 100644 --- a/backend/cases/tests/test_reopen.py +++ b/backend/cases/tests/test_reopen.py @@ -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/" diff --git a/backend/cases/tests/test_routing.py b/backend/cases/tests/test_routing.py index c57b5a2c..d7544057 100644 --- a/backend/cases/tests/test_routing.py +++ b/backend/cases/tests/test_routing.py @@ -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") diff --git a/backend/cases/tests/test_sla_pause.py b/backend/cases/tests/test_sla_pause.py index cde5a14e..bc323091 100644 --- a/backend/cases/tests/test_sla_pause.py +++ b/backend/cases/tests/test_sla_pause.py @@ -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" diff --git a/backend/cases/tests/test_solution_access.py b/backend/cases/tests/test_solution_access.py index c9f9a234..48087d3a 100644 --- a/backend/cases/tests/test_solution_access.py +++ b/backend/cases/tests/test_solution_access.py @@ -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}") diff --git a/backend/cases/tests/test_time_tracking.py b/backend/cases/tests/test_time_tracking.py index c6c70867..2d6333ec 100644 --- a/backend/cases/tests/test_time_tracking.py +++ b/backend/cases/tests/test_time_tracking.py @@ -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 diff --git a/backend/cases/tests/test_watchers.py b/backend/cases/tests/test_watchers.py index cf368f81..0aa8459e 100644 --- a/backend/cases/tests/test_watchers.py +++ b/backend/cases/tests/test_watchers.py @@ -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, diff --git a/backend/cases/tests/test_watchers_mentions.py b/backend/cases/tests/test_watchers_mentions.py index b92bbee2..e1e438be 100644 --- a/backend/cases/tests/test_watchers_mentions.py +++ b/backend/cases/tests/test_watchers_mentions.py @@ -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/` for the entire life of the feature. No - client has ever had a `/cases` route — the SvelteKit app serves tickets at - `/tickets/` — and its notification panel assigns `link` straight to + client has ever had a `/cases` route, the SvelteKit app serves tickets at + `/tickets/`, and its notification panel assigns `link` straight to `window.location.href`, so every notification ever sent landed on a 404. """ diff --git a/backend/cases/time_views.py b/backend/cases/time_views.py index f672eaf7..ee2507ff 100644 --- a/backend/cases/time_views.py +++ b/backend/cases/time_views.py @@ -3,17 +3,17 @@ Time-tracking endpoints (Tier 3 time-tracking). Case-scoped: -* ``GET /api/cases//time-entries/`` — list (visible to actor) -* ``POST /api/cases//time-entries/`` — manual entry -* ``POST /api/cases//time-entries/start/`` — start a running timer (409 if one active) -* ``GET /api/cases//time-summary/`` — totals + by-profile breakdown +* ``GET /api/cases//time-entries/``: list (visible to actor) +* ``POST /api/cases//time-entries/``: manual entry +* ``POST /api/cases//time-entries/start/``: start a running timer (409 if one active) +* ``GET /api/cases//time-summary/``: totals + by-profile breakdown Entry-scoped (registered at the project root under ``/api/time-entries/``): -* ``POST /api/time-entries//stop/`` — stop a running timer -* ``PUT /api/time-entries//`` — owner or admin -* ``DELETE /api/time-entries//`` — owner or admin -* ``GET /api/time-entries/timesheet/`` — week view, grouped by day +* ``POST /api/time-entries//stop/``: stop a running timer +* ``PUT /api/time-entries//``: owner or admin +* ``DELETE /api/time-entries//``: 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//time-summary/`` — totals + per-profile breakdown. + """``GET /api/cases//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//stop/`` — stop a running timer.""" + """``POST /api/time-entries//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=`` — list billable, + """``GET /api/time-entries/unbilled/?account=``: 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. """ diff --git a/backend/cases/urls.py b/backend/cases/urls.py index 9d700984..1e96c2e8 100644 --- a/backend/cases/urls.py +++ b/backend/cases/urls.py @@ -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//", 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 / patterns) + # Case ↔ Solution linking (M2M endpoints, must be before / patterns) path( "/solutions/", views.CaseSolutionLinkView.as_view(), @@ -274,7 +274,7 @@ urlpatterns = [ time_views.TimeSummaryView.as_view(), name="case_time_summary", ), - # Approval workflows (must be before / catchall) — Tier 3 approvals. + # Approval workflows (must be before / catchall), Tier 3 approvals. path( "approval-rules/", approval_views.ApprovalRuleListCreateView.as_view(), diff --git a/backend/cases/views.py b/backend/cases/views.py index f22fb84a..7bbc84dc 100644 --- a/backend/cases/views.py +++ b/backend/cases/views.py @@ -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. diff --git a/backend/cases/watcher_views.py b/backend/cases/watcher_views.py index e283f4ff..69a3f769 100644 --- a/backend/cases/watcher_views.py +++ b/backend/cases/watcher_views.py @@ -1,13 +1,13 @@ """Watch / unwatch / list-watchers REST endpoints. URL surface (mounted under /api/cases/): - POST //watch/ — subscribe the requesting profile (idempotent) - DELETE //watch/ — unsubscribe - GET //watchers/ — list everyone watching the case - GET /watching/ — cases the current profile watches + POST //watch/: subscribe the requesting profile (idempotent) + DELETE //watch/: unsubscribe + GET //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. diff --git a/backend/common/app_urls/__init__.py b/backend/common/app_urls/__init__.py index 8bc863a2..8dc0ef0b 100644 --- a/backend/common/app_urls/__init__.py +++ b/backend/common/app_urls/__init__.py @@ -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( diff --git a/backend/common/audit_log.py b/backend/common/audit_log.py index 1f3e83cd..cec5405e 100644 --- a/backend/common/audit_log.py +++ b/backend/common/audit_log.py @@ -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 diff --git a/backend/common/base.py b/backend/common/base.py index 1c770cf9..b507e9e8 100644 --- a/backend/common/base.py +++ b/backend/common/base.py @@ -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." diff --git a/backend/common/custom_fields.py b/backend/common/custom_fields.py index 202d8b28..292ed565 100644 --- a/backend/common/custom_fields.py +++ b/backend/common/custom_fields.py @@ -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 diff --git a/backend/common/management/commands/create_default_admin.py b/backend/common/management/commands/create_default_admin.py index 88436563..c5da7493 100644 --- a/backend/common/management/commands/create_default_admin.py +++ b/backend/common/management/commands/create_default_admin.py @@ -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!" ) ) diff --git a/backend/common/management/commands/devlogin.py b/backend/common/management/commands/devlogin.py index 98ea2fef..89e59ab2 100644 --- a/backend/common/management/commands/devlogin.py +++ b/backend/common/management/commands/devlogin.py @@ -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 ===")) diff --git a/backend/common/management/commands/seed_data.py b/backend/common/management/commands/seed_data.py index b11756d5..33723471 100644 --- a/backend/common/management/commands/seed_data.py +++ b/backend/common/management/commands/seed_data.py @@ -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() diff --git a/backend/common/middleware/get_company.py b/backend/common/middleware/get_company.py index cc0c3509..aef7f425 100644 --- a/backend/common/middleware/get_company.py +++ b/backend/common/middleware/get_company.py @@ -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 diff --git a/backend/common/middleware/rls_context.py b/backend/common/middleware/rls_context.py index bd23c75f..537677fa 100644 --- a/backend/common/middleware/rls_context.py +++ b/backend/common/middleware/rls_context.py @@ -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 diff --git a/backend/common/migrations/0019_activity_analytics_idx.py b/backend/common/migrations/0019_activity_analytics_idx.py index 99b8d101..59da5423 100644 --- a/backend/common/migrations/0019_activity_analytics_idx.py +++ b/backend/common/migrations/0019_activity_analytics_idx.py @@ -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. diff --git a/backend/common/migrations/0029_delete_sessiontoken.py b/backend/common/migrations/0029_delete_sessiontoken.py index 9f988d76..4a8a34e5 100644 --- a/backend/common/migrations/0029_delete_sessiontoken.py +++ b/backend/common/migrations/0029_delete_sessiontoken.py @@ -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. diff --git a/backend/common/migrations/0031_backfill_portal_tokens.py b/backend/common/migrations/0031_backfill_portal_tokens.py index 1d7caa0d..984f091e 100644 --- a/backend/common/migrations/0031_backfill_portal_tokens.py +++ b/backend/common/migrations/0031_backfill_portal_tokens.py @@ -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. """ diff --git a/backend/common/migrations/0034_rls_pipeline_tables.py b/backend/common/migrations/0034_rls_pipeline_tables.py index 917ac3b2..ad016663 100644 --- a/backend/common/migrations/0034_rls_pipeline_tables.py +++ b/backend/common/migrations/0034_rls_pipeline_tables.py @@ -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. # diff --git a/backend/common/models.py b/backend/common/models.py index ce58a3dc..586455b1 100644 --- a/backend/common/models.py +++ b/backend/common/models.py @@ -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 diff --git a/backend/common/notifications.py b/backend/common/notifications.py index c2d8af13..c9628780 100644 --- a/backend/common/notifications.py +++ b/backend/common/notifications.py @@ -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::`` — see +Channel naming: ``notif::``. 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) diff --git a/backend/common/packs/applier.py b/backend/common/packs/applier.py index abdfd7b3..673e80cf 100644 --- a/backend/common/packs/applier.py +++ b/backend/common/packs/applier.py @@ -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 diff --git a/backend/common/packs/loader.py b/backend/common/packs/loader.py index 3cb6d22f..9582de7e 100644 --- a/backend/common/packs/loader.py +++ b/backend/common/packs/loader.py @@ -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) diff --git a/backend/common/packs/schema.py b/backend/common/packs/schema.py index 9388c434..9100de9e 100644 --- a/backend/common/packs/schema.py +++ b/backend/common/packs/schema.py @@ -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. diff --git a/backend/common/pat_auth.py b/backend/common/pat_auth.py index 39f213a2..a78966c5 100644 --- a/backend/common/pat_auth.py +++ b/backend/common/pat_auth.py @@ -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 diff --git a/backend/common/permissions.py b/backend/common/permissions.py index 77088a57..2dd4a2bf 100644 --- a/backend/common/permissions.py +++ b/backend/common/permissions.py @@ -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." diff --git a/backend/common/portal_tokens.py b/backend/common/portal_tokens.py index c1e9282c..5f3f99d1 100644 --- a/backend/common/portal_tokens.py +++ b/backend/common/portal_tokens.py @@ -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 diff --git a/backend/common/rls/__init__.py b/backend/common/rls/__init__.py index e4233999..5de2c1cf 100644 --- a/backend/common/rls/__init__.py +++ b/backend/common/rls/__init__.py @@ -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. diff --git a/backend/common/search_views.py b/backend/common/search_views.py index 68716393..08361f2d 100644 --- a/backend/common/search_views.py +++ b/backend/common/search_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=`` — a handful of matches per record type.""" + """``GET /api/search/?q=``. 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] diff --git a/backend/common/serializer.py b/backend/common/serializer.py index d47fe7be..0bd18b14 100644 --- a/backend/common/serializer.py +++ b/backend/common/serializer.py @@ -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 diff --git a/backend/common/signals.py b/backend/common/signals.py index a7db0cf7..3ec623d3 100644 --- a/backend/common/signals.py +++ b/backend/common/signals.py @@ -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. diff --git a/backend/common/tasks.py b/backend/common/tasks.py index 3be3ee03..df15abe2 100644 --- a/backend/common/tasks.py +++ b/backend/common/tasks.py @@ -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. diff --git a/backend/common/tests/test_custom_fields.py b/backend/common/tests/test_custom_fields.py index 3def9d19..64f95ddc 100644 --- a/backend/common/tests/test_custom_fields.py +++ b/backend/common/tests/test_custom_fields.py @@ -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, diff --git a/backend/common/tests/test_docs_api_endpoints.py b/backend/common/tests/test_docs_api_endpoints.py index b4811568..f4cdd45c 100644 --- a/backend/common/tests/test_docs_api_endpoints.py +++ b/backend/common/tests/test_docs_api_endpoints.py @@ -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 diff --git a/backend/common/tests/test_documents_access.py b/backend/common/tests/test_documents_access.py index cebc3e24..de276340 100644 --- a/backend/common/tests/test_documents_access.py +++ b/backend/common/tests/test_documents_access.py @@ -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 diff --git a/backend/common/tests/test_magic_link.py b/backend/common/tests/test_magic_link.py index 3eb2cbc5..fd83c312 100644 --- a/backend/common/tests/test_magic_link.py +++ b/backend/common/tests/test_magic_link.py @@ -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 diff --git a/backend/common/tests/test_notification_sse.py b/backend/common/tests/test_notification_sse.py index 95df839c..a2045ef0 100644 --- a/backend/common/tests/test_notification_sse.py +++ b/backend/common/tests/test_notification_sse.py @@ -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): diff --git a/backend/common/tests/test_notifications.py b/backend/common/tests/test_notifications.py index e41262e6..b81b3016 100644 --- a/backend/common/tests/test_notifications.py +++ b/backend/common/tests/test_notifications.py @@ -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, diff --git a/backend/common/tests/test_organizations.py b/backend/common/tests/test_organizations.py index f180b199..e9ed3993 100644 --- a/backend/common/tests/test_organizations.py +++ b/backend/common/tests/test_organizations.py @@ -278,7 +278,7 @@ class TestProfileView: """A plain USER cannot set role/admin flags on their own profile here. The self-edit serializer names only phone and name, so a role or - is_organization_admin in the body is ignored (not 400'd) — the request + is_organization_admin in the body is ignored (not 400'd). The request succeeds, the privileged fields do not move. This is the /api/profile/ counterpart to the CreateProfileSerializer escalation that was closed on /api/user//; the door has to be shut on every self-edit path. diff --git a/backend/common/tests/test_packs_api.py b/backend/common/tests/test_packs_api.py index 822c7ce5..418eaa03 100644 --- a/backend/common/tests/test_packs_api.py +++ b/backend/common/tests/test_packs_api.py @@ -12,7 +12,7 @@ def orgless_user(): @pytest.fixture def orgless_client(orgless_user): """ - An authenticated JWT client whose token carries NO org_id claim — the + An authenticated JWT client whose token carries NO org_id claim. The shape of a user who has signed up but not yet created or joined an org. This is the exact audience GET /api/packs/ must serve (the pack chooser lives on the org-creation page); it's also the audience the write and @@ -48,7 +48,7 @@ class TestPackAPI: def test_traversal_pack_id_is_404_not_a_file_read(self, admin_client): # %2F decodes to "/" before URL resolution, so this never reaches - # PackApplyView at all — it 404s at the Django router, as a plain + # PackApplyView at all: it 404s at the Django router, as a plain # HTML page, not the view's {"error": ..., "errors": "Unknown pack."} # JSON shape. That still proves no file read happened, but it does # NOT exercise get_pack()/the registry lookup this test is named for. @@ -57,7 +57,7 @@ class TestPackAPI: # A slash-free traversal-shaped id (backslash-encoded, so the router # treats it as one path segment) DOES reach the view and get_pack(). - # This is what actually proves the registry lookup — a dict get() — + # This is what actually proves the registry lookup, a dict get(), # is what turns a path-shaped id into a 404, not a filesystem call. res = admin_client.post("/api/packs/..%5C..%5Cwindows%5Cwin.ini/apply/") assert res.status_code == 404 @@ -85,7 +85,7 @@ class TestPackAPI: def test_clear_sample_data_does_not_touch_another_org( self, admin_client, org_b_client, org_a, org_b ): - # org_b_client is org_b's own ADMIN (profile_b, role=ADMIN — see + # org_b_client is org_b's own ADMIN (profile_b, role=ADMIN. See # conftest.py), not admin_client's org_a profile: applying to org_b # must use an org_b actor now that apply_pack rejects a mismatched # (org, actor) pair. @@ -142,7 +142,7 @@ class TestPackListNoOrgContext: def test_packs_is_exempt_by_exact_match_only(self): # Pins the exemption to the exact-match list. If a later refactor # moves "/api/packs/" into the prefix-matched EXEMPT_PATHS, this - # fails — and so would the two tests above, since a prefix match + # fails, and so would the two tests above, since a prefix match # would also exempt /api/packs//apply/ and # /api/packs/sample-data/ from tenant-context enforcement. assert "/api/packs/" in RequireOrgContext.EXEMPT_EXACT_PATHS diff --git a/backend/common/tests/test_packs_applier.py b/backend/common/tests/test_packs_applier.py index d5b06ab0..018784c7 100644 --- a/backend/common/tests/test_packs_applier.py +++ b/backend/common/tests/test_packs_applier.py @@ -44,7 +44,7 @@ def test_second_apply_creates_nothing_and_mutates_nothing(org_a, admin_profile): for t in (LeadPipeline, LeadStage, CustomFieldDefinition, Tags) for row in t.objects.filter(org=org_a) } - # Org is the one row the applier IS allowed to write — the invariant only + # Org is the one row the applier IS allowed to write. The invariant only # holds if a no-op second apply leaves it untouched too. before_org = (org_a.vertical, dict(org_a.terminology)) @@ -67,7 +67,7 @@ def test_second_apply_creates_nothing_and_mutates_nothing(org_a, admin_profile): @pytest.mark.django_db def test_stale_org_reference_does_not_clobber_a_prior_apply(org_a, admin_profile): - """No concurrency needed to reproduce this — just two Org references. + """No concurrency needed to reproduce this, just two Org references. `stale` is loaded before either apply_pack call, so it never observes real-estate's terminology/vertical write. If _apply_terminology decides @@ -140,7 +140,7 @@ def test_apply_records_a_pack_application(org_a, admin_profile): @pytest.mark.django_db def test_apply_pack_rejects_an_actor_from_a_different_org(org_a, org_b, admin_profile): """admin_profile belongs to org_a. Applying to org_b with that actor must - be rejected outright, not silently create cross-org lead assignments — + be rejected outright, not silently create cross-org lead assignments; _apply_sample_data does lead.assigned_to.add(actor) with no org check of its own, so before this guard existed apply_pack(org_b, pack, admin_profile) assigned every sample lead it created in org_b to an org_a profile. diff --git a/backend/common/tests/test_packs_loader.py b/backend/common/tests/test_packs_loader.py index e80b1c87..73f0852e 100644 --- a/backend/common/tests/test_packs_loader.py +++ b/backend/common/tests/test_packs_loader.py @@ -110,7 +110,7 @@ def _with_lead(lead: dict) -> dict: def test_sample_lead_source_outside_lead_source_is_rejected(): # "website" reads as obviously valid and is not: LEAD_SOURCE has no such # entry, and Django does not enforce choices on .create(). Three shipped - # packs carried this before the guard existed — the rows wrote cleanly and + # packs carried this before the guard existed, the rows wrote cleanly and # surfaced only as a blank source column no filter could match. with pytest.raises(PackValidationError, match="source"): validate_manifest(_with_lead({"source": "website"})) diff --git a/backend/common/tests/test_packs_sample_data.py b/backend/common/tests/test_packs_sample_data.py index ce19dcfd..b42e7f3c 100644 --- a/backend/common/tests/test_packs_sample_data.py +++ b/backend/common/tests/test_packs_sample_data.py @@ -28,7 +28,7 @@ def test_clear_removes_only_sample_rows(org_a, admin_profile): assert result["retained"] == {} assert Lead.objects.filter(org=org_a).count() == 1 assert Lead.objects.filter(org=org_a).first().id == real.id - # The reserved tag is a label only now — clear_sample_data must never + # The reserved tag is a label only now; clear_sample_data must never # delete it (Critical fix: it also must never be the *deletion key*; see # test_clear_does_not_delete_a_real_lead_that_adopted_the_sample_tag # below). This assertion is intentionally the inverse of the original @@ -38,7 +38,7 @@ def test_clear_removes_only_sample_rows(org_a, admin_profile): @pytest.mark.django_db def test_clear_does_not_touch_another_org(org_a, org_b, admin_profile, profile_b): - # Each org is applied with an actor from that same org — apply_pack now + # Each org is applied with an actor from that same org; apply_pack now # rejects a mismatched (org, actor) pair (see the org guard in # applier.py), so org_b's apply uses profile_b, not admin_profile. apply_pack(org_a, get_pack("real-estate"), admin_profile) @@ -56,7 +56,7 @@ def test_clear_does_not_delete_a_real_lead_that_adopted_the_sample_tag( """Regression for the Critical finding: clear_sample_data must key off Lead.is_sample, never the "Sample data" tag. Tags.save() recomputes its slug on every save and the tag CRUD endpoints have no reserved-slug - guard, so a real lead can end up carrying this tag several ways — a + guard, so a real lead can end up carrying this tag several ways: a member attaching it from the ordinary tag picker (reproduced here), a tag getting renamed onto this slug, or a user's own pre-existing "Sample data" tag being adopted by an apply. None of those may destroy @@ -103,11 +103,11 @@ def test_apply_with_no_actor_still_creates_leads_unassigned(org_a): """Regression: actor is optional (mirrors PackApplication.applied_by being nullable). Before the fix, `lead.assigned_to.add(None)` on Postgres raised a NOT NULL violation on the through row that the - per-lead except clause misdiagnosed as "duplicate email" and swallowed — + per-lead except clause misdiagnosed as "duplicate email" and swallowed; apply_pack(org, pack, None) created zero leads and reported all of them skipped with a false reason. On SQLite (this whole suite) the same call was a silent no-op, so this test could not have caught the Postgres bug - by itself — it only pins the now-intended behavior: creation must + by itself: it only pins the now-intended behavior: creation must succeed, and no lead ends up with a None entry in assigned_to. """ pack = get_pack("real-estate") @@ -160,7 +160,7 @@ def test_sample_data_applies_cleanly_for_every_shipped_pack( org_a, admin_profile, pack_id ): """The only end-to-end coverage of education-admissions' and - professional-services' sample_data blocks — everything else in this file + professional-services' sample_data blocks, everything else in this file exercises real-estate only. Both other packs carry opportunity_amount / currency (Minor 9) and a full set of remapped `source` values (Important 4), so this is what actually proves those two packs' sample rows insert @@ -232,11 +232,11 @@ def test_sample_references_resolve_to_sample_rows(org_a, admin_profile): assert dana.account is not None assert dana.account.name == "Harbourline Developments" assert dana.account.is_sample - # Both sides of the account/contact link — the FK alone leaves the account + # Both sides of the account/contact link. The FK alone leaves the account # page's contact list empty. assert dana in dana.account.contacts.all() - deal = Opportunity.objects.get(org=org_a, name="Marina District 3 bed — Whitfield") + deal = Opportunity.objects.get(org=org_a, name="Marina District 3 bed, Whitfield") assert deal.account == dana.account ticket = Case.objects.get(org=org_a, name="Parking allocation not confirmed") @@ -309,7 +309,7 @@ def test_clear_keeps_a_sample_account_carrying_a_real_deal(org_a, admin_profile) @pytest.mark.django_db def test_clear_keeps_a_sample_ticket_carrying_billable_time(org_a, admin_profile): - """TimeEntry.case is CASCADE — billable hours logged against a demo ticket + """TimeEntry.case is CASCADE, billable hours logged against a demo ticket must survive a clear. """ from django.utils import timezone @@ -329,7 +329,7 @@ def test_clear_keeps_a_sample_ticket_carrying_billable_time(org_a, admin_profile assert TimeEntry.objects.filter(pk=entry.pk).exists() # Retention has to propagate: Case.account is CASCADE, so keeping the ticket # is worthless if its sample account is deleted a step later and takes the - # ticket — and the billable time — with it. + # ticket, and the billable time, with it. assert result["retained"]["sample_account"] == 1 ticket.refresh_from_db() assert ticket.account is not None @@ -357,6 +357,6 @@ def test_clear_does_not_raise_when_a_sample_account_has_a_protected_invoice( assert result["retained"]["sample_account"] == 1 assert Account.objects.filter(pk=account.pk).exists() assert Invoice.objects.filter(pk=invoice.pk).exists() - # The rest of the sample data still cleared — one blocked row must not + # The rest of the sample data still cleared. One blocked row must not # abort the whole operation. assert result["deleted"]["sample_lead"] == 25 diff --git a/backend/common/tests/test_pat_auth.py b/backend/common/tests/test_pat_auth.py index 443ea25e..8200f82a 100644 --- a/backend/common/tests/test_pat_auth.py +++ b/backend/common/tests/test_pat_auth.py @@ -85,7 +85,7 @@ class TestPATAuthentication: pat.refresh_from_db() first = pat.last_used_at assert first is not None - # Second auth immediately after — within the 60s throttle window — must not rewrite + # Second auth immediately after, within the 60s throttle window, must not rewrite self.auth.authenticate(req) pat.refresh_from_db() assert pat.last_used_at == first @@ -143,5 +143,5 @@ def test_cross_org_pat_cannot_read_other_orgs_leads(org_a, admin_profile): assert response.status_code == 200, response.content lead_ids = _collect_lead_ids(response.json()) assert str(lead_b.id) not in lead_ids, ( - "org B's lead leaked into an org A PAT's leads list — tenant isolation broken" + "org B's lead leaked into an org A PAT's leads list, tenant isolation broken" ) diff --git a/backend/common/tests/test_pat_org_api.py b/backend/common/tests/test_pat_org_api.py index d803ac94..9607cad3 100644 --- a/backend/common/tests/test_pat_org_api.py +++ b/backend/common/tests/test_pat_org_api.py @@ -1,10 +1,10 @@ -"""Org-wide token oversight — the admin's cross-user view and revoke. +"""Org-wide token oversight, the admin's cross-user view and revoke. Distinct from test_pat_api.py, which covers the SELF-scoped /profile/tokens/ endpoints. These endpoints (/org/tokens/) are the admin oversight half added for the /v2/settings/api-tokens page: read every token in the org, revoke any -of them. Every rule is asserted both ways — the allowed path AND the forbidden -one — because personal_access_token has no RLS, so the explicit org filter and +of them. Every rule is asserted both ways, the allowed path AND the forbidden +one, because personal_access_token has no RLS, so the explicit org filter and the IsOrgAdmin gate are the only barriers and a silent regression in either would leak or let through cross-tenant/cross-role access. """ @@ -46,7 +46,7 @@ class TestOrgTokenList: assert by_id[str(theirs.id)]["owner"]["is_active"] is True def test_non_admin_is_forbidden(self, user_client, user_profile): - # A member must not see the whole org's tokens — not even their own via + # A member must not see the whole org's tokens, not even their own via # this endpoint; self-service lives on /profile/tokens/. PersonalAccessToken.generate(profile=user_profile, name="mine") resp = user_client.get(LIST_URL) diff --git a/backend/common/tests/test_portal_tokens.py b/backend/common/tests/test_portal_tokens.py index eb473525..2c10e918 100644 --- a/backend/common/tests/test_portal_tokens.py +++ b/backend/common/tests/test_portal_tokens.py @@ -63,7 +63,7 @@ def test_register_by_hash_matches_csat_storage(org_a): token_hash = hashlib.sha256(raw.encode()).hexdigest() register_portal_token_hash(token_hash, org_a.id, "csat", org_a.id) assert resolve_portal_org_by_hash(token_hash, "csat") == str(org_a.id) - # The view resolves from the raw URL token — same key. + # The view resolves from the raw URL token, same key. assert resolve_portal_org(raw, "csat") == str(org_a.id) diff --git a/backend/common/tests/test_rls_pipeline_tables.py b/backend/common/tests/test_rls_pipeline_tables.py index 3620c6a6..04fd5984 100644 --- a/backend/common/tests/test_rls_pipeline_tables.py +++ b/backend/common/tests/test_rls_pipeline_tables.py @@ -3,7 +3,7 @@ These six tables carry org_id directly but were absent from ORG_SCOPED_TABLES, and neither leads/0012, cases/0009 nor tasks/0009 stamped a policy. Deployed databases had the policies from an out-of-band step, so the gap only existed on -databases built purely from migrations — CI, and any fresh deploy. +databases built purely from migrations, CI, and any fresh deploy. The pytest database is built by running migrations and nothing else, which is precisely the environment that was broken. common/0034 closes it; these tests diff --git a/backend/common/tests/test_search.py b/backend/common/tests/test_search.py index db214bd8..21dff963 100644 --- a/backend/common/tests/test_search.py +++ b/backend/common/tests/test_search.py @@ -2,7 +2,7 @@ The two that matter most are tenant isolation (org A never sees org B) and intra-org visibility (a non-admin never finds a record they could not open from -the list) — search is a classic place for both leaks. +the list). Search is a classic place for both leaks. """ import pytest @@ -126,7 +126,7 @@ class TestGlobalSearch: def test_solutions_visible_to_every_member( self, user_client, admin_user, user_profile, org_a ): - """Knowledge base is org-wide — a non-admin who owns nothing still finds it.""" + """Knowledge base is org-wide, a non-admin who owns nothing still finds it.""" with impersonate(admin_user): Solution.objects.create( org=org_a, diff --git a/backend/common/tests/test_tags.py b/backend/common/tests/test_tags.py index a94fa37d..ae9c31d9 100644 --- a/backend/common/tests/test_tags.py +++ b/backend/common/tests/test_tags.py @@ -44,7 +44,7 @@ class TestTagsListView: def test_unauthenticated(self, unauthenticated_client): # DRF converts the auth failure into a 401/403 Response (it does not # propagate the exception through the test client), so assert on the - # status — the endpoint still rejects an anonymous caller. + # status. The endpoint still rejects an anonymous caller. resp = unauthenticated_client.get(self.url) assert resp.status_code in ( status.HTTP_401_UNAUTHORIZED, @@ -202,7 +202,7 @@ class TestTagsUsageAndTotals: def test_usage_excludes_other_orgs_records(self, admin_client, org_a, org_b): """The usage subquery is explicitly org-scoped, so a foreign-org record - carrying the same tag row is not counted — proven here on SQLite where + carrying the same tag row is not counted. Proven here on SQLite where RLS is inert, so only the ORM filter can be doing the scoping.""" tag = Tags.objects.create(name="Shared", slug="shared", org=org_a) Account.objects.create(org=org_a, name="Ours").tags.add(tag) diff --git a/backend/common/tests/test_today.py b/backend/common/tests/test_today.py index 151987dc..80804202 100644 --- a/backend/common/tests/test_today.py +++ b/backend/common/tests/test_today.py @@ -2,8 +2,8 @@ Tests for the v2 "Today" action queue: GET /api/dashboard/today/ (ApiTodayView). Two things are under test: that each of the four sources (unanswered cases, -overdue invoices, quiet deals, due tasks) lands in the right bucket, and — the -part that matters for a multi-tenant app — that a member sees only their own +overdue invoices, quiet deals, due tasks) lands in the right bucket, and. The +part that matters for a multi-tenant app. That a member sees only their own rows and no request ever crosses an org boundary. Run with: pytest common/tests/test_today.py -v @@ -265,7 +265,7 @@ class TestTodayView: def test_admin_sees_colleague_items(self, admin_client, org_a, regular_user): """The same colleague-owned row the member is scoped to IS visible to an - admin — proving the member exclusion above is scoping, not a dropped row.""" + admin. Proving the member exclusion above is scoping, not a dropped row.""" _set_rls(org_a) inv = Invoice.objects.create( invoice_title="Someone else's overdue invoice", diff --git a/backend/common/tests/test_users.py b/backend/common/tests/test_users.py index 57a06298..4bd1f4da 100644 --- a/backend/common/tests/test_users.py +++ b/backend/common/tests/test_users.py @@ -501,7 +501,7 @@ class TestUserStatusView: def test_cannot_deactivate_last_admin( self, admin_client, org_a, admin_user, admin_profile ): - """Deactivating the org's only admin is refused — no admin, no recovery.""" + """Deactivating the org's only admin is refused, no admin, no recovery.""" response = admin_client.post( self._url(admin_user.id), {"status": "Inactive"}, @@ -559,7 +559,7 @@ class TestGetTeamsAndUsersView: class TestProfilePrivilegeEscalation: """`role` and the access flags are grantable only by an admin acting on someone else. A member editing their own profile was able to PATCH - themselves to role="ADMIN" — a live org-admin escalation. These pin both + themselves to role="ADMIN". A live org-admin escalation. These pin both the block and the legitimate admin path so neither drifts. """ @@ -653,7 +653,7 @@ class TestProfilePrivilegeEscalation: def test_admin_cannot_change_their_own_role( self, admin_client, admin_user, admin_profile ): - """Even an admin cannot self-demote here — anti self-lockout.""" + """Even an admin cannot self-demote here, anti self-lockout.""" response = admin_client.patch( self._url(admin_user.id), {"role": "USER"}, diff --git a/backend/common/urls.py b/backend/common/urls.py index f7d1d538..81f19dfb 100644 --- a/backend/common/urls.py +++ b/backend/common/urls.py @@ -88,7 +88,7 @@ urlpatterns = [ path("org/settings/", OrgSettingsView.as_view(), name="org_settings"), # These literal org/… paths must precede org// so they are not # captured as a pk (org/tokens/ would otherwise resolve to OrgUpdateView - # with pk="tokens"). org/tokens/ is ADMIN-only token oversight — separate + # with pk="tokens"). org/tokens/ is ADMIN-only token oversight, separate # from profile/tokens/ (self-scoped) so the self guard is never widened; an # admin sees and can revoke any token in their own org, a deactivated # colleague's included. @@ -101,7 +101,7 @@ urlpatterns = [ ), path("org//", OrgUpdateView.as_view()), path("profile/", ProfileView.as_view()), - # Personal Access Tokens (MCP server) — a user manages ONLY their own + # Personal Access Tokens (MCP server), a user manages ONLY their own path( "profile/tokens/", PersonalAccessTokenListCreateView.as_view(), @@ -165,7 +165,7 @@ urlpatterns = [ NotificationDetailView.as_view(), name="notifications_detail", ), - # Vertical packs — any member may list; apply/clear are ADMIN-only (see + # Vertical packs: any member may list; apply/clear are ADMIN-only (see # common/views/pack_views.py). sample-data/ must precede # /apply/ so it is never captured as a pack id. path("packs/", PackListView.as_view(), name="pack_list"), diff --git a/backend/common/views/auth_views.py b/backend/common/views/auth_views.py index df865bf6..25f6edc2 100644 --- a/backend/common/views/auth_views.py +++ b/backend/common/views/auth_views.py @@ -29,7 +29,7 @@ def _google_email_is_verified(claims): Google always sends it alongside the ``email`` scope, so its absence means the payload is not the shape we think it is. Without this check, a Google account carrying an address whose owner never proved control of it is enough - to become that address here — which matters because other parts of the + to become that address here, which matters because other parts of the system key off the email string. """ verified = claims.get("email_verified") @@ -39,7 +39,7 @@ def _google_email_is_verified(claims): def _disabled_account_response(): """403 for a login attempt by a deactivated account. - Deactivation is the offboarding lever, so every login path must honour it — + Deactivation is the offboarding lever, so every login path must honour it, otherwise a revoked user simply signs in again. Kept identical across the Google and magic-link flows so clients can handle one shape. """ @@ -646,7 +646,7 @@ class MagicLinkRequestView(APIView): ip_address=request.META.get("REMOTE_ADDR"), ) - # Send email via Celery — pass raw_code only when delivery is "code". + # Send email via Celery. Pass raw_code only when delivery is "code". send_magic_link_email.delay(str(token_obj.id), raw_code=raw_code) return Response( diff --git a/backend/common/views/custom_field_views.py b/backend/common/views/custom_field_views.py index 202aafad..a4a7d3bd 100644 --- a/backend/common/views/custom_field_views.py +++ b/backend/common/views/custom_field_views.py @@ -61,7 +61,7 @@ def _records_missing_map(org, definitions): field's key (the value pipeline never stores empty/None), so missing = − . Marking a field required only binds new writes, so this counts the rows that - predate the rule. Every query is explicitly `org=`-scoped — RLS is inert for + predate the rule. Every query is explicitly `org=`-scoped. RLS is inert for the app's DB role in dev/test, so the ORM filter is what keeps another org's record counts (and their existence) out of this number. """ @@ -73,7 +73,7 @@ def _records_missing_map(org, definitions): for target_model, defs in by_model.items(): model = _resolve_target_model(target_model) if model is None: - # Unknown/unwired target — can't count, report zero rather than 500. + # Unknown/unwired target, can't count, report zero rather than 500. for defn in defs: result[str(defn.id)] = 0 continue @@ -123,7 +123,7 @@ class CustomFieldDefinitionListCreateView(APIView): rows = CustomFieldDefinitionSerializer(qs, many=True).data # `records_missing_value` costs one COUNT over the org's whole record - # set per definition, plus one per target model — a `custom_fields ? + # set per definition, plus one per target model, a `custom_fields ? # key` scan of every Lead the org has, for each field. The settings # page needs those numbers; a record page that only wants labels and # types to render a form does not, and making it pay for the stat cards @@ -238,7 +238,7 @@ class CustomFieldDefinitionDetailView(APIView): {"error": True, "errors": "Custom field not found"}, status=status.HTTP_404_NOT_FOUND, ) - # Soft delete — historical values on entities stay readable; the field + # Soft delete: historical values on entities stay readable; the field # just stops appearing in admin UIs and stops accepting new writes. obj.is_active = False obj.save(update_fields=["is_active", "updated_at"]) diff --git a/backend/common/views/dashboard_views.py b/backend/common/views/dashboard_views.py index 3bf84bdb..532cccc7 100644 --- a/backend/common/views/dashboard_views.py +++ b/backend/common/views/dashboard_views.py @@ -27,7 +27,7 @@ from opportunity.workflow import DEFAULT_STAGE_EXPECTED_DAYS, ROTTEN_MULTIPLIER from tasks.models import Task from tasks.serializer import TaskSerializer -# Sales stages a deal can still be worked (and therefore "age") in — mirrors +# Sales stages a deal can still be worked (and therefore "age") in: mirrors # the open-stage list ApiHomeView uses for its revenue metrics. OPEN_STAGES = ["PROSPECTING", "QUALIFICATION", "PROPOSAL", "NEGOTIATION"] @@ -57,7 +57,7 @@ def _fmt_money(amount, currency): def _fmt_date(d): - """'Jul 5' — portable (avoids the platform-specific %-d).""" + """'Jul 5'. Portable (avoids the platform-specific %-d).""" return f"{d:%b} {d.day}" @@ -310,8 +310,8 @@ class ApiTodayView(APIView): * open deals that have gone quiet (stage-aging yellow/red), * tasks overdue or due today. - Security: every query is org-scoped — the org comes from the JWT via - middleware, never the client — and a member sees only rows assigned to or + Security: every query is org-scoped. The org comes from the JWT via + middleware, never the client, and a member sees only rows assigned to or created by them, the same visibility ``ApiHomeView`` applies. Admins see the whole org. ``HasOrgContext`` guarantees ``request.profile``/``org`` are set, so this never dereferences a ``None`` profile. @@ -354,7 +354,7 @@ class ApiTodayView(APIView): def mine(qs): """Restrict a queryset to rows a member may see. ``created_by`` is a User FK (compared to ``profile.user``); ``assigned_to`` is an M2M of - Profile — the OR-over-a-join can duplicate rows, so distinct().""" + Profile: the OR-over-a-join can duplicate rows, so distinct().""" return qs.filter( Q(assigned_to__id__in=[profile.id]) | Q(created_by=profile.user) ).distinct() diff --git a/backend/common/views/document_views.py b/backend/common/views/document_views.py index 181c60b3..40fc16b8 100644 --- a/backend/common/views/document_views.py +++ b/backend/common/views/document_views.py @@ -31,7 +31,7 @@ def _visible_to(profile): Three ways in, and each was broken in its own way: - * **Creator.** `Document.created_by` is a **User** FK — the create path + * **Creator.** `Document.created_by` is a **User** FK. The create path sets `created_by=request.profile.user`. The old checks compared it to `request.profile`, a different model, so the expression was always False and the uploader of a document could not open or delete it. @@ -44,7 +44,7 @@ def _visible_to(profile): anything while the interface presented it as a share. Honouring it is what makes the stored value mean what it says. - Scope: this only ever narrows within one org — the caller has already + Scope: this only ever narrows within one org. The caller has already filtered on `org=request.profile.org`, and RLS is underneath that. It is not a substitute for either. """ @@ -124,7 +124,7 @@ class DocumentListView(APIView, LimitOffsetPagination): ).data if results_documents_inactive: # This block computes the *inactive* envelope's offset, so it must - # key off the last inactive row — not `results_documents_active[-1]`, + # key off the last inactive row, not `results_documents_active[-1]`, # a copy-paste from the active block above. When the org has archived # documents but no active ones (e.g. the only document was just # archived), `results_documents_active` is empty and `[-1]` raised @@ -256,7 +256,7 @@ class DocumentDetailView(APIView): Being able to read a document does not imply being able to destroy it for everyone else, so this stays narrower than `_may_read`: the person - who uploaded it, or an admin. `created_by` is a User FK — comparing it + who uploaded it, or an admin. `created_by` is a User FK. Comparing it to a Profile is the bug that made this branch unreachable. """ if self.request.profile.role == "ADMIN" or self.request.user.is_superuser: @@ -267,15 +267,15 @@ class DocumentDetailView(APIView): """Editing is as consequential as deleting, so it is gated the same way. PUT and PATCH used to authorise on `_may_read`, which meant anyone a - document was merely *shared with* — or a member of a team it was shared - with — could overwrite its file, rename it, flip its status to + document was merely *shared with*, or a member of a team it was shared + with, could overwrite its file, rename it, flip its status to `inactive` (hiding it from the org), and, on PUT, wipe its entire `shared_to`/`teams` list and lock everyone else out. A share hands someone a copy to work with; it does not hand them the original to rewrite. Replacing the bytes behind a title is at least as destructive as deleting the row, so mutation stays exactly as narrow as `_may_delete`: the uploader or an admin. Reading (`_may_read`) remains - broad on purpose — creator, share, team, admin — because seeing a + broad on purpose (creator, share, team, admin), because seeing a document and changing it for everyone are different privileges. """ return self._may_delete(document) diff --git a/backend/common/views/notification_views.py b/backend/common/views/notification_views.py index 21a91bc2..44256b7e 100644 --- a/backend/common/views/notification_views.py +++ b/backend/common/views/notification_views.py @@ -1,11 +1,11 @@ """REST endpoints for in-app notifications. URL surface (mounted at /api/notifications/): - GET / — list, optional ?unread=true&limit=20&since= - GET /stream/ — SSE event stream (one event per new notification) - POST //read/ — mark a single notification read - POST /read-all/ — mark all (or all-before-iso) read - DELETE // — hard-delete a notification + GET /: list, optional ?unread=true&limit=20&since= + GET /stream/: SSE event stream (one event per new notification) + POST //read/: mark a single notification read + POST /read-all/: mark all (or all-before-iso) read + DELETE //: hard-delete a notification Rules: - All queries filter by `recipient=request.profile`. RLS adds an org @@ -41,8 +41,8 @@ KEEPALIVE_SECONDS = 15 # transparently reconnects when the server closes the stream, so capping this # is invisible to the user but critical for the server: under ASGI the *entire* # stream runs inside one request (one `ThreadSensitiveContext`), so an -# unbounded `while True` stream pins a worker thread — and any DB/redis -# connection bound to it — for as long as the tab stays open (hours). Left +# unbounded `while True` stream pins a worker thread, and any DB/redis +# connection bound to it. For as long as the tab stays open (hours). Left # unbounded this is what slowly exhausts a shared Postgres cluster and leaves # workers undrainable on deploy. 5 minutes bounds the leak window and lets # workers recycle. @@ -164,7 +164,7 @@ async def _aget_serialized(notif_id, recipient_id): return NotificationSerializer(notif).data finally: # This runs in asgiref's shared thread-sensitive executor, outside - # Django's request/response cycle — so `close_old_connections` + # Django's request/response cycle, so `close_old_connections` # never fires for it. Without this explicit close the connection # dangles open (the SSE tests need TransactionTestCase precisely # because of it). Close it so each fetch reclaims its connection. @@ -194,7 +194,7 @@ async def _open_pubsub(channel: str): Returns ``(client, pubsub)``. Both are ``None`` if Redis is unreachable; the stream then runs in keepalive-only mode (the frontend's polling-since path provides backfill). The caller owns closing BOTH the pubsub and the - underlying client connection pool — closing only the pubsub leaks the + underlying client connection pool, closing only the pubsub leaks the client's pooled connections, one per dropped SSE stream. """ try: @@ -265,7 +265,7 @@ async def _stream_events(channel: str, recipient_id, *, pubsub=None): except Exception: # pragma: no cover - best effort pass await _aclose_redis(pubsub) - # Close the underlying client pool too — not just the pubsub. + # Close the underlying client pool too, not just the pubsub. await _aclose_redis(client) @@ -294,7 +294,7 @@ def _drive_async_gen(agen): class _EventStreamRenderer(BaseRenderer): """Advertises text/event-stream for DRF content negotiation. - Never actually invoked — the view returns a StreamingHttpResponse directly. + Never actually invoked: the view returns a StreamingHttpResponse directly. Without this, an EventSource sending `Accept: text/event-stream` is rejected by DRF's content negotiation with HTTP 406 (no matching renderer). """ @@ -310,7 +310,7 @@ class NotificationStreamView(APIView): """GET /api/notifications/stream/ Long-lived Server-Sent Events response. Requires an ASGI server in - production — under WSGI this view ties up one worker per connected user. + production, under WSGI this view ties up one worker per connected user. """ permission_classes = (IsAuthenticated,) @@ -325,7 +325,7 @@ class NotificationStreamView(APIView): # Under ASGI the entire stream runs inside one request's # ThreadSensitiveContext, so the connection opened by auth/middleware # would otherwise stay checked out for the full (possibly hours-long) - # stream — one leaked Postgres connection per open browser tab, which + # stream, one leaked Postgres connection per open browser tab, which # is what exhausts the shared cluster. The stream's own reads go # through a separate executor (see `_aget_serialized`) and close # themselves, so dropping this one is safe. diff --git a/backend/common/views/organization_views.py b/backend/common/views/organization_views.py index 54b9cd15..2f930601 100644 --- a/backend/common/views/organization_views.py +++ b/backend/common/views/organization_views.py @@ -352,7 +352,7 @@ class ProfileView(APIView): user_obj = ProfileSerializer(profile).data # The page shows which teams the current user is on. Teams is a real # Profile.user_teams m2m, but it is not on the shared ProfileSerializer - # (nested into many records) — adding it here keeps the extra query and + # (nested into many records). Adding it here keeps the extra query and # the wider output on this one self-profile endpoint, off the hot path. user_obj["teams"] = list(profile.user_teams.values_list("name", flat=True)) return Response({"user_obj": user_obj}, status=status.HTTP_200_OK) @@ -381,7 +381,7 @@ class ProfileView(APIView): # Validate through a serializer that names ONLY phone and name. A bad # phone is now a clean 400 (the old code wrote request.data straight to - # the column), and — more importantly — role/org/access flags are not + # the column), and, more importantly, role/org/access flags are not # fields here, so no request body can escalate a profile through this # self-edit door. See ProfileSelfUpdateSerializer. update = ProfileSelfUpdateSerializer(data=request.data, partial=True) diff --git a/backend/common/views/pack_views.py b/backend/common/views/pack_views.py index d9237d8b..b9b77dfe 100644 --- a/backend/common/views/pack_views.py +++ b/backend/common/views/pack_views.py @@ -1,7 +1,7 @@ """Vertical-pack endpoints. Authorization: any authenticated member may list packs; only an org ADMIN may -apply one or clear sample data. The org is always request.profile.org — never a +apply one or clear sample data. The org is always request.profile.org, never a body field. Org.vertical is descriptive and is never read here for access control. """ @@ -29,12 +29,12 @@ class PackListView(APIView): # No HasOrgContext: this list is static repo content (JSON files), # byte-identical for every tenant, with no tenant data in the response. # It backs the pack chooser on the org-creation page, where a user - # creating their first org has no org_id claim yet — HasOrgContext would + # creating their first org has no org_id claim yet. HasOrgContext would # 403 exactly that audience. /api/packs/ is exempted from # RequireOrgContext by exact match (not prefix) in # common/middleware/rls_context.py; see the comment there for why the # write/destroy endpoints below must not be. IsAuthenticated still - # applies — this is not anonymous. + # applies. This is not anonymous. permission_classes = (IsAuthenticated,) def get(self, request): diff --git a/backend/common/views/pat_views.py b/backend/common/views/pat_views.py index b789475f..088ea101 100644 --- a/backend/common/views/pat_views.py +++ b/backend/common/views/pat_views.py @@ -5,13 +5,13 @@ A user manages ONLY their own tokens. Both list and revoke are filtered by token id 404s rather than leaking or being revoked (IDOR guard). The raw token is returned exactly once, on create; ``token_hash`` is never serialized. -The ``Org*`` views below are the admin's *oversight* half — org-wide read and -revoke, gated to admins — and are deliberately kept SEPARATE from the +The ``Org*`` views below are the admin's *oversight* half. Org-wide read and +revoke, gated to admins, and are deliberately kept SEPARATE from the self-scoped views above rather than widening them, so the "a user manages only their own" guard is never relaxed. ``personal_access_token`` is intentionally NOT RLS-protected (it is looked up by ``token_hash`` before any tenant context exists), so on every view here the explicit ``org=request.profile.org`` filter -is the *only* tenant barrier — load-bearing, not a belt over RLS's braces. +is the *only* tenant barrier, load-bearing, not a belt over RLS's braces. """ from django.shortcuts import get_object_or_404 @@ -87,11 +87,11 @@ class PersonalAccessTokenDetailView(APIView): class OrgAccessTokenListView(APIView): - """Org-wide token oversight — ADMIN only, read. + """Org-wide token oversight. ADMIN only, read. The self-scoped list above answers "my tokens". This one answers the admin's question: which tokens exist across the whole org, and which two - kinds are worth acting on — + kinds are worth acting on. * a token whose owner has been deactivated, and * a token nobody has used in a long time. @@ -105,7 +105,7 @@ class OrgAccessTokenListView(APIView): flag ``resolve_valid_pat`` checks, so a token on a deactivated owner is already rejected at login (see ``common/tests/test_pat_auth.py::test_inactive_profile_raises``). It is a - dormant liability, not a live credential — it would authenticate again only + dormant liability, not a live credential. It would authenticate again only if the account were reactivated. The count is surfaced so an admin can revoke it as part of offboarding, not because it is "still working". """ @@ -153,13 +153,13 @@ class OrgAccessTokenListView(APIView): class OrgAccessTokenDetailView(APIView): - """Admin revoke of ANY token in the org — org-scoped, not profile-scoped. + """Admin revoke of ANY token in the org, org-scoped, not profile-scoped. Separate from ``PersonalAccessTokenDetailView`` (which stays self-only): an admin cleaning up a deactivated colleague's token cannot use the self path. The org filter is the sole tenant barrier (no RLS on this table), so a pk in another org 404s rather than being revoked cross-tenant. Revoke is - idempotent — a second call on an already-revoked token is a no-op 200. + idempotent. A second call on an already-revoked token is a no-op 200. """ permission_classes = (IsAuthenticated, HasOrgContext, IsOrgAdmin) diff --git a/backend/common/views/tags_views.py b/backend/common/views/tags_views.py index 6fec66e6..1a7d937e 100644 --- a/backend/common/views/tags_views.py +++ b/backend/common/views/tags_views.py @@ -33,7 +33,7 @@ def _usage_subquery(model, org): One correlated subquery per relation rather than four `Count`s on a single query: joining all four M2M through-tables at once multiplies rows, and a subquery keeps each count independent (the subquery-rollup rule). Filtered - by `org` explicitly — usage is a per-org fact and must not lean on RLS, + by `org` explicitly. Usage is a per-org fact and must not lean on RLS, which is inert for the app's DB role in dev/test. """ return Coalesce( @@ -68,7 +68,7 @@ def _tag_totals(org): return { "count": len(all_tags), "active": len(active), - # Active tags applied to nothing — the only ones safe to delete. + # Active tags applied to nothing: the only ones safe to delete. "unused": sum(1 for t in active if total_usage(t) == 0), } diff --git a/backend/common/views/user_views.py b/backend/common/views/user_views.py index 84755d76..b91b80ec 100644 --- a/backend/common/views/user_views.py +++ b/backend/common/views/user_views.py @@ -37,7 +37,7 @@ def _valid_token_counts_by_profile(org): """{profile_id (str): count of non-revoked, non-expired PATs} for one org. One query for the whole roster. A token is "valid" if it has not been - revoked and has not expired — the same test PersonalAccessToken.is_valid() + revoked and has not expired. The same test PersonalAccessToken.is_valid() applies per row, expressed as a filter so the count is a single round trip. """ now = timezone.now() @@ -208,7 +208,7 @@ class UsersListView(APIView, LimitOffsetPagination): # A not-yet-revoked, unexpired token on a deactivated account is a # dormant liability the team page surfaces: it is rejected at login - # today (resolve_valid_pat checks profile.is_active — see + # today (resolve_valid_pat checks profile.is_active: see # test_pat_auth.py::test_inactive_profile_raises), but it would # authenticate again the moment the account is reactivated, so it is # worth revoking as part of offboarding. Count such tokens per profile @@ -285,7 +285,7 @@ class UserDetailView(APIView): Two rules, both of which the /v2/team UI already advertises as enforced: * only an admin (or superuser) may hand out access, and - * nobody may change their *own* role — an admin included, so the org + * nobody may change their *own* role. An admin included, so the org cannot be self-locked out of its last admin and a member cannot promote themselves. @@ -569,7 +569,7 @@ class UserStatusView(APIView): # org with no admin (self role-change is blocked in the # serializer), and an org with no admin can never invite, # promote or reconfigure itself again. Refuse to deactivate the - # last active admin — the rule the /v2/team page advertises. + # last active admin, the rule the /v2/team page advertises. target_is_admin = ( profile.role == "ADMIN" or profile.is_organization_admin ) diff --git a/backend/contacts/migrations/0013_contact_is_sample.py b/backend/contacts/migrations/0013_contact_is_sample.py index b713f1d1..9411778b 100644 --- a/backend/contacts/migrations/0013_contact_is_sample.py +++ b/backend/contacts/migrations/0013_contact_is_sample.py @@ -13,6 +13,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='contact', 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.'), ), ] diff --git a/backend/contacts/services/csv_import.py b/backend/contacts/services/csv_import.py index 15218720..b04d3386 100644 --- a/backend/contacts/services/csv_import.py +++ b/backend/contacts/services/csv_import.py @@ -6,14 +6,14 @@ phases re-run validation so the commit endpoint is safe even if called directly. Duplicate policy (per org): - * email — hard error (DB enforces a per-org case-insensitive unique + * email: hard error (DB enforces a per-org case-insensitive unique constraint; we mirror it in the importer so users see a clean row-level message instead of an IntegrityError at commit). - * phone — hard error. There is no DB constraint, but most callers + * phone: hard error. There is no DB constraint, but most callers consider phone a unique identifier in practice. We normalize using `common.validators.normalize_phone` (digits-only, last 10) so "+1 (202) 555-1234" matches "2025551234". - * full name — hard error ONLY when the row has neither email nor phone to + * full name. Hard error ONLY when the row has neither email nor phone to disambiguate from an existing same-named contact. Two people can legitimately share a name when other identifiers differ. @@ -265,7 +265,7 @@ def parse_and_validate(file_bytes: bytes, org) -> ImportResult: errors: list[RowError] = [] seen_emails: dict[str, int] = {} # email.lower() -> row number seen_phones: dict[str, int] = {} # normalized phone -> row number - # Tracks rows that had NO email and NO phone — the same disambiguation + # Tracks rows that had NO email and NO phone, the same disambiguation # rule used vs the DB. A row with an email/phone is exempt because the # email/phone uniqueness checks already prevent silent duplicates. seen_full_names: dict[str, int] = {} # "first|last".lower() -> row number @@ -371,7 +371,7 @@ def _build_ref_maps(parsed: list[tuple[int, dict[str, str]]], org) -> _RefMaps: if candidate_phones: # Phone has no DB constraint and no normalized column; we have to scan # all contacts that have a phone in this org and normalize in Python. - # Bounded by org size, not file size — fine for any reasonable tenant. + # Bounded by org size, not file size, fine for any reasonable tenant. for raw_phone in Contact.objects.filter(org=org).exclude( phone__isnull=True ).exclude(phone="").values_list("phone", flat=True): @@ -382,7 +382,7 @@ def _build_ref_maps(parsed: list[tuple[int, dict[str, str]]], org) -> _RefMaps: existing_full_names: set[str] = set() if candidate_full_names: # Only run this query if any row has a full name but no email and no - # phone — that's the only case where full-name dedup applies. + # phone. That's the only case where full-name dedup applies. for first, last in ( Contact.objects.filter(org=org) .annotate( @@ -415,7 +415,7 @@ def _validate_and_build( ) -> tuple[list[RowError], ValidatedRow | None]: """Validate a single row and, on success, return the resolved ValidatedRow. - All reference resolution uses the prefetched maps — no DB calls here. + All reference resolution uses the prefetched maps, no DB calls here. """ errors: list[RowError] = [] diff --git a/backend/contacts/tests/test_custom_fields.py b/backend/contacts/tests/test_custom_fields.py index ebde472c..491007e8 100644 --- a/backend/contacts/tests/test_custom_fields.py +++ b/backend/contacts/tests/test_custom_fields.py @@ -247,7 +247,7 @@ class TestContactDetailResponse: assert "other_org_field" not in keys def test_cross_org_does_not_leak_values(self, admin_client, admin_user, org_b): - """Same CF key in both orgs — admin (org_a) must not GET a contact + """Same CF key in both orgs. Admin (org_a) must not GET a contact from org_b, so its custom_fields values stay confined to org_b.""" _set_rls(org_b) contact_in_b = Contact.objects.create( diff --git a/backend/contacts/tests/test_import.py b/backend/contacts/tests/test_import.py index 10516fdf..945b3605 100644 --- a/backend/contacts/tests/test_import.py +++ b/backend/contacts/tests/test_import.py @@ -34,7 +34,7 @@ def _csv(headers: list[str], rows: list[list[str]]) -> SimpleUploadedFile: @pytest.fixture def existing_contact(org_a, admin_user): - """A contact already in org_a — used for vs-DB duplicate tests.""" + """A contact already in org_a. Used for vs-DB duplicate tests.""" return Contact.objects.create( first_name="Pat", last_name="Lee", @@ -149,7 +149,7 @@ class TestImportPreview: self, admin_client, existing_contact, admin_profile ): # existing_contact.phone == "+1 (202) 555-1234"; this is the same - # number written differently — normalizer strips to "2025551234". + # number written differently, normalizer strips to "2025551234". csv_file = _csv( ["first_name", "last_name", "phone"], [["Different", "Person", "202-555-1234"]], @@ -213,7 +213,7 @@ class TestImportPreview: def test_full_name_dup_within_file_blocked( self, admin_client, admin_profile ): - # Two name-only rows with no email/phone — second row must be flagged. + # Two name-only rows with no email/phone. Second row must be flagged. csv_file = _csv( ["first_name", "last_name"], [ @@ -234,7 +234,7 @@ class TestImportPreview: def test_linkedin_url_with_only_scheme_rejected( self, admin_client, admin_profile ): - # "https://" passes the old regex but is not a valid URL — Django's + # "https://" passes the old regex but is not a valid URL, Django's # URLValidator catches it. csv_file = _csv( ["first_name", "last_name", "linkedin_url"], @@ -374,7 +374,7 @@ class TestImportCommit: self, admin_client, org_a, existing_contact, admin_profile ): # Row 1 is valid; row 2 collides on email with the existing contact. - # The whole batch must be rejected — atomic = all-or-nothing. + # The whole batch must be rejected, atomic = all-or-nothing. csv_file = _csv( ["first_name", "last_name", "email"], [ @@ -408,7 +408,7 @@ class TestImportCommit: # Simulate the case where another request inserts a colliding contact # AFTER parse_and_validate has read the DB but BEFORE the create runs. # The DB then raises IntegrityError on the unique_contact_email_per_org - # constraint — this must surface as a 400 with a friendly message, not + # constraint. This must surface as a 400 with a friendly message, not # a 500 from Django's exception middleware. from django.db import IntegrityError diff --git a/backend/crm/asgi.py b/backend/crm/asgi.py index 0379a03e..3d465cd2 100644 --- a/backend/crm/asgi.py +++ b/backend/crm/asgi.py @@ -1,14 +1,14 @@ """ASGI entrypoint for Django-CRM. Mirror of `wsgi.py` for ASGI servers (uvicorn, hypercorn, daphne). Required -for the in-app notifications SSE stream — async views serving long-lived +for the in-app notifications SSE stream. Async views serving long-lived connections will hold a worker hostage when run under WSGI. It also optionally mounts the BottleCRM **MCP server** at ``/mcp`` so AI agents can connect over HTTP with no local install (each request authenticates with its own ``Authorization: Bearer `` header). The mount is best-effort: if the optional ``bcrm-mcp`` dependency is not installed, or ``BCRM_MCP_ENABLED`` -is false, this module serves Django alone — exactly as before. +is false, this module serves Django alone. Exactly as before. Production deploy must run an ASGI server pointing at this module: @@ -54,7 +54,7 @@ def _build_application(): from bcrm_mcp.auth import extract_bearer_token from bcrm_mcp.server import build_http_app except ImportError: - # Optional `mcp` extra not installed — serve Django only. + # Optional `mcp` extra not installed: serve Django only. return django_application # The CRM REST root the MCP tools call. Mounted in-process, so this is a @@ -65,7 +65,7 @@ def _build_application(): # MCP streamable endpoint lives at exactly "/mcp". We dispatch by prefix # ourselves rather than using Starlette's Mount, which (in the vendored # starlette) only matches "/mcp/…" and lets a slashless "/mcp" fall through - # to Django — users configure ".../mcp" without a trailing slash. + # to Django, users configure ".../mcp" without a trailing slash. mcp_app = build_http_app(base_url, path="/mcp") def _bearer_token(scope): @@ -87,7 +87,7 @@ def _build_application(): path = scope.get("path", "") if path == "/mcp" or path.startswith("/mcp/"): # Edge auth: reject anything without a well-formed bearer token - # BEFORE it reaches the MCP layer — so initialize/list-tools are + # BEFORE it reaches the MCP layer, so initialize/list-tools are # unreachable unauthenticated, not just tool calls. (The token's # validity is still checked by the backend on each API call.) if _bearer_token(scope) is None: diff --git a/backend/docs/PORTAL_RLS.md b/backend/docs/PORTAL_RLS.md index 4bf36ce6..a861e29d 100644 --- a/backend/docs/PORTAL_RLS.md +++ b/backend/docs/PORTAL_RLS.md @@ -1,7 +1,7 @@ # The client portal and RLS -The public endpoints — the invoice and estimate links emailed to customers, -and the CSAT survey link — are the only anonymous, org-less requests that need +The public endpoints: the invoice and estimate links emailed to customers, +and the CSAT survey link, are the only anonymous, org-less requests that need to read org-scoped rows. That combination puts them at odds with RLS, and this note records where that stands. @@ -16,7 +16,7 @@ middleware returned 403 {"detail": "Organization context is required. Please login again."} ``` -to every anonymous caller — that is, to every customer who clicked a link. The +to every anonymous caller. That is, to every customer who clicked a link. The middleware runs before the view, so token validity never entered into it. **Fixed**: both prefixes are now exempt. Regression tests live in @@ -50,7 +50,7 @@ context: So on a correctly configured production database, these endpoints now return **404 instead of 403**. The customer still cannot see their invoice. -This is masked in development, where `DBUSER=postgres` — a superuser, which +This is masked in development, where `DBUSER=postgres`. A superuser, which bypasses RLS entirely, so every public endpoint appears to work. Do not take a green dev run as evidence. diff --git a/backend/invoices/api_views.py b/backend/invoices/api_views.py index 0e85cdd4..54463bf5 100644 --- a/backend/invoices/api_views.py +++ b/backend/invoices/api_views.py @@ -860,7 +860,7 @@ class ProductListView(APIView, LimitOffsetPagination): if request.query_params.get("category"): queryset = queryset.filter(category=request.query_params.get("category")) - # `used_on` — how many distinct invoices each product is a line item on. + # `used_on`: how many distinct invoices each product is a line item on. # Annotated once here (the hot path) instead of per row so the list does # not fan out into N count queries; `ProductSerializer.get_used_on` reads # this attribute and falls back to its own query on the detail views. @@ -882,7 +882,7 @@ class ProductListView(APIView, LimitOffsetPagination): def post(self, request): # The catalogue is org-wide config: its list prices flow onto every # rep's invoices and estimates. Reading it is open to any member (they - # need it to build line items), but changing it is an admin act — the + # need it to build line items), but changing it is an admin act, the # same posture Organization and Team settings take. A non-admin who # curls this endpoint is refused here, not just hidden from in the UI. if request.profile.role != "ADMIN" and not request.user.is_superuser: @@ -1535,8 +1535,8 @@ def _forbid_non_admin_template_write(request): """Return a 403 Response for non-admins, or None to allow. Invoice templates are org-wide shared config: every member reads the - catalogue (to see how their invoices look), but changing a template — its - colours, its default flag, its raw HTML/CSS — affects everyone in the org + catalogue (to see how their invoices look), but changing a template: its + colours, its default flag, its raw HTML/CSS, affects everyone in the org and drives the server-side PDF render. So writes (create/update/delete) are admin-only, while reads stay open. Role is derived from ``request.profile``, never the request body. @@ -1619,7 +1619,7 @@ class InvoiceTemplateDetailView(APIView): {"error": True, "message": "Template not found"}, status=status.HTTP_404_NOT_FOUND, ) - # Safe serializer only — never returns the raw template_html/css. + # Safe serializer only, never returns the raw template_html/css. return Response(InvoiceTemplateListSerializer(template).data) @extend_schema(tags=["Invoice Templates"], operation_id="templates_update") @@ -2390,7 +2390,7 @@ class InvoiceFromOpportunityView(APIView): class InvoiceFromTimeEntriesView(APIView): - """``POST /api/invoices/from-time-entries/`` — turn billable time entries + """``POST /api/invoices/from-time-entries/``, turn billable time entries into a draft invoice (Tier 3 time-tracking). Body: ``{"account_id": "", "entry_ids": ["", ...]}``. @@ -2456,9 +2456,9 @@ class InvoiceFromTimeEntriesView(APIView): primary_contact = account.contacts.first() # Invoice.save() generates invoice_number, public_token, due_date, - # and recalculates totals — we just hand it the high-level fields. + # and recalculates totals. We just hand it the high-level fields. invoice = Invoice.objects.create( - invoice_title=f"Time entries — {timezone.now().date():%Y-%m-%d}", + invoice_title=f"Time entries, {timezone.now().date():%Y-%m-%d}", account=account, contact=primary_contact, currency=currency, diff --git a/backend/invoices/models.py b/backend/invoices/models.py index b0a40e68..668a1528 100644 --- a/backend/invoices/models.py +++ b/backend/invoices/models.py @@ -34,7 +34,7 @@ INVOICE_STATUS = ( # # Shared so that "what this account owes us" on an account page and the # accounts-receivable aging report cannot answer the same question differently. -# Note this deliberately includes "Overdue" — `mark_overdue_invoices` flips the +# Note this deliberately includes "Overdue", `mark_overdue_invoices` flips the # status on a daily schedule, so anything that only looked for "Overdue" would # report nothing at all on a day the task has not run yet. UNPAID_STATUSES = ("Sent", "Viewed", "Partially_Paid", "Overdue") @@ -783,7 +783,7 @@ class Estimate(AssignableMixin, BaseModel): declined_at = models.DateTimeField(_("Declined At"), null=True, blank=True) # Acceptance audit trail. Accepting authorises the quote's price, so we - # record who did it — the anonymous accept endpoint captures the name and + # record who did it. The anonymous accept endpoint captures the name and # email they entered plus the request IP/user-agent as best-effort evidence. accepted_by_name = models.CharField( _("Accepted By Name"), max_length=255, blank=True, default="" diff --git a/backend/invoices/pdf.py b/backend/invoices/pdf.py index 2a68581e..f166d55b 100644 --- a/backend/invoices/pdf.py +++ b/backend/invoices/pdf.py @@ -60,13 +60,13 @@ def _allowed_media_file(parsed): Three walls: the url path must sit under MEDIA_URL; the remainder must be one of the logo subtrees in ``_ALLOWED_MEDIA_SUBDIRS`` (so attachments/docs of any org are unreachable); and the joined path, once canonicalised, must not - escape MEDIA_ROOT (blocks ``..``/symlink traversal). Anything else — app - source, ``crm/settings.py``, ``.env``, keys, other orgs' attachments — is + escape MEDIA_ROOT (blocks ``..``/symlink traversal). Anything else, app + source, ``crm/settings.py``, ``.env``, keys, other orgs' attachments, is outside the allowlist and returns None. """ media_root = getattr(settings, "MEDIA_ROOT", "") if not media_root or not os.path.isabs(str(media_root)): - # No usable local MEDIA root (e.g. the S3 marker in prod) — no local + # No usable local MEDIA root (e.g. the S3 marker in prod), no local # file is legitimately loadable; the http(s) allowlist handles prod. return None @@ -102,11 +102,11 @@ def safe_pdf_url_fetcher(url): ``.env``, secret keys). This fetcher denies by default and only permits the three things a legitimate render actually needs: - * ``data:`` URIs — inert, no disk or network. - * A local logo file inside MEDIA_ROOT (see ``_allowed_media_file``) — dev. - * HTTPS to the configured S3 media host only — the org logo in prod. + * ``data:`` URIs, inert, no disk or network. + * A local logo file inside MEDIA_ROOT (see ``_allowed_media_file``), dev. + * HTTPS to the configured S3 media host only. The org logo in prod. - Must be passed to BOTH ``HTML(...)`` and ``CSS(...)`` — WeasyPrint resolves + Must be passed to BOTH ``HTML(...)`` and ``CSS(...)``, WeasyPrint resolves ``@import`` / ``@font-face`` / ``@color-profile`` at CSS parse time through the CSS object's own fetcher, so a CSS() left on the default fetcher would reopen the whole SSRF/LFI through ``template_css``. @@ -117,26 +117,26 @@ def safe_pdf_url_fetcher(url): if scheme == "data": return default_url_fetcher(url) - # http(s) — and protocol-relative //host/path — only to the S3 media host. + # http(s), and protocol-relative //host/path, only to the S3 media host. if scheme in ("http", "https") or (scheme == "" and parsed.netloc): s3_host = (getattr(settings, "AWS_S3_CUSTOM_DOMAIN", "") or "").lower() host = (parsed.hostname or "").lower() if s3_host and host == s3_host: # Residual: default_url_fetcher follows 30x, and only the first hop - # is host-checked. Low risk — the S3 host is trusted and fixed, and + # is host-checked. Low risk. The S3 host is trusted and fixed, and # only serves the org's own logo object; urllib refuses redirects to # non-http(s)/ftp, so no redirect->file://. return default_url_fetcher(url) raise ValueError(f"Blocked non-allowlisted host in PDF template: {url!r}") - # file:// or a bare local path — only files inside MEDIA_ROOT. + # file:// or a bare local path, only files inside MEDIA_ROOT. if scheme in ("", "file"): candidate = _allowed_media_file(parsed) if candidate: return default_url_fetcher("file://" + candidate) raise ValueError(f"Blocked local-file access in PDF template: {url!r}") - # ftp:, gopher:, jar:, dict: ... — deny by default. + # ftp:, gopher:, jar:, dict: .... Deny by default. raise ValueError(f"Blocked disallowed URL scheme in PDF template: {url!r}") diff --git a/backend/invoices/public_views.py b/backend/invoices/public_views.py index 6a06cb60..e95f4855 100644 --- a/backend/invoices/public_views.py +++ b/backend/invoices/public_views.py @@ -37,7 +37,7 @@ def _resolve_org_context(token, resource_type): the context first lets the query run under full RLS. An unknown token leaves the context empty; the scoped query then returns - nothing and the caller 404s — the same answer a disabled link gives, so a + nothing and the caller 404s. The same answer a disabled link gives, so a stranger learns nothing about whether a token is real. """ org_id = resolve_portal_org(token, resource_type) @@ -359,7 +359,7 @@ class PublicEstimateAcceptView(APIView): Accepting a quote authorises its price, so this is enforced, not cosmetic: the token holder must be within the estimate's validity window and must identify themselves. Both checks live here because the - frontend is not a trust boundary — hiding the button past expiry, or + frontend is not a trust boundary, hiding the button past expiry, or collecting a name the client could omit, protects nothing. """ _resolve_org_context(token, "estimate") diff --git a/backend/invoices/serializer.py b/backend/invoices/serializer.py index b3d1707d..cb264158 100644 --- a/backend/invoices/serializer.py +++ b/backend/invoices/serializer.py @@ -121,13 +121,13 @@ class ProductCreateSerializer(serializers.ModelSerializer): class InvoiceTemplateSerializer(serializers.ModelSerializer): - """Full template serializer — EXPOSES the raw ``template_html`` / + """Full template serializer, EXPOSES the raw ``template_html`` / ``template_css``. Those two fields are org-authored HTML/CSS rendered into a PDF server-side; handing them to a browser is a stored-XSS vector. This serializer must only ever back an admin-only editor fetch. No list, detail, create/update, or - nested response uses it — those all use ``InvoiceTemplateListSerializer`` + nested response uses it. Those all use ``InvoiceTemplateListSerializer`` (below), which never returns the markup. Do not wire this to a wide-read or non-admin surface. """ @@ -153,7 +153,7 @@ class InvoiceTemplateSerializer(serializers.ModelSerializer): class InvoiceTemplateListSerializer(serializers.ModelSerializer): - """Safe template representation — never returns the raw markup. + """Safe template representation, never returns the raw markup. ``template_html`` / ``template_css`` are org-authored HTML/CSS that WeasyPrint renders into a PDF server-side. The moment either reaches a @@ -613,7 +613,7 @@ class InvoiceCreateSerializer(serializers.ModelSerializer): # through the validated lifecycle endpoints (InvoiceSendView / # InvoiceMarkPaidView / InvoiceCancelView). Left writable here, a member # could POST status="Paid" and mint an invoice that reports as paid with - # no Payment behind it — corrupting AR, aging, and revenue. Read-only + # no Payment behind it: corrupting AR, aging, and revenue. Read-only # closes that on both create and update. read_only_fields = ("status",) @@ -660,7 +660,7 @@ class InvoiceCreateSerializer(serializers.ModelSerializer): """Validate template exists and belongs to org (if provided). Without this, a member could attach another org's InvoiceTemplate UUID - and render its markup/branding on this invoice's PDF — a cross-tenant + and render its markup/branding on this invoice's PDF, a cross-tenant IDOR. Mirrors the account/contact/opportunity checks above. """ if value is None: diff --git a/backend/invoices/templates/invoices/emails/assigned_to_email.html b/backend/invoices/templates/invoices/emails/assigned_to_email.html index e3b0b9de..859c18ea 100644 --- a/backend/invoices/templates/invoices/emails/assigned_to_email.html +++ b/backend/invoices/templates/invoices/emails/assigned_to_email.html @@ -15,7 +15,7 @@ Invoice - #{{ invoice_number }} — {{ invoice_title }} + #{{ invoice_number }}, {{ invoice_title }} {% if assigned_by %} diff --git a/backend/invoices/tests/test_invoices_api.py b/backend/invoices/tests/test_invoices_api.py index f7017add..df270d15 100644 --- a/backend/invoices/tests/test_invoices_api.py +++ b/backend/invoices/tests/test_invoices_api.py @@ -1243,7 +1243,7 @@ class TestProductDetailView: class TestProductWriteAuthorization: """The product catalogue is org-wide config: its list prices flow onto every rep's invoices and estimates. Reading it is open to any member (they build - line items from it), but creating/editing/deleting is admin-only — see + line items from it), but creating/editing/deleting is admin-only. See ProductListView.post and ProductDetailView._require_admin. Both directions are proven here: a non-admin is refused, an admin is allowed. """ @@ -1307,7 +1307,7 @@ class TestProductWriteAuthorization: @pytest.mark.django_db class TestProductUsedOn: """`used_on` reports the number of DISTINCT invoices a product is a line item - on — annotated across the list, computed on the single detail. Two line items + on, annotated across the list, computed on the single detail. Two line items on one invoice count once; it is why a retired product stays worth listing. """ @@ -1336,11 +1336,11 @@ class TestProductUsedOn: invoice=inv2, product=product, name="c", org=org_a ) - # Detail path — the serializer's fallback query. + # Detail path: the serializer's fallback query. detail = admin_client.get(f"/api/invoices/products/{product.id}/") assert detail.json()["used_on"] == 2 - # List path — the annotation. + # List path: the annotation. listed = admin_client.get("/api/invoices/products/") row = next(r for r in listed.json()["results"] if r["id"] == str(product.id)) assert row["used_on"] == 2 @@ -1502,14 +1502,14 @@ class TestEstimateListRowShape: from opportunity.models import Opportunity opp = Opportunity.objects.create( - name="Renewal — 40 seats", + name="Renewal, 40 seats", account=account_for_invoice, org=org_a, ) estimate.opportunity = opp estimate.save(update_fields=["opportunity"]) row = self._row(admin_client, estimate.id) - assert row["opportunity"] == {"id": str(opp.id), "name": "Renewal — 40 seats"} + assert row["opportunity"] == {"id": str(opp.id), "name": "Renewal, 40 seats"} @pytest.mark.django_db @@ -2179,7 +2179,7 @@ class TestInvoiceTemplateDetailView: class TestInvoiceTemplateWriteAuthorization: """Invoice templates are org-wide shared config: how every invoice reaches a customer. Any member reads the catalogue, but creating/editing/deleting a - template — or flipping the org default — is admin-only (see + template, or flipping the org default, is admin-only (see _forbid_non_admin_template_write). Both directions proven: a non-admin is refused, an admin is allowed. """ @@ -2258,7 +2258,7 @@ class TestInvoiceTemplateWriteAuthorization: @pytest.mark.django_db class TestInvoiceTemplateSafeShape: """The list/detail responses must never carry the raw template_html / - template_css (org-authored markup that WeasyPrint renders into a PDF — a + template_css (org-authored markup that WeasyPrint renders into a PDF, a stored-XSS vector the moment it reaches a browser DOM). They surface flags and a byte count instead, plus usage/authorship the page needs. """ @@ -2331,7 +2331,7 @@ class TestSafePdfUrlFetcher: def test_blocks_app_source(self, settings): # The exact LFI the sink enabled: base_url=BASE_DIR let markup read - # settings.py / .env / keys — all outside MEDIA_ROOT. + # settings.py / .env / keys. All outside MEDIA_ROOT. with pytest.raises(ValueError): self._fetcher()(f"file://{settings.BASE_DIR}/crm/settings.py") @@ -2388,7 +2388,7 @@ class TestSafePdfUrlFetcher: def test_blocks_non_logo_media_cross_tenant(self, settings, tmp_path): # Files under MEDIA_ROOT that are not logos (another org's attachments or - # documents — MEDIA_ROOT is a flat, cross-tenant tree) must be blocked. + # documents. MEDIA_ROOT is a flat, cross-tenant tree) must be blocked. # Only the logo subtrees are allowed. settings.MEDIA_ROOT = str(tmp_path) settings.MEDIA_URL = "/media/" @@ -2784,7 +2784,7 @@ class TestAgingReport: @pytest.mark.django_db class TestReportsAreAdminOnly: - """The three invoice reports are org-wide financials — admin-only. + """The three invoice reports are org-wide financials, admin-only. They roll up the whole org's invoiced/collected/overdue and its AR aging across every account. The list views scope a non-admin to their own records; @@ -2876,7 +2876,7 @@ class TestReportPayloadShape: # --------------------------------------------------------------------------- -# Public portal — reachability through the real middleware stack +# Public portal: reachability through the real middleware stack # # The view-level tests below this class use APIRequestFactory and call the view # directly, which skips MIDDLEWARE entirely. That is fine for testing what the @@ -2898,7 +2898,7 @@ class TestPublicPortalIsReachableAnonymously: def test_invoice_link_is_reachable(self, client, invoice): response = client.get(f"/api/public/invoice/{invoice.public_token}/") assert response.status_code == 200, ( - "Anonymous GET must not be rejected by RequireOrgContext — " + "Anonymous GET must not be rejected by RequireOrgContext; " "this is the emailed customer link." ) assert response.json()["invoice_number"] == invoice.invoice_number @@ -3252,7 +3252,7 @@ class TestPublicEstimateAcceptDecline: assert estimate.accepted_at is None def test_accept_on_expiry_day_still_allowed(self, estimate): - """is_expired is strictly past the date — the last day still counts.""" + """is_expired is strictly past the date. The last day still counts.""" import datetime estimate.status = "Sent" diff --git a/backend/invoices/tests/test_portal_rls.py b/backend/invoices/tests/test_portal_rls.py index 37e150e0..3cc1e8a7 100644 --- a/backend/invoices/tests/test_portal_rls.py +++ b/backend/invoices/tests/test_portal_rls.py @@ -47,7 +47,7 @@ def test_resolution_reads_estimate_that_empty_context_hides(): # Org is not org-scoped, so it inserts without a context. org = Org.objects.create(name="RLS Portal Org") - # Scoped inserts need the context set — this mimics an authenticated write. + # Scoped inserts need the context set. This mimics an authenticated write. _set_ctx(str(org.id)) try: estimate = Estimate.objects.create( diff --git a/backend/leads/admin.py b/backend/leads/admin.py index 0bd8ec5e..d1b08b12 100644 --- a/backend/leads/admin.py +++ b/backend/leads/admin.py @@ -7,7 +7,7 @@ from leads.models import Lead class LeadAdmin(admin.ModelAdmin): # is_sample marks leads created by the vertical-pack applier's sample # data and is the guard `clear_sample_data` uses to decide what it may - # delete (see common/packs/applier.py). It must never be editable here — - # a bare ModelAdmin would let a staff user flip it on a real lead, + # delete (see common/packs/applier.py). It must never be editable here. + # A bare ModelAdmin would let a staff user flip it on a real lead, # putting that lead in scope for the next clear-sample-data call. readonly_fields = ("is_sample",) diff --git a/backend/leads/migrations/0014_lead_is_sample.py b/backend/leads/migrations/0014_lead_is_sample.py index b2b982e6..5559b137 100644 --- a/backend/leads/migrations/0014_lead_is_sample.py +++ b/backend/leads/migrations/0014_lead_is_sample.py @@ -11,6 +11,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='lead', 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 lead 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 lead as sample and have it deleted.'), ), ] diff --git a/backend/leads/migrations/0015_alter_lead_is_sample.py b/backend/leads/migrations/0015_alter_lead_is_sample.py index 256fffc8..08c69bc9 100644 --- a/backend/leads/migrations/0015_alter_lead_is_sample.py +++ b/backend/leads/migrations/0015_alter_lead_is_sample.py @@ -13,6 +13,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='lead', 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.'), ), ] diff --git a/backend/leads/serializer.py b/backend/leads/serializer.py index adff1722..eeb599d4 100644 --- a/backend/leads/serializer.py +++ b/backend/leads/serializer.py @@ -77,7 +77,7 @@ class LeadSerializer(serializers.ModelSerializer): # Per-org custom fields (validated via common.custom_fields) "custom_fields", ) - # is_sample is server-set only (see leads/models.py) — read-only here + # is_sample is server-set only (see leads/models.py). Read-only here # even though this serializer is a read/list path today (writes go # through LeadCreateSerializer, which never lists this field at all). # Defense in depth: a future write path added to this serializer @@ -109,7 +109,7 @@ class LeadCreateSerializer(serializers.ModelSerializer): `unique_lead_email_per_org` is a `UniqueConstraint(Lower("email"), "org")` conditional on a non-empty email, and nothing checked it before - the insert — so a duplicate reached the database and came back as an + the insert, so a duplicate reached the database and came back as an IntegrityError, which the view does not catch. The caller got a 500 and no indication of which field was wrong. @@ -117,7 +117,7 @@ class LeadCreateSerializer(serializers.ModelSerializer): values for the same reasons the constraint does, or the two disagree and the 500 comes back for the cases this misses. - This is a check, not the enforcement — the constraint is still what + This is a check, not the enforcement. The constraint is still what guarantees it under a race. This exists so the ordinary case is a 400 that names the field. """ @@ -138,7 +138,7 @@ class LeadCreateSerializer(serializers.ModelSerializer): Validate the *source* state, not just the target. On an update, `self.instance` is the lead as it stands in the database, which is the - only trustworthy version — the client's idea of the current status is + only trustworthy version. The client's idea of the current status is not evidence of anything. Two distinct failures are prevented here: diff --git a/backend/leads/tests/test_custom_fields.py b/backend/leads/tests/test_custom_fields.py index 622d5a2f..e90a1ef4 100644 --- a/backend/leads/tests/test_custom_fields.py +++ b/backend/leads/tests/test_custom_fields.py @@ -184,7 +184,7 @@ class TestLeadUpdateWithCustomFields: def test_patch_converted_rejects_invalid_custom_fields( self, admin_client, lead_a, org_a ): - """Validation must run before conversion — bad payload should 400, not + """Validation must run before conversion. Bad payload should 400, not get smuggled through.""" _make_severity_def(org_a) response = admin_client.patch( diff --git a/backend/leads/tests/test_lead_access_and_totals.py b/backend/leads/tests/test_lead_access_and_totals.py index 716c1b2d..19ae724a 100644 --- a/backend/leads/tests/test_lead_access_and_totals.py +++ b/backend/leads/tests/test_lead_access_and_totals.py @@ -67,7 +67,7 @@ class TestLeadTotals: def test_count_covers_the_whole_list_not_one_page(self, admin_client, org_a): """The page holds 10; the count has to say 24. - This is the bug the v2 redesign exists to fix — a total reduced over + This is the bug the v2 redesign exists to fix: a total reduced over the rows the client happens to be holding, printed as though it described the list. """ @@ -136,7 +136,7 @@ class TestLeadTotals: assert admin_client.get(LEADS_URL).json()["totals"]["count"] == 2 def test_totals_respect_an_applied_filter(self, admin_client, org_a): - """A filtered list gets filtered totals — otherwise the number beside + """A filtered list gets filtered totals, otherwise the number beside the chips contradicts the rows under them.""" _lead(org_a, email="a@example.com", source="call") _lead(org_a, email="b@example.com", source="email") @@ -150,7 +150,7 @@ class TestLeadTotals: @pytest.mark.django_db class TestLeadDetailRead: """`get_context_data` used to `return Response(...)` on refusal, and - `get()` wrapped that in a second Response — so the intended 403 rendered + `get()` wrapped that in a second Response, so the intended 403 rendered as a 500. It also appended a User to a list of Profile ids, which meant the creator was refused access to their own lead.""" @@ -188,7 +188,7 @@ class TestLeadDetailRead: assert admin_client.get(_detail_url(lead.id)).status_code == 200 def test_unrelated_non_admin_is_refused(self, user_client, org_a): - """Neither assigned nor creator — a real 403, not a 500.""" + """Neither assigned nor creator, a real 403, not a 500.""" lead = _lead(org_a) assert user_client.get(_detail_url(lead.id)).status_code == 403 @@ -202,7 +202,7 @@ class TestLeadDetailRead: @pytest.mark.django_db class TestDuplicateEmail: """`unique_lead_email_per_org` had no serializer check in front of it, so a - duplicate reached the database and returned an IntegrityError as a 500 — + duplicate reached the database and returned an IntegrityError as a 500, with nothing naming the field that caused it.""" def test_duplicate_email_is_a_400_naming_the_field(self, admin_client, org_a): @@ -268,7 +268,7 @@ class TestDuplicateEmail: def test_clearing_an_email_is_allowed(self, admin_client, org_a): """The constraint is conditional on a non-empty email, so the check - has to skip empties too — otherwise a second blank-email lead is + has to skip empties too: otherwise a second blank-email lead is refused for colliding with the first.""" _lead(org_a, email="") mine = _lead(org_a, email="mine@example.com") @@ -287,7 +287,7 @@ class TestPutClearsRelations: `put` calls `.clear()` on tags, contacts, teams and assigned_to before re-adding whatever the body carried, so a form that owns only the scalar fields strips all four every time somebody corrects a phone number. This - is existing behaviour and is left alone — PUT means "replace the whole + is existing behaviour and is left alone. PUT means "replace the whole resource" and that reading is defensible. It is pinned here because it is load-bearing for the frontend's choice of verb, and a future change that makes PUT partial should have to notice this test. diff --git a/backend/leads/tests/test_lead_status_transitions.py b/backend/leads/tests/test_lead_status_transitions.py index 12eb2445..30fcb9a3 100644 --- a/backend/leads/tests/test_lead_status_transitions.py +++ b/backend/leads/tests/test_lead_status_transitions.py @@ -32,7 +32,7 @@ def _lead(org, **kw): @pytest.mark.django_db class TestConvertedIsIrreversible: def test_converting_an_open_lead_is_allowed(self, admin_client, org_a): - """The permitted direction — this must still work.""" + """The permitted direction. This must still work.""" lead = _lead(org_a, opportunity_amount="1000.00") response = admin_client.put( _detail_url(lead.id), @@ -81,7 +81,7 @@ class TestConvertedIsIrreversible: `validate_status` lives on the serializer, and `LeadDetailView.patch` has its own conversion branch that returns before the serializer runs. So the rule held on PUT and not on PATCH, and two PATCHes produced two - Opportunities against one Account — which is what the validator was + Opportunities against one Account, which is what the validator was written to prevent. """ lead = _lead(org_a, opportunity_amount="1000.00") @@ -179,7 +179,7 @@ class TestReversibleTransitionsStillWork: def test_creating_a_lead_as_converted_is_still_allowed(self, admin_client, org_a): """There is no prior state to contradict, and the create path does not - run the conversion service — so the new rule must not reach it.""" + run the conversion service, so the new rule must not reach it.""" response = admin_client.post( LEADS_URL, { diff --git a/backend/leads/tests/test_leads_api.py b/backend/leads/tests/test_leads_api.py index c696f470..0559e441 100644 --- a/backend/leads/tests/test_leads_api.py +++ b/backend/leads/tests/test_leads_api.py @@ -69,7 +69,7 @@ class TestLeadListView: """`is_sample` is server-set only (see leads/models.py) and it is the sole key common.packs.applier.clear_sample_data uses to decide what to delete. LeadCreateSerializer.Meta.fields never lists it, so DRF - silently drops it from validated_data rather than erroring — this + silently drops it from validated_data rather than erroring. This proves that drop actually holds and a user cannot mark their own lead "sample" (and have it swept up by a future clear) by adding the field to the request body. @@ -99,7 +99,7 @@ class TestLeadListView: def test_create_lead_unauthenticated(self, unauthenticated_client): """`HasOrgContext` refuses before anything reads the body. - Was already failing before the v2 leads work — it asserted that the + Was already failing before the v2 leads work: it asserted that the request *raises* `PermissionDenied` out of the client, but DRF handles the exception and renders it, so nothing propagates. Assert the response the caller actually receives. @@ -682,7 +682,7 @@ class TestLeadDetailView: self, admin_client, admin_user, org_a ): """Mirrors test_create_lead_ignores_is_sample_in_payload for the - update path — PUT/PATCH share LeadCreateSerializer with create, so + update path, PUT/PATCH share LeadCreateSerializer with create, so this is the "and False" half of proving the mass-assignment door is shut: an existing real lead cannot be flipped to is_sample=True and then destroyed by clear_sample_data. @@ -1389,7 +1389,7 @@ class TestLeadDetailViewNonAdmin: mention list read `created_by.username`, and this User model sets `USERNAME_FIELD = "email"` and has no `username` field. Being assigned a lead and then getting a 500 when you open it is not a branch worth - covering — it is the bug. + covering. It is the bug. """ lead = self._create_lead_with_creator( admin_user, diff --git a/backend/leads/views/lead_views.py b/backend/leads/views/lead_views.py index 603f6de1..d988a3be 100644 --- a/backend/leads/views/lead_views.py +++ b/backend/leads/views/lead_views.py @@ -53,14 +53,14 @@ class LeadListView(APIView, LimitOffsetPagination): permission_classes = (IsAuthenticated, HasOrgContext) #: A lead nobody has touched in this many days counts as unworked. Used - #: only for the headline count — it is not a status and nothing is filtered + #: only for the headline count. It is not a status and nothing is filtered #: on it. UNWORKED_AFTER_DAYS = 7 def get_totals(self, queryset_open): """Headline counts for the open-leads list. - Computed over the *whole* filtered queryset, deliberately — the caller + Computed over the *whole* filtered queryset, deliberately. The caller only ever holds one page, so a client-side reduction would report the page and label it the list. Every filter applied above is already baked into `queryset_open`, including the org scope and the @@ -69,7 +69,7 @@ class LeadListView(APIView, LimitOffsetPagination): `unworked_over_a_week` reads `last_contacted`, falling back to when the lead was created. A lead nobody has ever contacted is not unworked on - the day it arrives — it becomes unworked once it has sat that long. + the day it arrives. It becomes unworked once it has sat that long. Lead has no aging chain (StageAgingConfig and get_aging_status() are Opportunity-only), so this is the strongest signal the model actually carries. @@ -426,7 +426,7 @@ class LeadDetailView(APIView): Written once because the two hand-rolled copies this replaces had drifted apart. One built a list of Profile ids and then appended - `request.profile.user` — a User — so the creator's own id was never in + `request.profile.user`, a User, so the creator's own id was never in the list and the check denied the person it existed to admit. It raises rather than returning a Response: `get_context_data` returns @@ -468,13 +468,13 @@ class LeadDetailView(APIView): ) elif self.request.profile.user != self.lead_obj.created_by: # Two ways this used to be a 500, both masked by the permission - # check above returning a Response instead of raising — nothing + # check above returning a Response instead of raising, nothing # reached this line. # # `username` is not a field on this User model at all: it sets # `USERNAME_FIELD = "email"` and defines no `username`, so the # attribute access raised for *every* lead. And `created_by` is - # nullable — genuinely null on leads that arrived through + # nullable, genuinely null on leads that arrived through # `CreateLeadFromSite`, which has no authenticated user to # attribute them to. # @@ -678,8 +678,8 @@ class LeadDetailView(APIView): # `get_object` scopes to the org, so a lead belonging to another # tenant is already a 404 by the time we get here. What was missing is # the check *within* the org: this method had no role or ownership test - # at all, so any authenticated member could rewrite any lead by id — - # reassign it, change its value, or push it through conversion — + # at all, so any authenticated member could rewrite any lead by id, + # reassign it, change its value, or push it through conversion, # including the ones the list view deliberately hides from them. self.assert_lead_access() previous_assigned_to_users = list( @@ -875,7 +875,7 @@ class LeadDetailView(APIView): if params.get("status") == "converted" or params.get("is_converted"): # `LeadCreateSerializer.validate_status` is what refuses a repeat # conversion, and this branch returns before the serializer ever - # runs — so the rule was enforced on PUT and not here. Two PATCHes + # runs, so the rule was enforced on PUT and not here. Two PATCHes # built two Opportunities against the same Account, which is the # exact failure that validator was written for. if self.lead_obj.status in IRREVERSIBLE_STATUSES: @@ -893,7 +893,7 @@ class LeadDetailView(APIView): status=status.HTTP_400_BAD_REQUEST, ) # Persist any custom_fields supplied alongside the conversion before - # the converter runs — otherwise they'd be silently dropped because + # the converter runs, otherwise they'd be silently dropped because # this branch returns before the regular partial-update flow. if "custom_fields" in params: cf_payload = params.get("custom_fields") diff --git a/backend/leads/workflow.py b/backend/leads/workflow.py index e2414d37..4a21a559 100644 --- a/backend/leads/workflow.py +++ b/backend/leads/workflow.py @@ -1,7 +1,7 @@ """ Lead workflow rules. -This module used to be reference-only — it declared `TERMINAL_STATUSES` and +This module used to be reference-only. It declared `TERMINAL_STATUSES` and nothing imported it, so the rule existed as a comment while the API happily allowed the transitions it described. It is now imported by `leads.serializer.LeadCreateSerializer`, which is the only place that can @@ -11,7 +11,7 @@ is reachable from curl, the mobile client and a stale build. Two different ideas were previously conflated under one name, so they are now separate: -`IRREVERSIBLE_STATUSES` — statuses a lead can never leave, and can never be +`IRREVERSIBLE_STATUSES`: statuses a lead can never leave, and can never be re-entered. "converted" is the only one. `leads.services.convert_lead_to_account` creates an Account, a Contact and an Opportunity, migrates comments and attachments, and then sets the status; none of that is undone by editing @@ -20,12 +20,12 @@ separate: so re-sending it produces a *second* Opportunity against the same Account. Both directions have to be refused. -`CLOSING_STATUSES` — the statuses that take a lead out of the working list. +`CLOSING_STATUSES`: the statuses that take a lead out of the working list. Deliberately NOT irreversible: reopening a closed lead is ordinary work, and "recycled" exists precisely so a lead can come back. These are listed for the list/kanban split, not for validation. -`EMAIL_REQUIRED_STATUSES` — statuses that cannot be set without an email +`EMAIL_REQUIRED_STATUSES`: statuses that cannot be set without an email address, because conversion has to create a Contact somebody can reach. Also enforced in `Lead.clean()`. """ diff --git a/backend/macros/render.py b/backend/macros/render.py index 2d50a438..cb0dc072 100644 --- a/backend/macros/render.py +++ b/backend/macros/render.py @@ -18,8 +18,8 @@ _PLACEHOLDER_RE = re.compile(r"%([a-zA-Z_][a-zA-Z0-9_]*)%") def _profile_full_name(profile) -> str: """Best display name for a Profile. - The project's `User` model has no first_name/last_name fields — only - email — so the agent display falls back to the email's local part to + The project's `User` model has no first_name/last_name fields, only + email, so the agent display falls back to the email's local part to keep replies readable ("alex@…" rather than the full address). """ user = getattr(profile, "user", None) @@ -91,7 +91,7 @@ SUPPORTED_PLACEHOLDERS: tuple[dict[str, str], ...] = ( {"token": "%customer_email%", "resolves": "That contact's email"}, {"token": "%case_id%", "resolves": "The case number"}, {"token": "%case_subject%", "resolves": "The case title"}, - {"token": "%agent_name%", "resolves": "You — the local part of your email"}, + {"token": "%agent_name%", "resolves": "You: the local part of your email"}, {"token": "%agent_email%", "resolves": "Your email"}, {"token": "%org_name%", "resolves": "The organisation name"}, ) @@ -108,7 +108,7 @@ def find_unknown_placeholders(body: str | None) -> list[str]: order they appear. Tokens are returned with their `%...%` wrapping so the UI / API consumer can show them verbatim. The result is non-empty only when the macro author has typoed (`%custmer_name%`) or used a - token we don't expand server-side — same set as `render_macro`. + token we don't expand server-side. Same set as `render_macro`. """ if not body: return [] diff --git a/backend/macros/serializers.py b/backend/macros/serializers.py index 4fe1da72..77f4ea1a 100644 --- a/backend/macros/serializers.py +++ b/backend/macros/serializers.py @@ -42,8 +42,8 @@ class MacroSerializer(serializers.ModelSerializer): ) def get_owner_name(self, obj): - # `User` only stores `email` — no first_name/last_name on this model - # — so we surface the email directly. Frontend can display whatever + # `User` only stores `email`, no first_name/last_name on this model + #, so we surface the email directly. Frontend can display whatever # form it wants from there. if obj.owner is None or obj.owner.user is None: return None diff --git a/backend/macros/tests/conftest.py b/backend/macros/tests/conftest.py index af073eaa..1095e976 100644 --- a/backend/macros/tests/conftest.py +++ b/backend/macros/tests/conftest.py @@ -23,7 +23,7 @@ def personal_macro(org_a, user_profile): return Macro.objects.create( org=org_a, title="My closer", - body="Thanks, %customer_name% — case #%case_id% is closed.", + body="Thanks, %customer_name%. Case #%case_id% is closed.", scope=Macro.SCOPE_PERSONAL, owner=user_profile, is_active=True, diff --git a/backend/macros/tests/test_api.py b/backend/macros/tests/test_api.py index 16389fe5..d875aa17 100644 --- a/backend/macros/tests/test_api.py +++ b/backend/macros/tests/test_api.py @@ -142,7 +142,7 @@ class TestListTotals: def test_placeholders_are_server_owned_set(self, user_client, org_macro): resp = user_client.get(LIST_URL) placeholders = resp.json()["placeholders"] - # Exactly the server's supported set, in order, verbatim — the page + # Exactly the server's supported set, in order, verbatim. The page # must not carry its own copy that could drift. assert placeholders == [dict(p) for p in SUPPORTED_PLACEHOLDERS] assert {p["token"] for p in placeholders} == { @@ -412,7 +412,7 @@ class TestUnknownPlaceholders: """Backend-authoritative warning surface for typoed/unsupported tokens. The web UI does its own client-side check for live feedback, but mobile - and API consumers rely on this field — and the workspace rule says + and API consumers rely on this field, and the workspace rule says every UX constraint must also exist server-side. """ @@ -462,7 +462,7 @@ class TestUnknownPlaceholders: assert resp.json()["unknown_placeholders"] == ["%typo%"] def test_creation_is_not_blocked_by_unknown_placeholder(self, admin_client): - # Soft warning, not a save-blocker — matches `render_macro`'s + # Soft warning, not a save-blocker. Matches `render_macro`'s # leave-literal stance for unknown tokens. resp = admin_client.post( LIST_URL, diff --git a/backend/macros/tests/test_models.py b/backend/macros/tests/test_models.py index 63dca07b..0caf714b 100644 --- a/backend/macros/tests/test_models.py +++ b/backend/macros/tests/test_models.py @@ -1,4 +1,4 @@ -"""Macro model tests — primarily the scope/owner consistency constraint.""" +"""Macro model tests, primarily the scope/owner consistency constraint.""" import pytest from django.db import IntegrityError, transaction diff --git a/backend/macros/tests/test_render.py b/backend/macros/tests/test_render.py index 3f07d1ae..35a372e8 100644 --- a/backend/macros/tests/test_render.py +++ b/backend/macros/tests/test_render.py @@ -1,4 +1,4 @@ -"""Render-helper tests. Pure function — no API/RLS surface here.""" +"""Render-helper tests. Pure function, no API/RLS surface here.""" from types import SimpleNamespace @@ -49,7 +49,7 @@ class TestRenderPlaceholders: ) def test_unknown_token_is_left_literal(self): - # `%priority%` is not in SUPPORTED_TOKENS — must survive verbatim. + # `%priority%` is not in SUPPORTED_TOKENS, must survive verbatim. out = render_macro(_macro("priority=%priority%"), case=None, profile=None) assert out == "priority=%priority%" @@ -91,7 +91,7 @@ class TestRenderPlaceholders: assert "Reset password" in out def test_agent_name_and_email(self, case_factory, user_profile): - # The User model only has `email` — agent_name falls back to the + # The User model only has `email`, agent_name falls back to the # local-part of the email so signatures stay readable. case = case_factory() out = render_macro( diff --git a/backend/macros/views.py b/backend/macros/views.py index f94424d9..8481bcb5 100644 --- a/backend/macros/views.py +++ b/backend/macros/views.py @@ -1,15 +1,15 @@ """REST endpoints for macros / canned responses. Routes (all under /api/macros/): - GET / — list macros visible to the requester + GET /: list macros visible to the requester (org scope + own personal scope). - POST / — create. Non-admins forced into personal scope. - GET // — retrieve. - PUT // — update. Same scope rules as create. - PATCH // — partial update. - DELETE // — soft-deactivate org macros, hard-delete + POST /: create. Non-admins forced into personal scope. + GET //: retrieve. + PUT //: update. Same scope rules as create. + PATCH //: partial update. + DELETE //: soft-deactivate org macros, hard-delete personal ones (per spec). - POST //render/ — server-side substitute placeholders against + POST //render/: server-side substitute placeholders against the requested case and return the rendered body. Increments usage_count. """ @@ -57,7 +57,7 @@ def _compute_totals(visible_qs) -> dict: meaningful when the list itself is filtered. `with_unknown_placeholders` is not a DB aggregate (it depends on `find_unknown_placeholders`, the same check the serializer surfaces per row), so we fetch the three columns we - need and fold in Python — the visible set is small (org macros + the + need and fold in Python. The visible set is small (org macros + the requester's own personal ones). """ rows = list(visible_qs.values_list("scope", "is_active", "body")) @@ -154,7 +154,7 @@ class MacroDetailView(APIView): status=status.HTTP_403_FORBIDDEN, ) if macro.scope == Macro.SCOPE_PERSONAL and macro.owner_id != request.profile.id: - # A personal macro you don't own is invisible to you — the list and + # A personal macro you don't own is invisible to you. The list and # GET hide it (404). The write verbs must not confirm it exists via # a 403 either, or the id space leaks which rows are somebody else's # personal macros. Mirror the GET: 404. @@ -215,7 +215,7 @@ class MacroDetailView(APIView): class MacroRenderView(APIView): - """POST //render/ — substitute placeholders against a case.""" + """POST //render/, substitute placeholders against a case.""" permission_classes = (IsAuthenticated, HasOrgContext) diff --git a/backend/opportunity/migrations/0012_enable_rls_sales_goal.py b/backend/opportunity/migrations/0012_enable_rls_sales_goal.py index e109b5fa..6eb4478c 100644 --- a/backend/opportunity/migrations/0012_enable_rls_sales_goal.py +++ b/backend/opportunity/migrations/0012_enable_rls_sales_goal.py @@ -3,7 +3,7 @@ # SalesGoal is org-scoped (it has an ``org_id`` column and every view filters on # it) but it was never registered in ORG_SCOPED_TABLES and no *source* migration # stamped its RLS policies. A now-deleted migration once did on already-migrated -# databases, so those still carry the policies — but a fresh ``migrate`` from +# databases, so those still carry the policies, but a fresh ``migrate`` from # this source tree would ship ``sales_goal`` with no RLS at all, leaving tenant # isolation resting solely on the ORM ``.filter(org=...)`` calls. This migration # makes the protection reconstructible from source again. diff --git a/backend/opportunity/migrations/0013_enable_rls_stage_aging_config.py b/backend/opportunity/migrations/0013_enable_rls_stage_aging_config.py index e019966c..01f2275d 100644 --- a/backend/opportunity/migrations/0013_enable_rls_stage_aging_config.py +++ b/backend/opportunity/migrations/0013_enable_rls_stage_aging_config.py @@ -4,7 +4,7 @@ # and every view filters on it) but it was never registered in # ORG_SCOPED_TABLES and no *source* migration stamped its RLS policies. A # now-deleted migration once did on already-migrated databases, so those still -# carry the policies — but a fresh ``migrate`` from this source tree would ship +# carry the policies, but a fresh ``migrate`` from this source tree would ship # ``stage_aging_config`` with no RLS at all, leaving tenant isolation resting # solely on the ORM ``.filter(org=...)`` calls. This migration makes the # protection reconstructible from source again, mirroring diff --git a/backend/opportunity/migrations/0014_opportunity_is_sample.py b/backend/opportunity/migrations/0014_opportunity_is_sample.py index 55408e6a..a2fd321c 100644 --- a/backend/opportunity/migrations/0014_opportunity_is_sample.py +++ b/backend/opportunity/migrations/0014_opportunity_is_sample.py @@ -13,6 +13,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='opportunity', 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.'), ), ] diff --git a/backend/opportunity/models.py b/backend/opportunity/models.py index 123e2933..b0fcd1dd 100644 --- a/backend/opportunity/models.py +++ b/backend/opportunity/models.py @@ -114,7 +114,7 @@ class Opportunity(AssignableMixin, BaseModel): _("Stage Changed At"), null=True, blank=True ) - # Kanban positioning within a stage column (fractional indexing — same + # Kanban positioning within a stage column (fractional indexing, same # convention as Task.kanban_order: large stride between rows lets drag-drop # insert by averaging neighbors without needing to renumber the column). kanban_order = models.DecimalField( diff --git a/backend/opportunity/serializer.py b/backend/opportunity/serializer.py index 639cace9..68b51538 100644 --- a/backend/opportunity/serializer.py +++ b/backend/opportunity/serializer.py @@ -228,7 +228,7 @@ class OpportunityCreateSerializer(serializers.ModelSerializer): return name def _resolved(self, data, field): - """The value this save will end up with — the submitted one if the + """The value this save will end up with, the submitted one if the request carried the field, otherwise what is already on the record. Needed because PATCH is the verb the edit form uses: a request that @@ -242,8 +242,8 @@ class OpportunityCreateSerializer(serializers.ModelSerializer): def validate(self, data): """Enforce the two rules `Opportunity.clean()` declares. - `clean()` is a model method and DRF never calls it — `ModelSerializer` - does not run `full_clean()` — so both rules existed only as unit tests + `clean()` is a model method and DRF never calls it; `ModelSerializer` + does not run `full_clean()`, so both rules existed only as unit tests against the model. Through the API a deal could be marked Closed Won with no amount and no close date, and it silently landed in `SalesGoal.compute_progress()` (which sums `amount` over won deals) @@ -305,14 +305,14 @@ class OpportunityCreateSerializer(serializers.ModelSerializer): class _MinimalAccountField(serializers.RelatedField): """Account FK rendered as `{id, name}` so kanban cards can show the parent without the heavyweight AccountSerializer (which pulls in addresses, tags, - contacts, etc. — wasted bytes on a kanban card).""" + contacts, etc., wasted bytes on a kanban card).""" def to_representation(self, value): return {"id": str(value.pk), "name": getattr(value, "name", "") or ""} class OpportunityKanbanCardSerializer(serializers.ModelSerializer): - """Lightweight payload for kanban cards — only what the card UI renders.""" + """Lightweight payload for kanban cards, only what the card UI renders.""" account = _MinimalAccountField(read_only=True) assigned_to = ProfileSerializer(read_only=True, many=True) @@ -359,7 +359,7 @@ class OpportunityKanbanCardSerializer(serializers.ModelSerializer): class OpportunityMoveSerializer(serializers.Serializer): """Payload for PATCH /opportunities//move/. - `stage` is required (status-based kanban only — Opportunity has no + `stage` is required (status-based kanban only. Opportunity has no Pipeline/Stage model yet). `above_id`/`below_id` are neighbor hints used to compute the fractional kanban_order; explicit `kanban_order` wins if sent. """ @@ -461,15 +461,15 @@ class SalesGoalSerializer(serializers.ModelSerializer): class SalesGoalCreateSerializer(serializers.ModelSerializer): """Write serializer for SalesGoal (create + partial update). - ``org`` and ``created_by`` are set by the view from ``request.profile`` — - they are not fields here, so they can never be mass-assigned from the body. + ``org`` and ``created_by`` are set by the view from ``request.profile``. + They are not fields here, so they can never be mass-assigned from the body. ``assigned_to`` (a Profile) and ``team`` (a Teams) are the tenant-boundary risk on this serializer. DRF's default ``PrimaryKeyRelatedField`` resolves them against *every* row in the table, and ``common_profile`` is **not** RLS-protected (it is an auth-bootstrap table looked up before org context exists), so without the checks below an admin could POST/PUT a goal whose - ``assigned_to`` is a Profile in another org — leaking that person's name and + ``assigned_to`` is a Profile in another org, leaking that person's name and email into this org's goal detail and leaderboard. ``teams`` *is* RLS-scoped, but the contract is the explicit org check, not the safety net, so both are validated the same way. The view passes ``context={"request": request}`` for diff --git a/backend/opportunity/tests/test_deal_access_and_totals.py b/backend/opportunity/tests/test_deal_access_and_totals.py index 6a2b6f76..f7f7ce2b 100644 --- a/backend/opportunity/tests/test_deal_access_and_totals.py +++ b/backend/opportunity/tests/test_deal_access_and_totals.py @@ -2,7 +2,7 @@ Written while wiring the v2 pipeline pages to the real API. Driving the endpoints (rather than reading them) turned up five defects, and each one has a -test here in both directions — a permission check that has never been shown to +test here in both directions. A permission check that has never been shown to return `True` is indistinguishable from one that cannot. - `get`, `put`, `patch` and `post` compared `request.profile` (a Profile) to @@ -75,8 +75,8 @@ class TestDealDetailAccess: ): """The regression test for the 500. - This is the ordinary case — a rep opening a deal an admin created and - assigned to them — and it raised AttributeError on `created_by.user`. + This is the ordinary case, a rep opening a deal an admin created and + assigned to them, and it raised AttributeError on `created_by.user`. `_created_by` matters: with `created_by` left null the buggy branch is skipped and the test passes against broken code. """ @@ -101,13 +101,13 @@ class TestDealDetailAccess: assert response.status_code == status.HTTP_403_FORBIDDEN def test_other_org_is_not_found(self, org_b_client, org_a): - """404, not 403 — the reply must not confirm the id exists.""" + """404, not 403. The reply must not confirm the id exists.""" deal = _deal(org_a, name="Org A Only") response = org_b_client.get(_detail_url(deal.pk)) assert response.status_code == status.HTTP_404_NOT_FOUND def test_deal_with_no_creator_still_renders(self, user_client, user_profile, org_a): - """`created_by` is nullable — SET_NULL on user deletion.""" + """`created_by` is nullable. SET_NULL on user deletion.""" deal = _deal(org_a, name="Orphan") deal.assigned_to.add(user_profile) response = user_client.get(_detail_url(deal.pk)) @@ -330,7 +330,7 @@ class TestDealListTotals: def test_totals_narrow_for_a_non_admin( self, user_client, user_profile, admin_user, org_a ): - """A rep's header counts the rep's deals — the same subset the list + """A rep's header counts the rep's deals. The same subset the list shows them, since the queryset is already narrowed before totalling.""" _created_by(_deal(org_a, name="Theirs", amount=Decimal("500")), admin_user) mine = _created_by( diff --git a/backend/opportunity/tests/test_opportunities_api.py b/backend/opportunity/tests/test_opportunities_api.py index ee3dae90..7f00c7b9 100644 --- a/backend/opportunity/tests/test_opportunities_api.py +++ b/backend/opportunity/tests/test_opportunities_api.py @@ -143,7 +143,7 @@ class TestOpportunityListView: } # Asserts the response, not an exception. The old version expected # `PermissionDenied` to propagate out of the client call, which DRF - # never lets happen — it catches the exception and renders a 403 — so + # never lets happen, it catches the exception and renders a 403, so # the test could only ever fail. It had been failing. response = unauthenticated_client.post( OPPORTUNITIES_LIST_URL, payload, format="json" @@ -458,7 +458,7 @@ class TestOpportunityDetailView: assert response.status_code == status.HTTP_200_OK # Verify the comment was actually persisted AND echoed back. Without # this assertion the endpoint can silently drop the write (returns - # 200 with stale comments) — that's exactly how the original + # 200 with stale comments): that's exactly how the original # CommentSerializer.save(opportunity_id=...) regression slipped past # the test suite. assert "comments" in response.data @@ -738,7 +738,7 @@ class TestOpportunityDetailView: """PATCH with CLOSED_WON stage sets closed_by. Now sends the amount and the close date, because closing a deal - requires both. This used to pass with neither — see + requires both. This used to pass with neither. See `test_patch_closed_won_without_amount_is_rejected` in test_deal_access_and_totals.py. """ diff --git a/backend/opportunity/views/kanban_views.py b/backend/opportunity/views/kanban_views.py index baaad17b..b85b09d3 100644 --- a/backend/opportunity/views/kanban_views.py +++ b/backend/opportunity/views/kanban_views.py @@ -1,6 +1,6 @@ """Kanban views for opportunities. -Status-based only (Opportunity has no Pipeline/Stage model — it groups by the +Status-based only (Opportunity has no Pipeline/Stage model. It groups by the flat `stage` CharField). The layout mirrors tasks/views/kanban_views.py so the frontend KanbanBoard component can consume both with the same shape. """ @@ -25,7 +25,7 @@ from opportunity.serializer import ( # Column display config. Keys must match the stage choices in -# common.utils.STAGES — extra/unknown stages get the fallback. +# common.utils.STAGES. Extra/unknown stages get the fallback. STAGE_CONFIG = { "PROSPECTING": {"order": 1, "color": "#3B82F6", "type": "open", "label": "Prospecting"}, "QUALIFICATION": {"order": 2, "color": "#8B5CF6", "type": "open", "label": "Qualification"}, @@ -37,7 +37,7 @@ STAGE_CONFIG = { class OpportunityKanbanView(APIView): - """GET /api/opportunities/kanban/ — columns grouped by stage.""" + """GET /api/opportunities/kanban/, columns grouped by stage.""" permission_classes = (IsAuthenticated, HasOrgContext) @@ -64,7 +64,7 @@ class OpportunityKanbanView(APIView): ) # Match the list view's RBAC scoping so users only see opps they own - # or are assigned to — kanban shouldn't reveal more than the table. + # or are assigned to. Kanban shouldn't reveal more than the table. if request.profile.role != "ADMIN" and not request.user.is_superuser: queryset = queryset.filter( Q(created_by=request.profile.user) @@ -100,7 +100,7 @@ class OpportunityKanbanView(APIView): "is_status_column": True, "wip_limit": None, "item_count": opps.count(), - # Cap at 100 per column to keep the payload bounded — same + # Cap at 100 per column to keep the payload bounded. Same # cap tasks uses. "items": OpportunityKanbanCardSerializer( opps[:100], many=True, context={"aging_configs": aging_configs} @@ -138,7 +138,7 @@ class OpportunityKanbanView(APIView): class OpportunityMoveView(APIView): - """PATCH /api/opportunities//move/ — change stage and/or reorder. + """PATCH /api/opportunities//move/, change stage and/or reorder. Mirrors TaskMoveView's fractional-order algorithm: if both neighbors are given we average them; with only one we offset by ±1000; falling through @@ -176,7 +176,7 @@ class OpportunityMoveView(APIView): data = serializer.validated_data new_stage = data["stage"] - # If the user is moving INTO a closed stage, stamp closed_by — mirrors + # If the user is moving INTO a closed stage, stamp closed_by, mirrors # the OpportunityListView.post / OpportunityDetailView.put behavior so # kanban-driven close events look identical to form-driven ones. if new_stage in ("CLOSED_WON", "CLOSED_LOST") and opportunity.stage != new_stage: @@ -185,7 +185,7 @@ class OpportunityMoveView(APIView): opportunity.stage = new_stage opportunity.kanban_order = self._calculate_order(data, opportunity, org) - # Opportunity.save() auto-updates probability + stage_changed_at — no + # Opportunity.save() auto-updates probability + stage_changed_at, no # need to pass update_fields here, the model handles its own bookkeeping. opportunity.save() diff --git a/backend/opportunity/views/opportunity_views.py b/backend/opportunity/views/opportunity_views.py index cea91b3f..3dfad752 100644 --- a/backend/opportunity/views/opportunity_views.py +++ b/backend/opportunity/views/opportunity_views.py @@ -48,8 +48,8 @@ def stalled_filter(org): (falling back to `DEFAULT_STAGE_EXPECTED_DAYS`), multiplied by `ROTTEN_MULTIPLIER`. - It lives here, once, because two callers need it — the `?rotten=true` - filter and the `stalled` figure in the list totals — and a header that + It lives here, once, because two callers need it, the `?rotten=true` + filter and the `stalled` figure in the list totals, and a header that counts stalled deals differently from the pills on the rows underneath it is worse than no header. Note this cannot be an ORM annotation shared with the serializer: the threshold varies per stage and per org, so it has to be @@ -75,14 +75,14 @@ class OpportunityListView(APIView, LimitOffsetPagination): `opportunities_count` already existed but nothing else did, so a client wanting the pipeline value had to add up the rows it happened to be - holding — which is one page of ten. The resulting header reads as a + holding, which is one page of ten. The resulting header reads as a statement about the pipeline and is actually a statement about the page, and it changes when you paginate. `weighted_sum` is SUM(amount * probability / 100): the forecast, as opposed to `amount_sum`, which is what the deals are worth if every one - of them lands. `probability` is never null on a saved row — - `Opportunity.save()` fills it from `STAGE_PROBABILITIES` — but `amount` + of them lands. `probability` is never null on a saved row, + `Opportunity.save()` fills it from `STAGE_PROBABILITIES`, but `amount` is nullable, so both sums coalesce to zero rather than returning None to a caller that will format it as currency. """ @@ -100,8 +100,8 @@ class OpportunityListView(APIView, LimitOffsetPagination): "count": totals_queryset.count(), "amount_sum": aggregates["amount_sum"], "weighted_sum": aggregates["weighted_sum"], - # Closed deals are never stalled — `get_aging_status()` returns - # green for them — so the count excludes them regardless of whether + # Closed deals are never stalled; `get_aging_status()` returns + # green for them, so the count excludes them regardless of whether # the caller asked for open deals only. "stalled_count": totals_queryset.exclude(stage__in=CLOSED_STAGES) .filter(stage_changed_at__isnull=False) @@ -176,7 +176,7 @@ class OpportunityListView(APIView, LimitOffsetPagination): custom_fields__contains={cf_key: raw_value} ) - # `?open=true` — everything that is not Closed Won or Closed Lost. + # `?open=true`: everything that is not Closed Won or Closed Lost. # The existing `stage` filter is a `contains` match, so it cannot # express "not closed"; a caller wanting the working pipeline had to # fetch every deal and drop the closed ones client-side, which is @@ -395,7 +395,7 @@ class OpportunityListView(APIView, LimitOffsetPagination): opportunity_obj.id, str(request.profile.org.id), ) - # `id` is additive — no existing key changes — and without it a + # `id` is additive, no existing key changes, and without it a # client cannot open the deal it just created. The alternative is # guessing by name, which is a race and breaks on duplicates. return Response( @@ -424,13 +424,13 @@ class OpportunityDetailView(APIView): """Admins, the creator, and anyone assigned. Everyone else gets a 403. Four copies of this check used to live inline in `get`, `put`, `patch` - and `post`, and all four compared `request.profile` — a Profile — to + and `post`, and all four compared `request.profile`, a Profile, to `opportunity.created_by`, which is a FK to `User`. Those types are never equal, so the creator half was dead: a non-admin who created a deal and did not also assign it to themselves was refused their own record. `delete()` got the same comparison right (`request.profile.user != created_by`), which is how you could tell it - was a mistake rather than a policy — the same person could delete the + was a mistake rather than a policy. The same person could delete the deal they were not allowed to read. Raising beats returning a Response: a returned Response from a helper @@ -685,7 +685,7 @@ class OpportunityDetailView(APIView): elif self.opportunity.created_by: # `created_by` IS the User. The old code read `created_by.user.email`, # which raised AttributeError and returned a 500 for every non-admin - # assignee opening a deal somebody else had created — the common + # assignee opening a deal somebody else had created, the common # case, and invisible until the 403 above stopped firing wrongly. # Key is `user__email` to match the admin branch above; the two # returned different key names for the same list. @@ -765,7 +765,7 @@ class OpportunityDetailView(APIView): ) self.assert_deal_access(self.opportunity_obj) - # Create the comment directly via the generic Comment ORM path — the + # Create the comment directly via the generic Comment ORM path, the # previous code routed through CommentSerializer.save(opportunity_id=..., # commented_by_id=...) but CommentSerializer requires `object_id` and # `org` on input which the client never supplies, so is_valid() always diff --git a/backend/packs/education-admissions.json b/backend/packs/education-admissions.json index 28623d75..9e95fbd0 100644 --- a/backend/packs/education-admissions.json +++ b/backend/packs/education-admissions.json @@ -60,9 +60,9 @@ { "name": "Repeat family", "color": "amber" } ], "products": [ - { "name": "SAT Prep — 12 week", "sku": "ED-SAT-12W", "price": 1200, "category": "Test prep", + { "name": "SAT Prep, 12 week", "sku": "ED-SAT-12W", "price": 1200, "category": "Test prep", "description": "Twelve weeks, two sessions a week, four full mock tests." }, - { "name": "IELTS Coaching — 8 week", "sku": "ED-IELTS-8W", "price": 450, "category": "Test prep", + { "name": "IELTS Coaching, 8 week", "sku": "ED-IELTS-8W", "price": 450, "category": "Test prep", "description": "Eight weeks covering all four bands, with speaking practice." }, { "name": "Full-Stack Web Development bootcamp", "sku": "ED-FSWD", "price": 6500, "category": "Bootcamp", "description": "Sixteen-week intensive, includes placement support." }, @@ -72,7 +72,7 @@ "description": "All four sections, question bank access for twelve months." }, { "name": "MBA Foundation", "sku": "ED-MBA-FND", "price": 4500, "category": "Professional", "description": "Pre-MBA quantitative and case-method preparation." }, - { "name": "Spoken English — evening batch", "sku": "ED-SPEAK-EVE", "price": 180, "category": "Language", + { "name": "Spoken English, evening batch", "sku": "ED-SPEAK-EVE", "price": 180, "category": "Language", "description": "Six weeks, conversational focus, small groups." }, { "name": "Registration fee", "sku": "ED-REG", "price": 50, "category": "Administrative", "description": "One-off, non-refundable, charged at admission." } @@ -125,7 +125,7 @@ "description": "School-side contact for the annual SAT cohort." } ], "deals": [ - { "name": "Lincoln High SAT cohort — Fall 2026", "account": "Lincoln High School", "stage": "NEGOTIATION", + { "name": "Lincoln High SAT cohort, Fall 2026", "account": "Lincoln High School", "stage": "NEGOTIATION", "amount": 21600, "currency": "USD", "probability": 70, "closed_on": "2026-08-31", "lead_source": "PARTNER", "description": "Eighteen students at the group rate; school wants two on-site sessions." }, { "name": "Northgate data science sponsorship", "account": "Northgate Tech Employers Forum", "stage": "PROPOSAL", @@ -175,7 +175,7 @@ "stage": "In progress", "ticket": "Mock test score dispute", "description": "Second marker required per the academic policy." }, { "title": "Prepare the Lincoln High group quote", "status": "New", "priority": "High", "due_date": "2026-08-06", - "stage": "Scheduled", "deal": "Lincoln High SAT cohort — Fall 2026", + "stage": "Scheduled", "deal": "Lincoln High SAT cohort, Fall 2026", "description": "Eighteen students, two on-site sessions, show the per-head rate." }, { "title": "Draft the Northgate sponsorship agreement", "status": "In Progress", "priority": "Medium", "due_date": "2026-08-12", "stage": "In progress", "deal": "Northgate data science sponsorship", @@ -293,7 +293,7 @@ "email": "xavier.dumont@example.com", "phone": "+1 214 555 0251", "source": "partner", "stage": "Counselling done", "opportunity_amount": 28000, "currency": "USD", - "custom_fields": { "course_interest": "CPA Exam Prep", "intake": "Spring 2027", "previous_qualification": "Various — corporate cohort" } }, + "custom_fields": { "course_interest": "CPA Exam Prep", "intake": "Spring 2027", "previous_qualification": "Various, corporate cohort" } }, { "title": "Scholarship applicant, spoken English", "first_name": "Yara", "last_name": "Haddad", "email": "yara.haddad@example.com", "phone": "+1 305 555 0252", "source": "public relations", "stage": "Counsellor assigned", diff --git a/backend/packs/events-weddings.json b/backend/packs/events-weddings.json index dd7c52ba..a2f9b669 100644 --- a/backend/packs/events-weddings.json +++ b/backend/packs/events-weddings.json @@ -64,7 +64,7 @@ { "target": "Lead", "key": "venue_confirmed", "label": "Venue confirmed", "field_type": "checkbox", "display_order": 6 } ], "tags": [ - { "name": "Peak date", "color": "red", "description": "Falls in the peak season — the date will not hold for long" }, + { "name": "Peak date", "color": "red", "description": "Falls in the peak season. The date will not hold for long" }, { "name": "Destination", "color": "violet", "description": "Event away from the home city" }, { "name": "Corporate", "color": "blue", "description": "Company event rather than a private celebration" }, { "name": "Referral", "color": "teal", "description": "Introduced by a past client or a venue" } @@ -135,7 +135,7 @@ "description": "Booked the retreat directly with the venue this year." } ], "deals": [ - { "name": "Whitmore & Castellanos wedding — 12 September", "account": "Whitmore & Castellanos", "stage": "NEGOTIATION", + { "name": "Whitmore & Castellanos wedding, 12 September", "account": "Whitmore & Castellanos", "stage": "NEGOTIATION", "amount": 48000, "currency": "USD", "probability": 75, "closed_on": "2026-08-20", "lead_source": "PARTNER", "description": "Full planning plus decor. Negotiating the bar package and the late-night transport." }, { "name": "Ferris golden anniversary dinner", "account": "The Ferris Family", "stage": "PROPOSAL", @@ -182,7 +182,7 @@ "stage": "Done", "ticket": "AV quote came back over the approved budget", "description": "Two screens, one operator. Signed off on the call." }, { "title": "Chase the signed contract and the deposit", "status": "In Progress", "priority": "High", "due_date": "2026-08-06", - "stage": "In progress", "deal": "Whitmore & Castellanos wedding — 12 September", + "stage": "In progress", "deal": "Whitmore & Castellanos wedding. 12 September", "description": "Date is a peak Saturday and is only held until the deposit clears." }, { "title": "Send the anniversary proposal to the family", "status": "New", "priority": "High", "due_date": "2026-08-04", "stage": "Scheduled", "deal": "Ferris golden anniversary dinner", diff --git a/backend/packs/professional-services.json b/backend/packs/professional-services.json index 024b8d95..bdcad4a2 100644 --- a/backend/packs/professional-services.json +++ b/backend/packs/professional-services.json @@ -65,7 +65,7 @@ "description": "Six-week positioning, messaging and identity engagement." }, { "name": "Website design & build", "sku": "PS-WEB-BUILD", "price": 12500, "category": "Design", "description": "Fixed-scope marketing site, up to twelve templates." }, - { "name": "Monthly retainer — Growth", "sku": "PS-RET-GROWTH", "price": 4500, "category": "Retainer", + { "name": "Monthly retainer, Growth", "sku": "PS-RET-GROWTH", "price": 4500, "category": "Retainer", "description": "Ongoing strategy and delivery, forty hours a month." }, { "name": "Bookkeeping retainer", "sku": "PS-RET-BOOKS", "price": 1500, "category": "Retainer", "description": "Monthly reconciliation, payroll and management accounts." }, @@ -172,7 +172,7 @@ "description": "Scan and index before sending to the audit team." }, { "title": "Chase Northwind for the logo source files", "status": "New", "priority": "Medium", "due_date": "2026-08-04", "stage": "This week", "ticket": "Waiting on brand assets from the client", - "description": "Third request — escalate to Nora if nothing by Friday." }, + "description": "Third request, escalate to Nora if nothing by Friday." }, { "title": "Draft the brand refresh statement of work", "status": "In Progress", "priority": "High", "due_date": "2026-08-08", "stage": "In progress", "deal": "Northwind brand refresh", "description": "Fourteen stores, phased rollout, two rounds of revision." }, @@ -185,7 +185,7 @@ { "title": "Prepare the Meridian audit summary", "status": "Completed", "priority": "Medium", "due_date": "2026-07-25", "stage": "Delivered", "deal": "Meridian audit support", "description": "Delivered with the fieldwork pack." }, - { "title": "Quarterly account review — Lakeside", "status": "New", "priority": "Low", "due_date": "2026-09-30", + { "title": "Quarterly account review, Lakeside", "status": "New", "priority": "Low", "due_date": "2026-09-30", "stage": "Backlog", "account": "Lakeside Manufacturing", "description": "Retainer renewal conversation due in November." }, { "title": "Follow up the bookkeeping retainer enquiry", "status": "New", "priority": "Medium", @@ -273,7 +273,7 @@ "source": "partner", "stage": "Won", "opportunity_amount": 4800, "currency": "USD", "custom_fields": { "service_line": "Tax Advisory", "engagement_type": "project", "estimated_start": "2026-08-20" } }, - { "title": "Rebid — lost last year on price", "first_name": "Gregor", "last_name": "Novak", + { "title": "Rebid, lost last year on price", "first_name": "Gregor", "last_name": "Novak", "email": "gregor.novak@example.com", "phone": "+1 720 555 0226", "source": "existing customer", "stage": "New enquiry", "opportunity_amount": 11000, "currency": "USD", diff --git a/backend/packs/real-estate.json b/backend/packs/real-estate.json index 920ce7d0..f9bde928 100644 --- a/backend/packs/real-estate.json +++ b/backend/packs/real-estate.json @@ -126,22 +126,22 @@ "description": "Developer-side contact for allocation and pricing." } ], "deals": [ - { "name": "Marina District 3 bed — Whitfield", "account": "Harbourline Developments", "stage": "NEGOTIATION", + { "name": "Marina District 3 bed, Whitfield", "account": "Harbourline Developments", "stage": "NEGOTIATION", "amount": 850000, "currency": "USD", "probability": 70, "closed_on": "2026-09-30", "lead_source": "CAMPAIGN", "description": "Second viewing done; negotiating on the parking space." }, - { "name": "Ridgeview villa — Kowalski", "account": "Ridgeview Hills Partners", "stage": "PROPOSAL", + { "name": "Ridgeview villa, Kowalski", "account": "Ridgeview Hills Partners", "stage": "PROPOSAL", "amount": 1650000, "currency": "USD", "probability": 50, "closed_on": "2026-10-31", "lead_source": "PARTNER", "description": "Cash buyer, wants the plot survey before signing." }, - { "name": "Riverside 1 bed rental — Alvarez", "account": "Riverside Property Group", "stage": "QUALIFICATION", + { "name": "Riverside 1 bed rental, Alvarez", "account": "Riverside Property Group", "stage": "QUALIFICATION", "amount": 300000, "currency": "USD", "probability": 30, "closed_on": "2026-11-15", "lead_source": "PARTNER", "description": "Investor; needs the rental yield pack." }, - { "name": "Willow Creek corner plot — Okafor", "account": "Willow Creek Land Co.", "stage": "PROSPECTING", + { "name": "Willow Creek corner plot. Okafor", "account": "Willow Creek Land Co.", "stage": "PROSPECTING", "amount": 95000, "currency": "USD", "probability": 10, "closed_on": "2026-12-15", "lead_source": "OTHER", "description": "Early enquiry, no viewing yet." }, - { "name": "Coral Bay sea-view villa — Al-Sayed", "account": "Coral Bay Residences", "stage": "CLOSED_WON", + { "name": "Coral Bay sea-view villa: Al-Sayed", "account": "Coral Bay Residences", "stage": "CLOSED_WON", "amount": 1980000, "currency": "USD", "probability": 100, "closed_on": "2026-07-20", "lead_source": "OTHER", "description": "Booked. Handover scheduled for December." }, - { "name": "Cedar Grove 2 bed — Deshmukh", "account": "Cedar Grove Estates", "stage": "CLOSED_LOST", + { "name": "Cedar Grove 2 bed, Deshmukh", "account": "Cedar Grove Estates", "stage": "CLOSED_LOST", "amount": 500000, "currency": "USD", "probability": 0, "closed_on": "2026-07-10", "lead_source": "CALL", "description": "Financing fell through at the last stage." } ], @@ -164,13 +164,13 @@ ], "tasks": [ { "title": "Confirm second viewing time", "status": "New", "priority": "High", "due_date": "2026-08-05", - "stage": "Scheduled", "deal": "Marina District 3 bed — Whitfield", + "stage": "Scheduled", "deal": "Marina District 3 bed, Whitfield", "description": "Buyer prefers a Saturday morning." }, { "title": "Send the plot survey to the buyer", "status": "In Progress", "priority": "High", "due_date": "2026-08-04", - "stage": "In progress", "deal": "Ridgeview villa — Kowalski", - "description": "Chase the surveyor first — see the open service request." }, + "stage": "In progress", "deal": "Ridgeview villa, Kowalski", + "description": "Chase the surveyor first. See the open service request." }, { "title": "Prepare rental yield pack", "status": "Completed", "priority": "Medium", "due_date": "2026-07-28", - "stage": "Done", "deal": "Riverside 1 bed rental — Alvarez", + "stage": "Done", "deal": "Riverside 1 bed rental, Alvarez", "description": "Three-year figures with comparable rents." }, { "title": "Book handover inspection follow-up", "status": "New", "priority": "High", "due_date": "2026-08-08", "stage": "To do", "ticket": "Snagging list from handover inspection", diff --git a/backend/scripts/generate_endpoint_index.py b/backend/scripts/generate_endpoint_index.py index b983ddd1..ca395749 100644 --- a/backend/scripts/generate_endpoint_index.py +++ b/backend/scripts/generate_endpoint_index.py @@ -52,7 +52,7 @@ def main() -> None: if methods: rows.append(f"| `{path}` | {', '.join(methods)} |") OUTPUT.write_text(HEADER + "\n".join(rows) + "\n") - print(f"wrote {OUTPUT} — {len(rows)} paths") + print(f"wrote {OUTPUT}, {len(rows)} paths") if __name__ == "__main__": diff --git a/backend/tasks/access.py b/backend/tasks/access.py index 5f1189ac..ef7301ed 100644 --- a/backend/tasks/access.py +++ b/backend/tasks/access.py @@ -1,4 +1,4 @@ -"""Who may do what to a task — two rules, beside the three in `cases.access`. +"""Who may do what to a task, two rules, beside the three in `cases.access`. access admin · creator · assignee read it, edit it, comment on it delete admin · creator destroy it @@ -14,7 +14,7 @@ is the queryset form of `access` and the two are derived from the same clauses. **`created_by` is a `User`, not a `Profile`.** Every check this module replaced compared `request.profile` to it, which is never equal, so the creator clause -was dead everywhere it appeared — and the list, which got it right with +was dead everywhere it appeared, and the list, which got it right with `Q(created_by=profile.user)`, listed tasks the detail view then refused. The comparison here is `profile.user_id == task.created_by_id`. @@ -41,7 +41,7 @@ def is_org_admin(profile): def visible_tasks_qs(profile): - """Tasks ``profile`` is allowed to open — the queryset form of `access`.""" + """Tasks ``profile`` is allowed to open, the queryset form of `access`.""" qs = Task.objects.filter(org=profile.org) if is_org_admin(profile): return qs @@ -53,7 +53,7 @@ def get_task_or_404(profile, pk): ``Task.id`` is a UUID column and the URL captures ````, so ``filter(pk="nope")`` raises Django's ``ValidationError`` rather than - matching nothing — which surfaced as a 500 on every verb, including the + matching nothing, which surfaced as a 500 on every verb, including the two that only need to say "no such task". """ try: @@ -90,7 +90,7 @@ def has_task_delete_access(profile, task): def assert_task_delete_access(profile, task): """Raise 403 unless ``profile`` may destroy ``task``. - Narrower than `access` on purpose — see the module docstring. + Narrower than `access` on purpose: see the module docstring. """ if not has_task_delete_access(profile, task): raise PermissionDenied(_DENIED) diff --git a/backend/tasks/migrations/0012_task_is_sample.py b/backend/tasks/migrations/0012_task_is_sample.py index 4007f401..d9ff29f0 100644 --- a/backend/tasks/migrations/0012_task_is_sample.py +++ b/backend/tasks/migrations/0012_task_is_sample.py @@ -13,6 +13,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='task', 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.'), ), ] diff --git a/backend/tasks/serializer.py b/backend/tasks/serializer.py index 3c852361..d9eed19e 100644 --- a/backend/tasks/serializer.py +++ b/backend/tasks/serializer.py @@ -37,18 +37,18 @@ class BoardTaskSerializer(serializers.ModelSerializer): """Serializer for board tasks. The write surface is deliberately narrow. A card write may set only its own - content — title, description, order, priority, due_date — and its assignees + content (title, description, order, priority, due_date), and its assignees (through the write-only ``assigned_to_ids``, which the views apply after save and org-filter). Everything else is locked down: * ``column`` is applied server-side by the move endpoint (see ``BoardTaskDetailView.put``), so a card can't be mass-assigned into another - board's — or another org's — column. + board's, or another org's, column. * ``org``/``created_by``/``updated_by`` are server-derived audit facts. (``created_by`` FKs ``common.User``, which is *not* org-scoped by RLS, so leaving it writable let any user id be stamped onto a card.) * the CRM-link FKs (``account``/``contact``/``opportunity``) are render-only - context — no UI sets them, and while writable they exposed the same + context, no UI sets them, and while writable they exposed the same unfiltered, cross-org ``PrimaryKeyRelatedField`` queryset that ``TaskCreateSerializer`` explicitly hardens against. """ @@ -181,7 +181,7 @@ class BoardListSerializer(serializers.ModelSerializer): """The requesting profile's role on this board (``owner``/``admin``/ ``member``), or ``None`` when they have none. - Read-only and derived from the request, never the client — a client + Read-only and derived from the request, never the client. A client cannot claim a role it does not hold. The board's own owner always resolves to ``owner`` even if no explicit ``BoardMember`` row was created for them, so a board created outside the API still gates correctly. The @@ -242,7 +242,7 @@ class _MinimalLeadField(serializers.RelatedField): class TaskListSerializer(serializers.ModelSerializer): - """Slim payload for /api/tasks/ list pages — drops the comment/attachment + """Slim payload for /api/tasks/ list pages. Drops the comment/attachment bodies (sometimes hundreds of rows per task) and the contacts/teams M2Ms that the list UI doesn't render. Use TaskSerializer for the detail view.""" @@ -320,7 +320,7 @@ class TaskCreateSerializer(serializers.ModelSerializer): self.fields["title"].required = True # A `ModelSerializer` builds each FK as `PrimaryKeyRelatedField( - # queryset=Model.objects.all())` — every account, lead, case and + # queryset=Model.objects.all())`: every account, lead, case and # opportunity in the database, not in the org. Proven live: a task in # one org was created with another org's account, and the list endpoint # then rendered that org's account *name* back to the requester. Org is @@ -332,7 +332,7 @@ class TaskCreateSerializer(serializers.ModelSerializer): """A task links to at most one parent entity. The model says so too, in `Task.clean()`, and `Task.save()` calls - `full_clean()` — but Django's `ValidationError` is not DRF's, so the + `full_clean()`, but Django's `ValidationError` is not DRF's, so the model's refusal arrived as a 500. Checking here gets the client a 400 with the field named. @@ -381,7 +381,7 @@ class TaskCreateSerializer(serializers.ModelSerializer): "created_at", ) # `created_by` was writable, so a client could name anyone in any org - # the creator of a task — proven live, a MicroPyramid task ended up + # the creator of a task. Proven live, a MicroPyramid task ended up # created by a user of another org. That was a data-integrity bug while # nothing read the field; the moment the creator clause in # `tasks.access` starts returning True it is privilege escalation, so diff --git a/backend/tasks/tests/test_boards.py b/backend/tasks/tests/test_boards.py index e59d48d2..88732f1c 100644 --- a/backend/tasks/tests/test_boards.py +++ b/backend/tasks/tests/test_boards.py @@ -91,7 +91,7 @@ class TestBoardListView: def test_list_boards_reports_member_role( self, user_client, admin_profile, user_profile, org_a ): - """A plain member sees ``my_role == 'member'`` — the frontend uses this + """A plain member sees ``my_role == 'member'``. The frontend uses this to hide the owner/admin-only column controls it could never use.""" board = Board.objects.create( name="Shared Board", owner=admin_profile, org=org_a @@ -570,7 +570,7 @@ class TestBoardTasks: def test_move_board_task_to_another_column( self, admin_client, admin_profile, admin_user, org_a ): - """PUT with a sibling column id relocates the card — the core kanban move. + """PUT with a sibling column id relocates the card. The core kanban move. Regression: ``column`` was read-only on the serializer, so the change was silently dropped and the card never left its lane (200, but no move).""" @@ -711,7 +711,7 @@ class TestBoardTasks: def test_cannot_overwrite_created_by_on_update( self, admin_client, admin_profile, admin_user, org_a, user_b ): - """created_by is a server-derived audit fact — it FKs common.User, which + """created_by is a server-derived audit fact. It FKs common.User, which RLS does not org-scope, so it must not be mass-assignable from the body.""" _board, todo, _doing = self._board_with_two_columns( admin_profile, admin_user, org_a diff --git a/backend/tasks/tests/test_task_access.py b/backend/tasks/tests/test_task_access.py index 731a3fbf..dbe3f0b3 100644 --- a/backend/tasks/tests/test_task_access.py +++ b/backend/tasks/tests/test_task_access.py @@ -7,11 +7,11 @@ order a person would meet them: 1. **A member could create a task and then never open it again.** `POST` succeeded, the task appeared in their list, and `GET` answered 500, with `PATCH`, comment and `DELETE` all answering 403. `created_by` is a `User` - and every check compared it to a `Profile`, so the creator clause was dead - — except in the list query, which spelled it correctly and therefore + and every check compared it to a `Profile`, so the creator clause was dead, + except in the list query, which spelled it correctly and therefore listed tasks the detail view refused. 2. **No non-admin could open *any* task, including their own.** - `created_by.user.email` — `created_by` is already the user — threw + `created_by.user.email`, `created_by` is already the user, threw `AttributeError` behind an always-true branch. 3. **The 403 path was a 500.** `get_context_data` returned a `Response`, which `get()` wrapped in a second one: "Object of type Response is not @@ -19,7 +19,7 @@ order a person would meet them: 4. **Any admin could delete any attachment in the database.** `Attachments` is one generic table and the lookup had no `org=`. Proven by destroying a file that belonged to another org and hung off a lead, not a task. -5. **The uploader could not delete their own attachment** — same +5. **The uploader could not delete their own attachment**: same `Profile`/`User` comparison. 6. **`created_by` was writable.** A task could be handed a creator from another org. Harmless only while the field was never read; the moment @@ -29,8 +29,8 @@ order a person would meet them: 8. **"One parent entity" did not survive `PATCH`.** Sending a second parent one request at a time walked past the serializer, and the model's own refusal surfaced as a 500 because Django's `ValidationError` is not DRF's. -9. **Every malformed id was a 500** — task, comment and attachment, on - every verb — and a well-formed id for a missing comment or attachment too. +9. **Every malformed id was a 500**: task, comment and attachment, on + every verb, and a well-formed id for a missing comment or attachment too. """ import uuid @@ -51,7 +51,7 @@ def _task(org, creator, assignees=(), **kwargs): ``BaseModel.save()`` stamps ``created_by`` from the crum thread-local, which is empty in a test, so passing it to ``create()`` is silently - dropped. ``.update()`` goes round ``save()`` and actually sets it — the + dropped. ``.update()`` goes round ``save()`` and actually sets it, the same trick the contacts and cases suites needed. """ task = Task.objects.create( @@ -74,7 +74,7 @@ def other_user(): @pytest.fixture def other_profile(other_user, org_a): - """A second plain member of org A — the person on neither side of a task.""" + """A second plain member of org A: the person on neither side of a task.""" return Profile.objects.create( user=other_user, org=org_a, role="USER", is_active=True ) @@ -89,7 +89,7 @@ def other_client(other_user, org_a, other_profile): @pytest.mark.django_db class TestWhoMayOpenATask: - """`access` — admin, creator, assignee. Each clause, both directions.""" + """`access`: admin, creator, assignee. Each clause, both directions.""" def test_the_creator_can_open_their_own_task( self, user_client, regular_user, admin_user, org_a @@ -171,7 +171,7 @@ class TestTheListAndTheDetailAgree: class TestWritingAndDeletingDifferOnPurpose: """`access` covers reading and writing; `delete` is narrower. - Being handed a task is a reason to work it, not a reason to erase it — the + Being handed a task is a reason to work it, not a reason to erase it, the same line `cases.access` draws for an assignee, and it has to be asserted separately or a later tidy-up will collapse the two. """ @@ -222,7 +222,7 @@ class TestWritingAndDeletingDifferOnPurpose: assert response.status_code == 403 def test_the_creator_can_run_the_whole_loop(self, user_client, regular_user, org_a): - """Create, open, edit, comment, delete — as one plain member. + """Create, open, edit, comment, delete, as one plain member. Before the fix this read 200 / 500 / 403 / 403 / 403: a create endpoint that handed every non-admin a task they could never touch again. @@ -300,7 +300,7 @@ class TestCreatedByIsServerDerived: @pytest.mark.django_db class TestParentsAreScopedToTheOrg: - """A task links to one account, opportunity, case or lead — one of *ours*. + """A task links to one account, opportunity, case or lead, one of *ours*. Every one of these was a 200 before, and the list endpoint then rendered the other org's record name back to the requester. @@ -438,7 +438,7 @@ class TestAttachmentDeleteIsScopedToTheOrg: """`Attachments` is one table shared by every module. Without `org=` this endpoint was "delete any attachment in the database by - UUID" for anybody's admin — proven live against a file in another org that + UUID" for anybody's admin. Proven live against a file in another org that was attached to a lead. """ @@ -590,7 +590,7 @@ class TestTheListPayload: """Same narrowing the detail view has always applied. `other_profile` is a second plain member, and its absence from the - answer is the point — otherwise this would pass on an empty list. + answer is the point. Otherwise this would pass on an empty list. """ emails = [ row["user__email"] for row in user_client.get("/api/tasks/").json()["users"] @@ -602,8 +602,8 @@ class TestTheListPayload: ): """Fixed count, not a ceiling: a ceiling passes while it rots. - The first request of a process pays for a few one-off lookups — the - JWT's user and profile, the content types — so the count depends on + The first request of a process pays for a few one-off lookups, the + JWT's user and profile, the content types, so the count depends on what ran before it. Measuring the *second* request makes the number the query cost of serving the page and nothing else, which is the number worth pinning. @@ -621,7 +621,7 @@ class TestTheListPayload: Before `select_related`/`prefetch_related` the parents and assignees were fetched per row, so this is the assertion that would have caught - it — and it holds whatever the fixed number turns out to be. + it, and it holds whatever the fixed number turns out to be. """ for i in range(2): _task(org_a, admin_user, title=f"Small {i}") diff --git a/backend/tasks/tests/test_tasks_api.py b/backend/tasks/tests/test_tasks_api.py index 9e9865de..cb89d438 100644 --- a/backend/tasks/tests/test_tasks_api.py +++ b/backend/tasks/tests/test_tasks_api.py @@ -48,7 +48,7 @@ class TestTaskListView: This used to assert that `PermissionDenied` propagated out of the client call, which it never does: DRF's exception handler turns it into a response, which is the whole point of raising it. The endpoint - was behaving correctly the entire time the test was red — so assert the + was behaving correctly the entire time the test was red, so assert the refusal the caller actually sees, and that nothing was written. """ response = unauthenticated_client.post( @@ -128,7 +128,7 @@ class TestTaskListView: """Titles are not unique per org, and should not be. This test used to assert a 400 for a duplicate title. No such - constraint exists on `Task` — and adding one would be wrong: "Call + constraint exists on `Task`, and adding one would be wrong: "Call back", "Send the quote" and "Chase invoice" are the titles a real task list is *made* of, one per customer. The rule the test wanted belongs to a ticket subject, not to a to-do. @@ -351,7 +351,7 @@ class TestTaskListView: ): """Response should include status, priority, accounts_list, contacts_list. - The convention `cases` and `opportunity` already follow — the list + The convention `cases` and `opportunity` already follow, the list endpoint hands the form the pickers it needs instead of making the client fetch three more catalogues. Tasks did not follow it, so this test was red rather than wrong. @@ -735,8 +735,8 @@ class TestTaskDetailView: ): """A non-admin assignee can open the task they were handed. - This used to assert the opposite — `pytest.raises(AttributeError, - match="has no attribute 'user'")` — because the view read + This used to assert the opposite: `pytest.raises(AttributeError, + match="has no attribute 'user'")`, because the view read `created_by.user.email` when `created_by` is already the `User`. The test was green and the endpoint was unusable for every non-admin in the product. Asserting the crash is not coverage; it is a bug with a @@ -783,7 +783,7 @@ class TestTaskDetailView: The assertion was always right; the docstring used to explain it by the `Profile`/`User` comparison bug, which made every creator a non-creator. That bug is fixed, and this member really is not the - creator — `created_by` below is the admin. + creator; `created_by` below is the admin. """ task = Task.objects.create( title="User Delete Task", @@ -1363,7 +1363,7 @@ class TestTaskDetailCreatedByCoverage: """A non-admin assignee gets `users_mention` and a narrowed `users`. Someone who did not create the task can mention its creator, and is - offered the org's admins to reassign to — not the whole org. This + offered the org's admins to reassign to, not the whole org. This also used to assert `AttributeError`: the mention list was built from `created_by.user.email`, and `created_by` is the `User`. """ diff --git a/backend/tasks/views/board_views.py b/backend/tasks/views/board_views.py index 3845edcd..a0b71e70 100644 --- a/backend/tasks/views/board_views.py +++ b/backend/tasks/views/board_views.py @@ -508,7 +508,7 @@ class BoardTaskDetailView(APIView): # serializer, so the drag-and-drop target is validated and applied here # instead: it must be a column of *this* board (which, since the board is # already org-scoped, keeps the card inside its org). Sending a column - # from another board — or a garbage id — is a 400, not a silent no-op. + # from another board, or a garbage id, is a 400, not a silent no-op. source_column = task.column target_column = source_column raw_column = data.get("column") diff --git a/backend/tasks/views/task_views.py b/backend/tasks/views/task_views.py index b94e58eb..eda94a0b 100644 --- a/backend/tasks/views/task_views.py +++ b/backend/tasks/views/task_views.py @@ -56,8 +56,8 @@ from tasks.serializer import ( def _model_errors(exc): """Django's ``ValidationError`` in the shape this API answers 400 with. - ``Task.save()`` calls ``full_clean()``, so a model-level rule — today, "one - parent entity" — fires on the way to the database. Django's exception is + ``Task.save()`` calls ``full_clean()``, so a model-level rule, today, "one + parent entity", fires on the way to the database. Django's exception is not DRF's, so nothing caught it and the client got a 500 for breaking a documented rule. Same rule, same wording, now with the right status. """ @@ -74,7 +74,7 @@ class TaskListView(APIView, LimitOffsetPagination): params = self.request.query_params # `visible_tasks_qs` is the same rule the detail view enforces. It used # to be spelled out here and spelled differently there, which is how a - # member came to have tasks in their list that answered 403 — or, once + # member came to have tasks in their list that answered 403, or, once # the creator clause is live, tasks they created and could not open. queryset = ( visible_tasks_qs(self.request.profile) @@ -187,7 +187,7 @@ class TaskListView(APIView, LimitOffsetPagination): ) # What a task *form* needs, from the endpoint that already knows the - # answer — the same keys `cases` and `opportunity` publish, because a + # answer, the same keys `cases` and `opportunity` publish, because a # second name for the same list is how clients end up with two. context["status"] = Task.STATUS_CHOICES context["priority"] = Task.PRIORITY_CHOICES @@ -198,7 +198,7 @@ class TaskListView(APIView, LimitOffsetPagination): profiles = profiles.filter(role="ADMIN") context["users"] = list(profiles.values("id", "user__email")) # The catalogues are for the parent picker and grow with the org, not - # with the page — `?slim=true` omits them for callers that only want + # with the page, `?slim=true` omits them for callers that only want # the list. Default unchanged, so v1 and mobile see what they saw. if params.get("slim") != "true": context["accounts_list"] = AccountSerializer( @@ -335,7 +335,7 @@ class TaskListView(APIView, LimitOffsetPagination): # The parent FKs used to be re-read from `params` and re-saved here # with an org filter, *after* the serializer had already written # whatever the client sent. On create that second pass only ever - # assigned — it never cleared — so another org's lead survived it + # assigned, it never cleared, so another org's lead survived it # and the list rendered that org's name back. The serializer now # scopes the four querysets itself, which is one place instead of # three and refuses out-of-org ids instead of quietly dropping them. @@ -359,7 +359,7 @@ class TaskDetailView(APIView): def get_context_data(self, **kwargs): context = {} # Raise, don't return. This used to hand a `Response` back to `get()`, - # which wrapped it in a second `Response` — so the one path that was + # which wrapped it in a second `Response`, so the one path that was # supposed to say 403 answered `TypeError: Object of type Response is # not JSON serializable` and a 500 instead. assert_task_access(self.request.profile, self.task_obj) @@ -383,7 +383,7 @@ class TaskDetailView(APIView): # therefore always true, which made this line unreachable-by-intent and # unavoidable-in-practice: every non-admin who opened any task got an # AttributeError and a 500. Not "a member cannot open someone else's - # task" — a member could not open *their own*. + # task". A member could not open *their own*. if is_org_admin(self.request.profile): users_mention = list( Profile.objects.filter( @@ -489,7 +489,7 @@ class TaskDetailView(APIView): # Use the generic ContentType-based create directly. Validating # through CommentSerializer fails silently here because that # serializer requires object_id and org, neither of which the - # client sends — is_valid() returns False and the save is skipped, + # client sends; is_valid() returns False and the save is skipped, # leaving the user with a 200 and no comment. Comment.objects.create( content_type=task_content_type, @@ -636,7 +636,7 @@ class TaskDetailView(APIView): ) task_obj.tags.add(*tag_objs) - # The parent FKs are the serializer's job now — it scopes all four + # The parent FKs are the serializer's job now. It scopes all four # querysets to the org, so an id from somewhere else is a 400 here # instead of a silent `None`. A form that says "link this to Acme" # and gets a 200 back should not have unlinked it. @@ -968,7 +968,7 @@ class TaskAttachmentView(APIView): # lookup by pk alone reaches every attachment in the database. Without # `org=`, this endpoint was a "delete any attachment anywhere by UUID" # primitive for any org admin: proven live, an admin of one org - # destroyed a file belonging to another org — and it was attached to a + # destroyed a file belonging to another org, and it was attached to a # lead, not even to a task. The org filter is the fix; the uploader # clause below is a separate bug in the same three lines. try: diff --git a/docs/api/.endpoint-check-allowlist b/docs/api/.endpoint-check-allowlist index 2ef74084..cde1095b 100644 --- a/docs/api/.endpoint-check-allowlist +++ b/docs/api/.endpoint-check-allowlist @@ -1,4 +1,4 @@ # Endpoints documented in docs/api/ that deliberately do not appear in the # generated OpenAPI schema. One "METHOD /path/" per line, each with a comment -# explaining why. An empty allowlist is the expected steady state — if you are +# explaining why. An empty allowlist is the expected steady state, if you are # adding a line here, first check the endpoint is not simply misspelled. diff --git a/docs/api/accounts.md b/docs/api/accounts.md index 3e72b7ee..2f2d7f68 100644 --- a/docs/api/accounts.md +++ b/docs/api/accounts.md @@ -7,7 +7,7 @@ repeated here, and [Errors](errors.md) for the 404-vs-403 principle. ## List accounts `GET /api/accounts/` (`AccountsListView.get`, `backend/accounts/views.py:312-314`) splits the result -into active and inactive accounts rather than open/closed — `Account` has no status field, only +into active and inactive accounts rather than open/closed; `Account` has no status field, only `is_active` (`accounts/views.py:237-238`): ```json @@ -29,15 +29,15 @@ into active and inactive accounts rather than open/closed — `Account` has no s A non-admin caller only sees accounts they created or are assigned to (`accounts/views.py:195-199`). Filters: `name`, `city`, `industry` (all `icontains`); `tags`, -`assigned_to` (repeatable, `__id__in`); `search` (matches `name` only — unlike leads' `search`, which +`assigned_to` (repeatable, `__id__in`); `search` (matches `name` only, unlike leads' `search`, which ORs across four fields); `created_at__gte`/`created_at__lte`; `cf_` (`accounts/views.py:201-233`). -Every account in the list (and the detail view below) carries a **`rollups`** object — +Every account in the list (and the detail view below) carries a **`rollups`** object; `won_amount`, `won_count`, `open_pipeline`, `open_deal_count`, `overdue_amount`, `open_tickets`, -`first_won_on` — computed as correlated subqueries over that account's `Opportunity`, `Invoice` and +`first_won_on`: computed as correlated subqueries over that account's `Opportunity`, `Invoice` and `Case` rows, never stored (`annotate_rollups`, `accounts/views.py:135-182`). `rollups` is `null`, not zero-filled, on any serialization that skips the annotation (`AccountSerializer.get_rollups`, -`accounts/serializer.py:41-54`) — both the list and detail endpoints here apply it, so in practice you +`accounts/serializer.py:41-54`), both the list and detail endpoints here apply it, so in practice you will always see it populated through this page's endpoints. ## Create an account @@ -51,12 +51,12 @@ will always see it populated through this page's endpoints. ``` Two validators worth knowing: **`name` must be unique per org, case-insensitively** -(`validate_name`, `accounts/serializer.py:199-210`) — `{"error": true, "errors": {"name": ["Account already exists with this name"]}}` +(`validate_name`, `accounts/serializer.py:199-210`); `{"error": true, "errors": {"name": ["Account already exists with this name"]}}` on a duplicate; and **`annual_revenue` cannot be negative** (`validate_annual_revenue`, `:212-224`), rejected as `400` rather than reaching the database's own `account_revenue_non_negative` check constraint as an unhandled `500`. -`contacts`, `tags`, `teams` and `assigned_to` are not part of the serializer — the view resolves them +`contacts`, `tags`, `teams` and `assigned_to` are not part of the serializer, the view resolves them from the request body via the shared `handle_m2m_assignment` / `get_or_create_tags` helpers (`common/utils.py:437-510`), each scoped to `org=request.profile.org` (`accounts/views.py:347-368`). An `account_attachment` multipart file is accepted the same way. @@ -64,16 +64,16 @@ from the request body via the shared `handle_m2m_assignment` / `get_or_create_ta ## Retrieve, update, delete `GET /api/accounts/{id}/` (`AccountDetailView.get`, `accounts/views.py:604-704`) scopes the lookup to -the caller's org and treats a malformed id the same as a missing one — both `404` +the caller's org and treats a malformed id the same as a missing one. Both `404` (`get_object`, `:407-421`, catching `UUIDField`'s `ValidationError` that `get_object_or_404` alone does not). `assert_account_access` (`:423-445`) then requires the caller to be an admin, the account's creator, or one of its assignees, or the request is refused with `403`. The response nests the record under `account_obj`, and also carries `contacts` (`Account.contacts`, not the `Contact.account` -foreign key — see [Contacts: linked accounts](contacts.md#fields)), `opportunity_list`, `cases`, +foreign key. See [Contacts: linked accounts](contacts.md#fields)), `opportunity_list`, `cases`, `tasks`, `invoices`, `emails`, `comments`, `attachments`, `teams`, `users`, `users_mention`, `comment_permission`, `leads`, `countries`, `currencies`, `case_types`, `case_priority`, `case_status`, `custom_field_definitions`, a fixed, two-value `status: ["open", "close"]` -(`accounts/views.py:692` — a static list, unrelated to `Account.is_active`, which is what actually +(`accounts/views.py:692`: a static list, unrelated to `Account.is_active`, which is what actually drives [List accounts](#list-accounts)'s active/inactive split), and two keys worth flagging explicitly: @@ -84,23 +84,23 @@ explicitly: } ``` -(`accounts/views.py:673-674`.) These are `Opportunity`'s `STAGES` and `SOURCES` enums — surfaced here +(`accounts/views.py:673-674`.) These are `Opportunity`'s `STAGES` and `SOURCES` enums, surfaced here because the account detail page is where an "add opportunity against this account" form lives, not because an account has stages or sources of its own. `sources` on this endpoint is the *uppercase* -vocabulary that includes `WEBSITE`; it is not `Lead.source`'s `LEAD_SOURCE` — see +vocabulary that includes `WEBSITE`; it is not `Lead.source`'s `LEAD_SOURCE`. See [Opportunities: Stages](opportunities.md#stages) for the distinction, and [Leads](leads.md#list-leads) for why conflating the two is a real failure mode with this API. `PUT /api/accounts/{id}/` (`:453-575`) and `PATCH /api/accounts/{id}/` (`:763-871`) both call -`assert_account_access` **before** validating the request body — deliberately, so an unauthorized +`assert_account_access` **before** validating the request body, deliberately, so an unauthorized caller learns they're forbidden before learning whether their payload was well-formed (`accounts/views.py:456-459`). Both accept the same `contacts`/`tags`/`teams`/`assigned_to` M2M handling and `account_attachment` upload as create. Success: `{"error": false, "message": "Account Updated Successfully"}`. `DELETE /api/accounts/{id}/` (`:582-597`) allows an admin or the account's own creator -(`request.profile.user_id != self.object.created_by_id` is the rejection condition, `:584-591`) — -anyone else gets `403`. +(`request.profile.user_id != self.object.created_by_id` is the rejection condition, `:584-591`). +Anyone else gets `403`. ## Comments and attachments @@ -111,20 +111,20 @@ and/or an `account_attachment` file to an existing account, gated by the same `PUT` / `PATCH` / `DELETE /api/accounts/comment/{id}/` (`AccountCommentView`, `accounts/views.py:874-968`) is restricted to an admin or the comment's own author -(`request.profile == obj.commented_by` — correct, since `commented_by` is a `Profile` foreign key). +(`request.profile == obj.commented_by`. Correct, since `commented_by` is a `Profile` foreign key). `DELETE /api/accounts/attachment/{id}/` (`AccountAttachmentView.delete`, `:971-1010`) has its lookup correctly scoped to the caller's org (`get_object_or_404(self.model, pk=pk, org=self.request.profile.org)`, `:989-991`, with malformed ids also mapped to `404`) and its ownership check compares the right types (`request.profile.user_id == self.object.created_by_id`, `:997`). That makes accounts one of five apps -in this API whose attachment-delete lookup is org-scoped — the same is true of contacts +in this API whose attachment-delete lookup is org-scoped. The same is true of contacts (`contacts/views.py:1046`), cases (`cases/views.py:1119-1121`), tasks (`tasks/views/task_views.py:975-977`) and invoices (`invoices/api_views.py:1821`). Only **leads and -opportunity** still have the unscoped version — see +opportunity** still have the unscoped version. See [Leads: Comments and attachments](leads.md#comments-and-attachments) and [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks). -(One stale comment in `cases/views.py` claims tasks is still broken — it isn't; the fix landed and the +(One stale comment in `cases/views.py` claims tasks is still broken. It isn't; the fix landed and the comment didn't get updated. Verified against `tasks/views/task_views.py:975-987` directly rather than taken on the comment's word.) @@ -140,10 +140,10 @@ serializer has `blank=True, null=True` or a default. | `name` | string | **required** | Unique per org, case-insensitive | | `email` | string | optional | | | `phone` | string | optional | | -| `website` | string | optional | `URLField` — validated as a URL, unlike `Lead.website` (see [Leads](leads.md#fields)) | +| `website` | string | optional | `URLField`, validated as a URL, unlike `Lead.website` (see [Leads](leads.md#fields)) | | `industry` | one of `INDCHOICES` | optional | e.g. `SOFTWARE` | | `number_of_employees` | integer | optional | Non-negative (`PositiveIntegerField`) | -| `annual_revenue` | decimal | optional | Non-negative — checked in the serializer, not just the DB | +| `annual_revenue` | decimal | optional | Non-negative, checked in the serializer, not just the DB | | `currency` | one of `CURRENCY_CODES` | optional | Defaults from the org's `default_currency` if omitted and `annual_revenue` is set | | `address_line`, `city`, `state`, `postcode` | string | optional | | | `country` | one of `COUNTRIES` | optional | | diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 0eb3d6ec..d37ac6e6 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -1,14 +1,14 @@ # Authentication This page documents the calls a client makes to sign in and stay signed in: what to send, what -comes back, and what each failure mode means. For the design behind these endpoints — token +comes back, and what each failure mode means. For the design behind these endpoints. Token lifetimes, claims, why rotation is implemented by hand, why personal access tokens and organization -API keys exist alongside the JWT — see [Architecture: Authentication](../architecture/authentication.md), +API keys exist alongside the JWT. See [Architecture: Authentication](../architecture/authentication.md), which this page does not repeat or contradict. For a guided walkthrough of picking a sign-in path locally, see [First sign-in](../getting-started/first-sign-in.md). -All three interactive sign-in paths below — Google OAuth, Google ID token, and both magic-link -verify endpoints — mint tokens through the same `OrgAwareRefreshToken.for_user_and_org()` helper and +All three interactive sign-in paths below, Google OAuth, Google ID token, and both magic-link +verify endpoints, mint tokens through the same `OrgAwareRefreshToken.for_user_and_org()` helper and are public: `permission_classes = []`, `authentication_classes = []` (`backend/common/views/auth_views.py`). None of them require an existing token to call. @@ -28,12 +28,12 @@ Request body: } ``` -`redirect_uri` is taken from this field, not from any server-side setting — see +`redirect_uri` is taken from this field, not from any server-side setting. See [Architecture: Authentication](../architecture/authentication.md#organization-api-keys) for the related, dead `GOOGLE_REDIRECT_URI` config, and [Google OAuth](../self-hosting/google-oauth.md) for the setting that actually matters. The view exchanges these with Google, decodes the returned ID token, and rejects the sign-in -outright if Google's own `email_verified` claim is not `true` — an unverified Google address never +outright if Google's own `email_verified` claim is not `true`. An unverified Google address never reaches the database. On success: ```json @@ -45,7 +45,7 @@ reaches the database. On success: ``` The token is minted with no org claim (`OrgAwareRefreshToken.for_user_and_org(user, None)`, -`:190`) — both Google flows always hand back an org-less token (the magic-link flows differ; see +`:190`), both Google flows always hand back an org-less token (the magic-link flows differ; see [Magic links](#magic-links) below), so the client's next call is normally [switching organization](#switching-organization). @@ -77,7 +77,7 @@ against `GOOGLE_CLIENT_ID`) and applies the same `email_verified` check as the w } ``` -Note the access token field here is `JWTtoken`, not `access_token` — the two Google flows do not +Note the access token field here is `JWTtoken`, not `access_token`. The two Google flows do not share a response shape, so a client that supports both cannot read the access token the same way from each. This response also includes `organizations` directly (the caller's existing memberships), which the web callback response above does not; a mobile client can offer org selection without an @@ -98,14 +98,14 @@ Passwordless sign-in, in two deliveries, across three endpoints: `delivery` is optional and defaults to `"link"`; the only other value is `"code"`. This endpoint **always returns `200`** with the same body, regardless of whether the address has an account, whether it failed validation (a malformed or disposable-domain email), or whether it was rate -limited (five tokens per address per hour) — every one of those cases falls through to the same +limited (five tokens per address per hour), every one of those cases falls through to the same generic response: ```json {"message": "If this email is valid, you will receive a sign-in link."} ``` -This is deliberate — see [First sign-in](../getting-started/first-sign-in.md#magic-links) — and +This is deliberate, see [First sign-in](../getting-started/first-sign-in.md#magic-links), and means a client cannot distinguish "sent" from "rejected" from this response; there is no `400` to handle here at all. A generated token expires after 10 minutes. @@ -148,13 +148,13 @@ one of them and the response also includes: {"current_org": {"id": "", "name": "Acme"}} ``` -This is not gated on having exactly one membership — `Profile.objects.filter(user=user, +This is not gated on having exactly one membership; `Profile.objects.filter(user=user, is_active=True).first()` (`backend/common/views/auth_views.py:740,745-746` for verify, `:874,878-879` for verify-code) picks whichever active profile sorts first, and `Profile`'s default ordering is `("-created_at",)` (`backend/common/models.py:238`), so for a user in more than one org this binds to their **most recently created** membership, not necessarily the one they intend to use next. A client for a user who might belong to multiple orgs cannot assume magic-link verification always -leaves org selection to a later step — it has to check whether `current_org` is present and let the +leaves org selection to a later step. It has to check whether `current_org` is present and let the user switch away from it if it's the wrong one, via [switching organization](#switching-organization). If there is no active org membership at all, the token comes back org-less, same as the OAuth flows. @@ -166,7 +166,7 @@ if the email has never signed in before, exactly like the OAuth flows do. ## Using an access token -Send the access token as `Authorization: Bearer `. It is valid for one hour — see +Send the access token as `Authorization: Bearer `. It is valid for one hour. See [Token model](../architecture/authentication.md#token-model) for why, and what happens after it expires. @@ -187,9 +187,9 @@ user and every organization they have an active profile in: Two things worth knowing about this specific endpoint: it sets `authentication_classes = [JWTAuthentication]` explicitly (`:327`), so unlike most endpoints it does not also accept a -personal access token or an organization API key — only a JWT works here. And its exact path is one +personal access token or an organization API key, only a JWT works here. And its exact path is one of a short list `GetProfileAndOrg` skips org-context resolution for entirely -(`backend/common/middleware/get_company.py:29-38`) — the middleware returns immediately without +(`backend/common/middleware/get_company.py:29-38`). The middleware returns immediately without trying to set `request.profile`/`request.org`, so this endpoint never requires an `org_id` claim. That is what makes it work with the org-less token an org-less sign-in above produces, and exactly what makes it useful for letting a freshly-authenticated client list organizations to choose from. @@ -208,7 +208,7 @@ pair: {"access": "", "refresh": ""} ``` -Note the field names here — `access` and `refresh` — do not match `access_token`/`refresh_token` +Note the field names here, `access` and `refresh`, do not match `access_token`/`refresh_token` used by the sign-in endpoints above; this is the one response on this page that uses the shorter pair. Failure responses: `400` if `refresh` is missing; `401` if the token is expired, malformed, or already blacklisted (`TokenError`); `403` if the user account has been deactivated since the @@ -241,7 +241,7 @@ that has none yet: `refresh` is optional; when it is sent and belongs to the caller, it is blacklisted as part of the switch so the org-less (or previous-org) token stops working immediately rather than staying valid -for the rest of its 14-day life — see +for the rest of its 14-day life. See [Organization switching](../architecture/authentication.md#organization-switching) for why the ownership check on that field matters. Failure responses: `400` if `org_id` is missing or not a valid UUID; `403` if the caller has no active profile in the requested org. @@ -249,7 +249,7 @@ valid UUID; `403` if the caller has no active profile in the requested org. ## Local development For local development against a running backend with `DEBUG=True`, the fastest way to obtain a -token pair is the `devlogin` management command rather than any of the endpoints above — it needs +token pair is the `devlogin` management command rather than any of the endpoints above. It needs no OAuth provider and no outbound email. It refuses to run at all unless `DEBUG` is `True`. See [First sign-in: Local development](../getting-started/first-sign-in.md) for its exact invocation, flags, and org-resolution behavior; this page's endpoints are what `devlogin` is a shortcut around, diff --git a/docs/api/cases.md b/docs/api/cases.md index 2830d779..10d5e3cc 100644 --- a/docs/api/cases.md +++ b/docs/api/cases.md @@ -8,7 +8,7 @@ CSV import in `backend/cases/import_views.py` / `backend/cases/services/csv_impo [Conventions](conventions.md) for pagination, filtering and response-shape rules assumed rather than repeated here, and [Errors](errors.md) for the 404-vs-403 principle referenced below. -Cases are also the largest module in this codebase — Kanban pipelines, merge/unmerge, parent/child +Cases are also the largest module in this codebase: Kanban pipelines, merge/unmerge, parent/child trees, time tracking, escalation, routing, inbound email and analytics all live under `/api/cases/...` too. This page covers only the endpoints this documentation task scopes: list, create, detail, comments/attachments, approvals, the knowledge base, and CSV import. @@ -16,7 +16,7 @@ create, detail, comments/attachments, approvals, the knowledge base, and CSV imp ## A case's read, write and delete rules differ on purpose Before anything else: **do not collapse a case's access rules into one check.** `cases/access.py` -states three explicit, different rules, and its own module docstring explains why — before this +states three explicit, different rules, and its own module docstring explains why, before this module existed the three verbs had drifted into three different answers by accident, and a watcher's ticket showed up in their list but answered `403` when they opened it: @@ -28,26 +28,26 @@ delete admin · creator (`cases/access.py:20-22`.) This is the same "read wide, write narrow" principle [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#read-wide-write-narrow) -documents in general — that page is the place to read the reasoning; this page just applies it. +documents in general. That page is the place to read the reasoning; this page just applies it. Every endpoint under [Retrieve, update, delete](#retrieve-update-delete) and [Comments and attachments](#comments-and-attachments) calls one of `assert_case_read_access`/`assert_case_write_access`/`assert_case_delete_access` -(`cases/access.py:90-113`), and the admin check itself is the two-spellings-of-admin form — -`profile.role == "ADMIN" or bool(getattr(profile, "is_admin", False))` (`cases/access.py:38-40`) — -that recurs throughout this codebase. **The [Approvals](#approvals) endpoints are the exception**: +(`cases/access.py:90-113`), and the admin check itself is the two-spellings-of-admin form: +`profile.role == "ADMIN" or bool(getattr(profile, "is_admin", False))` (`cases/access.py:38-40`). +That recurs throughout this codebase. **The [Approvals](#approvals) endpoints are the exception**: `cases/approval_views.py` never imports `cases.access` at all, and none of its case-touching views -call any of the three functions above — see [Approvals](#approvals) for exactly what that means in +call any of the three functions above. See [Approvals](#approvals) for exactly what that means in practice. ## List cases `GET /api/cases/` (`CaseListView.get`, `cases/views.py:285-287`) returns one paginated list, ordered -`-created_at, -id` (`:169-174`) — a comment on the queryset explains the tiebreak exists because `-id` +`-created_at, -id` (`:169-174`): a comment on the queryset explains the tiebreak exists because `-id` alone is a random UUID and was, in practice, no ordering at all. Soft-deleted cases (`is_active=False`) are excluded by default; an org admin can pass `?include_deleted=true` to see them (`:176-180`). Merged duplicates (`merged_into` set, or `status="Duplicate"`) are excluded by default too; `?show_merged=true` includes them (`:181-186`). A non-admin caller sees only cases where they are -creator, assignee, or watcher — the same `visible_cases_qs` the detail view's read check uses +creator, assignee, or watcher, the same `visible_cases_qs` the detail view's read check uses (`:190-198`), so list and detail agree by construction. ```json @@ -69,20 +69,20 @@ creator, assignee, or watcher — the same `visible_cases_qs` the detail view's (`cases/views.py:222-263`.) `open_count`, `urgent_count` and `awaiting_first_reply` are counted over the whole filtered queryset, not the page. `awaiting_first_reply` counts open cases -(`status` in `New`/`Assigned`/`Pending`) with no `first_response_at` yet — it is not the same thing as +(`status` in `New`/`Assigned`/`Pending`) with no `first_response_at` yet. It is not the same thing as an SLA breach, which depends on the org's business calendar and is computed per-row rather than in -this aggregate. `accounts_list`/`contacts_list` feed the create form's pickers — org-wide for an admin, +this aggregate. `accounts_list`/`contacts_list` feed the create form's pickers, org-wide for an admin, but narrowed to accounts/contacts the caller created or is assigned to for a non-admin, the same split the case queryset itself gets (`:187-206`); either way they can be heavy, and `?slim=true` omits both (`:254-256`). Filters read directly from `request.query_params` (`apply_case_list_filters`, `cases/views.py:85-155`, -shared with `GET /api/cases/watching/`): `name` (contains), `status` (repeatable — a list applies +shared with `GET /api/cases/watching/`): `name` (contains), `status` (repeatable. A list applies `status__in`, a single value applies exact match), `priority` (exact), `account` (exact id), `case_type` (exact), `assigned_to` (repeatable id list), `tags` (id list), `search` (`name` or `description`, contains), `created_at__gte`/`created_at__lte` (date range), `sla_breached=true` (a wall-clock approximation of the SLA badge, Postgres-only raw SQL), `cf_` (custom field equals), -and `ordering` — whitelisted to `created_at`, `-created_at`, `priority`, `-priority`, `id`, `-id`, +and `ordering`: whitelisted to `created_at`, `-created_at`, `priority`, `-priority`, `id`, `-id`, `name`, `-name` (`:71-82,150-153`); anything else is silently ignored rather than erroring. ## Create a case @@ -103,35 +103,35 @@ an optional `case_attachment` file) or JSON, validated by `CaseCreateSerializer` A validation failure returns `400` with `{"error": true, "errors": {...}}`. Two validators worth knowing: -- **`account`, if given, must belong to the caller's org** — `validate_account` rejects a +- **`account`, if given, must belong to the caller's org**: `validate_account` rejects a cross-tenant id with `"No such account."` (`cases/serializer.py:151-165`); the comment on it notes this closes both a cross-tenant *write* and a cross-tenant *read*, since the create response nests the account back through `CaseSerializer` → `AccountSerializer`. - **`name` must be unique per org, case-insensitive** (`validate_name`, `:232-244`). -`contacts`, `teams`, `assigned_to` and `tags` are **not** part of `CaseCreateSerializer` — the view +`contacts`, `teams`, `assigned_to` and `tags` are **not** part of `CaseCreateSerializer`. The view reads each from the request body after the serializer succeeds, resolved against `org=request.profile.org` (`:332-386`). Unlike [Leads](leads.md#fields), which documents a real per-field, per-verb inconsistency in how these four are *parsed*, cases parses all four identically on every verb: each is `json.loads`'d when it arrives as a string (which happens whenever the same request also uploads `case_attachment` as multipart) before the id list is extracted. `case_attachment` -itself is read straight off `request.FILES` (`:388-395`) — on `POST` only; see +itself is read straight off `request.FILES` (`:388-395`): on `POST` only; see [Retrieve, update, delete](#retrieve-update-delete) for why `PATCH` can't set it at all, and for how *writing* these four fields (not just parsing them) differs sharply across verbs. ## Retrieve, update, delete `GET /api/cases/{id}/` (`CaseDetailView.get`, `cases/views.py:612-731`) looks the case up scoped to -the caller's org (`get_case_or_404`, `cases/access.py:57-71` — a malformed UUID or a case in another +the caller's org (`get_case_or_404`, `cases/access.py:57-71`: a malformed UUID or a case in another org both answer `404`, never `500`, matching [Errors](errors.md#not-found-versus-forbidden)). If the -case has been merged into another one, the response is a redirect hint instead of the case itself — -`{"redirect_to": "", "merged_into": "", "source_case_id": "", "source_case_name": "..."}` -— unless the caller passes `?show_merged=true` (`:614-630`). **This merged-case branch returns before -any read-access check runs at all** — `assert_case_read_access` isn't called until `:633`, several -lines after the merged branch's own `return` at `:630` — so `source_case_name` is disclosed to any org +case has been merged into another one, the response is a redirect hint instead of the case itself: +`{"redirect_to": "", "merged_into": "", "source_case_id": "", "source_case_name": "..."}`, +unless the caller passes `?show_merged=true` (`:614-630`). **This merged-case branch returns before +any read-access check runs at all**; `assert_case_read_access` isn't called until `:633`, several +lines after the merged branch's own `return` at `:630`, so `source_case_name` is disclosed to any org member who can supply a valid case id, including one with no read access to the case whatsoever, as long as it happens to be a merged duplicate. On the ordinary, non-merged path, read access genuinely is -checked before the rest of the response body is built (`assert_case_read_access`, `:633` — the comment +checked before the rest of the response body is built (`assert_case_read_access`, `:633`, the comment on this line notes the previous order built the payload for a case the requester was about to be refused); it's specifically the merged-redirect shortcut that skips the check entirely. The response nests the record under `cases_obj`, alongside `attachments`, `comments` (public only), `internal_notes`, @@ -139,95 +139,95 @@ the record under `cases_obj`, alongside `attachments`, `comments` (public only), `merged_from_cases`, `custom_field_definitions`, `status`, `priority`, `type_of_case`, `comment_permission` and `users_mention` (`:707-730`). `comment_permission` is computed with the same `has_case_write_access` the comment-post endpoint enforces (`:642`), so the button a client shows and -the answer the server gives agree by construction — the comment on this line notes that used to not be +the answer the server gives agree by construction, the comment on this line notes that used to not be true (`comment_permission` was creator-or-admin while the write endpoint below also allowed assignees). `PUT /api/cases/{id}/` (`:448-570`) and `PATCH /api/cases/{id}/` (`:823-942`) both call `assert_case_write_access` before touching anything. **`account` cannot be changed by either verb.** `CaseCreateSerializer.__init__` sets `self.fields["account"].read_only = True` whenever `self.instance` -is set (`cases/serializer.py:146-149`) — true for both update calls, never for create — so on `PUT`/ +is set (`cases/serializer.py:146-149`) (true for both update calls, never for create), so on `PUT`/ `PATCH` an `account` in the body is silently dropped before validation ever runs; `validate_account` never executes and no error is returned. `PUT` is a non-partial serializer instantiation, so it still requires `name`/`status`/`priority` the same as `POST` does; `PATCH` passes `partial=True` -(`:832`), so none of the three are required there — a `PATCH` may touch just one field. +(`:832`), so none of the three are required there. A `PATCH` may touch just one field. Closing a case is validated as a *transition*, not just a target value: `CaseCreateSerializer.validate()` (`cases/serializer.py:167-230`) requires `closed_on` whenever `status` is being set to `"Closed"` from -anything other than `"Closed"`, and — when an active `pre_close` `ApprovalRule` matches the case's -priority/case_type/team — requires an `Approval` row in state `approved` for that case and rule before +anything other than `"Closed"`, and, when an active `pre_close` `ApprovalRule` matches the case's +priority/case_type/team. Requires an `Approval` row in state `approved` for that case and rule before the close is allowed. This exists because `Case.clean()` states the same two rules (`cases/models.py:180-249`) but `Case.save()` (`:264-283`) never calls `full_clean()`, so without the serializer-level check a matching rule could be armed and a `PATCH {"status": "Closed"}` would still -return `200` and record zero approvals — proven live per the comment on `validate()`. Success on either +return `200` and record zero approvals. Proven live per the comment on `validate()`. Success on either verb, identical literal string on both (`views.py:564,936`): `{"error": false, "message": "Case Updated Successfully"}`. -**Three of `Case.clean()`'s parent-tree guards are dead code through the API — and one field that +**Three of `Case.clean()`'s parent-tree guards are dead code through the API, and one field that looks like a partial re-implementation of the model isn't a re-implementation at all.** `Case.clean()` (`cases/models.py:180-249`) states four rules about `parent`: a case cannot be its own parent (`:222-223`); linking cannot create a multi-level cycle, walking the whole parent chain to check (`:226-238`); the tree is capped at `PARENT_MAX_DEPTH = 3` levels (`:178,240-243`); and a case that is itself `status="Duplicate"` cannot be given a parent at all (`:247-250`, the self-side of the merge -guard — distinct from `parent.status == "Duplicate"`, checked separately at `:245-246`). None of the +guard, distinct from `parent.status == "Duplicate"`, checked separately at `:245-246`). None of the four run through any API write, because `Case.save()` (`:264-283`) never calls `full_clean()`. Of the four, `CaseCreateSerializer.validate_parent()` (`cases/serializer.py:246-263`) independently re-implements exactly two: the self-parent check, and refusing a parent whose own `status` is -`Duplicate`. **The cycle walk and the depth limit are simply absent from the API — not partially -covered, not approximated — and so is the "a Duplicate case cannot receive a parent" direction.** +`Duplicate`. **The cycle walk and the depth limit are simply absent from the API, not partially +covered, not approximated, and so is the "a Duplicate case cannot receive a parent" direction.** `validate_parent()` also rejects a cross-org `parent`, but that check has no model-side counterpart at -all — `Case.clean()` never compares `org_id`, so this is a serializer-only addition, not a partial port +all: `Case.clean()` never compares `org_id`, so this is a serializer-only addition, not a partial port of anything the model already states. Net effect: a same-org, non-self parent assignment that would create a longer cycle (A's parent is B, then B's parent is set to A), exceed three levels, or give a -merged case a parent, is accepted by every write endpoint today — reported here, not fixed, per this +merged case a parent, is accepted by every write endpoint today: reported here, not fixed, per this documentation task's scope. -`DELETE /api/cases/{id}/` (`:586-593`) calls `assert_case_delete_access` — admin or creator only, no +`DELETE /api/cases/{id}/` (`:586-593`) calls `assert_case_delete_access`, admin or creator only, no assignee exception (see [above](#a-cases-read-write-and-delete-rules-differ-on-purpose)). ## Comments and attachments Adding a comment or an attachment is the same `POST /api/cases/{id}/` route the detail endpoint uses, just with a body (`CaseDetailView.post`, `cases/views.py:749-806`), gated by -`assert_case_write_access` (`:755`) — the same rule `comment_permission` on `GET` reports. Send -`comment` (text, and optionally `is_internal` — a truthy string or boolean) and/or `case_attachment` +`assert_case_write_access` (`:755`). The same rule `comment_permission` on `GET` reports. Send +`comment` (text, and optionally `is_internal`, a truthy string or boolean) and/or `case_attachment` (multipart file), either or both. A comment created with `is_internal=true` is an internal note, not visible to a customer-facing surface; the response splits both back out as `comments` (public) and -`internal_notes` (`:794-805`), and both are returned to any caller with read access — `is_internal` is +`internal_notes` (`:794-805`), and both are returned to any caller with read access; `is_internal` is a display split, not an access-control split on this endpoint. Editing or deleting an *existing* comment goes through `PUT`/`PATCH`/`DELETE /api/cases/comment/{id}/` -(`CaseCommentView`, `cases/views.py:945-1078`) — restricted to an admin, the `is_organization_admin` -flag (exposed as `.is_admin`, `common/models.py:243-246` — org-scoped, not the platform-level +(`CaseCommentView`, `cases/views.py:945-1078`). Restricted to an admin, the `is_organization_admin` +flag (exposed as `.is_admin`, `common/models.py:243-246`: org-scoped, not the platform-level superuser flag), or the comment's own author (`request.profile == obj.commented_by`, e.g. `:982`). `Comment.commented_by` is a `Profile` foreign key (`common/models.py:275-277`), so this comparison is -correct — unlike some `created_by` comparisons elsewhere in this codebase; see +correct, unlike some `created_by` comparisons elsewhere in this codebase; see [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks). **`PUT` cannot, in practice, edit a comment.** `.put()` builds `CommentSerializer(obj, data=params)` with no `partial=True` (`:984`), and `CommentSerializer.Meta.fields` includes `object_id` and `org` -(`common/serializer.py:239-250`) — plain model fields, not declared `read_only` the way `content_type` +(`common/serializer.py:239-250`). Plain model fields, not declared `read_only` the way `content_type` and `commented_by` are on the same serializer. A non-partial serializer requires every field that isn't -read-only, so a body of just `{"comment": "..."}` — everything the documented -`CaseCommentEditSwaggerSerializer` contract asks for — fails validation on the missing `object_id`/ +read-only, so a body of just `{"comment": "..."}`, everything the documented +`CaseCommentEditSwaggerSerializer` contract asks for. Fails validation on the missing `object_id`/ `org` and answers `400`. Only `.patch()` (`partial=True`, `:1028`) can actually change a comment's text. Separately, because `object_id` and `org` are genuinely writable on `PATCH`, a comment's own -author can send both in a `PATCH` body to repoint the comment at a different record — including one in +author can send both in a `PATCH` body to repoint the comment at a different record, including one in another org, or one the author cannot otherwise read. `Comment.clean()` (`common/models.py:299-314`) checks that the new `org` matches the target record's `org`, and `Comment.save()` calls `self.full_clean()` (`:316-318`), so a mismatched pair raises Django's `ValidationError`, uncaught here, as an unhandled `500`; a *matching* cross-org pair (new `org` set to the target record's real org) saves successfully, leaving a comment authored by a profile from a -different org sitting on that org's record. Neither of these two behaviors is fixed here — reported +different org sitting on that org's record. Neither of these two behaviors is fixed here, reported per this documentation task's scope. `DELETE /api/cases/attachment/{id}/` (`CaseAttachmentView.delete`, `cases/views.py:1098-1142`) is org-scoped (`.filter(pk=pk, org=request.profile.org)`, `:1119-1121`) and its ownership check compares -`request.profile.user_id` to `self.object.created_by_id` — both `User` ids, correctly typed +`request.profile.user_id` to `self.object.created_by_id`, both `User` ids, correctly typed (`:1128-1130`). The view's own docstring documents two defects that were live in this exact endpoint before the fix (unscoped lookup; a `Profile`-to-`User` comparison that made the endpoint silently -admin-only) — both are fixed here today. Whether the equivalent endpoint in another app still has +admin-only). Both are fixed here today. Whether the equivalent endpoint in another app still has either defect is that app's own question; verify against that app's current source rather than this -docstring, which itself goes stale — see +docstring, which itself goes stale. See [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks) for the current, cross-app answer. @@ -235,70 +235,70 @@ for the current, cross-app answer. Two kinds of endpoint: admin-configured rules, and per-case requests/decisions against them (`cases/approval_views.py`). A rule (`ApprovalRule`, `cases/approvals.py:47-116`) matches a case on -`trigger_event` (only `"pre_close"` exists today), and optionally `priority`, `case_type` and `team` — -the most-specific active match wins ties by most recent (`find_matching_rule`, `:119-136`). +`trigger_event` (only `"pre_close"` exists today), and optionally `priority`, `case_type` and `team`. +The most-specific active match wins ties by most recent (`find_matching_rule`, `:119-136`). -- `GET /api/cases/approval-rules/` (`ApprovalRuleListCreateView.get`, `approval_views.py:87-103`) — - any org member; each rule row carries a `pending_count` computed from the `Approval` log. -- `POST /api/cases/approval-rules/` (`:105-133`) — admin only. The approver list and match-team are +- `GET /api/cases/approval-rules/` (`ApprovalRuleListCreateView.get`, `approval_views.py:87-103`). + Any org member; each rule row carries a `pending_count` computed from the `Approval` log. +- `POST /api/cases/approval-rules/` (`:105-133`), admin only. The approver list and match-team are each validated to belong to the caller's org before save (`:117-129`). -- `GET/PUT/DELETE /api/cases/approval-rules/{id}/` (`ApprovalRuleDetailView`, `:136-194`) — `GET` is +- `GET/PUT/DELETE /api/cases/approval-rules/{id}/` (`ApprovalRuleDetailView`, `:136-194`); `GET` is open to any member; `PUT`/`DELETE` are admin only. Deleting a rule with request history soft-disables it (`is_active=False`) instead of a hard delete (`:183-192`), since `Approval.rule` is `on_delete=PROTECT`. -- `POST /api/cases/{id}/request-approval/` (`CaseRequestApprovalView.post`, `:201-279`) — **no case +- `POST /api/cases/{id}/request-approval/` (`CaseRequestApprovalView.post`, `:201-279`), **no case access check of any kind.** The view fetches the case with a bare - `get_object_or_404(Case, id=pk, org=org)` (`:209`) — org-scoped, but nothing from `cases.access`; + `get_object_or_404(Case, id=pk, org=org)` (`:209`), org-scoped, but nothing from `cases.access`; `approval_views.py` doesn't import that module at all. Any org member, including one refused the case itself with a `403` on `GET /api/cases/{id}/`, can file an approval request against it. The `201` response is `ApprovalSerializer(approval).data`, whose `case_summary` - (`cases/serializer.py:1002-1013`) returns the case's `name`, `status`, `priority` and account name — - the same read-around `CaseSolutionLinkView` was fixed for (see [Solutions](#solutions)), reintroduced + (`cases/serializer.py:1002-1013`) returns the case's `name`, `status`, `priority` and account name. + The same read-around `CaseSolutionLinkView` was fixed for (see [Solutions](#solutions)), reintroduced here. Also note: because the lookup is `get_object_or_404` rather than the `get_case_or_404` helper - every other case endpoint on this page uses, a malformed (non-UUID) `id` here isn't a clean `404` — + every other case endpoint on this page uses, a malformed (non-UUID) `id` here isn't a clean `404`; `get_object_or_404` only catches `Model.DoesNotExist`, not the `ValidationError`/`ValueError` a bad UUID raises against `Case.id`, so it reaches the client as an unhandled `500`, the exact failure mode `get_case_or_404` (`cases/access.py:57-71`) exists to prevent elsewhere on this page. Beyond existence-and-org-scope, the request is validated against an explicit `rule_id` (which must actively match the case) or, when omitted, the best `find_matching_rule` result. A second request against the same case+rule while one is already `pending` is refused with `409`, not a duplicate row (`:244-258`). -- `GET /api/cases/approvals/` (`ApprovalInboxView.get`, `:282-341`) — the inbox, also with no +- `GET /api/cases/approvals/` (`ApprovalInboxView.get`, `:282-341`), the inbox, also with no case-level access check: the queryset is `Approval.objects.filter(org=org)` (`:296`), not filtered through `visible_cases_qs` or any case-access helper, so a member's `mine=false` inbox view can - include approval rows — and their `case_summary` — for cases that member has no access to. + include approval rows, and their `case_summary`, for cases that member has no access to. `?state=` filters (default `pending`; `all` drops the filter), `?case=` scopes to one case, and - `?mine=true` restricts to rows the caller can currently act on (see below) — which deliberately + `?mine=true` restricts to rows the caller can currently act on (see below), which deliberately excludes the caller's own requests, "mine to decide" rather than "mine to have filed" (`:318-332`). - Neither gap is fixed here — reported per this documentation task's scope. + Neither gap is fixed here, reported per this documentation task's scope. **Approving and rejecting both enforce that the requester cannot be the approver, with no admin exception.** `ApprovalApproveView.post` (`:354-407`) and `ApprovalRejectView.post` (`:410-475`) both check, in order: the approval is still `pending` (else `400`); the caller is in the rule's approver -pool via `can_be_acted_on_by` (else `403`); and — separately — +pool via `can_be_acted_on_by` (else `403`); and, separately, `approval.requested_by_id == request.profile.id` (else `403`, "You cannot approve/reject your own request; another approver must decide it.", `:382-390` and `:443-451`). The comment on both is explicit -that this holds even for "an admin who defaults into every rule's pool" — being an approver and being +that this holds even for "an admin who defaults into every rule's pool", being an approver and being the requester are mutually exclusive for a single decision, regardless of role. `Approval.can_be_acted_on_by` (`cases/approvals.py:188-196`) itself is: explicitly listed in -`rule.approvers`, **or** `profile.role == rule.approver_role` — note this second clause checks only +`rule.approvers`, **or** `profile.role == rule.approver_role`, note this second clause checks only `role`, not the `is_organization_admin` flag `is_org_admin` elsewhere in this codebase also treats as admin, so a profile that is only an admin via that flag is not automatically an approver unless explicitly added to `rule.approvers`. `ApprovalSerializer.can_act` and `.is_own_request` (`cases/serializer.py:1045-1062`) mirror the approve endpoint's checks exactly, so a client rendering the inbox from the list response gets the same answer -the action endpoints would give — both need `context={"request": request}` to resolve; without it they +the action endpoints would give. Both need `context={"request": request}` to resolve; without it they default to `False`, which the comment on the serializer notes is the safe default rather than a bug. `POST /api/cases/approvals/{id}/cancel/` (`ApprovalCancelView.post`, `:478-518`) is a **different** -rule from approve/reject: only the original requester or an admin may cancel (`:498-505`) — there is no +rule from approve/reject: only the original requester or an admin may cancel (`:498-505`). There is no "not the requester" restriction here, because cancelling your own request is the ordinary case. ## Solutions The knowledge base (`cases/solution_views.py`, `cases/kb_access.py`). Read access is org-wide by -design — every member may read every article, "a knowledge base whose articles are hidden from the -agents answering the tickets is not a knowledge base" (`cases/kb_access.py` module docstring) — but +design. Every member may read every article, "a knowledge base whose articles are hidden from the +agents answering the tickets is not a knowledge base" (`cases/kb_access.py` module docstring), but write and release are two different, narrower rules: ``` @@ -309,12 +309,12 @@ delete author · admin `release` is deliberately narrower than `write`: the review workflow (`draft → reviewed → approved`, plus a separate `is_published` flag) only means something if the person who approves an article is not -its own author. `assert_solution_release_access` takes no article argument at all — being the author is +its own author. `assert_solution_release_access` takes no article argument at all. Being the author is the one thing that must not grant it (`cases/kb_access.py:72-81`). `GET /api/cases/solutions/` (`SolutionListView.get`, `cases/solution_views.py:88-142`) is the one endpoint in this API that calls DRF's `get_paginated_response()` and returns its standard -`count`/`next`/`previous`/`results` envelope — [Conventions](conventions.md#pagination) names this +`count`/`next`/`previous`/`results` envelope, [Conventions](conventions.md#pagination) names this exact endpoint as the sole example. It adds one extra top-level key, `totals` (`count`/`published`/`draft`/`reviewed`/`approved_unpublished`), computed over the *whole* knowledge base rather than the current filter, so the four status cards stay meaningful under a `?status=` @@ -323,13 +323,13 @@ literal string `"true"`), `?search=` (title or description, contains). `POST /api/cases/solutions/` (`:150-167`) is open to any member for an ordinary draft, but is gated by `assert_solution_release_access` the moment the payload would create an already-`approved` or -already-`is_published` article in one request (`_wants_release`, `:28-46`) — without this, an author +already-`is_published` article in one request (`_wants_release`, `:28-46`): without this, an author could write and publish an article in a single `POST`, bypassing the review workflow entirely. `GET/PUT/PATCH/DELETE /api/cases/solutions/{id}/` (`SolutionDetailView`, `:170-237`): `GET` is open to any member; `PUT`/`PATCH` require `assert_solution_write_access` (author or admin) plus, again, `assert_solution_release_access` if the request would move the article to `approved` or flip -`is_published`; `DELETE` requires the same write rule (not a separate, narrower one — deleting your own +`is_published`; `DELETE` requires the same write rule (not a separate, narrower one. Deleting your own draft is ordinary tidying). `POST /api/cases/solutions/{id}/publish/` and `POST /api/cases/solutions/{id}/unpublish/` @@ -339,11 +339,11 @@ otherwise); the comment on the unpublish view is explicit that pulling a bad ans switch as giving it to customers and therefore gated the same way, even though the urgency argument runs the other direction. -Reading which solutions are linked to a case is not a separate endpoint — the linked set is returned +Reading which solutions are linked to a case is not a separate endpoint. The linked set is returned as part of `GET /api/cases/{id}/` (`solutions` key, [above](#retrieve-update-delete)). Linking is `POST /api/cases/{id}/solutions/` and unlinking is `DELETE /api/cases/{id}/solutions/{solution_id}/` (`CaseSolutionLinkView`, `cases/views.py:1145-1208`), gated by `assert_case_write_access` on the -*case* — the comment on `.post` notes this closes a read-around: before this check, a member refused a +*case*: the comment on `.post` notes this closes a read-around: before this check, a member refused a case with `403` could still link an article to it and then read the case's name, description, account and contacts back out through that article's own `linked_cases`. The read side of the same gap is closed in `cases/solution_serializers.py`'s `SolutionDetailSerializer.get_linked_cases`, which filters @@ -353,15 +353,15 @@ than returning every case the article happens to be linked to. ## CSV import Two endpoints, gated to org admins or members with `has_sales_access` (`_can_import`, -`cases/import_views.py:23-31`) — anyone else gets `403`. +`cases/import_views.py:23-31`). Anyone else gets `403`. `POST /api/cases/import/preview/` (`CaseImportPreviewView`, `import_views.py:56-88`) reads a `multipart/form-data` upload in a field named `file` (`.csv` extension required). The 5 MB cap is -checked against `upload.size` — Django's reported size for the uploaded file, available before -`.read()` is called — **not** the bytes actually read, and the check is skipped entirely when +checked against `upload.size`, Django's reported size for the uploaded file, available before +`.read()` is called, **not** the bytes actually read, and the check is skipped entirely when `upload.size` is falsy (`if upload.size and upload.size > MAX_UPLOAD_BYTES`, `import_views.py:48-51`). It then validates every row without writing anything. Row numbers are 1-based over *data* rows, not -file lines — the header itself is never counted, so the first row of data is `"row": 1` +file lines. The header itself is never counted, so the first row of data is `"row": 1` (`enumerate(data_rows, start=1)` over `rows[1:]`, `services/csv_import.py:216-217`): ```json @@ -381,18 +381,18 @@ file lines — the header itself is never counted, so the first row of data is ` ``` `POST /api/cases/import/commit/` (`CaseImportCommitView`, `:91-125`) re-validates the same file and -writes every valid row inside one transaction — **if any row fails validation, nothing is written** +writes every valid row inside one transaction, **if any row fails validation, nothing is written** (`services/csv_import.py` `commit_rows`, "Don't write anything if any row failed; users must fix the file first"). On success (`200`): `{"error": false, "created": 12, "ids": ["", "..."]}`. Required headers: `name`, `status`, `priority`. Recognized optional headers: `description`, `case_type`, `account_name`, `contact_emails`, `assigned_emails`, `team_names`, `tags`, `closed_on` -(`REQUIRED_HEADERS`/`OPTIONAL_HEADERS`, `services/csv_import.py:36-46`) — an unknown header, or a +(`REQUIRED_HEADERS`/`OPTIONAL_HEADERS`, `services/csv_import.py:36-46`), an unknown header, or a missing required one, fails the whole file with `header_error` before any row is checked. `contact_emails`, `assigned_emails` and `team_names` accept `;`-separated multiple values per cell and must resolve to an existing contact/active member/team in the caller's org, or the row errors (`:396-438`). `status`/`priority`/`case_type` are matched case-insensitively against the same choice -sets [Fields](#fields) documents for the API proper. A case name must be unique per org — checked +sets [Fields](#fields) documents for the API proper. A case name must be unique per org, checked against both existing cases and other rows earlier in the same file (`:338-355`). The file is capped at 5,000 data rows (`MAX_ROWS`, `:49`). @@ -401,7 +401,7 @@ at 5,000 data rows (`MAX_ROWS`, `:49`). `CaseCreateSerializer.Meta.fields` (`cases/serializer.py:265-281`) is what `POST /api/cases/`, `PUT /api/cases/{id}/` and `PATCH /api/cases/{id}/` accept. "Required" below means required on `POST` and on `PUT` (which is non-partial); `PATCH` passes `partial=True`, so nothing is required -there — see [Retrieve, update, delete](#retrieve-update-delete). +there: see [Retrieve, update, delete](#retrieve-update-delete). | Field | Type | Required | Notes | | --- | --- | --- | --- | @@ -412,22 +412,22 @@ there — see [Retrieve, update, delete](#retrieve-update-delete). | `closed_on` | date | **required when closing** | See [Retrieve, update, delete](#retrieve-update-delete); optional otherwise | | `description` | text | optional | | | `is_active` | boolean | optional | Defaults `true`; soft-delete flag, hidden from `GET /api/cases/` by default | -| `account` | uuid | optional on create; **write-once** | Must belong to the caller's org; silently `read_only` on `PUT`/`PATCH` — see [Retrieve, update, delete](#retrieve-update-delete) | +| `account` | uuid | optional on create; **write-once** | Must belong to the caller's org; silently `read_only` on `PUT`/`PATCH`. See [Retrieve, update, delete](#retrieve-update-delete) | | `custom_fields` | object | optional | Validated against the org's `CustomFieldDefinition` rows | | `parent` | uuid | optional | Same-org, non-self, parent not `Duplicate`; see the dead model-level guards [above](#retrieve-update-delete) | | `is_problem` | boolean | optional | Defaults `false`; marks an ITIL "problem" (umbrella) ticket | -| `org` | — | **read-only** | Server-derived from `request.profile.org`; listed in `Meta.fields` but not writable | +| `org` |. | **read-only** | Server-derived from `request.profile.org`; listed in `Meta.fields` but not writable | Not part of the serializer, but accepted in the same request body and resolved by the view (each a list of ids, org-scoped): `contacts`, `teams`, `assigned_to`, `tags`. Parsing is identical on every verb (see [Create a case](#create-a-case)), but *writing* is not, and the difference is a real data-loss trap: `POST` only ever adds. `PUT` calls `.clear()` on all four M2Ms **unconditionally**, -before checking whether the request even mentioned them (`views.py:485,501,515,531`) — so a `PUT` +before checking whether the request even mentioned them (`views.py:485,501,515,531`), so a `PUT` that omits `assigned_to` from the body unassigns every assignee on the case, not just leaves them unchanged. `PATCH` clears a given M2M only when its key is present in the body at all -(`if "assigned_to" in params:`, `:903`, and identically for the other three) — omitting a key on +(`if "assigned_to" in params:`, `:903`, and identically for the other three). Omitting a key on `PATCH` genuinely leaves it untouched. `case_attachment` (a multipart file) is accepted on `POST` and -`PUT` (`views.py:388,545`) but **not on `PATCH`** — `CaseDetailView.patch` never reads +`PUT` (`views.py:388,545`) but **not on `PATCH`**; `CaseDetailView.patch` never reads `request.FILES` at all, so a multipart `PATCH` carrying `case_attachment` silently drops the file with no error. diff --git a/docs/api/contacts.md b/docs/api/contacts.md index 566bf61b..65509557 100644 --- a/docs/api/contacts.md +++ b/docs/api/contacts.md @@ -10,12 +10,12 @@ repeated here. Before anything else: **a contact can be joined to an account two different ways**, and they are independent. `Contact.account` is a foreign key the model itself calls "Primary account this contact belongs to" (`contacts/models.py:71-78`); `Account.contacts` is a separate many-to-many -(`accounts/models.py:63-65`). Nothing in the database keeps them in sync — setting one does not set +(`accounts/models.py:63-65`). Nothing in the database keeps them in sync. Setting one does not set the other by itself. The view layer bridges them on write: `link_primary_account(contact)` (`contacts/views.py:48-65`) adds the contact to `contact.account.contacts` whenever `account` is set -via `POST /api/contacts/`, `PUT /api/contacts/{id}/` or `PATCH /api/contacts/{id}/` — but clearing the +via `POST /api/contacts/`, `PUT /api/contacts/{id}/` or `PATCH /api/contacts/{id}/`, but clearing the FK later does **not** remove the M2M membership, on purpose (a person can be "no longer the primary -contact" without having "left the company" — `contacts/views.py:59-62`). +contact" without having "left the company"; `contacts/views.py:59-62`). Both links are exposed on every `ContactSerializer` response, deliberately, because reading only one tells the wrong story depending on the data: @@ -30,15 +30,15 @@ tells the wrong story depending on the data: `account_detail` resolves the FK (`contacts/serializer.py:28-39`); `linked_accounts` is the M2M membership (`:41-56`). The account detail page's contact list is built from `Account.contacts` (the -M2M), not the FK — so a contact whose `account` FK is set but was never added to that account's +M2M), not the FK, so a contact whose `account` FK is set but was never added to that account's `contacts` (see [CSV import](#csv-import) below for exactly this case) will not appear there even though `account_detail` on the contact itself looks correct. ## List contacts `GET /api/contacts/` (`ContactsListView.get`, `contacts/views.py:199-201`) returns a single -paginated list — unlike leads and accounts, it is not split into two sections. It still doesn't call -DRF's `get_paginated_response()` though — [Conventions](conventions.md#pagination) names +paginated list. Unlike leads and accounts, it is not split into two sections. It still doesn't call +DRF's `get_paginated_response()` though. [Conventions](conventions.md#pagination) names `GET /api/cases/solutions/` as the only endpoint in this API that does. The view hand-builds `count` and `results` itself (`contacts/views.py:166-167`), with no `next`/`previous`, alongside a legacy set of keys kept for backwards compatibility (`:168-171`): @@ -60,7 +60,7 @@ of keys kept for backwards compatibility (`:168-171`): `active_count` and `inactive_count` (`contacts/views.py:147-148`) are computed over the full filtered queryset *before* any `is_active` filter is applied, so a client can show "12 active, 3 inactive" while also asking for only one of the two via `?is_active=true`/`?is_active=false` -(`:149-150`) — the only exact-match boolean filter on this endpoint; every other filter here is a +(`:149-150`), the only exact-match boolean filter on this endpoint; every other filter here is a substring match: `name` (`first_name` or `last_name`), `city`, `phone`, `email`, `search` (`first_name`, `last_name`, `email` or `phone`), plus `assigned_to`/`tags` (repeatable id lists), `created_at__gte/lte`, and `cf_` (`contacts/views.py:90-139`). A non-admin caller only sees @@ -76,17 +76,17 @@ contacts assigned to them or created by them (`:83-87`). {"error": false, "message": "Contact created Successfuly", "id": ""} ``` -(The message string is spelled exactly that way in the response — `Successfuly`, not `Successfully`; +(The message string is spelled exactly that way in the response, `Successfuly`, not `Successfully`; document your integration tests against the real string, not the expected one.) Two validators worth knowing: **`email` must be unique per org, case-insensitive**, checked only when supplied (`validate_email`, `contacts/serializer.py:129-145`); and **`account`, if given, must belong to the -caller's org** — `validate_account` rejects a UUID from any other org's `Account` table with +caller's org**; `validate_account` rejects a UUID from any other org's `Account` table with `"No such account."` rather than silently attaching a cross-tenant record (`contacts/serializer.py:114-127`). `teams`, `assigned_to` and `tags` are not part of the serializer -either — the view resolves each from the request body, tolerating either a JSON array or a +either, the view resolves each from the request body, tolerating either a JSON array or a JSON-encoded string of ids (or of `{"id": "..."}` objects), consistently across all three fields and -across `POST` (`contacts/views.py:247-284`), `PUT` (`:478-518`) and `PATCH` (`:832-876`) alike — -contacts does not have the per-field, per-verb inconsistency [Leads](leads.md#fields) documents for +across `POST` (`contacts/views.py:247-284`), `PUT` (`:478-518`) and `PATCH` (`:832-876`) alike. +Contacts does not have the per-field, per-verb inconsistency [Leads](leads.md#fields) documents for the equivalent fields there. `contact_attachment` is accepted as a multipart file. ## Retrieve, update, delete @@ -95,29 +95,29 @@ the equivalent fields there. `contact_attachment` is accepted as a multipart fil lookup and maps a malformed id to `404` the same way accounts does (`get_object`, `:318-332`). `assert_contact_access` (`:334-361`) is the broadest access rule in this group of four pages: an admin, the contact's creator, one of its assignees, **or anyone assigned to -an account the contact is linked to by either route** (FK or M2M — `account_ids`, `:363-369`) may +an account the contact is linked to by either route** (FK or M2M; `account_ids`, `:363-369`) may read, update or comment on the contact; anyone else gets `403`. The response nests the record under `contact_obj` and also returns `address_obj` (the flat address fields, echoed as their own object), `countries` (`contacts/views.py:608`), `comments`, `attachments`, `assigned_data`, `tasks`, `users_mention`, `custom_field_definitions`, and three computed, capped lists: `opportunities` (up to 10, via `Opportunity.contacts`), -`cases` (up to 10, via `Case.contacts`), and `colleagues` (up to 8 — other contacts sharing either +`cases` (up to 10, via `Case.contacts`), and `colleagues` (up to 8: other contacts sharing either account link, matched on the account relationship rather than the free-text `organization` field, -which frequently names a different company — `contacts/views.py:371-424`). +which frequently names a different company, `contacts/views.py:371-424`). `PUT /api/contacts/{id}/` (`:441-544`) and `PATCH /api/contacts/{id}/` (`:793-881`) both call `link_primary_account` after saving (`:477`, `:830`), so changing `account` through either verb keeps `Account.contacts` in step the same way create does. Success: `{"error": false, "message": "Contact Updated Successfully"}`. -`DELETE /api/contacts/{id}/` (`:670-691`) is narrower than read/write access on purpose — no assignee +`DELETE /api/contacts/{id}/` (`:670-691`) is narrower than read/write access on purpose, no assignee exception, only an admin or the contact's own creator may delete (`self.request.profile.user_id != self.object.created_by_id`, `:676-679`). ## CSV import Two endpoints, both gated to org admins or members with `has_sales_access` -(`_can_import`, `contacts/import_views.py:23-31`) — an ordinary member gets `403`: +(`_can_import`, `contacts/import_views.py:23-31`). An ordinary member gets `403`: `POST /api/contacts/import/preview/` (`ContactImportPreviewView`, `import_views.py:64-96`) reads a `multipart/form-data` upload in a field named `file` (must end in `.csv`, capped at 5 MB, checked @@ -143,7 +143,7 @@ against the actual bytes read rather than the client-supplied `Content-Length`, `POST /api/contacts/import/commit/` (`ContactImportCommitView`, `:99-133`) re-validates the same file and writes inside one transaction (`commit_rows` / `_commit_validated`, -`contacts/services/csv_import.py:634-714`) — if any row fails validation, **nothing is written** +`contacts/services/csv_import.py:634-714`), if any row fails validation, **nothing is written** (`:650-657`); a concurrent conflict (e.g. a duplicate email created by another request between preview and commit) rolls the whole batch back and reports `400` rather than partially importing (`:659-672`). On success (`200`): @@ -165,11 +165,11 @@ the row supplies **neither** an email nor a phone to disambiguate it from an exi **A row with `account_name` sets only the `account` foreign key, not the `Account.contacts` membership.** `_commit_validated` creates each `Contact` with `account_id=vr.account_id` directly -(`csv_import.py:681-700`) and never calls `link_primary_account` — unlike `POST /api/contacts/` and +(`csv_import.py:681-700`) and never calls `link_primary_account`, unlike `POST /api/contacts/` and the `PUT`/`PATCH` update endpoints, which always do (see [Two links to an account](#two-links-to-an-account-not-one)). A contact imported this way resolves correctly under `account_detail`, but will not show up on that account's contact list (`Account.contacts`, the M2M) until something else adds it there. This looks -like an oversight rather than a documented design choice — it is reported here, not fixed, per this +like an oversight rather than a documented design choice. It is reported here, not fixed, per this documentation task's scope. ## Fields @@ -177,7 +177,7 @@ documentation task's scope. `CreateContactSerializer.Meta.fields` (`contacts/serializer.py:147-174`) is what `POST /api/contacts/`, `PUT /api/contacts/{id}/` and `PATCH /api/contacts/{id}/` accept. Unlike `Lead.first_name`/`last_name`, `Contact.first_name` and `Contact.last_name` have neither -`blank=True` nor a serializer override (`contacts/models.py:19-20`) — **both are genuinely +`blank=True` nor a serializer override (`contacts/models.py:19-20`). **both are genuinely required**. | Field | Type | Required | Notes | @@ -186,7 +186,7 @@ required**. | `last_name` | string | **required** | | | `email` | string | optional | Unique per org, case-insensitive, when supplied | | `phone` | string | optional | | -| `organization` | string | optional | Free text — may name a different company than `account` links to | +| `organization` | string | optional | Free text, may name a different company than `account` links to | | `title` | string | optional | | | `department` | string | optional | | | `do_not_call` | boolean | optional | Defaults `false` | diff --git a/docs/api/conventions.md b/docs/api/conventions.md index 93d95d3e..f1213cb7 100644 --- a/docs/api/conventions.md +++ b/docs/api/conventions.md @@ -1,23 +1,23 @@ # Conventions -This page describes patterns that hold across most of the API — how the base URL is put together, +This page describes patterns that hold across most of the API: how the base URL is put together, how a client authenticates, how list endpoints paginate and filter, and what a response body looks like. It is deliberately drawn from the code that is actually there (a real pagination class, a real list view, the real `REST_FRAMEWORK` settings) rather than from what a typical DRF API "should" -look like — several of these conventions are less uniform than that would suggest, and the +look like. Several of these conventions are less uniform than that would suggest, and the differences matter to anyone integrating against them. ## Base URL Every endpoint in this section, and every endpoint in [Endpoint index](endpoint-index.md), is mounted under `/api/` at the Django root (`backend/crm/urls.py`: `path("api/", -include("common.app_urls", ...))`), which in turn includes `backend/common/urls.py` — the file most -of this section's page references point at — at the same, empty prefix. Individual apps are mounted +include("common.app_urls", ...))`), which in turn includes `backend/common/urls.py`, the file most +of this section's page references point at, at the same, empty prefix. Individual apps are mounted under their own segment from there, for example `path("leads/", include("leads.urls", ...))`, so a lead endpoint is `/api/leads/…`. Locally this is `http://localhost:8000/api/` (the default in the Docker quick start's -`.env.docker`, and what `PUBLIC_DJANGO_API_URL` points the frontend at — see +`.env.docker`, and what `PUBLIC_DJANGO_API_URL` points the frontend at. See [Docker quick start](../getting-started/docker-quick-start.md)). In a self-hosted deployment it is whatever host you put `DOMAIN_NAME` / `ALLOWED_HOSTS` on, still under `/api/`. @@ -41,14 +41,14 @@ API key is only ever read from `Token` (`backend/common/external_auth.py`, `APIKeyAuthentication.authenticate`). See [Authentication](authentication.md) for how a JWT is obtained and [Tokens and API keys](tokens-and-api-keys.md) for the other two. Which identity a request authenticates as, and which org it is scoped to, is never something the request supplies -directly — it is derived from whichever of these three the server can validate. +directly. It is derived from whichever of these three the server can validate. One thing the generated OpenAPI schema does *not* reflect accurately: a large minority of -operations — 112 of the schema's 350, about a third — declare an `org` header parameter +operations (112 of the schema's 350, about a third) declare an `org` header parameter (`organization_params_in_header`, defined in `backend/common/swagger_params.py` and copied into the `swagger_params.py` of six other apps: `leads`, `cases`, `opportunity`, `contacts`, `accounts` and `tasks`). Nothing in the -request-handling code reads a header by that name — org context comes only from the JWT's `org_id` +request-handling code reads a header by that name. Org context comes only from the JWT's `org_id` claim, the PAT's owning profile, or the API key's associated org, as above. Sending an `org` header has no effect; do not rely on it. @@ -59,7 +59,7 @@ List endpoints paginate with `rest_framework.pagination.LimitOffsetPagination` `offset`. The default page size is `PAGE_SIZE = 10` from the same settings block; there is no `max_limit` set on the pagination class, so a caller can request a larger page and get it. -That default pagination class is not applied automatically, though — there is no +That default pagination class is not applied automatically, though. There is no `generics.ListAPIView` anywhere in this codebase. Every list endpoint is a hand-written `APIView` that mixes `LimitOffsetPagination` in directly and calls `self.paginate_queryset(...)` itself, for example: @@ -92,8 +92,8 @@ context["open_leads"] = { } ``` -(`backend/leads/views/lead_views.py:180-184`.) But `get_paginated_response()` — and with it, DRF's -standard `count`/`next`/`previous`/`results` shape — does appear: `GET /api/cases/solutions/` +(`backend/leads/views/lead_views.py:180-184`.) But `get_paginated_response()`, and with it, DRF's +standard `count`/`next`/`previous`/`results` shape, does appear: `GET /api/cases/solutions/` (`SolutionListView`, `backend/cases/solution_views.py:56`, routed at `backend/cases/urls.py:59`) calls it directly and adds one extra key on top: @@ -104,13 +104,13 @@ return response ``` (`backend/cases/solution_views.py:140-142`.) So both shapes are real. Treat the exact pagination -field names, and whether the standard DRF envelope or a hand-rolled one is used, as per-endpoint — +field names, and whether the standard DRF envelope or a hand-rolled one is used, as per-endpoint: `limit`/`offset` as query parameters are consistent, but where the resulting count, next-offset and results end up in the response body is not; check the endpoint you're calling. ## Filtering and search -There is no shared filter backend — `DEFAULT_FILTER_BACKENDS` is not set in `REST_FRAMEWORK`, and +There is no shared filter backend; `DEFAULT_FILTER_BACKENDS` is not set in `REST_FRAMEWORK`, and no view uses `django_filters`. Each list view reads `request.query_params` directly inside its own `get`/`get_context_data` and applies whatever filters it defines. `GET /api/leads/` (`backend/leads/views/lead_views.py:105-160`) is representative of how rich these can get: @@ -131,12 +131,12 @@ no view uses `django_filters`. Each list view reads `request.query_params` direc | `close_date__gte`, `close_date__lte` | date range | | `cf_` | custom field `` equals the given value (`custom_fields__contains`) | -For example, `GET /api/leads/?status=assigned&source=partner&search=acme` — `status` and `source` +For example, `GET /api/leads/?status=assigned&source=partner&search=acme`; `status` and `source` are exact matches against `LEAD_STATUS` and `LEAD_SOURCE` (`backend/common/utils.py:50-67`: statuses are `assigned`, `in process`, `converted`, `recycled`, `closed`; sources are `call`, `email`, `existing customer`, `partner`, `public relations`, `compaign`, `other`), so a filter value outside those exact strings matches nothing rather than erroring. Other list endpoints define -their own, generally smaller, filter sets in the same style — there is no single reference for +their own, generally smaller, filter sets in the same style. There is no single reference for "every filter the API supports"; read the view (or watch for a dedicated section on that resource's page, where one exists) rather than assuming this table applies elsewhere. @@ -164,7 +164,7 @@ The same `{"error": false, "tokens": [...]}` shape is used by the personal-acces **Bare serializer data.** Some newer endpoints skip the `error` wrapper entirely and return `serializer.data` (or the created/updated object's serialization) directly, relying on -`serializer.is_valid(raise_exception=True)` and the HTTP status code to signal failure — for +`serializer.is_valid(raise_exception=True)` and the HTTP status code to signal failure. For example `MacroListCreateView.post` returns `Response(MacroSerializer(macro).data, status=201)` (`backend/macros/views.py:136`). @@ -190,7 +190,7 @@ at the top level of the response; check the endpoint. `REST_FRAMEWORK` sets no `DEFAULT_PARSER_CLASSES`, so DRF's own defaults apply: a request body may be `application/json`, `application/x-www-form-urlencoded`, or `multipart/form-data` (`JSONParser`, `FormParser`, `MultiPartParser`, in that order). `multipart/form-data` is what every -endpoint that accepts a file upload alongside other fields needs — for example `POST /api/leads/` +endpoint that accepts a file upload alongside other fields needs. For example `POST /api/leads/` reads an optional `lead_attachment` file straight off `request.FILES` in the same call that creates the lead (`backend/leads/views/lead_views.py:330`), so a lead with an initial attachment is created with one multipart request rather than a create-then-upload round trip. @@ -198,5 +198,5 @@ with one multipart request rather than a create-then-upload round trip. `REST_FRAMEWORK` sets no `DEFAULT_RENDERER_CLASSES` either, so both of DRF's defaults are live: `JSONRenderer` for `application/json` (what every example on this page assumes, and what every client integration should send `Accept: application/json` for) and `BrowsableAPIRenderer` for -`text/html` — request the same endpoint with a browser's default `Accept` header and DRF's +`text/html`: request the same endpoint with a browser's default `Accept` header and DRF's interactive HTML form is what comes back instead of JSON. diff --git a/docs/api/errors.md b/docs/api/errors.md index caf44fa7..43cb6066 100644 --- a/docs/api/errors.md +++ b/docs/api/errors.md @@ -2,7 +2,7 @@ How to tell a failed request from a successful one, and how to read the body of a failed one. `REST_FRAMEWORK["EXCEPTION_HANDLER"]` in `backend/crm/settings.py` is set to DRF's own -`rest_framework.views.exception_handler` — unmodified, no custom subclass — so an exception DRF +`rest_framework.views.exception_handler` (unmodified, no custom subclass), so an exception DRF itself raises (a bad permission check, a missing object, an unhandled `ValidationError`) comes back in DRF's own default shape. A view that catches its own errors and builds a `Response` by hand, which most views in this codebase do, can and often does return something else. Both are documented @@ -10,32 +10,32 @@ below with real examples, because a client has to handle both. ## Status codes -`200` for a successful read or a successful write — many mutation endpoints return `200` rather +`200` for a successful read or a successful write. Many mutation endpoints return `200` rather than `201` even on creation, wrapping the result in the `{"error": false, ...}` shape described in [Conventions](conventions.md#response-shape) rather than relying on the status code alone (see `POST /api/leads/`, `backend/leads/views/lead_views.py:402-404`). Some do use `201`: personal access token creation, for instance, returns `201` (`backend/common/views/pat_views.py:69`). Neither is -"more correct" than the other here — check the endpoint. +"more correct" than the other here, check the endpoint. Beyond that: `400` for a request the server understood but rejected (validation, or a request-shaped error like a missing required field); `401` for a request with no valid credentials at all; `403` for a request that authenticated fine but isn't allowed to do this; `404` for a resource that doesn't exist, or that exists but the caller must not be able to distinguish from not -existing — see [Not found versus forbidden](#not-found-versus-forbidden); `502` where a view depends +existing. See [Not found versus forbidden](#not-found-versus-forbidden); `502` where a view depends on an external service and that service is unreachable, for example the Google token exchange in [Google OAuth](authentication.md#google-oauth-web) (`backend/common/views/auth_views.py:110-114`). -There is no application-level `429` — `REST_FRAMEWORK` sets no `DEFAULT_THROTTLE_CLASSES`, so DRF's +There is no application-level `429`; `REST_FRAMEWORK` sets no `DEFAULT_THROTTLE_CLASSES`, so DRF's built-in throttling is not in play anywhere. The one place this codebase does rate-limit (`POST /api/auth/magic-link/request/`, five tokens per address per hour) enforces it by silently returning its normal `200` rather than a `429`, specifically so the response can't be used to -distinguish "rate limited" from "sent" from "invalid address" — see +distinguish "rate limited" from "sent" from "invalid address". See [Magic links](authentication.md#magic-links). ## Validation errors The dominant pattern in this codebase is not DRF's automatic one. Most views call `serializer.is_valid()` without `raise_exception=True`, check the boolean themselves, and build the -error response by hand as `{"error": true, "errors": serializer.errors}` — this exact shape appears +error response by hand as `{"error": true, "errors": serializer.errors}`. This exact shape appears well over 200 times across the backend. A `validate_` failure lands under that field's key, DRF's normal behavior: @@ -49,7 +49,7 @@ def validate_email(self, value): return value ``` -(`backend/leads/serializer.py:107-133`, `LeadCreateSerializer.validate_email` — the comment above it +(`backend/leads/serializer.py:107-133`, `LeadCreateSerializer.validate_email`. The comment above it explains this exists because a duplicate email used to reach the database as an unhandled `IntegrityError` and come back as an opaque `500`.) A `POST /api/leads/` with a duplicate email returns: @@ -62,8 +62,8 @@ A `validate()` failure that isn't tied to one field lands under DRF's default `non_field_errors` key (`NON_FIELD_ERRORS_KEY` is not overridden anywhere in this codebase), still nested the same way: `{"error": true, "errors": {"non_field_errors": ["..."]}}`. -A smaller, newer set of views instead call `serializer.is_valid(raise_exception=True)` — for -example `MacroListCreateView.post` (`backend/macros/views.py:123`) — and let DRF's exception handler +A smaller, newer set of views instead call `serializer.is_valid(raise_exception=True)`, for +example `MacroListCreateView.post` (`backend/macros/views.py:123`), and let DRF's exception handler build the response itself, with no `error`/`errors` wrapper at all: ```json @@ -72,7 +72,7 @@ build the response itself, with no `error`/`errors` wrapper at all: Both shapes exist in the API today. Check the specific endpoint's page (or the view, if it isn't documented yet) rather than assuming one; do not assume the presence or absence of an `error` key -tells you whether the request succeeded — the status code is what does that. +tells you whether the request succeeded. The status code is what does that. ## Authorization errors @@ -89,11 +89,11 @@ codebase set: ``` The second is `HasOrgContext.message` (`backend/common/permissions.py:11-41`), one of the two -permission classes almost every org-scoped view lists — `permission_classes = (IsAuthenticated, +permission classes almost every org-scoped view lists: `permission_classes = (IsAuthenticated, HasOrgContext)`, and often a role check on top such as `IsOrgAdmin` (`backend/common/permissions.py:44-59`, `"You must be an organization administrator to perform this action."`). Whether a failure comes back as `401` or `403` is not decided by which permission class -failed — DRF's `APIView.permission_denied()` raises `NotAuthenticated` (`401`) whenever no +failed. DRF's `APIView.permission_denied()` raises `NotAuthenticated` (`401`) whenever no authenticator on the request actually succeeded, and `PermissionDenied` (`403`, carrying the failing permission class's `message`) whenever one did but the permission check itself failed. In practice: no credentials, or invalid ones → `401`; a valid, authenticated session that isn't allowed to do @@ -104,7 +104,7 @@ this → `403`. The rule that holds everywhere an object is looked up by id: a record outside the caller's org returns `404`, the same as a record that never existed at all, never `403`. Every such lookup filters on `org=request.profile.org` (or the RLS-protected queryset equivalent) before matching the -id, so a request for another org's record simply finds nothing — +id, so a request for another org's record simply finds nothing; `get_object_or_404(Macro, pk=pk, org=request.profile.org)` (`backend/macros/views.py:148`, repeated at `:168` for the plain `GET`) is one of dozens of examples of the same shape. `invoices/permissions.py` states the reasoning explicitly in its own module docstring: @@ -115,22 +115,22 @@ at `:168` for the plain `GET`) is one of dozens of examples of the same shape. 2. May this caller act on it? -> 403 if not. ``` -(`backend/invoices/permissions.py:5-9`.) A `403` response inherently confirms the record exists — -it's not possible to be forbidden from acting on nothing — so reserving `403` for "exists, but you +(`backend/invoices/permissions.py:5-9`.) A `403` response inherently confirms the record exists. +It's not possible to be forbidden from acting on nothing, so reserving `403` for "exists, but you may not do this" and `404` for everything else is what stops an id becoming an oracle a caller in another org can probe with. Within a single org, whether a permission failure still returns `403` or drops all the way to `404` depends on what the resource is. Invoices and estimates keep step 2 above as a real `403`: an org member who is neither the record's creator, an assignee, nor an admin is refused with -`{"error": true, "message": "Permission denied"}` (`backend/invoices/permissions.py:80-84`) — a +`{"error": true, "message": "Permission denied"}` (`backend/invoices/permissions.py:80-84`). A colleague can tell the invoice exists, just not read or change it. A personal (as opposed to org-scope) macro goes further and hides itself completely from everyone except its owner and org admins, `404` even for a same-org caller, precisely to avoid the invoice case's disclosure: ```python if macro.scope == Macro.SCOPE_PERSONAL and macro.owner_id != request.profile.id: - # A personal macro you don't own is invisible to you — the list and + # A personal macro you don't own is invisible to you. The list and # GET hide it (404). The write verbs must not confirm it exists via # a 403 either, or the id space leaks which rows are somebody else's # personal macros. Mirror the GET: 404. @@ -138,6 +138,6 @@ if macro.scope == Macro.SCOPE_PERSONAL and macro.owner_id != request.profile.id: ``` (`backend/macros/views.py:156-164`.) Both are deliberate, documented choices for the resource in -question, not an inconsistency to paper over — when you add an authorization check to a new +question, not an inconsistency to paper over, when you add an authorization check to a new endpoint, decide which of the two your resource needs (does an org-mate being aware this record exists leak anything?) rather than defaulting to whichever is easier to write. diff --git a/docs/api/invoices.md b/docs/api/invoices.md index 965d36fb..cce9119f 100644 --- a/docs/api/invoices.md +++ b/docs/api/invoices.md @@ -1,7 +1,7 @@ # Invoices and estimates Authenticated routes in `backend/invoices/api_urls.py`, all mounted under `/api/invoices/...`. **The -DRF views live in `backend/invoices/api_views.py` — not `views.py`**, which is a legacy, +DRF views live in `backend/invoices/api_views.py`, not `views.py`**, which is a legacy, non-API module in this app. Anonymous client-portal routes are in `backend/invoices/public_urls.py`, served by `backend/invoices/public_views.py`, mounted at `/api/public/...`. Authorization for every authenticated invoice/estimate/recurring-invoice endpoint @@ -9,7 +9,7 @@ is centralized in `backend/invoices/permissions.py`. See [Conventions](conventio filtering and response-shape rules assumed rather than repeated here, and [Errors](errors.md) for the 404-vs-403 principle this page's own module docstring states almost verbatim. -This page covers invoices, estimates, line items and the public portal — the endpoints this +This page covers invoices, estimates, line items and the public portal, the endpoints this documentation task scopes. Payments, products, templates, recurring invoices, dashboards/reports, and invoice-from-opportunity/from-time-entries are also part of this module (`api_urls.py`) but are out of scope for this page. @@ -25,16 +25,16 @@ scope for this page. 2. May this caller act on it? -> 403 if not ``` -— the exact two-step [Errors: Not found versus forbidden](errors.md#not-found-versus-forbidden) quotes +The exact two-step [Errors: Not found versus forbidden](errors.md#not-found-versus-forbidden) quotes as this module's own docstring. Step 2, `has_object_access` (`:32-58`), grants access to an org admin, -a Django superuser, the record's creator, or any profile in its `assigned_to` set — there is no +a Django superuser, the record's creator, or any profile in its `assigned_to` set. There is no separate, narrower rule for write vs. delete the way [Cases](cases.md#a-cases-read-write-and-delete-rules-differ-on-purpose) has; one rule covers read, write and delete alike for invoices and estimates. **The two comparisons inside that rule are against deliberately different types.** `created_by` is a `User` FK (from `AuditModel`), so it is compared against `request.user`; `assigned_to` holds `Profile` rows, so it is compared against `request.profile` (`:42-58`). The module docstring calls out that -getting this backwards — comparing `request.profile` to `created_by`, as the estimate views used to — +getting this backwards (comparing `request.profile` to `created_by`, as the estimate views used to) is not merely always-`False` the way the same mistake is elsewhere in this codebase: because it reaches the ORM as `Q(created_by=)`, Django raises `ValueError` on the type mismatch, and the non-admin estimate list answered `500` rather than an empty or wrong list. That specific defect is @@ -46,7 +46,7 @@ of why this codebase keeps insisting a permission check be provably tested in bo `GET /api/invoices/` (`InvoiceListView.get`, `invoices/api_views.py:176-194`) hand-builds a response with the same `count`/`next`/`previous`/`results` key names DRF's `get_paginated_response()` would produce, plus a `totals` block computed over every invoice the caller can see (not just the page or the -current filter). This is the hand-rolled shape, not the DRF-generated one — +current filter). This is the hand-rolled shape, not the DRF-generated one, [Conventions](conventions.md#pagination) and [Cases](cases.md#solutions) both name `GET /api/cases/solutions/` as the only endpoint in this API that actually calls `get_paginated_response()`; this view reaches the same four key names by building the dict itself: @@ -66,10 +66,10 @@ current filter). This is the hand-rolled shape, not the DRF-generated one — ``` `totals` is computed over `Invoice.objects.filter(pk__in=queryset.values("pk"))` rather than the -visibility-scoped queryset directly (`_totals`, `:196-235`) — the comment explains why: a non-admin's +visibility-scoped queryset directly (`_totals`, `:196-235`), the comment explains why: a non-admin's queryset joins `assigned_to` (a many-to-many), so summing across that join would multiply an invoice's amount by its number of assignees; re-querying by `pk__in` drops the join before the `Sum`. -`action_needed` is a **count**, not an amount — invoices still `Draft`, plus anything unpaid and past +`action_needed` is a **count**, not an amount, invoices still `Draft`, plus anything unpaid and past `due_date` (`:209`). A non-admin caller sees only invoices they created or are assigned to (`:96-103`, the same rule [above](#one-access-rule-for-invoices-estimates-and-recurring-invoices) applies at list scope). @@ -77,7 +77,7 @@ scope). Filters (`filter_queryset`, `:105-174`): `search` (matches `invoice_title`, `invoice_number`, `client_name` or `client_email`, contains, capped to 100 characters), `status` (exact), `account`/`contact`/`opportunity` (exact id), `assigned_to` (exact id), `created_by` (exact id), -`issue_date_gte`/`issue_date_lte`, `due_date_gte`/`due_date_lte`, `cf_`, and `sort` — whitelisted +`issue_date_gte`/`issue_date_lte`, `due_date_gte`/`due_date_lte`, `cf_`, and `sort`: whitelisted to `created_at`/`due_date`/`issue_date`/`total_amount`/`status`, each optionally `-`-prefixed (`:163-172`); an unrecognized `sort` value is silently ignored, leaving the default `-created_at`. @@ -95,7 +95,7 @@ already has assignees, `send_email` to notify them (`:261-278`). `{"invoice": ..., "attachments": [...], "comments": [...], "history": [...], "custom_field_definitions": [...]}`. `PUT /api/invoices/{id}/` (`:343-400`) accepts a **partial** body (`partial=True` is passed even though this is `PUT`, `:376`) through the same `InvoiceCreateSerializer`. **`InvoiceDetailView` defines only -`get` and `put` — no delete method of any kind exists for an invoice**; an invoice is retired via +`get` and `put`, no delete method of any kind exists for an invoice**; an invoice is retired via `POST /api/invoices/{id}/cancel/`, never removed. (Contrast [Estimates](#estimates), which does support a hard delete.) @@ -104,18 +104,18 @@ Three lifecycle actions, each `get_invoice_or_error`-gated and each a `POST` wit - `POST /api/invoices/{id}/send/` (`InvoiceSendView.post`, `:403-440`) refuses to re-send a `Paid` or `Cancelled` invoice (`400`); moves `Draft` → `Sent`, stamps `sent_at`/`is_email_sent`, and queues `send_invoice_to_client`. -- `POST /api/invoices/{id}/mark-paid/` (`InvoiceMarkPaidView.post`, `:443-481`) records a `Payment` — - it defaults `amount` to the current `amount_due`, `payment_method` to `OTHER`, and `payment_date` to +- `POST /api/invoices/{id}/mark-paid/` (`InvoiceMarkPaidView.post`, `:443-481`) records a `Payment`. + It defaults `amount` to the current `amount_due`, `payment_method` to `OTHER`, and `payment_date` to today when the caller omits them, but routes the whole thing through `PaymentCreateSerializer` (`invoices/serializer.py:365-396`) either way, so a payment amount of `0` or one exceeding the outstanding balance is still rejected (`400`). - `POST /api/invoices/{id}/cancel/` (`InvoiceCancelView.post`, `:563-599`) refuses an already-`Cancelled` - invoice and refuses a `Paid` one (`400` either way) — cancelling is for money that will never be + invoice and refuses a `Paid` one (`400` either way). Cancelling is for money that will never be collected, not for reversing a completed payment. `POST /api/invoices/{id}/duplicate/` (`InvoiceDuplicateView.post`, `:484-560`) copies the invoice's client/billing/discount/tax/template fields and every line item into a new `Draft` invoice with a fresh -`invoice_number`, `issue_date` reset to today, and no payments — `201` with the new +`invoice_number`, `issue_date` reset to today, and no payments; `201` with the new `InvoiceSerializer` payload. `GET /api/invoices/{id}/pdf/` (`InvoicePDFView.get`, `:602-631`) streams @@ -126,15 +126,15 @@ client/billing/discount/tax/template fields and every line item into a new `Draf The same list/detail/lifecycle shape as invoices, with one structural difference: **an estimate can be hard-deleted; an invoice cannot** (see [above](#invoices)). -`GET /api/invoices/estimates/` (`EstimateListView.get`, `:1027-1064`) — same hand-built +`GET /api/invoices/estimates/` (`EstimateListView.get`, `:1027-1064`). Same hand-built `count`/`next`/`previous`/`results` shape as the invoice list above, without a `totals` block. Filters: `status`, `account`, `search` (matches -`estimate_number`, `title` or `client_name`), `cf_`. Visibility follows the same rule as invoices -— non-admin sees only estimates they created or are assigned to. +`estimate_number`, `title` or `client_name`), `cf_`. Visibility follows the same rule as invoices, +non-admin sees only estimates they created or are assigned to. `POST /api/invoices/estimates/` (`:1066-1100`) is validated by `EstimateCreateSerializer` (`invoices/serializer.py:892-1018`; see [Fields](#fields)). **Unlike `InvoiceCreateSerializer`, -`EstimateCreateSerializer` does not mark `status` read-only** — nothing stops an authenticated, +`EstimateCreateSerializer` does not mark `status` read-only**, nothing stops an authenticated, same-org caller with write access from `POST`ing or `PUT`ing an estimate directly into `status: "Accepted"`. Doing so does **not** run any of the checks or side effects the public accept flow enforces (see [Public portal endpoints](#public-portal-endpoints)): no expiry check, and none of @@ -142,7 +142,7 @@ flow enforces (see [Public portal endpoints](#public-portal-endpoints)): no expi the model comment above those fields calls acceptance "an authorisation" that the app records who gave. This is reported here, not fixed, per this documentation task's scope. -`GET/PUT/DELETE /api/invoices/estimates/{id}/` (`EstimateDetailView`, `:1103-1187`) — `DELETE` is a +`GET/PUT/DELETE /api/invoices/estimates/{id}/` (`EstimateDetailView`, `:1103-1187`); `DELETE` is a real hard delete (`estimate.delete()`, `:1182`), gated by the same access rule as read/write. `POST /api/invoices/estimates/{id}/convert/` (`EstimateConvertView.post`, `:1189-1266`) creates a new @@ -151,7 +151,7 @@ real hard delete (`estimate.delete()`, `:1182`), gated by the same access rule a (`:1253-1257`). **This is the field that stops a second conversion from duplicating the invoice**: the view's very first check is `if estimate.converted_to_invoice: return 400` (`:1200-1204`), before anything else runs. Any client rendering an estimate list needs to read this field, not just `status`, -to decide whether to offer "raise an invoice" — +to decide whether to offer "raise an invoice", `EstimateListSerializer.converted_to_invoice` (`invoices/serializer.py:832,846,866-870`) is on the list row specifically so the worklist can tell "accepted, not yet billed" apart from "already billed" without a second request per row; the class docstring is explicit that omitting it "would offer to raise a @@ -168,24 +168,24 @@ Two ways line items reach the database, with different validation coverage. **Nested in an invoice create/update.** `InvoiceCreateSerializer`'s `line_items` (`InvoiceLineItemCreateSerializer`, many, `invoices/serializer.py:288-304`) is written inside the same request as the invoice itself; `validate()` additionally rejects any line item whose `product` belongs -to a different org (`:689-704` — the comment notes the nested serializer's own `product` field is an +to a different org (`:689-704`: the comment notes the nested serializer's own `product` field is an unscoped `PrimaryKeyRelatedField` over every org's products, so this cross-org check has to happen at -the parent serializer, where org context exists). On `PUT`, sending `line_items` **replaces** the set — -existing rows are deleted and recreated (`InvoiceCreateSerializer.update`, `:731-753`); omitting the key +the parent serializer, where org context exists). On `PUT`, sending `line_items` **replaces** the set. +Existing rows are deleted and recreated (`InvoiceCreateSerializer.update`, `:731-753`); omitting the key entirely leaves existing line items untouched. **Standalone, via `POST /api/invoices/{invoice_id}/line-items/`** (`InvoiceLineItemListView.post`, `invoices/api_views.py:653-679`) and **`PUT`/`DELETE /api/invoices/{invoice_id}/line-items/{id}/`** (`InvoiceLineItemDetailView`, `:682-751`). Both route through `InvoiceLineItemCreateSerializer` -directly, with no wrapping parent-serializer `validate()` — and **neither has an application-code check +directly, with no wrapping parent-serializer `validate()`, and **neither has an application-code check that `product` belongs to the caller's org**: `InvoiceLineItemCreateSerializer` declares no `product` field of its own (`invoices/serializer.py:288-304`), so DRF derives a plain -`PrimaryKeyRelatedField(queryset=Product.objects.all())` — every org's products — and both views use it +`PrimaryKeyRelatedField(queryset=Product.objects.all())`, every org's products, and both views use it raw, unlike the *nested* path, which does check (`InvoiceCreateSerializer.validate()`, `:689-704`; grep confirms this is the only `product`-org check anywhere in `invoices/serializer.py`). This is a real gap -against [the ORM-filter contract](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract) — -the explicit application-level filter that page calls non-optional is missing here — but it is not an +against [the ORM-filter contract](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract), +the explicit application-level filter that page calls non-optional is missing here, but it is not an unconditional cross-tenant read. `product` (the table, along with `invoice_line_item`) is itself RLS-protected: both are in `ORG_SCOPED_TABLES` (`common/rls/__init__.py:97`), with policy enabled by @@ -193,31 +193,31 @@ unconditional cross-tenant read. `product` (the table, along with [PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md) mandates, the unscoped `Product.objects.all()` queryset sees zero rows for a foreign org's id under RLS's fail-safe default, and the request answers `400` ("invalid pk") rather than -attaching the row — the missing ORM filter is caught by the safety net. Under the *default* -configuration — `DBUSER` unset, which falls back to the Postgres superuser, and therefore bypasses RLS -entirely, per [Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract) -— nothing stops it: the row attaches, and `InvoiceLineItemSerializer.product_name` +attaching the row. The missing ORM filter is caught by the safety net. Under the *default* +configuration, `DBUSER` unset, which falls back to the Postgres superuser, and therefore bypasses RLS +entirely, per [Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract), +nothing stops it: the row attaches, and `InvoiceLineItemSerializer.product_name` (`source="product.name"`, `:254,263`) discloses that other org's product name on every subsequent read. Whether this is exploitable in a given deployment is exactly the question "is `DBUSER` a superuser?" -answers — which is also why it's worth fixing regardless of how any one deployment is configured: the +answers, which is also why it's worth fixing regardless of how any one deployment is configured: the ORM filter is the contract, RLS is the net, and this endpoint is missing the contract. Reported here, not fixed, per this documentation task's scope. Every write to a line item (either path) recalculates and saves the parent invoice's totals -immediately afterward (`invoice.recalculate_totals(); invoice.save()`) — a client does not need a +immediately afterward (`invoice.recalculate_totals(); invoice.save()`). A client does not need a separate call to refresh `total_amount`/`amount_due` after adding, editing or removing a line item. `InvoiceLineItemCreateSerializer.Meta.fields` (`invoices/serializer.py:291-303`): `product`, `name`, `description`, `quantity`, `unit_price`, `discount_type`, `discount_value`, -`tax_rate`, `order`. Every one of these is optional — `product` is nullable, `name` is `blank=True, +`tax_rate`, `order`. Every one of these is optional; `product` is nullable, `name` is `blank=True, null=True`, `description` is `blank=True`, `quantity` defaults `1`, `unit_price`/`discount_value`/ `tax_rate`/`order` all default `0`, and `discount_type` is `blank=True` over `DISCOUNT_TYPES` (`PERCENTAGE`, `FIXED`) (`invoices/models.py:512-565,71-74`). Posting `{}` is valid and creates a -line item with `quantity=1`, `unit_price=0`. **Estimate line items work identically** — -`EstimateLineItemCreateSerializer` has the same field list (`invoices/serializer.py:306-321`) — except +line item with `quantity=1`, `unit_price=0`. **Estimate line items work identically**; +`EstimateLineItemCreateSerializer` has the same field list (`invoices/serializer.py:306-321`), except there is no standalone endpoint for them at all: estimate line items are only ever written nested inside `POST`/`PUT /api/invoices/estimates/{id}/`, and `EstimateCreateSerializer.validate()` -(`:969-980`) has **no application-level product-org check whatsoever** — not even the +(`:969-980`) has **no application-level product-org check whatsoever**, not even the parent-serializer-level check `InvoiceCreateSerializer` has. The same RLS caveat applies here as above: `product` is RLS-protected, so this is a missing-ORM-filter gap caught by the safety net under a correctly-configured (non-superuser) database role, and a real cross-tenant read under the default, @@ -225,48 +225,48 @@ superuser one. ## Public portal endpoints -**Mounted at `/api/public/...`, not under `/api/invoices/...`** — `backend/crm/urls.py:23`: +**Mounted at `/api/public/...`, not under `/api/invoices/...`**: `backend/crm/urls.py:23`: `path("api/public/", include("invoices.public_urls", ...))`. Every view here sets -`authentication_classes = []` and `permission_classes = []` (`invoices/public_views.py`) — these +`authentication_classes = []` and `permission_classes = []` (`invoices/public_views.py`). These endpoints are genuinely anonymous, reachable by anyone holding the URL, by design (an emailed invoice or estimate link). -**Resolving the org happens before the RLS context is set, from an unscoped token lookup — this is the +**Resolving the org happens before the RLS context is set, from an unscoped token lookup. This is the whole reason the endpoint works at all under RLS.** `_resolve_org_context(token, resource_type)` (`invoices/public_views.py:30-46`) calls `common.portal_tokens.resolve_portal_org`, which hashes the -raw URL token (`sha256`) and looks it up in `common.models.PortalAccessToken` — a small table carrying -no RLS policy — to get an `org_id`, then calls `set_rls_context(org_id)`. Only *after* that does the +raw URL token (`sha256`) and looks it up in `common.models.PortalAccessToken`, a small table carrying +no RLS policy. To get an `org_id`, then calls `set_rls_context(org_id)`. Only *after* that does the view run its own `Invoice`/`Estimate` query. An unknown or malformed token leaves the RLS context empty; the subsequent scoped query then returns nothing under RLS's fail-safe default, and the caller -gets the same `404` a disabled link would give — a stranger probing token values learns nothing about +gets the same `404` a disabled link would give, a stranger probing token values learns nothing about whether one is real. [Architecture: Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md#portal-tokens) documents this mechanism in full, including why the lookup table has to be unscoped in the first place (you cannot set `app.current_org` until you know the org, and you cannot look up the row that would -tell you the org until the context is set) — read that page for the general pattern; this page is the +tell you the org until the context is set). Read that page for the general pattern; this page is the concrete instance of it for invoices and estimates. Every endpoint below additionally requires -`public_link_enabled=True` on the record, independent of the token resolving at all — an admin can +`public_link_enabled=True` on the record, independent of the token resolving at all. An admin can revoke a previously-shared link without rotating the token. -- `GET /api/public/invoice/{token}/` (`PublicInvoiceView.get`, `:61-163`) — returns the invoice's +- `GET /api/public/invoice/{token}/` (`PublicInvoiceView.get`, `:61-163`). Returns the invoice's client-facing fields, billing address, line items and payment history. First view stamps `viewed_at` and flips `Sent` → `Viewed`. -- `GET /api/public/invoice/{token}/pdf/` (`PublicInvoicePDFView.get`, `:166-206`) — same PDF the +- `GET /api/public/invoice/{token}/pdf/` (`PublicInvoicePDFView.get`, `:166-206`), same PDF the authenticated endpoint generates, `include_payments=True`. -- `GET /api/public/estimate/{token}/` (`PublicEstimateView.get`, `:209-301`) — same shape, for +- `GET /api/public/estimate/{token}/` (`PublicEstimateView.get`, `:209-301`), same shape, for estimates; also stamps `viewed_at` on first view. - `GET /api/public/estimate/{token}/pdf/` (`PublicEstimatePDFView.get`, `:304-344`). -- `POST /api/public/estimate/{token}/accept/` (`PublicEstimateAcceptView.post`, `:347-430`) — the +- `POST /api/public/estimate/{token}/accept/` (`PublicEstimateAcceptView.post`, `:347-430`). The authenticated `status` write does not exist for the anonymous caller, so acceptance is its own endpoint with its own rules, all enforced server-side because "the frontend is not a trust boundary" (comment at `:359-363`): the estimate must currently be `Sent` or `Viewed` (`400` otherwise); it must not be expired (`estimate.is_expired`, checked against `expiry_date`, `400` with the expiry date in - the message if it is — enforced here for the first time; the model comment notes `expiry_date` was + the message if it is, enforced here for the first time; the model comment notes `expiry_date` was previously "stored and printed on the estimate but enforced nowhere"); and the request body must supply `name` and a syntactically valid `email` (`400` otherwise). On success, the view records who accepted: `accepted_by_name`, `accepted_by_email` (truncated to the column's max length), `accepted_ip` - (best-effort, from `X-Forwarded-For` or `REMOTE_ADDR` — spoofable, treated as evidence, not proof), + (best-effort, from `X-Forwarded-For` or `REMOTE_ADDR`, spoofable, treated as evidence, not proof), and `accepted_user_agent`, alongside `status = "Accepted"` and `accepted_at`. -- `POST /api/public/estimate/{token}/decline/` (`PublicEstimateDeclineView.post`, `:433-468`) — same +- `POST /api/public/estimate/{token}/decline/` (`PublicEstimateDeclineView.post`, `:433-468`): same `Sent`/`Viewed` precondition, no identity capture, sets `status = "Declined"` and `declined_at`. ## Fields @@ -280,7 +280,7 @@ and `PUT /api/invoices/{id}/` accept. | `account_id` | uuid | **required** | Must belong to the caller's org; the model itself allows null, but the serializer makes it mandatory | | `contact_id` | uuid | **required** | Must belong to the caller's org; must belong to `account_id` if both given | | `opportunity_id` | uuid | optional | Must belong to the caller's org | -| `template_id` | uuid | optional | Must belong to the caller's org — without this check a cross-org template's branding/markup would render on this invoice's PDF | +| `template_id` | uuid | optional | Must belong to the caller's org, without this check a cross-org template's branding/markup would render on this invoice's PDF | | `client_name`, `client_email`, `client_phone` | string | optional | Denormalized for the PDF and portal; both `client_name`/`client_email` default `""` | | `billing_*` / `client_*` address fields | string | optional | `billing_country`/`client_country` are one of `COUNTRIES` | | `discount_type` | one of `PERCENTAGE`, `FIXED` | optional | | @@ -289,21 +289,21 @@ and `PUT /api/invoices/{id}/` accept. | `issue_date` | date | optional | Defaults today | | `due_date` | date | optional | | | `payment_terms` | one of `PAYMENT_TERMS` | optional | Defaults `NET_30` | -| `reminder_enabled`, `reminder_days_before`, `reminder_days_after`, `reminder_frequency` | — | optional | | +| `reminder_enabled`, `reminder_days_before`, `reminder_days_after`, `reminder_frequency` |, | optional | | | `public_link_enabled` | boolean | optional | Gates the portal endpoints independently of the token; defaults `true` | | `notes`, `terms` | text | optional | | | `billing_period`, `po_number` | string | optional | | | `line_items` | list | optional | See [Line items](#line-items) | | `status` | one of `INVOICE_STATUS` | **read-only** | Always created `Draft`; changed only via `send`/`mark-paid`/`cancel` | -`invoice_number` is not part of the serializer at all — `Invoice.save()` generates and assigns it +`invoice_number` is not part of the serializer at all, `Invoice.save()` generates and assigns it server-side the first time the row is saved (`invoices/models.py:384-411`). `EstimateCreateSerializer.Meta.fields` (`invoices/serializer.py:904-928`) mirrors the invoice shape closely: `title` (**required**), `account_id`/`contact_id` (**required**, org-validated), `opportunity_id` (optional, org-validated), the same client/address/discount/tax/currency/notes/terms -fields, `expiry_date` (optional — this is what the public accept endpoint checks), `public_link_enabled`, -and `line_items`. **`status` is writable here** — see the caveat in [Estimates](#estimates). Like +fields, `expiry_date` (optional. This is what the public accept endpoint checks), `public_link_enabled`, +and `line_items`. **`status` is writable here**. See the caveat in [Estimates](#estimates). Like invoices, `estimate_number` is server-generated on first save, not part of the serializer. `GET` responses on both (`InvoiceSerializer`/`EstimateSerializer`, `invoices/serializer.py:444-539, @@ -311,5 +311,5 @@ invoices, `estimate_number` is server-generated on first save, not part of the s `tax_amount`, `total_amount`, `amount_paid`, `amount_due` (all computed by `recalculate_totals()`), `sent_at`/`viewed_at`/`paid_at`/`cancelled_at` (invoice) or `sent_at`/`viewed_at`/`accepted_at`/`declined_at`/`accepted_by_*` (estimate), `public_token`, -`public_url`, `created_by`, `created_at`, `updated_at`, and — on the estimate only — +`public_url`, `created_by`, `created_at`, `updated_at`, and, on the estimate only, `converted_to_invoice` (see [Estimates](#estimates)). diff --git a/docs/api/leads.md b/docs/api/leads.md index 6c34ba18..e15652f9 100644 --- a/docs/api/leads.md +++ b/docs/api/leads.md @@ -8,7 +8,7 @@ rather than repeats, and [Errors](errors.md) for the 404-vs-403 principle refere ## List leads `GET /api/leads/` (`LeadListView.get`, `backend/leads/views/lead_views.py:246-248`) returns two -separately paginated sections rather than one list — open leads and closed leads — because the view +separately paginated sections rather than one list, open leads and closed leads, because the view excludes converted leads from the queryset entirely and then splits what's left on `status`: ```python @@ -45,24 +45,24 @@ the hand-rolled envelope [Conventions](conventions.md#pagination) describes for ``` `page_number` is worth calling out: the view builds it as `(int(self.offset / 10) + 1,)` -(`lead_views.py:178`) — a one-element Python tuple, trailing comma and all — which the JSON renderer +(`lead_views.py:178`) (a one-element Python tuple, trailing comma and all) which the JSON renderer turns into a one-element array like `[1]`, not a bare integer. `totals.unworked_over_a_week` counts leads in the filtered, org-scoped `open` queryset whose `last_contacted` (or, absent that, creation -date) is more than 7 days old (`UNWORKED_AFTER_DAYS`, `lead_views.py:58,60-86`) — not a stored status, +date) is more than 7 days old (`UNWORKED_AFTER_DAYS`, `lead_views.py:58,60-86`), not a stored status, just a computed staleness signal. **`source` accepts only the values in `LEAD_SOURCE`** (`backend/common/utils.py:59-67`): `call`, `email`, `existing customer`, `partner`, `public relations`, `compaign`, `other`. There is no -`website` value in this enum — that spelling belongs to a completely different, uppercase vocabulary +`website` value in this enum: that spelling belongs to a completely different, uppercase vocabulary (`Opportunity.lead_source`, using `SOURCES`; see [Opportunities](opportunities.md#stages)). Note also -that `compaign` is not a typo in this documentation — it is the literal stored value for "Campaign" +that `compaign` is not a typo in this documentation. It is the literal stored value for "Campaign" leads. A client that sends `source=campaign` (the correctly-spelled word) gets zero rows back, not an error, because `?source=` is an exact match (`lead_views.py:115-116`), and a client that tries to *create* a lead with `source: "campaign"` gets a validation error, because `campaign` is not one of the choices `Lead.source` accepts (`leads/models.py:75-77`). -Filters beyond `source` and `status` — `name`, `salutation`, `assigned_to`, `tags`, `city`, `email`, -`rating`, `search`, `created_at__gte/lte`, `close_date__gte/lte`, `cf_` — are documented in full, +Filters beyond `source` and `status`, `name`, `salutation`, `assigned_to`, `tags`, `city`, `email`, +`rating`, `search`, `created_at__gte/lte`, `close_date__gte/lte`, `cf_`, are documented in full, with exact match-vs-contains semantics for each, in [Conventions: Filtering and search](conventions.md#filtering-and-search), which uses this exact endpoint as its example. @@ -72,7 +72,7 @@ endpoint as its example. `POST /api/leads/` (`LeadListView.post`, `lead_views.py:273-409`) accepts `multipart/form-data` (for an optional `lead_attachment` file, read straight off `request.FILES`, `lead_views.py:331`) or JSON. The body is validated by `LeadCreateSerializer` (`leads/serializer.py:88-224`); see -[Fields](#fields) below for exactly which keys it accepts. On success (`200`, not `201` — see +[Fields](#fields) below for exactly which keys it accepts. On success (`200`, not `201`. See [Errors: Status codes](errors.md#status-codes)): ```json @@ -99,29 +99,29 @@ worth knowing about before you integrate: - **Email uniqueness is case-insensitive per org**, and only checked when an email is actually supplied (`validate_email`, `leads/serializer.py:107-134`): `{"error": true, "errors": {"email": ["Another lead in this organisation already uses that email address."]}}`. -- **Email becomes required, but only when `status` is `"converted"`** — `LeadCreateSerializer.__init__` +- **Email becomes required, but only when `status` is `"converted"`**: `LeadCreateSerializer.__init__` flips `self.fields["email"].required = True` exactly in that case, having otherwise made `first_name`, `last_name` and `salutation` optional regardless of what the `Lead` model itself requires (`leads/serializer.py:100-104`). Every other field on this serializer is optional; see [Fields](#fields). -`tags`, `contacts`, `teams` and `assigned_to` are **not** part of `LeadCreateSerializer` — the view +`tags`, `contacts`, `teams` and `assigned_to` are **not** part of `LeadCreateSerializer`. The view reads each straight off the request body after the serializer succeeds, resolved against `org=request.profile.org` once parsed. They are **not** handled uniformly, and the gap matters because -this same endpoint accepts `multipart/form-data` (above) — see [Fields](#fields) for the exact +this same endpoint accepts `multipart/form-data` (above). See [Fields](#fields) for the exact per-verb, per-field table and the concrete failure a multipart request hits. ## Retrieve, update, delete `GET /api/leads/{id}/` (`LeadDetailView.get`, `lead_views.py:568-571`) looks the lead up scoped to the -caller's org (`get_object`, `:416-417` — a lead in another org is a `404`, same as one that never +caller's org (`get_object`, `:416-417`. A lead in another org is a `404`, same as one that never existed, matching the cross-org rule [Errors](errors.md#not-found-versus-forbidden) states generally). It then calls `assert_lead_access` (`:419-443`): **an admin or superuser sees any lead; anyone else -must be the lead's creator or one of its assignees, or the request is refused with a `403`** — not a -`404` — even though `LeadListView` would have hidden that same lead from that same user entirely. +must be the lead's creator or one of its assignees, or the request is refused with a `403`**, not a +`404`, even though `LeadListView` would have hidden that same lead from that same user entirely. [Errors: Not found versus forbidden](errors.md#not-found-versus-forbidden) doesn't name leads at -all — its own text says the within-org choice between `403` and `404` "depends on what the resource -is," naming invoices (`403`) and personal macros (`404`) as its two worked examples — so treat this +all, its own text says the within-org choice between `403` and `404` "depends on what the resource +is," naming invoices (`403`) and personal macros (`404`) as its two worked examples, so treat this paragraph, not that page, as the source for what leads actually does: there is no comment or test in `leads/` recording this as a deliberate disclosure decision the way there is for invoices or macros; it's simply the behavior as written. The response nests the record under `lead_obj`, matching the @@ -134,50 +134,50 @@ creator-or-assignee-or-admin rule before touching anything, and both can convert `status` (or, for `PATCH` only, `is_converted`) is set. `leads/workflow.py` is explicit that this guard covers less than "any already-closed-off status": it deliberately separates two ideas that used to be conflated under one name (`leads/workflow.py:11-30`). **`IRREVERSIBLE_STATUSES` is `{"converted"}` -alone** (`:34`) — converting an already-`converted` lead a second time is refused with `400` rather +alone** (`:34`), converting an already-`converted` lead a second time is refused with `400` rather than silently creating a second `Opportunity` against the same `Account`. `CLOSING_STATUSES` is the separate, deliberately-*reversible* set `{"closed"}` (`:37`; "reopening a closed lead is ordinary work," per the module docstring at `:24`), and `recycled` is in neither set. **Converting a `closed` or `recycled` lead succeeds** and -runs the full conversion, same as converting an `assigned` or `in process` one — there is no guard +runs the full conversion, same as converting an `assigned` or `in process` one. There is no guard against it. `PATCH`'s conversion branch returns before `LeadCreateSerializer` (and its `validate_status`, which enforces the same `IRREVERSIBLE_STATUSES`-only rule) ever runs, so it repeats the check inline instead (`lead_views.py:875-894`). Both verbs re-attach `tags`/`contacts`/`teams`/ -`assigned_to` from the request body when present, but — like `POST` — not identically across the four +`assigned_to` from the request body when present, but, like `POST`, not identically across the four fields; see [Fields](#fields) for the exact per-verb table. Success returns `{"error": false, "message": "Lead updated Successfully"}` (or the conversion shape above); failure returns `{"error": true, "errors": {...}}`. `DELETE /api/leads/{id}/` (`:1080-1095`) allows an admin, a superuser, or the lead's own creator -(`request.profile.user == self.object.created_by`) — anyone else gets `403` — and additionally +(`request.profile.user == self.object.created_by`), anyone else gets `403`, and additionally requires the lead's org to match the caller's, even though `get_object` already guarantees that by construction. ## Comments and attachments -Adding a comment or an attachment to a lead is not a separate endpoint — it's the same +Adding a comment or an attachment to a lead is not a separate endpoint: it's the same `POST /api/leads/{id}/` route the create endpoint uses, just with an existing `pk` (`LeadDetailView.post`, `lead_views.py:589-651`). The caller must be an admin/superuser, the lead's creator, or one of its assignees (`:599-610`). Send `comment` (text) and/or `lead_attachment` (a -multipart file) — either or both — and the response echoes the same `lead_obj`/`attachments`/`comments` +multipart file), either or both, and the response echoes the same `lead_obj`/`attachments`/`comments` shape as `GET`. Editing or removing an *existing* comment goes through a dedicated route: `PUT` / `PATCH` / `DELETE /api/leads/comment/{id}/` (`LeadCommentView`, -`backend/leads/views/lead_interactions.py:60-183`) — restricted to an admin, a superuser, or the +`backend/leads/views/lead_interactions.py:60-183`), restricted to an admin, a superuser, or the comment's own author (`request.profile == obj.commented_by`; `commented_by` is a `Profile` foreign -key, so this comparison is correct, unlike some `created_by` comparisons elsewhere in this codebase — -see [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks)). +key, so this comparison is correct, unlike some `created_by` comparisons elsewhere in this codebase. +See [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks)). `DELETE /api/leads/attachment/{id}/` (`LeadAttachmentView.delete`, `lead_interactions.py:203-221`) has a real gap, documented in [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks): the lookup is `self.model.objects.get(pk=pk)` with **no `org=` filter at all** (`lead_interactions.py:204`), so the id space for attachment deletion is not scoped to the caller's -org by the ORM query — only PostgreSQL RLS, if correctly configured for the deployment, stands between +org by the ORM query, only PostgreSQL RLS, if correctly configured for the deployment, stands between this endpoint and a cross-tenant delete. The ownership check on the same endpoint -(`request.profile.user == self.object.created_by`, `:208`) is correct — it compares a `User` to a -`User` — so once an attachment is found, only its uploader, an admin, or a superuser can delete it; +(`request.profile.user == self.object.created_by`, `:208`) is correct: it compares a `User` to a +`User`, so once an attachment is found, only its uploader, an admin, or a superuser can delete it; the defect is specifically the missing org scope in the lookup, not the permission check. ## Fields @@ -186,10 +186,10 @@ the defect is specifically the missing org scope in the lookup, not the permissi `PUT /api/leads/{id}/` and `PATCH /api/leads/{id}/` accept. Every field the `Lead` model itself declares is optional (`blank=True, null=True`, or has a default) except `first_name` and `last_name`, which have `null=True` but not `blank=True` at the model level and would default to required under -DRF's normal derivation — the serializer's `__init__` explicitly overrides both to `required=False` +DRF's normal derivation: the serializer's `__init__` explicitly overrides both to `required=False` (`leads/serializer.py:102-103`, `leads/models.py:49-50`). The practical result: **a lead can be created with no name at all**, as long as `status` isn't `"converted"` (which requires `email` -instead — see [Create a lead](#create-a-lead)). +instead. See [Create a lead](#create-a-lead)). | Field | Type | Required | Notes | | --- | --- | --- | --- | @@ -203,12 +203,12 @@ instead — see [Create a lead](#create-a-lead)). | `website` | string | optional | Free text, not validated as a URL | | `linkedin_url` | string | optional | Validated as a URL | | `status` | one of `LEAD_STATUS` | optional | `assigned`, `in process`, `converted`, `recycled`, `closed` | -| `source` | one of `LEAD_SOURCE` | optional | `call`, `email`, `existing customer`, `partner`, `public relations`, `compaign`, `other` — no `website` | +| `source` | one of `LEAD_SOURCE` | optional | `call`, `email`, `existing customer`, `partner`, `public relations`, `compaign`, `other`, no `website` | | `industry` | one of `INDCHOICES` | optional | e.g. `SOFTWARE` | | `rating` | `HOT` \| `WARM` \| `COLD` | optional | | | `opportunity_amount` | decimal | optional | Non-negative (DB constraint) | | `currency` | one of `CURRENCY_CODES` | optional | Defaults from the org's `default_currency` if omitted and `opportunity_amount` is set | -| `probability` | integer, 0–100 | optional | | +| `probability` | integer, 0-100 | optional | | | `close_date` | date | optional | | | `address_line`, `city`, `state`, `postcode` | string | optional | | | `country` | one of `COUNTRIES` | optional | | @@ -225,22 +225,22 @@ arrive as strings, never as JSON arrays): | Field | `POST /api/leads/` | `PUT /api/leads/{id}/` | `PATCH /api/leads/{id}/` | | --- | --- | --- | --- | -| `tags` | plain `id__in=data.get("tags")` — no string handling (`lead_views.py:318-323`) | same gap (`:715-721`) | `json.loads`'d if a string, `{"id": …}` unwrapped (`:981-995`) | +| `tags` | plain `id__in=data.get("tags")`, no string handling (`lead_views.py:318-323`) | same gap (`:715-721`) | `json.loads`'d if a string, `{"id": …}` unwrapped (`:981-995`) | | `contacts` | same gap (`:325-329`) | `json.loads`'d if a string (`:732-745`) | `json.loads`'d if a string (`:997-1011`) | | `teams` | `json.loads`'d if a string (`:340-350`) | same (`:747-758`) | same (`:1013-1027`) | | `assigned_to` | `json.loads`'d if a string (`:352-364`) | same (`:760-773`) | same (`:1029-1043`) | Sending `tags` or `contacts` as a JSON-encoded string in the same request as a `multipart/form-data` -`lead_attachment` upload — on `POST`, or on `PUT` for `tags` specifically — hands Django's `id__in` +`lead_attachment` upload (on `POST`, or on `PUT` for `tags` specifically) hands Django's `id__in` lookup a raw string. Django iterates it character by character; every character fails `UUIDField` -parsing, and the request dies as an unhandled `500` rather than a clean `400` — the same failure mode +parsing, and the request dies as an unhandled `500` rather than a clean `400`, the same failure mode already documented for a different field at `contacts/views.py:105-108`. Sending those two fields as a real JSON body (no file) works fine, because DRF parses a JSON request body into an actual list before the view ever reads it. `GET /api/leads/{id}/` and `GET /api/leads/` additionally return, but never accept as input: `id`, -`created_by`, `created_at`, `updated_at`, `is_sample` (server-set only — see +`created_by`, `created_at`, `updated_at`, `is_sample` (server-set only. See `leads/serializer.py:80-85`), `lead_attachment` and `lead_comments` (nested read-only serializations of -this lead's attachments and comments — `leads/serializer.py:64-65`), `stage` and `kanban_order` +this lead's attachments and comments: `leads/serializer.py:64-65`), `stage` and `kanban_order` (managed by the Kanban endpoints, not this page), and `custom_fields` (validated separately against the org's `CustomFieldDefinition` rows). diff --git a/docs/api/opportunities.md b/docs/api/opportunities.md index e4a557b2..3e668fc7 100644 --- a/docs/api/opportunities.md +++ b/docs/api/opportunities.md @@ -28,18 +28,18 @@ just the current page (`get_totals`, `:73-110`): } ``` -`weighted_sum` is the forecast — `SUM(amount * probability / 100)` — as opposed to `amount_sum`, +`weighted_sum` is the forecast, `SUM(amount * probability / 100)`, as opposed to `amount_sum`, which is what every open and closed deal in the filtered set is worth at face value (`:90-98`). `stalled_count` uses the same per-stage aging thresholds as `?rotten=true` below and as `Opportunity.get_aging_status()`; see [Stages](#stages). A non-admin caller only sees deals they created or are assigned to (`:119-131`). -Filters: `name`, `search` (both `icontains` against `name` only — `search` here does **not** OR +Filters: `name`, `search` (both `icontains` against `name` only; `search` here does **not** OR across multiple fields the way it does on [Leads](leads.md#list-leads)); `account` (exact); `stage`, -`lead_source` (**`__contains`, not exact** — see [Stages](#stages) for why this matters); `tags`, +`lead_source` (**`__contains`, not exact**. See [Stages](#stages) for why this matters); `tags`, `assigned_to` (repeatable, id lists); `created_at__gte/lte`, `closed_on__gte/lte`, `amount__gte/lte`; `cf_`; and two boolean flags, `open=true` (excludes closed stages) and -`rotten=true` (stalled deals only) — see [Stages](#stages) (`opportunity_views.py:133-192`). +`rotten=true` (stalled deals only). See [Stages](#stages) (`opportunity_views.py:133-192`). ## Create an opportunity @@ -53,8 +53,8 @@ success (`200`): **`name` must be unique per org, case-insensitive** (`validate_name`, `opportunity/serializer.py:213-228`). The serializer also enforces, at the API layer, the two rules -`Opportunity.clean()` states at the model layer — closed deals need a close date, and `CLOSED_WON` -deals need an amount (`validate()`, `:242-268`; see [Stages](#stages)) — because DRF's +`Opportunity.clean()` states at the model layer. Closed deals need a close date, and `CLOSED_WON` +deals need an amount (`validate()`, `:242-268`; see [Stages](#stages)), because DRF's `ModelSerializer` never calls `Model.clean()`, so without this the model's own rules would only be enforced by unit tests against the model, never through the API. `contacts`, `tags`, `teams` and `assigned_to` are resolved from the request body the same @@ -64,7 +64,7 @@ list-of-ids way as [Leads](leads.md#create-a-lead) and [Accounts](accounts.md#cr ## Retrieve, update, delete `GET /api/opportunities/{id}/` (`OpportunityDetailView.get`, `opportunity_views.py:661-736`) looks the -deal up scoped to the caller's org — unlike leads/accounts/contacts, this lookup returns `None` rather +deal up scoped to the caller's org. Unlike leads/accounts/contacts, this lookup returns `None` rather than raising, so a missing or cross-org id comes back as an explicit `404` response (`"error": true, "errors": "Opportunity not found."`, `:663-667`) instead of Django's `get_object_or_404`. `assert_deal_access` (`:423-447`) then requires the caller to be an admin, the @@ -76,13 +76,13 @@ record under `opportunity_obj`, and also returns `comments`, `attachments`, `con `PUT /api/opportunities/{id}/` (`:464-596`) and `PATCH /api/opportunities/{id}/` (`:831-953`) both enforce `assert_deal_access`, and both stamp `closed_by = request.profile` and persist it with an explicit `.save()` whenever the incoming `stage` is `CLOSED_WON` or `CLOSED_LOST` -(`:537-539`, `:942-944`) — a deal moved into a closed stage always records who closed it. Both clear +(`:537-539`, `:942-944`), a deal moved into a closed stage always records who closed it. Both clear and re-add `contacts`/`tags`/`teams`/`assigned_to` from the request body the same way create does. Success: `{"error": false, "message": "Opportunity Updated Successfully"}`. `DELETE /api/opportunities/{id}/` (`:612-637`) allows an admin, a superuser, or the deal's own creator -(`request.profile.user != self.object.created_by`, a `User`-to-`User` comparison, `:624-632`) — -anyone else gets `403`. +(`request.profile.user != self.object.created_by`, a `User`-to-`User` comparison, `:624-632`). +Anyone else gets `403`. ## Stages @@ -91,11 +91,11 @@ anyone else gets `403`. (`opportunity/models.py:54-56`; `STAGES` in `backend/common/utils.py:87-94`): `PROSPECTING`, `QUALIFICATION`, `PROPOSAL`, `NEGOTIATION`, `CLOSED_WON`, `CLOSED_LOST`. There is no `Pipeline`/`Stage` model behind it the way there is for the other three record types that have a -Kanban board in this API — `LeadPipeline`/`LeadStage` (`leads/models.py:229-330`), +Kanban board in this API: `LeadPipeline`/`LeadStage` (`leads/models.py:229-330`), `CasePipeline`/`CaseStage` (`cases/models.py:490,527`), and `Board`/`BoardColumn` for tasks (`tasks/models.py:16,83`) are all per-org, admin-configurable. Opportunity's Kanban view says this -about itself in its own module docstring: "Status-based only (Opportunity has no Pipeline/Stage model -— it groups by the flat `stage` CharField)" (`opportunity/views/kanban_views.py:1-6`). Every org gets +about itself in its own module docstring: "Status-based only (Opportunity has no Pipeline/Stage model. +It groups by the flat `stage` CharField)" (`opportunity/views/kanban_views.py:1-6`). Every org gets the same six stages; there is no endpoint to add, rename or reorder them. Two consequences of moving a deal between stages, both enforced server-side (see @@ -103,10 +103,10 @@ Two consequences of moving a deal between stages, both enforced server-side (see `closed_on`; entering `CLOSED_WON` specifically also requires `amount` (`CLOSED_STAGES`, `AMOUNT_REQUIRED_STAGES`, `opportunity/workflow.py:17-21`). `probability` is auto-filled from a fixed per-stage default (`STAGE_PROBABILITIES`, `workflow.py:8-15`) whenever the -value on the record is `0` or unset (`Opportunity.save()`, `opportunity/models.py:262-264`) — sending +value on the record is `0` or unset (`Opportunity.save()`, `opportunity/models.py:262-264`). Sending your own `probability` still works, this only fills the gap when you don't. -**Filtering by stage is a substring match, not an exact one** — `?stage=` and `?lead_source=` both +**Filtering by stage is a substring match, not an exact one**: `?stage=` and `?lead_source=` both compile to `__contains` (`opportunity_views.py:138-143`), unlike `Lead.status`/`Lead.source`, which are exact matches (see [Leads](leads.md#list-leads)). `?stage=CLOSED` therefore matches both `CLOSED_WON` and `CLOSED_LOST`; `?stage=closed` (lowercase) matches neither, because `__contains` on a @@ -134,20 +134,20 @@ column per `STAGES` value, applying the same org/role scoping and a subset of th } ``` -`"pipeline": null` is not a placeholder for missing data — it is always `null` here, precisely because +`"pipeline": null` is not a placeholder for missing data. It is always `null` here, precisely because there is no pipeline object behind this board. `PATCH /api/opportunities/{id}/move/` (`OpportunityMoveView.patch`, `kanban_views.py:140-204`) -**requires `stage`** — `OpportunityMoveSerializer.stage` is a `ChoiceField` with no `required=False` +**requires `stage`**: `OpportunityMoveSerializer.stage` is a `ChoiceField` with no `required=False` (`opportunity/serializer.py:362-367`, whose own docstring says so explicitly), and the view reads -`data["stage"]` unconditionally (`kanban_views.py:178`) — a reorder-only body with no `stage` key gets +`data["stage"]` unconditionally (`kanban_views.py:178`), a reorder-only body with no `stage` key gets a `400`, not a silent no-op reorder. When `stage` is sent, `kanban_order` can optionally come with it to reorder within the (possibly new) column at the same time (fractional indexing, same convention as -tasks — averaging two neighbors' `kanban_order`, or offsetting by ±1000 with only one neighbor given). +tasks, averaging two neighbors' `kanban_order`, or offsetting by ±1000 with only one neighbor given). Its own access check intends to mirror `assert_deal_access` (admin, creator, or assignee) but the creator half is broken: `is_owner = request.profile == opportunity.created_by` compares a `Profile` to `Opportunity.created_by`, -which is a `User` foreign key — the two are never equal, so a non-admin who created a deal but did not +which is a `User` foreign key. The two are never equal, so a non-admin who created a deal but did not also assign it to themselves cannot move it and gets `403` (`kanban_views.py:161-168`). This is one of the two open `created_by` type-mismatch call sites in this codebase's `opportunity` app; see [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks) @@ -167,9 +167,9 @@ accept. Only `name` is required at the model level (`opportunity/models.py:46`, | `opportunity_type` | one of `OPPORTUNITY_TYPES` | optional | `NEW_BUSINESS`, `EXISTING_BUSINESS`, `RENEWAL`, `UPSELL`, `CROSS_SELL` | | `currency` | one of `CURRENCY_CODES` | optional | Defaults from the org's `default_currency` if omitted | | `amount` | decimal | **required if `stage: "CLOSED_WON"`** | Otherwise optional; non-negative (DB constraint) | -| `probability` | integer, 0–100 | optional | Auto-filled from `stage` if `0`/unset — see [Stages](#stages) | +| `probability` | integer, 0-100 | optional | Auto-filled from `stage` if `0`/unset. See [Stages](#stages) | | `closed_on` | date | **required if `stage` is `CLOSED_WON` or `CLOSED_LOST`** | Otherwise optional | -| `lead_source` | one of `SOURCES` | optional | Uppercase — `NONE`, `CALL`, `EMAIL`, `EXISTING CUSTOMER`, `PARTNER`, `PUBLIC RELATIONS`, `CAMPAIGN`, `WEBSITE`, `OTHER`. **Not** `Lead.source`'s `LEAD_SOURCE` — see [Leads](leads.md#list-leads) | +| `lead_source` | one of `SOURCES` | optional | Uppercase, `NONE`, `CALL`, `EMAIL`, `EXISTING CUSTOMER`, `PARTNER`, `PUBLIC RELATIONS`, `CAMPAIGN`, `WEBSITE`, `OTHER`. **Not** `Lead.source`'s `LEAD_SOURCE`. See [Leads](leads.md#list-leads) | | `description` | text | optional | | | `is_active` | boolean | optional | Defaults `true` | @@ -179,7 +179,7 @@ list of ids, org-scoped), and `opportunity_attachment` (a multipart file). `GET /api/opportunities/` and `GET /api/opportunities/{id}/` additionally return, but never accept as input: `id`, `closed_by`, `created_by`, `created_at`, `org` (nested), `created_on_arrow`, -`amount_source` (`MANUAL` or `CALCULATED` — server-derived by `recalculate_amount()` from whether the +`amount_source` (`MANUAL` or `CALCULATED`, server-derived by `recalculate_amount()` from whether the deal has line items, `opportunity/models.py:68-73,185-202`; not writable through this serializer), `stage_changed_at`, `days_in_stage`, `aging_status` (`green`/`yellow`/`red`, from `Opportunity.get_aging_status()`), `line_items`, `line_items_total`, and `custom_fields` (validated diff --git a/docs/api/tasks.md b/docs/api/tasks.md index b923c3c2..24abc706 100644 --- a/docs/api/tasks.md +++ b/docs/api/tasks.md @@ -2,7 +2,7 @@ Routes in `backend/tasks/urls.py`. CRUD, comments and attachments are in `backend/tasks/views/task_views.py`; Kanban pipelines/stages and the move endpoint in -`backend/tasks/views/kanban_views.py`; Kanban **boards** — a separate feature — in +`backend/tasks/views/kanban_views.py`; Kanban **boards**, a separate feature, in `backend/tasks/views/board_views.py`. Object-level authorization is centralized in `backend/tasks/access.py`. See [Conventions](conventions.md) for pagination, filtering and response-shape rules assumed rather than repeated here, and [Errors](errors.md) for the @@ -12,40 +12,40 @@ response-shape rules assumed rather than repeated here, and [Errors](errors.md) Before anything else, one fact that changes how you should read every write endpoint below: **`Task.save()` explicitly calls `self.full_clean()`** (`tasks/models.py:402-404`). This is not the -default — [Architecture: Data model](../architecture/data-model.md#conventions) documents that most +default, [Architecture: Data model](../architecture/data-model.md#conventions) documents that most models here (`Lead`, `Opportunity`, `Case`) define real validation in `clean()` that is never invoked through the API, because their `save()` methods don't call `full_clean()`. `Task` is one of a small handful that do (alongside `Comment.save()` and `Attachments.save()`, which enforce that a comment or -attachment's `org` matches its parent record's org — same page). The consequence is the opposite +attachment's `org` matches its parent record's org. Same page). The consequence is the opposite problem: when `full_clean()` *does* run and finds a violation, it raises Django's own -`django.core.exceptions.ValidationError` — not DRF's — and [Errors](errors.md) states plainly, in its +`django.core.exceptions.ValidationError`, not DRF's, and [Errors](errors.md) states plainly, in its own opening paragraph, that this codebase's `EXCEPTION_HANDLER` is DRF's default, unmodified, which does not know how to render that exception. Unless the specific view code path catches it, a model-level rule violation on a `Task` reaches the client as an unhandled **`500`**, not a clean `400`. -The one rule `Task.clean()` currently enforces is "at most one parent entity" — a task may link to +The one rule `Task.clean()` currently enforces is "at most one parent entity". A task may link to at most one of `account`, `opportunity`, `case`, `lead` (`tasks/models.py:384-400`). The main create/update surface guards against this twice: `TaskCreateSerializer.validate()` (`tasks/serializer.py:331-365`) re-implements the same rule and returns a clean `400` with the field named, *and* `TaskListView.post` / `TaskDetailView.put` / `TaskDetailView.patch` additionally wrap `serializer.save()` in `try/except django.core.exceptions.ValidationError` as a backstop -(`_model_errors`, `tasks/views/task_views.py:56-66`, used at `:277-281,577-581,702-706`) — so for +(`_model_errors`, `tasks/views/task_views.py:56-66`, used at `:277-281,577-581,702-706`), so for `POST /api/tasks/`, `PUT /api/tasks/{id}/` and `PATCH /api/tasks/{id}/` specifically, this rule reaches the client as `{"error": true, "errors": {"account": "..."}}` with `400` today, not a `500`. **Not every write path shares that backstop.** `PATCH /api/tasks/{id}/move/` -(`TaskMoveView.patch`, `tasks/views/kanban_views.py:253-318`) — the Kanban drag-and-drop endpoint — +(`TaskMoveView.patch`, `tasks/views/kanban_views.py:253-318`), the Kanban drag-and-drop endpoint, sets `task.stage`, `task.status` and `task.kanban_order` directly and calls `task.save()` with no `try/except` around it (`:310`) and without going through `TaskCreateSerializer` at all. `full_clean()` validates every field on the instance, not only the ones a given endpoint touched, so any model-level -violation it finds here — on this task's existing `account`/`opportunity`/`case`/`lead` combination, or -any other `clean_fields()` rule — surfaces as the unhandled `500` described above. This isn't only a +violation it finds here, on this task's existing `account`/`opportunity`/`case`/`lead` combination, or +any other `clean_fields()` rule, surfaces as the unhandled `500` described above. This isn't only a theoretical exposure through the parent-entity fields: `TaskStage.maps_to_status` (`tasks/models.py:257-263`) is a plain `CharField`, `blank=True, null=True`, with no `choices=` -constraint — free text an admin sets when configuring a pipeline stage, not validated against +constraint. Free text an admin sets when configuring a pipeline stage, not validated against `Task.STATUS_CHOICES` at all. When a task moves into a stage that has one set, `if stage.maps_to_status: task.status = stage.maps_to_status` (`kanban_views.py:297-298`) assigns it -straight onto `task.status` — a field that *does* have `choices=` at the model level. A single typo'd +straight onto `task.status`. A field that *does* have `choices=` at the model level. A single typo'd `maps_to_status` on one admin-configured stage turns every future move into that stage into an unhandled `500` for every user, until an admin notices and fixes the stage. This endpoint has no equivalent of the two-layer guard the main CRUD endpoints have. @@ -53,7 +53,7 @@ equivalent of the two-layer guard the main CRUD endpoints have. ## List tasks `GET /api/tasks/` (`TaskListView.get`, `tasks/views/task_views.py:233-235`) returns one paginated -list. Visibility is `visible_tasks_qs` (`tasks/access.py:43-48`) — admin sees every task in the org; +list. Visibility is `visible_tasks_qs` (`tasks/access.py:43-48`), admin sees every task in the org; anyone else sees only tasks they created or are assigned to. This is the same rule the detail view enforces (`tasks/access.py` module docstring: "the list has to agree with the detail view or the queue lies"). @@ -73,14 +73,14 @@ lies"). ``` (`tasks/views/task_views.py:130-209`.) `totals` is one aggregate query over the whole filtered, -visibility-scoped queryset, not the page — `overdue`/`due_today`/`due_this_week`/`no_due_date`/ +visibility-scoped queryset, not the page, `overdue`/`due_today`/`due_this_week`/`no_due_date`/ `unassigned` all additionally require the task still be open (`status != "Completed"`), so a task that finished late does not count as overdue (`:145-167`). `accounts_list`/`contacts_list` are the full org catalogues for the parent-entity picker; `?slim=true` omits both (`:203-209`). Filters, read directly from `request.query_params` (`tasks/views/task_views.py:86-129`): `title` (contains), `status` (exact), `priority` (exact), -`assigned_to` (repeatable id list), `tags` (id list), `search` (matches `title`, contains — not a +`assigned_to` (repeatable id list), `tags` (id list), `search` (matches `title`, contains, not a combined search across other fields the way [Leads](leads.md#list-leads) or [Contacts](contacts.md#list-contacts) define one), `due_date__gte`/`due_date__lte`, `created_at__gte`/`created_at__lte`, `account`, `opportunity`, `case`, `lead` (each an exact parent-id @@ -91,53 +91,53 @@ match), and `cf_` (custom field equals). `POST /api/tasks/` (`TaskListView.post`, `tasks/views/task_views.py:252-349`) accepts JSON or `multipart/form-data`, validated by `TaskCreateSerializer` (`tasks/serializer.py:315-389`; see [Fields](#fields)). On success (`200`): `{"error": false, "message": "Task Created Successfully"}`. A -validation failure returns `400` with `{"error": true, "errors": {...}}` — including the "at most one +validation failure returns `400` with `{"error": true, "errors": {...}}`, including the "at most one parent" rule and the model's own `full_clean()` backstop, both described [above](#tasksave-calls-full_clean). `account`, `opportunity`, `case` and `lead` are each scoped to the caller's org in -`TaskCreateSerializer.__init__` (`tasks/serializer.py:328-329`) — the comment there notes a plain +`TaskCreateSerializer.__init__` (`tasks/serializer.py:328-329`), the comment there notes a plain `ModelSerializer` would otherwise build each as a `PrimaryKeyRelatedField` over *every* row in the table, and that a task in one org was proven to accept another org's account, with the list endpoint then rendering that org's account name back to the requester. `created_by` is listed in -`Meta.fields` but is `read_only` (`:389`) — the comment explains that leaving it writable let a client +`Meta.fields` but is `read_only` (`:389`), the comment explains that leaving it writable let a client name any user in any org as a task's creator, which becomes a privilege-escalation vector the moment -the creator clause in `tasks/access.py` is consulted for anything (which it is — see +the creator clause in `tasks/access.py` is consulted for anything (which it is. See [Fields](#fields)). -`contacts`, `teams`, `assigned_to` and `tags` are **not** part of `TaskCreateSerializer` — the view +`contacts`, `teams`, `assigned_to` and `tags` are **not** part of `TaskCreateSerializer`: the view resolves each from the request body after the serializer succeeds, org-scoped, `json.loads`'d when sent as a string (the same shape [Cases](cases.md#create-a-case) uses). *Parsing* is consistent across -all four fields on every verb — tasks does not have the per-field inconsistency +all four fields on every verb. Tasks does not have the per-field inconsistency [Leads](leads.md#fields) documents. *Writing* is a different story, and the same data-loss trap [Cases](cases.md#fields) documents applies here verbatim: `POST` only adds (`task_views.py:282,296,308,322`); `PUT` calls `.clear()` on all four **unconditionally**, before checking whether the body mentioned them at all (`:582,597,610,625`), so a `PUT` that omits -`assigned_to` unassigns the task from everyone — including, via `visible_tasks_qs` +`assigned_to` unassigns the task from everyone, including, via `visible_tasks_qs` (`tasks/access.py:43-48`), removing it from those people's task lists entirely; `PATCH` clears a given M2M only when its key is present in the body (`:709-758`). ## Retrieve, update, delete `GET /api/tasks/{id}/` (`TaskDetailView.get`, `tasks/views/task_views.py:460-463`) org-scopes the -lookup (`get_task_or_404`, `tasks/access.py:51-65` — a malformed id or a cross-org task both answer -`404`) and enforces `assert_task_access` before building the response (`:365`) — raised, not returned, +lookup (`get_task_or_404`, `tasks/access.py:51-65`, a malformed id or a cross-org task both answer +`404`) and enforces `assert_task_access` before building the response (`:365`): raised, not returned, specifically because a `Response` object handed back through this method used to be wrapped in a second `Response` and crash with a `500` instead of answering `403` (comment at `:361-364`). The response nests the record under `task_obj`, alongside `attachments`, `comments`, `users_mention`, `assigned_data`, `custom_field_definitions`, `users` (admins see every active member; non-admins see -only other admins — the same shape [Cases](cases.md#list-cases) uses for its assignee picker), +only other admins, the same shape [Cases](cases.md#list-cases) uses for its assignee picker), `users_excluding_team` and `teams`. `PUT /api/tasks/{id}/` (`:545-650`) and `PATCH /api/tasks/{id}/` (`:667-781`) both enforce -`assert_task_access` — access here has only two tiers, not three the way a case does: **`access` +`assert_task_access`: access here has only two tiers, not three the way a case does: **`access` (read/write together) is admin · creator · assignee; `delete` is the narrower admin · creator** (`tasks/access.py:1-10`). The module docstring is explicit about why a task collapses read and write into one rule where a case does not: a case can have a *watcher*, someone following a ticket without -being handed it, and that extra read-only tier has no equivalent on a task — "everyone who may open one +being handed it, and that extra read-only tier has no equivalent on a task; "everyone who may open one may also work it." `contacts`/`teams`/`assigned_to`/`tags` are re-attached from the request body the same way `POST` does. Success: `{"error": false, "message": "Task updated Successfully"}`. -`DELETE /api/tasks/{id}/` (`:797-805`) calls `assert_task_delete_access` — admin or creator only, no +`DELETE /api/tasks/{id}/` (`:797-805`) calls `assert_task_delete_access`: admin or creator only, no assignee exception, same reasoning as [Cases](cases.md#retrieve-update-delete): being assigned a task is a reason to work it, not to erase it. @@ -147,44 +147,44 @@ The same shape as [Cases](cases.md#comments-and-attachments): `POST /api/tasks/{ (`TaskDetailView.post`, `tasks/views/task_views.py:481-528`) adds a `comment` and/or a `task_attachment` file to an existing task, gated by `assert_task_access`. The comment on this method notes a real trap: validating the comment through `CommentSerializer` fails *silently* here, because -that serializer requires `object_id` and `org`, neither of which the client sends — `is_valid()` +that serializer requires `object_id` and `org`, neither of which the client sends; `is_valid()` returns `False` and the intended write is simply skipped, so the view constructs the `Comment` directly with Django's ORM instead of going through the serializer (`:488-500`). `PUT`/`PATCH`/`DELETE /api/tasks/comment/{id}/` (`TaskCommentView`, `:808-946`) edit or remove an -existing comment — admin, or the comment's own author (`request.profile == obj.commented_by`, a +existing comment: admin, or the comment's own author (`request.profile == obj.commented_by`, a `Profile`-to-`Profile` comparison, correct). **`PUT` cannot, in practice, edit a comment.** It builds `CommentSerializer(obj, data=params)` with no `partial=True` (`:851`), and `CommentSerializer.Meta.fields` includes the plain (not `read_only`) model fields `object_id` and `org` -(`common/serializer.py:239-250`) — a body of just `{"comment": "..."}`, everything the documented +(`common/serializer.py:239-250`): a body of just `{"comment": "..."}`, everything the documented `TaskCommentEditSwaggerSerializer` contract asks for, fails validation on the missing `object_id`/`org` and answers `400`. Only `.patch()` (`partial=True`, `:896`) can actually change a comment's text. Separately, because `object_id`/`org` genuinely are writable on `PATCH`, a comment's own author can supply both there to repoint the comment at a different record, including one in another org or one -they cannot otherwise read — `Comment.clean()` (`common/models.py:299-314`) only catches the mismatched +they cannot otherwise read, `Comment.clean()` (`common/models.py:299-314`) only catches the mismatched case (`org` doesn't match the new target's `org`), and only because `Comment.save()` calls `self.full_clean()` (`:316-318`), which turns that mismatch into an unhandled `500` rather than a `400` (same failure mode as [above](#tasksave-calls-full_clean), on a different model); a *matching* cross-org -pair saves without error. Neither behavior is fixed here — reported per this documentation task's +pair saves without error. Neither behavior is fixed here, reported per this documentation task's scope. The identical `PUT`/`PATCH` pattern exists on [Cases](cases.md#comments-and-attachments)' own comment-edit endpoint too. `DELETE /api/tasks/attachment/{id}/` (`TaskAttachmentView.delete`, `:949-1000`) is org-scoped (`.filter(pk=pk, org=request.profile.org)`, `:975-977`) and its ownership check compares -`request.profile.user_id` to `self.object.created_by_id` (`:987`) — both fixed here. The comment on +`request.profile.user_id` to `self.object.created_by_id` (`:987`), both fixed here. The comment on this method documents that both defects (an unscoped lookup across every org's attachments, and a `Profile`-to-`User` comparison that silently made the endpoint admin-only) were proven live before the -fix — see [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks) +fix. See [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks) for the current, cross-app state of the same defect pattern in other modules. ## Boards and columns **Kanban *boards* are a separate feature from a task's own `stage`/`pipeline` fields, with their own -model family (`Board`, `BoardColumn`, `BoardTask`, `BoardMember` — `tasks/models.py:16-194`) and their +model family (`Board`, `BoardColumn`, `BoardTask`, `BoardMember`, `tasks/models.py:16-194`) and their own endpoints mounted at `/api/boards/...`, not under `/api/tasks/...`.** Confirmed in `backend/common/app_urls/__init__.py:26`: `path("boards/", include((board_urlpatterns, "api_boards")))`, where `board_urlpatterns` is imported from `tasks/urls.py:58-75`. A `BoardTask` card is not a `Task` -row — it has its own title/description/priority/assignees and only optionally references an +row. It has its own title/description/priority/assignees and only optionally references an `account`/`contact`/`opportunity` for context (`tasks/models.py:116-195`); the `/api/tasks/kanban/`, `/api/tasks/pipelines/...`, `/api/tasks/pipelines/{pipeline_id}/stages/` and `/api/tasks/stages/{id}/` endpoints documented implicitly above (via `stage` in [Fields](#fields)) operate on real `Task` rows and @@ -193,33 +193,33 @@ are a different Kanban system entirely. Access to a board is per-board membership, not org-wide: `Board.owner` plus `BoardMember` rows carry a `role` of `owner`/`admin`/`member` (`tasks/models.py:43-81`). Every endpoint that acts on an *existing* board checks membership before doing anything (the one exception is creating a board in the first -place — `POST /api/boards/` requires nothing beyond org membership, since there's no board to be a +place; `POST /api/boards/` requires nothing beyond org membership, since there's no board to be a member of yet); some further gate by role: | Endpoint | Who | | --- | --- | | `GET /api/boards/` | Any authenticated member of the org; results are owner-or-member boards only (`board_views.py:82-89`) | | `POST /api/boards/` | Any org member; creates the board and, by default, three starter columns (`:126-157`) | -| `GET /api/boards/{id}/` | Board owner or member (`404` otherwise — not `403`, `:165-192`) | +| `GET /api/boards/{id}/` | Board owner or member (`404` otherwise, not `403`, `:165-192`) | | `PUT/PATCH /api/boards/{id}/` | Board `owner` or `admin` role (`:206-271`) | | `DELETE /api/boards/{id}/` | Board owner only (`:282-299`) | | `GET /api/boards/{board_id}/columns/` | Board owner or member (`:316-329`) | | `POST /api/boards/{board_id}/columns/` | Board `owner` or `admin` role (`:355-367`) | | `GET/POST /api/boards/columns/{column_id}/tasks/` | Any board member (no role check, `:413-455`) | | `PUT /api/boards/tasks/{id}/` | Any board member (no role check, `:490-503`) | -| `DELETE /api/boards/tasks/{id}/` | Any board member — the role check lists `owner`/`admin`/`member`, which is every role that can exist (`:570-583`) | +| `DELETE /api/boards/tasks/{id}/` | Any board member, the role check lists `owner`/`admin`/`member`, which is every role that can exist (`:570-583`) | **A malformed board/column/card id is a `500`, not a `404`, on every endpoint in this table.** Every lookup in `board_views.py` uses Django's `get_object_or_404` against a UUID primary key -(`:167,319,358,416,445,493,573`) — and `get_object_or_404` only catches the model's `DoesNotExist`, not +(`:167,319,358,416,445,493,573`), and `get_object_or_404` only catches the model's `DoesNotExist`, not the `ValidationError`/`ValueError` a non-UUID string raises against a UUID field. This is the exact bug class `get_task_or_404`/`get_case_or_404` (`tasks/access.py:51-65`, `cases/access.py:57-71`) exist to -prevent on the rest of this API; boards never adopted that helper. Not fixed here — reported per this +prevent on the rest of this API; boards never adopted that helper. Not fixed here, reported per this documentation task's scope. **There is no endpoint to update or delete a single column once created.** `board_urlpatterns` -(`tasks/urls.py:58-75`) defines only `GET`/`POST /api/boards/{board_id}/columns/` — a list-and-create -route — with no `columns/{id}/` detail path at all. A column's name, color, order or WIP `limit` cannot +(`tasks/urls.py:58-75`) defines only `GET`/`POST /api/boards/{board_id}/columns/`, a list-and-create +route, with no `columns/{id}/` detail path at all. A column's name, color, order or WIP `limit` cannot be changed, and a column cannot be removed, through this API today. **`GET /api/boards/{id}/` does not return each column's cards.** `BoardSerializer.columns` uses @@ -229,7 +229,7 @@ per column, not the cards themselves. To read the actual cards, call nests nested `BoardTaskSerializer` cards under each column (`board_views.py:334-343`). `PUT /api/boards/tasks/{id}/` (`BoardTaskDetailView.put`, `board_views.py:490-559`) is also how a card -moves between columns: send `column` (validated to be a column of the same board — a column id from +moves between columns: send `column` (validated to be a column of the same board, a column id from another board, or from another org's board by construction, is a `400`, not a silent no-op, `:512-529`) and/or `order`; the view renumbers both the source and target columns to a dense `0..n-1` ordering after the move so drag positions survive a reload (`_resequence_column`, `:22-48`). @@ -239,10 +239,10 @@ write may set `title`, `description`, `order`, `priority`, `due_date`, and assig write-only `assigned_to_ids`, applied by the view after save and org-filtered). Everything else is locked down, by two different mechanisms: `id`, `created_at`, `updated_at`, `completed_at`, `org`, `column`, `created_by`, `updated_by`, `contact` and `opportunity` are all in `read_only_fields` -(`:67-78`) — `column` because moving a card is the dedicated `PUT` flow above, and the CRM-link FKs +(`:67-78`): `column` because moving a card is the dedicated `PUT` flow above, and the CRM-link FKs because, unfiltered, they exposed the same cross-org `PrimaryKeyRelatedField` gap `TaskCreateSerializer` was hardened against. `account` reaches the same outcome a different way: it's -overridden as a plain `SerializerMethodField()` (`:60`), not listed in `read_only_fields` at all — +overridden as a plain `SerializerMethodField()` (`:60`), not listed in `read_only_fields` at all, overriding a model FK with a method field removes it from the write surface just as effectively, per the field's own comment (`:82-84`). @@ -254,20 +254,20 @@ per the field's own comment (`:82-84`). | Field | Type | Required | Notes | | --- | --- | --- | --- | | `title` | string, max 200 | **required** | Explicitly forced `required=True` in `__init__` (`:321`), matching the model | -| `status` | one of `Task.STATUS_CHOICES` | **required** | `New`, `In Progress`, `Completed` — a different vocabulary from `Case.status`, see [Cases: Fields](cases.md#fields) | -| `priority` | one of `Task.PRIORITY_CHOICES` | **required** | `Low`, `Medium`, `High` — a different vocabulary from `Case.priority` | +| `status` | one of `Task.STATUS_CHOICES` | **required** | `New`, `In Progress`, `Completed`, a different vocabulary from `Case.status`, see [Cases: Fields](cases.md#fields) | +| `priority` | one of `Task.PRIORITY_CHOICES` | **required** | `Low`, `Medium`, `High`, a different vocabulary from `Case.priority` | | `due_date` | date | optional | | | `description` | text | optional | | -| `account` | uuid | optional | Org-scoped; at most one of `account`/`opportunity`/`case`/`lead` may be set — see [above](#tasksave-calls-full_clean) | +| `account` | uuid | optional | Org-scoped; at most one of `account`/`opportunity`/`case`/`lead` may be set. See [above](#tasksave-calls-full_clean) | | `opportunity` | uuid | optional | Same one-parent rule | | `case` | uuid | optional | Same one-parent rule | | `lead` | uuid | optional | Same one-parent rule | -| `created_by` | — | **read-only** | Listed in `Meta.fields`, but server-derived; see [Create a task](#create-a-task) | +| `created_by` |. | **read-only** | Listed in `Meta.fields`, but server-derived; see [Create a task](#create-a-task) | Not part of `TaskCreateSerializer`, but accepted in the same request body: `contacts`, `teams`, -`assigned_to`, `tags` (each a list of ids, org-scoped — see [above](#retrieve-update-delete) for how +`assigned_to`, `tags` (each a list of ids, org-scoped. See [above](#retrieve-update-delete) for how *writing* them diverges across verbs); plus `task_attachment` (a multipart file) and `custom_fields` -(an object). Neither of the last two goes through the serializer either — the view reads +(an object). Neither of the last two goes through the serializer either. The view reads `custom_fields` straight off `request.data`, validates it against the org's `CustomFieldDefinition` rows, and passes the cleaned result to `serializer.save(custom_fields=...)` on all three write verbs (`task_views.py:256-276,556-574,681-699`); `task_attachment` is read off @@ -278,15 +278,15 @@ the slimmer `TaskListSerializer`, which additionally drops `description`, `custo and `teams` (compare `TaskSerializer.Meta.fields`, `tasks/serializer.py:287-309`, against `TaskListSerializer.Meta.fields`, `:256-271`). **Two fields declared on `TaskSerializer` never actually reach the client.** `task_attachment` and `task_comments` are declared as nested, read-only serializer -fields (`:280-281`, listed in `Meta.fields` at `:306-307`) — but `Task` has no attribute by either name. +fields (`:280-281`, listed in `Meta.fields` at `:306-307`), but `Task` has no attribute by either name. There is no `GenericRelation` defined anywhere in this codebase (`grep -rn "GenericRelation"` outside the virtualenv returns nothing), so DRF's attempt to read `task_attachment`/`task_comments` off the instance raises `AttributeError`, which a read-only field with no explicit default converts into -`SkipField` — the keys are silently omitted from every response, not emitted empty. The comments and +`SkipField`: the keys are silently omitted from every response, not emitted empty. The comments and attachments a client actually needs come from the separate top-level `attachments`/`comments` keys -`TaskDetailView.get` builds independently (`:421-430`, [above](#retrieve-update-delete)) — a field +`TaskDetailView.get` builds independently (`:421-430`, [above](#retrieve-update-delete)), a field being present in `Meta.fields` is not proof it is ever emitted; check what the model can actually resolve. **`stage` and `kanban_order` are not returned by either `GET`, and are not in `Meta.fields` -on either serializer at all** — they exist only on `TaskKanbanCardSerializer` +on either serializer at all**. They exist only on `TaskKanbanCardSerializer` (`:519-540`), which backs the Kanban-specific endpoints (`/api/tasks/kanban/`, `/api/tasks/{id}/move/`), not the create/update endpoints this page covers. diff --git a/docs/api/tokens-and-api-keys.md b/docs/api/tokens-and-api-keys.md index 45785ae0..1150459c 100644 --- a/docs/api/tokens-and-api-keys.md +++ b/docs/api/tokens-and-api-keys.md @@ -11,12 +11,12 @@ and [Security hardening](../self-hosting/security-hardening.md#token-surfaces). A personal access token (`bcrm_pat_…`, `PersonalAccessToken` in `backend/common/models.py:854-907`) authenticates as the profile that created it and inherits that profile's role and org in full. It -carries a `scopes` field, but nothing in the request-handling code enforces it — read the model's +carries a `scopes` field, but nothing in the request-handling code enforces it. Read the model's own comment before assuming otherwise: ```python # 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. ``` @@ -24,7 +24,7 @@ own comment before assuming otherwise: restrict what the token can do today. `GET /api/profile/tokens/` (`PersonalAccessTokenListCreateView`, -`backend/common/views/pat_views.py:33-47`) lists the caller's own tokens — filtered on +`backend/common/views/pat_views.py:33-47`) lists the caller's own tokens, filtered on `profile=request.profile` as well as `org=request.profile.org`, since this table carries no RLS policy of its own (it's looked up by `token_hash` before any org context exists, so the explicit filter here is the only tenant boundary, not a belt over RLS's braces): @@ -48,7 +48,7 @@ filter here is the only tenant boundary, not a belt over RLS's braces): ``` `token_hash` is never serialized (`PersonalAccessTokenListSerializer`, -`backend/common/serializer.py:1054-1067`, whose `fields` is the exhaustive list above) — only +`backend/common/serializer.py:1054-1067`, whose `fields` is the exhaustive list above), only `token_prefix` identifies a token in any listing, on this endpoint or the admin one below. ## Creating and revoking @@ -60,7 +60,7 @@ filter here is the only tenant boundary, not a belt over RLS's braces): ``` `name` is required (non-empty, at most 255 characters); `scopes`, if present, must be a list of -strings (at most 32); `expires_at` is optional, but if present must be in the future — +strings (at most 32); `expires_at` is optional, but if present must be in the future; `validate_expires_at` rejects a past timestamp with `"expires_at must be in the future."` (`backend/common/serializer.py:1092-1097`). On success (`201`): @@ -79,13 +79,13 @@ strings (at most 32); `expires_at` is optional, but if present must be in the fu } ``` -The `token` field is the only time the raw value is ever returned — it is generated, hashed, and +The `token` field is the only time the raw value is ever returned. It is generated, hashed, and the plaintext discarded; there is no way to retrieve it again later, only to revoke it and create a new one. A validation failure returns `400` with `{"error": true, "errors": {...}}`, the same shape described in [Errors](errors.md#validation-errors). -`DELETE /api/profile/tokens/{id}/` (`PersonalAccessTokenDetailView`, `:72-86`) revokes a token — -sets `revoked_at`, it does not delete the row. It is scoped to `org=request.profile.org` **and** +`DELETE /api/profile/tokens/{id}/` (`PersonalAccessTokenDetailView`, `:72-86`) revokes a token. +Sets `revoked_at`, it does not delete the row. It is scoped to `org=request.profile.org` **and** `profile=request.profile` in the same lookup, so another user's token id returns `404`, not `403`: revoking is unavailable to anyone but the token's own owner, and the lookup gives no sign a different user's token even exists at that id. Revoking an already-revoked token is a no-op that @@ -94,8 +94,8 @@ still returns `200`. ## Organization token oversight `GET /api/org/tokens/` (`OrgAccessTokenListView`, `backend/common/views/pat_views.py:89-152`) is a -separate, admin-only endpoint — `permission_classes = (IsAuthenticated, HasOrgContext, -IsOrgAdmin)` — that lists every personal access token in the org, not just the caller's own. It is +separate, admin-only endpoint: `permission_classes = (IsAuthenticated, HasOrgContext, +IsOrgAdmin)`: that lists every personal access token in the org, not just the caller's own. It is deliberately kept apart from `profile/tokens/` above rather than folded into it with an admin-widened filter, so the "a user manages only their own tokens" guard on the self-service endpoint is never the thing that has to be loosened to support oversight: @@ -123,7 +123,7 @@ endpoint is never the thing that has to be loosened to support oversight: `owner.is_active: false` with `is_live: true` is the specific case this view exists to surface: a token belonging to a profile that has since been deactivated. Deactivating a profile invalidates its -tokens as a side effect, not through any separate revocation step — +tokens as a side effect, not through any separate revocation step, `resolve_valid_pat` (`backend/common/pat_auth.py:29-46`) checks `pat.profile.is_active` (and `pat.org.is_active`) on every single use, so the token in the example above cannot actually authenticate again unless the owner's profile is reactivated first. `"orphaned"` in `totals` counts @@ -132,7 +132,7 @@ admin doing offboarding cleanup would revoke explicitly with the endpoint below. `DELETE /api/org/tokens/{id}/` (`OrgAccessTokenDetailView`, `:155-176`) revokes any token in the admin's own org, filtered only on `org=request.profile.org` (no `profile=` filter, unlike the -self-service delete above) — an admin can revoke a colleague's token, deactivated or not. A pk from +self-service delete above). An admin can revoke a colleague's token, deactivated or not. A pk from another org still `404`s. Revoking an already-revoked token is a no-op `200`, same as the self-service endpoint. @@ -140,11 +140,11 @@ self-service endpoint. Each org has exactly one API key (`Org.api_key`), managed through `GET`/`POST /api/org/api-key/` (`OrgApiKeyView`, `backend/common/views/organization_views.py:244-326`), admin-only in both -directions — checked by hand in `_admin_org_or_error` (`:255-275`) rather than an `IsOrgAdmin` +directions, checked by hand in `_admin_org_or_error` (`:255-275`) rather than an `IsOrgAdmin` permission class, but the same rule: `request.profile.role == "ADMIN"` or `request.profile.is_organization_admin`. -Unlike a personal access token, this key doesn't authenticate as any particular person — a request +Unlike a personal access token, this key doesn't authenticate as any particular person, a request bearing it in the `Token` header resolves to the org's first active `ADMIN` profile (`backend/common/middleware/get_company.py:157-183`), so a leaked key is equivalent to a leaked admin session for that org, not one user's session, and there's no way to scope it down. The org is @@ -157,7 +157,7 @@ ever read or rotate their own org's key. {"error": false, "api_key": ""} ``` -`POST /api/org/api-key/` rotates it — generates a new key and immediately overwrites the old one: +`POST /api/org/api-key/` rotates it, generates a new key and immediately overwrites the old one: ```json { diff --git a/docs/architecture/authentication.md b/docs/architecture/authentication.md index d4c9b8fe..55acd906 100644 --- a/docs/architecture/authentication.md +++ b/docs/architecture/authentication.md @@ -1,8 +1,8 @@ # Authentication -BottleCRM has three separate credential types — a short-lived JWT for interactive clients, a +BottleCRM has three separate credential types, a short-lived JWT for interactive clients, a personal access token for agents and scripts, and a per-org API key for server-to-server -integrations — and this page describes how each one proves identity and how the server decides +integrations, and this page describes how each one proves identity and how the server decides what it's allowed to do. The rule underneath all three is the same one stated in [Multi-tenancy and RLS](multi-tenancy-and-rls.md): identity, org, and role are facts the server derives and verifies, never values a client supplies. Nothing in a request body, query string, or @@ -25,12 +25,12 @@ SIMPLE_JWT = { } ``` -Access tokens live one hour; refresh tokens live 14 days. Both are signed with `SECRET_KEY` itself -— there is no separate JWT signing key setting anywhere in this codebase, so rotating `SECRET_KEY` +Access tokens live one hour; refresh tokens live 14 days. Both are signed with `SECRET_KEY` itself. +There is no separate JWT signing key setting anywhere in this codebase, so rotating `SECRET_KEY` invalidates every outstanding token immediately. See [Security hardening](../self-hosting/security-hardening.md#secrets) for the operational consequences of that. `rest_framework_simplejwt.token_blacklist` is in `INSTALLED_APPS` -specifically so that `RefreshToken.blacklist()` exists to call — without that app installed, +specifically so that `RefreshToken.blacklist()` exists to call: without that app installed, `BLACKLIST_AFTER_ROTATION` would be a silent no-op, which is exactly the kind of failure mode this codebase's own comment on the `INSTALLED_APPS` entry calls out. @@ -53,16 +53,16 @@ radii compare. - `user_email`, `user_name` (derived from the email, since `User` has no first/last name fields), `user_profile_pic` -- `org_id`, `org_name` — only when an org is present; a token minted before the caller has picked +- `org_id`, `org_name`, only when an org is present; a token minted before the caller has picked an org (see [Organization switching](#organization-switching)) carries neither -- `org_settings` — `default_currency`, `currency_symbol`, `default_country` -- `role` — only when a `Profile` was supplied, i.e. only once an org is known +- `org_settings`: `default_currency`, `currency_symbol`, `default_country` +- `role`, only when a `Profile` was supplied, i.e. only once an org is known `org_id` is the claim that matters most: it's what `GetProfileAndOrg` (`backend/common/middleware/get_company.py`) reads to resolve `request.org`, and by extension it's -what `RequireOrgContext` uses to set the PostgreSQL session variable every RLS policy checks — see +what `RequireOrgContext` uses to set the PostgreSQL session variable every RLS policy checks. See [Overview](overview.md#request-lifecycle). Critically, the middleware does not simply trust the -claim: it re-validates it against an active `Profile` row on every single request — +claim: it re-validates it against an active `Profile` row on every single request: ```python profile = Profile.objects.select_related("org").get( @@ -70,11 +70,11 @@ profile = Profile.objects.select_related("org").get( ) ``` -— so a token minted while a user was a member of an org keeps working only as long as that +So a token minted while a user was a member of an org keeps working only as long as that membership stays active. If the membership is revoked after the token was issued, this lookup raises `Profile.DoesNotExist` and the middleware turns that into a 403 asking the user to log in again, rather than trusting a claim that's gone stale. `role` in the token is a display -convenience, not the source of truth for authorization — every permission check in +convenience, not the source of truth for authorization, every permission check in [Permissions and roles](permissions-and-roles.md) reads `request.profile.role` fresh from the database-backed `Profile` the middleware just re-validated, not from the JWT payload. @@ -83,7 +83,7 @@ database-backed `Profile` the middleware just re-validated, not from the JWT pay The old documentation for this project asserted refresh-token rotation and server-side tracking without pointing at code. Here is what the code actually does. `POST /api/auth/refresh-token/` (`OrgAwareTokenRefreshView`, `backend/common/views/auth_views.py`, wired at -`backend/common/urls.py:58-62`) is the only refresh endpoint — there's no separate route using +`backend/common/urls.py:58-62`) is the only refresh endpoint. There's no separate route using simplejwt's stock `TokenRefreshView`, so the `ROTATE_REFRESH_TOKENS`/`BLACKLIST_AFTER_ROTATION` settings shown above describe the intent, but the actual rotation logic is hand-written in this view rather than delegated to simplejwt's default serializer: @@ -95,17 +95,17 @@ with transaction.atomic(): ``` The presented refresh token is decoded and validated, its `org_id` claim (if any) is re-checked -against an active `Profile` exactly as in [Claims](#claims) above — a revoked membership gets a 403 -here too, not a fresh token — and then, in one atomic transaction, the presented token is +against an active `Profile` exactly as in [Claims](#claims) above. A revoked membership gets a 403 +here too, not a fresh token, and then, in one atomic transaction, the presented token is blacklisted and a replacement is minted carrying the same org context. Both writes share a transaction specifically so a failure can never leave the caller tokenless while the old token remains usable. This is real rotation with real server-side state: `RefreshToken.blacklist()` writes to simplejwt's own `OutstandingToken`/`BlacklistedToken` tables (from the `token_blacklist` app), which is what a stolen refresh token being replayed after the legitimate -client has already refreshed would hit — the blacklisted token fails validation on reuse. Those +client has already refreshed would hit. The blacklisted token fails validation on reuse. Those bookkeeping rows aren't kept forever; `flush_expired_refresh_tokens` (`backend/common/tasks.py`) deletes `OutstandingToken` rows once their own `expires_at` has passed, -scheduled nightly — see [Background jobs](background-jobs.md). +scheduled nightly. See [Background jobs](background-jobs.md). An expired or already-blacklisted refresh token fails with `TokenError`, which this view turns into a 401. A refresh token for a `User` whose account has since been deactivated (`user.is_active`) @@ -116,12 +116,12 @@ also fails, explicitly, with a 403. A signed-in user can belong to more than one org, and a single JWT only ever carries one `org_id`. `POST /api/auth/switch-org/` (`OrgSwitchView`) is how a client moves to a different one: it requires only `IsAuthenticated` (not `HasOrgContext`), because it has to work for a token that has -no org claim at all yet — the state a fresh Google OAuth login is in, since +no org claim at all yet, the state a fresh Google OAuth login is in, since `GoogleOAuthCallbackView`/`GoogleIdTokenView` both mint their initial token with `org=None` and leave org selection to a separate step. -The view re-validates membership the same way the refresh endpoint does — -`Profile.objects.get(user=request.user, org_id=org_id, is_active=True)` — and 403s +The view re-validates membership the same way the refresh endpoint does; +`Profile.objects.get(user=request.user, org_id=org_id, is_active=True)`, and 403s (with an audit-logged `permission_denied` entry) if the caller has no active profile in the requested org. It then optionally retires the refresh token the client is switching away from: @@ -131,30 +131,30 @@ if str(token.get("user_id")) != str(request.user.id): token.blacklist() ``` -That ownership check is not incidental — without it, `refresh` would let any authenticated caller +That ownership check is not incidental. Without it, `refresh` would let any authenticated caller blacklist *any* refresh token they could observe, turning a convenience parameter into a remote logout for someone else's session. Retiring the old token is optional by design (existing clients don't all send it, and failing the switch over a missing optional field would be worse than letting one token expire on its own 14-day schedule), but when it is sent and does belong to the caller, it stops working immediately rather than staying valid against the previous org for the rest of its -life. The org switch itself — retiring the old token and minting the replacement — happens inside +life. The org switch itself, retiring the old token and minting the replacement, happens inside one transaction for the same reason the refresh endpoint's does. ## Personal access tokens Personal access tokens (`bcrm_pat_…`, `PersonalAccessToken` in `backend/common/models.py`) are -for agents and scripts — the MCP server integration in particular — that need to authenticate as a +for agents and scripts, the MCP server integration in particular, that need to authenticate as a specific user without going through the JWT/OAuth flow on every call. A token is generated once (`PersonalAccessToken.generate(profile, name, ...)`), shown to the caller exactly once, and stored only as a SHA-256 hash (`token_hash`); the plaintext is not recoverable from the database afterward. Resolution happens twice per request, deliberately: `GetProfileAndOrg` resolves the PAT first (so -`request.org` — and, downstream, the RLS context — is set before `RequireOrgContext` runs), and +`request.org` (and, downstream, the RLS context) is set before `RequireOrgContext` runs), and DRF's `PATAuthentication` class (`backend/common/pat_auth.py`) reuses that same resolved token (stashed as `request._pat`) rather than looking it up a second time. Both share one validation function, `resolve_valid_pat`, which checks the token isn't revoked or expired -(`PersonalAccessToken.is_valid()`) and that both the owning profile and its org are still active — +(`PersonalAccessToken.is_valid()`) and that both the owning profile and its org are still active, so deactivating a profile cuts off its personal access tokens immediately, with no separate revocation step. @@ -164,14 +164,14 @@ restricts anything: ```python # 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. ``` -Treat a personal access token as exactly as powerful as the user who created it — an admin's PAT +Treat a personal access token as exactly as powerful as the user who created it. An admin's PAT can do everything that admin can do through the UI. Self-service management lives at -`profile/tokens/` (a user manages only their own); admin oversight of every token in the org — -including a deactivated colleague's — is a deliberately separate, admin-only endpoint at +`profile/tokens/` (a user manages only their own); admin oversight of every token in the org, +including a deactivated colleague's, is a deliberately separate, admin-only endpoint at `org/tokens/`, so the self-service guard is never the thing that has to be loosened to support oversight. See [Security hardening](../self-hosting/security-hardening.md#token-surfaces) for that distinction in more depth. @@ -182,7 +182,7 @@ Each `Org` has exactly one API key (`Org.api_key`, generated at creation), manag `GET`/`POST /api/org/api-key/` (`OrgApiKeyView`), admin-only in both directions. Unlike a personal access token, this key doesn't authenticate as any particular person: `GetProfileAndOrg`'s `_process_api_key_auth` resolves a request bearing this key (in a `Token` header) to the org's -**first active `ADMIN` profile** — +**first active `ADMIN` profile**: ```python profile = Profile.objects.filter( @@ -190,15 +190,15 @@ profile = Profile.objects.filter( ).first() ``` -— which makes a leaked org API key equivalent to a leaked admin session for that org, not a leaked +Which makes a leaked org API key equivalent to a leaked admin session for that org, not a leaked individual user session. There's no way to scope it down to less than full admin, so it's the highest-privilege of the three credential types this page describes. Rotating it -(`POST /api/org/api-key/`) invalidates the previous key immediately — the response is explicit +(`POST /api/org/api-key/`) invalidates the previous key immediately. The response is explicit that the old key is no longer valid. It's excluded from every nested API representation (`OrganizationSerializer` never embeds it) and only ever served by this one endpoint. One piece of related configuration to be aware of: `GOOGLE_REDIRECT_URI` is defined in -`backend/crm/settings.py:360` but is dead — grep the backend and it's the only place that name +`backend/crm/settings.py:360` but is dead, grep the backend and it's the only place that name appears. The actual redirect URI used in the Google OAuth code exchange (`GoogleOAuthCallbackView.post`) comes from the client's request body on each call (`request.data.get("redirect_uri")`), not from this setting. Don't spend time configuring diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md index 35120a69..c60d2497 100644 --- a/docs/architecture/background-jobs.md +++ b/docs/architecture/background-jobs.md @@ -2,7 +2,7 @@ This page is for anyone writing a new Celery task. If you're operating a deployment rather than writing code, see [Email and Celery](../self-hosting/email-and-celery.md#rls-in-background-tasks) -instead — it covers running the worker and beat processes and has the same rule below from the +instead. It covers running the worker and beat processes and has the same rule below from the operator's side. This page exists because the rule is easy to get right by copying an existing task and easy to get wrong by writing one from scratch. @@ -11,7 +11,7 @@ task and easy to get wrong by writing one from scratch. `backend/crm/celery.py` defines the app: `Celery("crm")`, configured from Django settings under the `CELERY_` namespace, so `CELERY_BROKER_URL` and `CELERY_RESULT_BACKEND` (`backend/crm/settings.py`, both defaulting to `redis://localhost:6379/0`) are what it actually -connects with — there's no separate Celery-only config file. `app.autodiscover_tasks()` finds +connects with. There's no separate Celery-only config file. `app.autodiscover_tasks()` finds `tasks.py` in each app; a second call, `app.autodiscover_tasks(related_name="celery_tasks")`, exists specifically because the `tasks` Django app's own task module is named `celery_tasks.py` to avoid colliding with the app's own name. @@ -19,9 +19,9 @@ to avoid colliding with the app's own name. Two processes run against this one app definition, and neither is required for the API itself to answer requests: -- **`celery -A crm worker`** executes tasks queued by the API — welcome emails, magic-link +- **`celery -A crm worker`** executes tasks queued by the API, welcome emails, magic-link delivery, assignment notifications, and more. -- **`celery -A crm beat`** fires the schedule in `app.conf.beat_schedule` (ten entries — recurring +- **`celery -A crm beat`** fires the schedule in `app.conf.beat_schedule` (ten entries, recurring invoice generation, overdue-invoice checks, payment reminders, expired-estimate checks, stale-opportunity and goal-milestone scans, case SLA-breach scanning every five minutes, stale-timer cleanup, and nightly cleanup of read notifications and expired refresh-token @@ -33,13 +33,13 @@ commands and what each scheduled entry does operationally. ## Writing a task Celery workers never go through Django's HTTP middleware stack. `GetProfileAndOrg` and -`RequireOrgContext` — the two middleware classes that establish tenancy for a normal request, see -[Overview](overview.md#request-lifecycle) — simply never run for a task, so nothing sets the +`RequireOrgContext`: the two middleware classes that establish tenancy for a normal request, see +[Overview](overview.md#request-lifecycle). Simply never run for a task, so nothing sets the PostgreSQL session variable `app.current_org` on the worker's connection for you. A fresh worker connection has an empty context, and per RLS's fail-safe design (see [Multi-tenancy and RLS](multi-tenancy-and-rls.md#policies-and-org_scoped_tables)) an empty context makes every org-scoped query on a correctly-configured, non-superuser database role return zero -rows — not another org's rows, but not the rows you wanted either. +rows, not another org's rows, but not the rows you wanted either. The fix is one line, and it has to be the first thing a task that touches an org-scoped table does. `set_rls_context(org_id)`, defined in `backend/common/tasks.py`, sets the same session variable the @@ -62,10 +62,10 @@ def send_email_to_assigned_user(recipients, contact_id, org_id): ``` Any task queued from a view already has `org_id` available (`request.profile.org.id`) to pass along -as an argument — pass it, and call `set_rls_context` with it before the first query, every time. +as an argument. Pass it, and call `set_rls_context` with it before the first query, every time. A task that has to sweep every org rather than act on one record calls `set_rls_context` once per -org, inside the loop, immediately before that org's queries — for example +org, inside the loop, immediately before that org's queries, for example `cases.tasks.scan_for_breached_cases`: ```python @@ -77,12 +77,12 @@ for org in Org.objects.filter(id__in=list(org_ids)): logger.exception("Escalation scan failed for org=%s", org.id) ``` -Two kinds of task legitimately skip this call. First, a task whose table isn't org-scoped at all — +Two kinds of task legitimately skip this call. First, a task whose table isn't org-scoped at all: `flush_expired_refresh_tokens` operates on simplejwt's `OutstandingToken` table, which carries no `org_id` column and isn't in `ORG_SCOPED_TABLES`, so there's no per-org context to set in the first place. Second, a task that already sets context per-org inside a loop, as above, doesn't need it again outside the loop. What doesn't qualify as an exception is "the query looks like it filters by -something else" — see below. +something else". See below. ## The RLS rule @@ -97,7 +97,7 @@ than a cutoff: ```python @shared_task def purge_read_notifications(days=NOTIFICATION_PURGE_DAYS): - """... Runs once across all orgs — RLS does not need a per-org context + """... Runs once across 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) @@ -108,12 +108,12 @@ def purge_read_notifications(days=NOTIFICATION_PURGE_DAYS): ``` The docstring's reasoning doesn't hold: whether a *query condition* is org-specific has nothing to -do with whether the *table* is org-scoped, and `notification` is — it's in `ORG_SCOPED_TABLES` +do with whether the *table* is org-scoped, and `notification` is. It's in `ORG_SCOPED_TABLES` (`backend/common/rls/__init__.py`) and carries the same `FORCE ROW LEVEL SECURITY` policy as every other table on that list. This task never calls `set_rls_context`, so on the non-superuser database role this project requires, it runs with an empty `app.current_org`, and its `DELETE` matches zero rows across every org, silently, on every scheduled run. It doesn't fail loudly and it doesn't -purge the wrong org's data — it just never purges anything, which is its own quietly bad outcome: +purge the wrong org's data. It just never purges anything, which is its own quietly bad outcome: the table grows unbounded while the schedule entry that's supposed to prevent that keeps reporting success. Read the code a task actually executes, not the comment sitting above it, before trusting either one. diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 864c1b6c..8be7f257 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -1,41 +1,41 @@ # Data model This page describes the shape every model in this codebase follows, the handful of ways core CRM -entities connect to each other, and a few conventions that are easy to assume rather than verify — +entities connect to each other, and a few conventions that are easy to assume rather than verify, which is exactly why they're written down here with the file and line that makes each one true. ## Core entities -- **Lead** (`backend/leads/models.py`) — a prospect not yet qualified into a customer. +- **Lead** (`backend/leads/models.py`), a prospect not yet qualified into a customer. `LeadPipeline`/`LeadStage` give each org its own configurable Kanban columns. -- **Account** (`backend/accounts/models.py`) — a company or customer. `AccountEmail`/ +- **Account** (`backend/accounts/models.py`), a company or customer. `AccountEmail`/ `AccountEmailLog` support account-linked inbound mailboxes. -- **Contact** (`backend/contacts/models.py`) — a person, optionally linked to an `Account`. -- **Opportunity** (`backend/opportunity/models.py`) — a sales deal in progress. +- **Contact** (`backend/contacts/models.py`), a person, optionally linked to an `Account`. +- **Opportunity** (`backend/opportunity/models.py`). A sales deal in progress. `OpportunityLineItem` holds quoted products; `StageAgingConfig` is per-org "how many days is too long in this stage" configuration; `SalesGoal` is a quota/target tracked against `Opportunity` data. -- **Case** (`backend/cases/models.py`) — a support ticket. By far the largest cluster of supporting +- **Case** (`backend/cases/models.py`), a support ticket. By far the largest cluster of supporting models: `CaseWatcher`, `CsatSurvey`, `Solution` (knowledge base), `CasePipeline`/`CaseStage`, `ReopenPolicy`, `EscalationPolicy`, `InboundMailbox`/`EmailMessage` (email-to-ticket), `RoutingRule`/`RoutingRuleState`, and `TimeEntry`. `Case.account` is a direct foreign key (not nullable through an intermediate table); Cases carry their own SLA fields (`sla_first_response_hours`, `sla_resolution_hours`, `first_response_at`, `resolved_at`) directly on the model. -- **Task** (`backend/tasks/models.py`) — a to-do item, with its own configurable +- **Task** (`backend/tasks/models.py`). A to-do item, with its own configurable `TaskPipeline`/`TaskStage`, and a separate `Board`/`BoardColumn`/`BoardTask`/`BoardMember` family for freeform Kanban boards distinct from the Task pipeline. -- **Invoice** (`backend/invoices/models.py`) — billing, alongside `Estimate`, `RecurringInvoice`, +- **Invoice** (`backend/invoices/models.py`): billing, alongside `Estimate`, `RecurringInvoice`, `Product`, `Payment`, and `InvoiceTemplate` in the same app. This app also owns the anonymous client-portal views (see [Multi-tenancy and RLS](multi-tenancy-and-rls.md#portal-tokens)). -- **Order** (`backend/orders/models.py`) — `Order`/`OrderLineItem`. +- **Order** (`backend/orders/models.py`), `Order`/`OrderLineItem`. ## Relationships The lead-to-customer path is a one-time conversion, not a standing foreign key. `leads.services.convert_lead_to_account(lead_obj, request, create_opportunity=True)` (`backend/leads/services.py`) runs as a side effect when a lead's `status` is updated to -`"converted"` through the normal lead update views (`backend/leads/views/lead_views.py`) — there is +`"converted"` through the normal lead update views (`backend/leads/views/lead_views.py`). There is no dedicated "convert" endpoint. It: 1. `get_or_create`s an `Account` (case-insensitive name match within the org). @@ -48,7 +48,7 @@ no dedicated "convert" endpoint. It: 5. Sets `lead_obj.status = "converted"`. There is deliberately no persisted link back from the created `Account`/`Contact`/`Opportunity` to -the originating `Lead` — `Lead`'s own model file documents this: earlier `converted_account`/ +the originating `Lead`, `Lead`'s own model file documents this: earlier `converted_account`/ `converted_contact`/`converted_opportunity`/`conversion_date` fields were removed because they were "never populated, conversion just sets status." If you need to trace a record back to the lead it came from, there's nothing in the schema to follow; only the account/contact name or email is @@ -68,19 +68,19 @@ FK to `Account`, and `Case.contacts` is a many-to-many to `Contact`. Nearly every model in this codebase inherits `BaseModel` (`backend/common/base.py`), which via `AuditModel` (`backend/common/mixins.py`) provides: -- `id` — a `UUIDField` primary key (`default=uuid.uuid4`), not an auto-incrementing integer. -- `created_at`/`updated_at` — set automatically (`auto_now_add`/`auto_now`). -- `created_by`/`updated_by` — foreign keys to **`User`**, not `Profile`, set from +- `id`: a `UUIDField` primary key (`default=uuid.uuid4`), not an auto-incrementing integer. +- `created_at`/`updated_at`: set automatically (`auto_now_add`/`auto_now`). +- `created_by`/`updated_by`: foreign keys to **`User`**, not `Profile`, set from `crum.get_current_user()` in `BaseModel.save()`. This is worth being precise about: a permission check that compares `request.profile` directly to `some_object.created_by` is comparing a - `Profile` instance to a `User` foreign key and will always be `False` — see + `Profile` instance to a `User` foreign key and will always be `False`. See [Permissions and roles](permissions-and-roles.md#object-level-checks) for the bug class that causes and how to compare them correctly (`profile.user_id == obj.created_by_id`). Three models opt out of this entirely and inherit plain `models.Model` instead: `User` (`backend/common/models.py:34`), `MagicLinkToken` (`:524`), and `PortalAccessToken` (`:910`). All three still declare their own `id = models.UUIDField(default=uuid.uuid4, primary_key=True)` by -hand, so the UUID-primary-key convention holds even for them — but none of the three has +hand, so the UUID-primary-key convention holds even for them, but none of the three has `created_at`/`updated_by`/`created_by` fields, which makes sense for what they are: `User` predates and sits outside the org-membership model entirely (`Profile` is what's org-scoped), and `MagicLinkToken`/`PortalAccessToken` are short-lived, auth-bootstrap rows with no meaningful @@ -92,37 +92,37 @@ Org-scoped models add one more field: an `org` foreign key to `common.Org`, eith which and why both are valid as long as the table is also registered and migrated for RLS. **`Opportunity.stage` is a fixed enum; `Lead`, `Case`, and `Task` stages are configurable -per-org pipelines — these are not the same kind of field even though they sound similar.** -`Opportunity.stage` is a plain `CharField` over `common.utils.STAGES` — `PROSPECTING`, -`QUALIFICATION`, `PROPOSAL`, `NEGOTIATION`, `CLOSED_WON`, `CLOSED_LOST` — the same six stages for +per-org pipelines. These are not the same kind of field even though they sound similar.** +`Opportunity.stage` is a plain `CharField` over `common.utils.STAGES`: `PROSPECTING`, +`QUALIFICATION`, `PROPOSAL`, `NEGOTIATION`, `CLOSED_WON`, `CLOSED_LOST`: the same six stages for every organization, with no model backing a per-org column set. `Lead.stage`, `Case.stage`, and `Task.stage`, by contrast, are nullable foreign keys to a per-org `LeadStage`/`CaseStage`/ `TaskStage` row (itself belonging to a `LeadPipeline`/`CasePipeline`/`TaskPipeline`), so each org can define its own Kanban columns for leads, cases, and tasks. All three of those models also keep a separate, fixed-choices `status` field (`Lead.status`, `Case.status`, `Task.status`) alongside the -configurable `stage` FK — a stage's `maps_to_status` field is what keeps the two in sync when a +configurable `stage` FK. A stage's `maps_to_status` field is what keeps the two in sync when a record moves between stages. A deal in `Opportunity` has no equivalent second field, because it has no configurable stage to keep in sync with anything. -Several models — `Lead`, `Opportunity`, `Case` among them — carry a `custom_fields = JSONField()` +Several models (`Lead`, `Opportunity`, `Case` among them) carry a `custom_fields = JSONField()` whose values are validated against `common.CustomFieldDefinition`, a per-org schema extension mechanism that doesn't require a migration to add a field for one org. Finally, one validation gotcha worth knowing before you rely on `Model.clean()`: DRF's `ModelSerializer` calls `instance.save()`, not `instance.full_clean()`, so a model's `clean()` method is invoked through the API only if that model's own `save()` explicitly calls -`self.full_clean()` itself — most don't. `Lead.clean()`, `Opportunity.clean()`, and `Case.clean()` +`self.full_clean()` itself: most don't. `Lead.clean()`, `Opportunity.clean()`, and `Case.clean()` (`backend/cases/models.py:180`) all define real validation rules this way, and by default those rules are silently unenforced through the API, because none of `Lead.save()`, `Opportunity.save()`, or `Case.save()` calls `full_clean()`. A handful of other models do call it from their own `save()`, so their `clean()` rules reach the API as a Django -`ValidationError` — a 500 unless the view happens to catch it, not a clean DRF 400: `Task.save()` +`ValidationError`: a 500 unless the view happens to catch it, not a clean DRF 400: `Task.save()` (`backend/tasks/models.py:403`), and `Comment.save()`/`Attachments.save()` (`backend/common/models.py:316-318` and `:430-432`, both there specifically to enforce that a comment or attachment's `org` matches the org of whatever record it's attached to). There's no -blanket rule for which models do this — check the individual `save()` method rather than assuming. +blanket rule for which models do this, check the individual `save()` method rather than assuming. Where an unenforced `clean()` rule turned out to matter through the API, it's been reimplemented -directly in the serializer instead of relying on `full_clean()` — `opportunity/serializer.py`'s +directly in the serializer instead of relying on `full_clean()`, `opportunity/serializer.py`'s `validate()` duplicates the two rules from `Opportunity.clean()` for exactly this reason, per its own comment. If you add a `clean()` method to a model, verify whether that model's `save()` calls `full_clean()` before assuming the rule is enforced anywhere outside a unit test that calls diff --git a/docs/architecture/multi-tenancy-and-rls.md b/docs/architecture/multi-tenancy-and-rls.md index 133cc925..361108f1 100644 --- a/docs/architecture/multi-tenancy-and-rls.md +++ b/docs/architecture/multi-tenancy-and-rls.md @@ -3,7 +3,7 @@ This page is for anyone about to add an org-scoped model, or trying to understand why one query returned no rows when it should have returned some. It explains the tenancy model from the application-code side; [PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md) covers the -same mechanism from the operator's side — creating the database role, running `manage_rls`, and +same mechanism from the operator's side: creating the database role, running `manage_rls`, and proving isolation holds against real data. Read that page too before you deploy anything; this one is about what to do when you're writing a new model. @@ -12,13 +12,13 @@ is about what to do when you're writing a new model. Every tenant's data lives in the same database, the same tables, distinguished by an `org_id` column. Isolation is enforced twice, independently: -1. **In application code**, every queryset that touches an org-scoped table filters explicitly — - `Model.objects.filter(org=request.profile.org)` — and every `create()`/`serializer.save()` +1. **In application code**, every queryset that touches an org-scoped table filters explicitly, + `Model.objects.filter(org=request.profile.org)`, and every `create()`/`serializer.save()` passes `org=request.profile.org` rather than trusting a client-supplied value. 2. **In PostgreSQL**, via Row-Level Security. Each org-scoped table carries a policy that compares its `org_id` column to the session variable `app.current_org` (`backend/common/rls/__init__.py`), which `RequireOrgContext` sets from the caller's validated - JWT on every request — see [Overview](overview.md#request-lifecycle) for exactly when. + JWT on every request: see [Overview](overview.md#request-lifecycle) for exactly when. Neither layer is optional, and neither is a superset of the other in practice: see [The two-layer contract](#the-two-layer-contract) below for why both have to hold. @@ -34,8 +34,8 @@ docstring states the intent plainly: "All tenant-owned models should inherit fro That said, verify this against the actual model files before assuming every org-scoped model follows it, because most don't. Searching the codebase for classes that actually inherit `BaseOrgModel` turns up four: `Order` and `OrderLineItem` (`backend/orders/models.py`), and -`PersonalAccessToken` and `PackApplication` (`backend/common/models.py`). Every core CRM model — -`Lead`, `Account`, `Contact`, `Opportunity`, `Case`, `Task` — and most of the supporting tables +`PersonalAccessToken` and `PackApplication` (`backend/common/models.py`). Every core CRM model, +`Lead`, `Account`, `Contact`, `Opportunity`, `Case`, `Task`, and most of the supporting tables around them (`CaseWatcher`, `CsatSurvey`, `Macro`, `BusinessCalendar`, and others) instead inherit plain `BaseModel` and declare their own `org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="...")` field by hand, shaped identically to what `BaseOrgModel` would have given @@ -48,29 +48,29 @@ 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. """ ``` -(The `docs/cases/COORDINATION_DECISIONS.md` file that comment and two others like it — in -`backend/business_hours/models.py` and `backend/macros/models.py` — cite does not exist in this +(The `docs/cases/COORDINATION_DECISIONS.md` file that comment and two others like it, in +`backend/business_hours/models.py` and `backend/macros/models.py`. Cite does not exist in this checkout; treat the reasoning captured in the code comments themselves as the source of truth.) The practical takeaway: inheriting `BaseOrgModel` is a correct and recommended way to add an `org` -field to a new model — doing so also gets you the `OrgScopedManager` helpers +field to a new model. Doing so also gets you the `OrgScopedManager` helpers (`Model.objects.for_org(org)`, `Model.objects.for_request(request)`) for free, which is itself a -reason to prefer it — and you should default to it unless you have a specific reason to match this +reason to prefer it, and you should default to it unless you have a specific reason to match this codebase's more common hand-rolled-FK convention instead (for consistency with a sibling model in the same app, for instance). But inheriting it is not what makes RLS protection work, -and it is not sufficient on its own — `PersonalAccessToken` inherits `BaseOrgModel` and is +and it is not sufficient on its own; `PersonalAccessToken` inherits `BaseOrgModel` and is *deliberately excluded* from RLS (see [Policies and ORG_SCOPED_TABLES](#policies-and-org_scoped_tables) below). What actually determines whether a table is protected is the next section. ## Policies and ORG_SCOPED_TABLES `ORG_SCOPED_TABLES` in `backend/common/rls/__init__.py` is the single list that determines which -tables get RLS policies — at the time of writing it has 61 entries. `get_enable_policy_sql(table)` +tables get RLS policies. At the time of writing it has 61 entries. `get_enable_policy_sql(table)` generates the SQL that protects each one: ```sql @@ -89,16 +89,16 @@ CREATE POLICY org_insert_check ON "" WITH CHECK (org_id::text = (select NULLIF(current_setting('app.current_org', true), ''))); ``` -`FORCE ROW LEVEL SECURITY` matters because without it the table owner — typically the role that ran -migrations — would be exempt from its own table's policies. `NULLIF` turns an unset (empty-string) +`FORCE ROW LEVEL SECURITY` matters because without it the table owner, typically the role that ran +migrations, would be exempt from its own table's policies. `NULLIF` turns an unset (empty-string) session variable into SQL `NULL`, and `org_id::text = NULL` is never true, so an empty context yields zero rows rather than every org's rows: fail-safe by construction, not by convention. Adding RLS to a new org-scoped model is exactly three steps, and skipping any one of them leaves the table looking protected while it isn't: -1. **Give the model an `org` field.** Inherit `BaseOrgModel`, or — following this codebase's - dominant pattern, see [BaseOrgModel](#baseorgmodel) above — declare `org = +1. **Give the model an `org` field.** Inherit `BaseOrgModel`, or: following this codebase's + dominant pattern, see [BaseOrgModel](#baseorgmodel) above: declare `org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="...")` directly. 2. **Add the table's `db_table` name to `ORG_SCOPED_TABLES`** in `backend/common/rls/__init__.py`. 3. **Write a migration** that calls `get_enable_policy_sql('your_table')` inside a `RunPython` @@ -140,16 +140,16 @@ class Migration(migrations.Migration): ] ``` -`get_enable_policy_sql` opens with the two `ALTER TABLE` statements, then — before either -`CREATE POLICY` — drops both policies with `DROP POLICY IF EXISTS`, so re-running this kind of -migration is idempotent — useful if a table's policy needs to be recreated after a schema change. +`get_enable_policy_sql` opens with the two `ALTER TABLE` statements, then. Before either +`CREATE POLICY`: drops both policies with `DROP POLICY IF EXISTS`, so re-running this kind of +migration is idempotent. Useful if a table's policy needs to be recreated after a schema change. Skip step 2 and the consequence is worse than "no protection": `manage_rls --status` only reports on tables that are actually in `ORG_SCOPED_TABLES`, so an unregistered table doesn't show up as -"disabled" — it doesn't show up at all, and the gap is invisible to the one tool that's supposed to +"disabled". It doesn't show up at all, and the gap is invisible to the one tool that's supposed to catch it. The one deliberate exception in this codebase is `personal_access_token`: it inherits `BaseOrgModel` (so it has an `org` field) but is intentionally left out of `ORG_SCOPED_TABLES`, -because it's looked up by `token_hash` before any tenant context can exist — an "auth-bootstrap" +because it's looked up by `token_hash` before any tenant context can exist, an "auth-bootstrap" table, in the same category as `Org` itself. Its isolation is enforced instead by explicit `org`/`profile` filters in `backend/common/views/pat_views.py`. That exception is the proof that inheriting `BaseOrgModel` and being RLS-protected are two separate facts, not one. @@ -158,14 +158,14 @@ inheriting `BaseOrgModel` and being RLS-protected are two separate facts, not on RLS is a safety net, not a replacement for the ORM filter. Every view and serializer still writes `Model.objects.filter(org=request.profile.org)` and passes `org=request.profile.org` explicitly on -create — that ORM filter is the contract; RLS is what catches the case where a filter is missing, +create. That ORM filter is the contract; RLS is what catches the case where a filter is missing, forgotten, or wrong. The two layers fail differently, which is exactly why both are required: - If the ORM filter is missing but RLS is correctly configured (a non-superuser database role, see [PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md#why-rls)), the query still returns - only the caller's org's rows — RLS silently saved you. -- If the database role is a superuser — which is the default (`DBUSER` unset falls back to - `postgres`) unless you deliberately change it — RLS is bypassed entirely, silently, with no error + only the caller's org's rows, RLS silently saved you. +- If the database role is a superuser, which is the default (`DBUSER` unset falls back to + `postgres`) unless you deliberately change it. RLS is bypassed entirely, silently, with no error and no log line. In that configuration, the ORM filter is the *only* thing standing between a request and another org's data. A development environment where the DB role happens to be a superuser will look identical whether the ORM filter is present or not; only @@ -177,15 +177,15 @@ Treat every new queryset the same way regardless of which layer you're thinking ## Portal tokens -The client portal — invoice and estimate links emailed to customers, and CSAT survey links — is the +The client portal (invoice and estimate links emailed to customers, and CSAT survey links) is the one place in this codebase that has to read org-scoped rows with no authenticated user and no JWT at all. That's a genuine chicken-and-egg problem for RLS: you cannot set `app.current_org` until you know the org, and with RLS enforcing isolation, you cannot read the row that would tell you the org until the context is already set. `common.models.PortalAccessToken` (`backend/common/models.py`, defined starting at line 910) breaks -the cycle. It's a small, deliberately **unscoped** table — absent from `ORG_SCOPED_TABLES`, no RLS -policy — that maps `sha256(url_token)` to an `org_id`, populated at the same moment each portal +the cycle. It's a small, deliberately **unscoped** table: absent from `ORG_SCOPED_TABLES`, no RLS +policy. That maps `sha256(url_token)` to an `org_id`, populated at the same moment each portal token is minted (`common/portal_tokens.py:register_portal_token`). A public view hashes the token it was handed in the URL, looks up the org in this unscoped table, and only then sets the RLS context and queries the actual resource: @@ -201,11 +201,11 @@ def _resolve_org_context(token, resource_type): An unknown or malformed token leaves the RLS context empty; the subsequent scoped query then returns nothing under RLS's fail-safe default, and the caller gets the same 404 a disabled link -would produce — a stranger probing token values learns nothing about whether one is real. This is +would produce. A stranger probing token values learns nothing about whether one is real. This is the same "resolve org first via an unscoped, narrow lookup, then call `set_rls_context`" pattern Celery tasks use (see [Background jobs](background-jobs.md)); it adds a dedicated, narrow surface rather than weakening the isolation policy on `invoice`, `estimate`, or `csat_survey` themselves. `backend/docs/PORTAL_RLS.md` documents the reasoning and the alternatives that were deliberately -rejected — notably, granting the anonymous path `BYPASSRLS` — but its "what is still open" section +rejected (notably, granting the anonymous path `BYPASSRLS`), but its "what is still open" section describes the state *before* `PortalAccessToken` existed; the resolution above is what actually runs today. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 31b9c234..58f2dd74 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,7 +1,7 @@ # Architecture overview -This section is for developers extending or auditing BottleCRM, not for operators deploying it — -see Self-hosting for that. It explains how the backend is put together: what runs before a view +This section is for developers extending or auditing BottleCRM, not for operators deploying it. +See Self-hosting for that. It explains how the backend is put together: what runs before a view ever sees a request, what each Django app owns, and where to go for the detail on tenancy, authentication, permissions, the data model, and background jobs. @@ -9,12 +9,12 @@ authentication, permissions, the data model, and background jobs. A SvelteKit web app (`frontend/`), a Flutter mobile app (`mobile/`), and any third-party integration all talk to the same Django REST Framework backend (`backend/`) over the same JSON -API — there is no separate mobile API or web-only endpoint set. `crm/urls.py` mounts the entire +API: there is no separate mobile API or web-only endpoint set. `crm/urls.py` mounts the entire API under `/api/`, split into `common.app_urls` (auth, org/profile management, teams, tags, notifications, documents, custom fields, and one route each into `accounts`, `contacts`, `leads`, `opportunity`, `tasks`, `cases`, `invoices`, boards, business hours, and macros) plus `invoices.public_urls` for the anonymous client-portal endpoints. Because every client shares one -API, an authorization rule enforced in a view protects all three clients at once — and, +API, an authorization rule enforced in a view protects all three clients at once, and, conversely, a rule enforced only in the SvelteKit UI protects none of them, since a script or a modified mobile build reaches the API directly. @@ -44,17 +44,17 @@ The two that matter for multi-tenancy run last, in this order: 1. **`GetProfileAndOrg`** (`backend/common/middleware/get_company.py`) resolves who the caller is and which org they're acting in. For a request bearing a personal access token it resolves the - token first — deliberately before the JWT branch, so a PAT is never handed to the JWT decoder. + token first, deliberately before the JWT branch, so a PAT is never handed to the JWT decoder. For a JWT-bearing request it decodes the access token, reads the `org_id` claim out of the - signed payload (never out of a header or body — see + signed payload (never out of a header or body. See [Authentication](authentication.md#claims)), and looks up an active `Profile` for that `user_id`/`org_id` pair; only if that profile exists does it set `request.profile` and `request.org`. For an org-API-key request it resolves the key to the org's first active `ADMIN` profile. In every path, the org on the request is a fact the server derived and - verified — never a value taken as-is from the client. + verified, never a value taken as-is from the client. 2. **`RequireOrgContext`** (`backend/common/middleware/rls_context.py`) runs next and does two things: it rejects any request to a non-exempt path with a 403 if `request.org` was never set - by the step above, and — this is the step that actually enables tenant isolation — it sets the + by the step above, and, this is the step that actually enables tenant isolation, it sets the PostgreSQL session variable `app.current_org` to that org's id with `SELECT set_config('app.current_org', %s, false)`, and resets it to an empty string after the response. Every Row-Level Security policy in the schema keys off this one session variable; see @@ -62,23 +62,23 @@ The two that matter for multi-tenancy run last, in this order: So the order is: identity and org are established and validated from the JWT first, and only once that has succeeded is the database told which org's rows the rest of this request may see. A view -never runs before both steps have completed — `RequireOrgContext` sits after `GetProfileAndOrg` in +never runs before both steps have completed; `RequireOrgContext` sits after `GetProfileAndOrg` in the list above and returns a response itself (the 403) rather than calling `get_response` when the context is missing, so a request that fails tenancy resolution never reaches URL dispatch. This file also defines an older class, `SetOrgContext`, whose own docstring describes it as the -one wired into `MIDDLEWARE` — that docstring is stale. `SetOrgContext` sets the session variable +one wired into `MIDDLEWARE`. That docstring is stale. `SetOrgContext` sets the session variable but never rejects a context-less request; `RequireOrgContext` is what `settings.py` actually lists, and it is the one enforced. One layer above middleware, DRF's own authentication runs at view dispatch time (`DEFAULT_AUTHENTICATION_CLASSES` in `REST_FRAMEWORK`: `PATAuthentication`, then simplejwt's `JWTAuthentication`, then `APIKeyAuthentication`) to populate `request.user` for `IsAuthenticated` -and similar permission classes. This is a second, independent pass over the same credential — +and similar permission classes. This is a second, independent pass over the same credential, `PATAuthentication` reuses the PAT the middleware already resolved (stashed as `request._pat`) to avoid a second lookup, but a JWT is decoded once by the middleware for org context and again by `JWTAuthentication` for `request.user`. Every view still needs `permission_classes` set correctly; -authentication alone only establishes identity, not what that identity may do — see +authentication alone only establishes identity, not what that identity may do. See [Permissions and roles](permissions-and-roles.md). ## Backend layout @@ -108,7 +108,7 @@ backend/ `common/pat_auth.py`, `common/external_auth.py`). Every domain app's *models* build on `common` (`BaseModel`/`BaseOrgModel` from `common.base`, `Org`/`Profile` from `common.models`) and never the other way around. That one-directional rule doesn't extend to `common`'s own *views*, though: -several of them are cross-cutting by nature and import domain-app models directly — the dashboard +several of them are cross-cutting by nature and import domain-app models directly, the dashboard (`common/views/dashboard_views.py`), global search (`common/search_views.py`), tag usage counts (`common/views/tags_views.py`), and vertical-pack application (`common/packs/applier.py`) all pull in `Lead`, `Opportunity`, `Case`, and others, because aggregating across every domain app is diff --git a/docs/architecture/permissions-and-roles.md b/docs/architecture/permissions-and-roles.md index fc89ff5e..cacf8e56 100644 --- a/docs/architecture/permissions-and-roles.md +++ b/docs/architecture/permissions-and-roles.md @@ -9,17 +9,17 @@ answer the first, coarse half of that; object-level checks inside the view answe `Profile.role` (`backend/common/models.py`) is a two-value `CharField`: `common.utils.ROLES = (("ADMIN", "ADMIN"), ("USER", "USER"))`, defaulting to `USER`. That's the -role a person holds *within one org* — a user with profiles in two orgs can be `ADMIN` in one and +role a person holds *within one org*: a user with profiles in two orgs can be `ADMIN` in one and `USER` in the other, because a role lives on the `Profile` (the user-org membership row), not on `User` itself. A second, independent flag exists alongside it: `Profile.is_organization_admin`, a plain boolean, -exposed as the `.is_admin` property. It is genuinely a separate field from `role` — nothing -enforces that the two agree — and a fair amount of permission-checking code treats either one as +exposed as the `.is_admin` property. It is genuinely a separate field from `role`, nothing +enforces that the two agree, and a fair amount of permission-checking code treats either one as sufficient for "this person is an admin": ```python -# common/permissions.py — IsOrgAdmin +# common/permissions.py. IsOrgAdmin return request.profile.role == "ADMIN" or request.profile.is_organization_admin ``` @@ -30,15 +30,15 @@ def is_org_admin(profile): return profile.role == "ADMIN" or bool(getattr(profile, "is_admin", False)) ``` -When you write a new admin check, check both, the way these two do — a check that only tests +When you write a new admin check, check both, the way these two do, a check that only tests `role == "ADMIN"` will silently deny someone whose admin status was granted only through `is_organization_admin`, and vice versa. Separately from any org role, `IsSuperAdmin` (`backend/common/permissions.py`) gates -platform-level access on `User.is_superuser` — an explicit flag set with `manage.py +platform-level access on `User.is_superuser`. An explicit flag set with `manage.py createsuperuser`, the Django admin, or another audited path, never inferred from an email address or domain. The class's own docstring explains why that matters: deriving super-admin status from -an email domain would hand platform-wide access — every org, every user — to anyone who can obtain +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. This flag is unrelated to any `Profile.role`; a `User` can be `is_superuser` with no org membership at all. @@ -46,13 +46,13 @@ is unrelated to any `Profile.role`; a `User` can be `is_superuser` with no org m `backend/common/permissions.py` defines the building blocks every view composes: -- **`HasOrgContext`** — requires `request.profile` and `request.org` to both be set (by the +- **`HasOrgContext`**: requires `request.profile` and `request.org` to both be set (by the middleware described in [Overview](overview.md#request-lifecycle)) and `request.profile.is_active` to be true. This is the check that turns "the JWT didn't carry a valid org claim" into a clean 403 with the message "Organization context is required. Please login again." at the view layer, mirroring what `RequireOrgContext` already enforces at the middleware layer. -- **`IsOrgAdmin`** — `role == "ADMIN"` or `is_organization_admin`, as shown above. -- **`IsSuperAdmin`** — `user.is_superuser`, as shown above. +- **`IsOrgAdmin`**: `role == "ADMIN"` or `is_organization_admin`, as shown above. +- **`IsSuperAdmin`**: `user.is_superuser`, as shown above. The standard combination on nearly every org-scoped view in this codebase is @@ -60,13 +60,13 @@ The standard combination on nearly every org-scoped view in this codebase is permission_classes = (IsAuthenticated, HasOrgContext) ``` -— for example `CaseListView`, `CaseAttachmentView`, and `CaseCommentView` in +For example `CaseListView`, `CaseAttachmentView`, and `CaseCommentView` in `backend/cases/views.py`, or `TagsListView` in `backend/common/views/tags_views.py`. Notice what that combination does *not* do: it doesn't check role, and it doesn't check which specific object the request is about. `IsAuthenticated` + `HasOrgContext` gets you "a real, active member of this org is making this request" and nothing more specific than that. Role checks (`request.profile.role == "ADMIN"`) and object-level checks are layered on inside the view methods -themselves — DRF's declarative `permission_classes` list doesn't see the object being acted on +themselves: DRF's declarative `permission_classes` list doesn't see the object being acted on until the view has already fetched it, so per-object authorization has to live in code, which is what the next section covers. @@ -76,7 +76,7 @@ what the next section covers. done deliberately, in one place, rather than re-implemented per view. Its own module docstring explains the problem it fixes: before this module existed, "who may read/write/delete a case" was answered independently in five different call sites, and had drifted into three different answers -by accident — a watcher could see their case in the list but got a 403 opening it, because the list +by accident. A watcher could see their case in the list but got a 403 opening it, because the list view's queryset included watchers and the detail view's permission check didn't. That's the consequence of not centralizing this kind of check: verbs disagree with each other silently, and the person who notices is a user hitting a wall the product didn't intend. @@ -106,32 +106,32 @@ def assert_case_delete_access(profile, case): ``` Two things worth noticing in `has_case_write_access`: the comparison is `profile.user_id == -case.created_by_id` — a `User` id compared to a `User` id — not `profile == case.created_by`. +case.created_by_id`, a `User` id compared to a `User` id, not `profile == case.created_by`. `Case.created_by` (from `common/mixins.py`'s `AuditModel`, which nearly every model inherits -transitively through `BaseModel` — see [Data model](data-model.md#conventions) for the few +transitively through `BaseModel`. See [Data model](data-model.md#conventions) for the few exceptions) is a foreign key to `User`, not to `Profile`. Comparing a `Profile` instance directly to -a `User` foreign key is always `False` — a comparison that type-checks in Python but can never be +a `User` foreign key is always `False`. A comparison that type-checks in Python but can never be true, which quietly turns an intended "creator or admin" check into an "admin only" check with no error anywhere. `backend/cases/views.py`'s own `CaseAttachmentView.delete` docstring documents two separate defects -having been live in that endpoint before the fix — an unscoped `objects.get(pk=pk)` lookup (no org -filter at all), and this same `created_by` type mismatch — and its closing line says only that "the +having been live in that endpoint before the fix. An unscoped `objects.get(pk=pk)` lookup (no org +filter at all), and this same `created_by` type mismatch, and its closing line says only that "the same one-line **lookup** bug is still open in `leads`, `tasks` and `opportunity`." That sentence is -about the missing-org-filter defect specifically, not the `created_by` comparison, and — checked -against the current source of all three apps rather than taken on the docstring's word — it's now +about the missing-org-filter defect specifically, not the `created_by` comparison, and, checked +against the current source of all three apps rather than taken on the docstring's word. It's now stale for one of the three: -- **`leads`** (`leads/views/lead_interactions.py`) — the unscoped lookup is still open (`.objects +- **`leads`** (`leads/views/lead_interactions.py`). The unscoped lookup is still open (`.objects .get(pk=pk)` at line 204, no `org=`). The `created_by` comparison here is correct, though: `request.profile.user == self.object.created_by` (line 208) compares a `User` to a `User`, not a `Profile` to a `User`, so this endpoint's `created_by` check works. -- **`tasks`** (`tasks/views/task_views.py`) — both defects are fixed: the lookup is +- **`tasks`** (`tasks/views/task_views.py`). Both defects are fixed: the lookup is org-filtered (`.filter(pk=pk, org=request.profile.org)`, lines 975-977) and the comparison is `request.profile.user_id == self.object.created_by_id` (line 987), with a comment at lines 982-984 explaining why. The cases docstring's claim that the lookup bug is "still open" in `tasks` is stale. -- **`opportunity`** (`opportunity/views/opportunity_interactions.py` and `kanban_views.py`) — both +- **`opportunity`** (`opportunity/views/opportunity_interactions.py` and `kanban_views.py`). Both defects are open, and the type mismatch exists at two separate call sites: `request.profile == self.object.created_by` in the attachment delete (`opportunity_interactions.py:158` for the unscoped lookup, `:162` for the comparison) and again as `is_owner = request.profile == @@ -139,7 +139,7 @@ stale for one of the three: So as of this writing: the `created_by` type-mismatch bug itself is open in `opportunity` only (two call sites); the separate unscoped-lookup bug is open in `leads` and `opportunity`. Neither defect -is something this documentation task fixes — it's flagged here, precisely, because a docstring that +is something this documentation task fixes. It's flagged here, precisely, because a docstring that lists which apps still have a bug goes stale exactly like any other comment does, and the fix in this case is checking the current source yourself, the way this paragraph did, rather than trusting either the docstring or a prior summary of it. When you write a `created_by` comparison, compare @@ -160,11 +160,11 @@ write admin · creator · assignee delete admin · creator ``` -Watching a case is subscribing to updates, not being handed the keys to it — so the watcher clause +Watching a case is subscribing to updates, not being handed the keys to it, so the watcher clause that widens *read* access is deliberately not carried into *write* or *delete*. Each verb states its own rule; none is inferred from another. -**Shared, org-wide configuration** — tags, vertical packs, and similar org-level settings — follows +**Shared, org-wide configuration** (tags, vertical packs, and similar org-level settings) follows a simpler but equally deliberate version of the same shape: any active member of the org may read it, only an admin may change it. `TagsListView` (`backend/common/views/tags_views.py`) is a clean example: `get` has no additional check beyond the standard `(IsAuthenticated, HasOrgContext)`, but @@ -185,4 +185,4 @@ def post(self, request, *args, **kwargs): When you're adding a new endpoint over something that's genuinely shared, org-wide configuration rather than a record with a specific owner, this is the default to reach for: don't gate the read at all beyond org membership, and gate every write and destroy behind an explicit admin check in -the view — not a `permission_classes` entry that would also block the read. +the view, not a `permission_classes` entry that would also block the read. diff --git a/docs/contributing/adding-an-org-scoped-model.md b/docs/contributing/adding-an-org-scoped-model.md index 52bfa8b9..fc7b471f 100644 --- a/docs/contributing/adding-an-org-scoped-model.md +++ b/docs/contributing/adding-an-org-scoped-model.md @@ -1,7 +1,7 @@ # Adding an org-scoped model -This page walks through adding a new tenant-owned model end to end, using a worked example — a -`CallLog` model recording calls made against a `Lead` — so every code block is something you could +This page walks through adding a new tenant-owned model end to end, using a worked example. A +`CallLog` model recording calls made against a `Lead`, so every code block is something you could actually drop into the tree, not pseudocode. It assumes you've read [Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md), which explains *why* each of these steps exists; this page is the checklist for *doing* it. @@ -14,9 +14,9 @@ these steps exists; this page is the checklist for *doing* it. 3. **Write a migration** that calls `get_enable_policy_sql('your_table')` inside a `RunPython` operation, with a `dependencies` entry that guarantees the table already exists when it runs. 4. **Filter every queryset** that touches the model by `org=request.profile.org`. -5. **Pass `org=...` explicitly** on every `create()` / `serializer.save()` — never trust a +5. **Pass `org=...` explicitly** on every `create()` / `serializer.save()`, never trust a client-supplied org. -6. **Write a test for both the allowed and the denied path** — an in-org request that succeeds, and +6. **Write a test for both the allowed and the denied path**: an in-org request that succeeds, and a cross-org request that doesn't see or can't write the row. Steps 2 and 3 are both required and neither substitutes for the other: registering the table @@ -56,29 +56,29 @@ class CallLog(BaseOrgModel): indexes = [models.Index(fields=["org", "-created_at"])] ``` -`BaseOrgModel` is abstract, and — this is easy to get wrong, and this codebase has a live example of -getting it wrong — a subclass's own `Meta` class does **not** automatically inherit the abstract +`BaseOrgModel` is abstract, and. This is easy to get wrong, and this codebase has a live example of +getting it wrong. A subclass's own `Meta` class does **not** automatically inherit the abstract parent's `indexes` just because the model inherits `BaseOrgModel`. `Order` (`backend/orders/models.py`, one of the few models here that actually inherits `BaseOrgModel` rather than hand-declaring its own `org` foreign key) declares its own `Meta` with `db_table`, `verbose_name`, and `ordering`, but no -`indexes` — so `Order._meta.indexes` is `[]` at runtime, not the `["org", "-created_at"]` index +`indexes`, so `Order._meta.indexes` is `[]` at runtime, not the `["org", "-created_at"]` index `BaseOrgModel.Meta` defines. `backend/orders/migrations/0003_remove_order_orders_org_id_9026a7_idx_and_more.py` is the receipt: it drops that exact index from both `order` and `order_line_item`, tables that otherwise get every other query filtered by `org`. `PersonalAccessToken` (`backend/common/models.py`) -is the codebase's real example of doing this correctly — its own `Meta` explicitly redeclares +is the codebase's real example of doing this correctly: its own `Meta` explicitly redeclares `indexes = [models.Index(fields=["org", "-created_at"])]` rather than assuming it's inherited, which is exactly what the `CallLog` example above copies. `BaseOrgModel` reliably gives you `id` (UUID primary key), `org`, `created_at`, `updated_at`, `created_by`, and `updated_by` regardless of whether -your model declares its own `Meta` — those are fields and a `save()` override on the class itself, not +your model declares its own `Meta`. Those are fields and a `save()` override on the class itself, not something `Meta` mediates. The `["org", "-created_at"]` index is the one thing on `BaseOrgModel` that your own `Meta` can silently drop, so redeclare it (as above) any time your model needs its own `Meta` for a `db_table`, `ordering`, or anything else. ## Registering the table -Add the table's `db_table` name — `call_log`, matching `Meta.db_table` above — to +Add the table's `db_table` name (`call_log`, matching `Meta.db_table` above) to `ORG_SCOPED_TABLES` in `backend/common/rls/__init__.py`. This list currently has 61 entries (see the -file itself for the current count — it changes as the project grows, so don't rely on a number +file itself for the current count. It changes as the project grows, so don't rely on a number copied from documentation); a new entry looks like any other: ```python @@ -87,7 +87,7 @@ ORG_SCOPED_TABLES = [ ... "task", "invoice", - "call_log", # CallLog — call records against a lead + "call_log", # CallLog. Call records against a lead # Supporting entities "comment", ... @@ -134,32 +134,32 @@ class Migration(migrations.Migration): ] ``` -`atomic = False` matches every RLS migration in this codebase — `DROP`/`CREATE POLICY` each take an +`atomic = False` matches every RLS migration in this codebase; `DROP`/`CREATE POLICY` each take an `ACCESS EXCLUSIVE` lock, so committing per-statement instead of holding one long transaction keeps each lock brief. `get_enable_policy_sql` itself opens with `DROP POLICY IF EXISTS` before creating either policy, so the migration is idempotent if it's ever re-run. -!!! warning "The dependency is not decorative — get it wrong and the migration silently does nothing" +!!! warning "The dependency is not decorative. Get it wrong and the migration silently does nothing" `dependencies = [("leads", "0016_call_log")]` above is what guarantees Django applies this migration *after* the one that runs `CreateModel` for `CallLog`. If that dependency were missing - or pointed at an earlier migration, Django would be free to apply this one first — and + or pointed at an earlier migration, Django would be free to apply this one first, and `ALTER TABLE "call_log" ENABLE ROW LEVEL SECURITY` against a table that doesn't exist yet would raise a hard error, not fail silently, *unless* the migration is written defensively with an existence check first. `backend/common/migrations/0034_rls_pipeline_tables.py` is exactly that - defensive style — it checks `get_check_table_exists_sql()` before stamping each table, because + defensive style. It checks `get_check_table_exists_sql()` before stamping each table, because it registers six tables across three different apps in one migration and couldn't express a single clean dependency chain for all of them. Its own comment explains the cost: get the dependencies wrong there and the check silently prints `Skipping
(table does not exist)` - and moves on — the migration "succeeds," CI is green, and the table simply never gets a policy. + and moves on. The migration "succeeds," CI is green, and the table simply never gets a policy. For a single new model in one app, prefer the plain `opportunity/0012` shape above (a hard dependency, no existence check, a real error if the ordering is ever wrong) over copying - `0034`'s defensive pattern — a loud failure during development is much cheaper than a policy + `0034`'s defensive pattern: a loud failure during development is much cheaper than a policy that quietly never gets created. ## The view Every view touching the new model needs both the standard permission classes and an explicit org -filter — RLS is the safety net, not the contract (see +filter. RLS is the safety net, not the contract (see [The two-layer contract](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract)): ```python @@ -193,16 +193,16 @@ class CallLogListView(APIView): Two things to notice: the `Lead` lookup itself is org-filtered (`get_object_or_404(Lead, id=lead_id, org=request.profile.org)`), so a cross-org caller gets a 404 on the parent object before ever -reaching the `CallLog` query — and `org` is passed to `serializer.save()` explicitly rather than +reaching the `CallLog` query, and `org` is passed to `serializer.save()` explicitly rather than accepted from `request.data`, the same [server-owned field](security-rules.md#server-owned-fields) rule that applies everywhere else. ## The tests Write both directions, using the shared fixtures in `backend/conftest.py` (`org_a`, `org_b`, -`admin_client`, `org_b_client`, and the rest) — modeled on the real pair in +`admin_client`, `org_b_client`, and the rest), modeled on the real pair in `opportunity/tests/test_sales_goals.py`. `org_a`, `org_b`, `admin_client`, and `org_b_client` are -real, shared fixtures already defined in `backend/conftest.py`; `lead_in_org_a` below is not — it's a +real, shared fixtures already defined in `backend/conftest.py`; `lead_in_org_a` below is not. It's a local, file-scoped fixture this example defines itself, the same way `leads/tests/ test_lead_access_and_totals.py` has its own local `_lead()` helper rather than a shared one (there is no org-scoped `Lead` fixture shared across the suite, because what fields a test needs on its `Lead` @@ -239,7 +239,7 @@ def test_admin_can_create_call_log(admin_client, org_a, lead_in_org_a): @pytest.mark.django_db def test_org_b_cannot_read_org_a_call_logs(org_b_client, org_a, lead_in_org_a): - """The denied path — mirrors test_org_isolation in test_sales_goals.py.""" + """The denied path: mirrors test_org_isolation in test_sales_goals.py.""" CallLog.objects.create(lead=lead_in_org_a, org=org_a, direction="OUTBOUND") response = org_b_client.get(f"/api/leads/{lead_in_org_a.id}/call-logs/") @@ -250,7 +250,7 @@ def test_org_b_cannot_read_org_a_call_logs(org_b_client, org_a, lead_in_org_a): ``` The second test is the one that actually earns its keep: a test suite that only ever checks the -allowed path can pass at 100% coverage while the org filter above is missing entirely — see +allowed path can pass at 100% coverage while the org filter above is missing entirely. See [Testing](testing.md#the-sqlite-caveat) for why this particular pair of tests is also the only thing proving the ORM filter is correct, independent of whether RLS is even enforced in the environment the test runs in. diff --git a/docs/contributing/code-style.md b/docs/contributing/code-style.md index b006e93e..b69cba23 100644 --- a/docs/contributing/code-style.md +++ b/docs/contributing/code-style.md @@ -18,40 +18,40 @@ ignore = ["E501"] ``` That selects pycodestyle errors (`E`), pyflakes (`F`), and import sorting (`I`), and explicitly -ignores line-length (`E501`) — so this project has decided not to enforce a line-length limit, not +ignores line-length (`E501`), so this project has decided not to enforce a line-length limit, not that it forgot to. **But read this before running anything:** as of this writing, none of `black`, `isort`, or `ruff` -is declared as a dependency anywhere in `backend/pyproject.toml` — not in `[project.dependencies]`, +is declared as a dependency anywhere in `backend/pyproject.toml`, not in `[project.dependencies]`, not in the `dev` or `docs` groups under `[dependency-groups]`, not in the `mcp` extra, and not pulled in transitively (`backend/uv.lock` has no entry for any of the three). `uv sync` on a fresh clone will not install any of them, and `uv run black .` fails on a fresh clone with `error: Failed to spawn: 'black' / Caused by: No such file or directory`. The same is true of `isort` and `ruff`. CI does not run any of them either: `.github/workflows/tests.yml`'s `backend-tests` job runs a migration check and `pytest`, and nothing -else — there is no lint or format step for Python anywhere in this repository's CI. In practice, +else. There is no lint or format step for Python anywhere in this repository's CI. In practice, Python style in this project is currently enforced by human review, not tooling. Two things follow from that: 1. **Don't `uv add black isort ruff` to make the documented commands work.** Adding a dependency - changes `pyproject.toml` and re-locks `uv.lock` for every future `uv sync` — including CI's — so + changes `pyproject.toml` and re-locks `uv.lock` for every future `uv sync`, including CI's, so it's a decision to make deliberately, in its own PR, not something to smuggle in while fixing an unrelated bug. See [Development setup](development-setup.md#backend) for what `uv add` actually does. 2. **The codebase does not currently conform to what these tools would enforce**, so even running - them read-only produces a large, unrelated diff: `uvx ruff check .` — `uvx` runs a tool from - PyPI in an ephemeral environment without touching this project's lockfile — reports on the order + them read-only produces a large, unrelated diff: `uvx ruff check .`; `uvx` runs a tool from + PyPI in an ephemeral environment without touching this project's lockfile, reports on the order of 150+ findings across the tree today, and `uvx black --check --diff .` reports on the order of 170 files that would be reformatted. Neither number is a defect to fix in passing; it reflects that these tools have not been run consistently against this codebase's history. If you want to check your own changes without installing anything project-wide, scope `uvx` to the files you touched (`uvx ruff check path/to/your_file.py`, `uvx black --check path/to/your_file.py`) rather - than the whole tree — running `black .` or `ruff check --fix .` across everything will reformat + than the whole tree, running `black .` or `ruff check --fix .` across everything will reformat files you didn't mean to touch and turn a focused PR into an unreviewable one. -Match the surrounding file's conventions by eye — 4-space indentation, double-quoted strings, -Django/DRF import grouping (standard library, then third-party, then local `common`/app imports) — +Match the surrounding file's conventions by eye, 4-space indentation, double-quoted strings, +Django/DRF import grouping (standard library, then third-party, then local `common`/app imports), the way `ruff.toml`'s settings describe, even without the tool enforcing it for you. ## Frontend @@ -68,10 +68,10 @@ Unlike the backend, these tools are installed (`prettier`, `eslint`, `svelte-che plugins are all in `frontend/package.json`'s `devDependencies`) and CI runs all three, but not symmetrically: `.github/workflows/tests.yml`'s `frontend-checks` job runs the Prettier check with a `|| echo "::warning::..."` fallback, so a formatting difference shows up as a CI **warning**, not a -failed build. The ESLint step (`pnpm eslint .`) has no such fallback — a lint error fails the job. +failed build. The ESLint step (`pnpm eslint .`) has no such fallback. A lint error fails the job. `pnpm check` (svelte-check) and `pnpm build` run unconditionally after that and also fail the job on error. So in practice: fix ESLint errors and type errors before opening a PR, but don't be surprised -if Prettier differences are flagged as a warning rather than a hard failure — run `pnpm run format` +if Prettier differences are flagged as a warning rather than a hard failure. Run `pnpm run format` yourself rather than relying on CI to catch it. ## Flutter @@ -84,8 +84,8 @@ flutter analyze --no-fatal-infos `mobile/analysis_options.yaml` includes `package:flutter_lints/flutter.yaml` with no project-specific rules added or removed. Neither of this repository's two GitHub Actions workflows -(`tests.yml`, `codeql-analysis.yml`) mentions Flutter, Dart, or `mobile/` at all, so — like the -backend's formatters — nothing in CI enforces Dart formatting or lint cleanliness; running these +(`tests.yml`, `codeql-analysis.yml`) mentions Flutter, Dart, or `mobile/` at all, so, like the +backend's formatters, nothing in CI enforces Dart formatting or lint cleanliness; running these commands yourself before opening a PR is the only check that happens. See [Build and configure](../mobile/build-and-configure.md#tests-and-analysis) for the full command set including `flutter test`. diff --git a/docs/contributing/development-setup.md b/docs/contributing/development-setup.md index 3c214685..c6bf648e 100644 --- a/docs/contributing/development-setup.md +++ b/docs/contributing/development-setup.md @@ -1,10 +1,10 @@ # Development setup -This page gets a clone of the three BottleCRM projects — `backend/`, `frontend/`, `mobile/` — running +This page gets a clone of the three BottleCRM projects (`backend/`, `frontend/`, `mobile/`) running locally for development. For exact required versions (Python, PostgreSQL, Node, pnpm) see [Requirements](../self-hosting/requirements.md); this page assumes you already have them installed. For a from-scratch install walkthrough (including creating the database and `.env`), see -[Manual setup](../getting-started/manual-setup.md) — this page focuses on the parts specific to +[Manual setup](../getting-started/manual-setup.md): this page focuses on the parts specific to contributing: running the test suite, adding dependencies, and running the mobile app alongside the other two. @@ -24,17 +24,17 @@ uv run python manage.py runserver ``` Edit `.env` first if your local PostgreSQL role, database name, or Redis URL differ from the -defaults — see [Manual setup](../getting-started/manual-setup.md) and +defaults. See [Manual setup](../getting-started/manual-setup.md) and [Environment variables](../self-hosting/environment-variables.md). -Run the test suite from `backend/` — see [Testing](testing.md) for the fast-iteration flags and +Run the test suite from `backend/`. See [Testing](testing.md) for the fast-iteration flags and what the markers mean: ```bash uv run pytest ``` -To add a dependency, use `uv add`, not a manual edit to `pyproject.toml` — `uv add` resolves the +To add a dependency, use `uv add`, not a manual edit to `pyproject.toml`; `uv add` resolves the version, writes it to `pyproject.toml`, and re-locks `uv.lock` in one step, so the two files never drift out of sync: @@ -44,18 +44,18 @@ uv add --group dev # dev-only dependency (tests, tooling), added to ``` To refresh every locked version against the current `pyproject.toml` constraints without adding or -removing a dependency — for example, to pick up a transitive security fix — use `uv lock --upgrade` +removing a dependency (for example, to pick up a transitive security fix) use `uv lock --upgrade` instead; it re-resolves and rewrites `uv.lock` in place. -`backend/pyproject.toml` currently declares two dependency groups — `dev` (`pytest`, -`pytest-django`, `pytest-cov`) and `docs` (`mkdocs-material`) — plus one optional extra, `mcp`, for +`backend/pyproject.toml` currently declares two dependency groups: `dev` (`pytest`, +`pytest-django`, `pytest-cov`) and `docs` (`mkdocs-material`), plus one optional extra, `mcp`, for the hosted MCP server (`uv sync --extra mcp`, not installed by default). `dev` is treated as a default group by `uv`, so `uv sync` on its own installs it; a plain `uv sync --group docs` installs -`docs` **in addition to** `dev`, not instead of it — that's why CI's `docs-build` job, which runs +`docs` **in addition to** `dev`, not instead of it: that's why CI's `docs-build` job, which runs exactly `uv sync --frozen --group docs`, is able to run `uv run pytest ...` in the very next step (see [Pull requests](pull-requests.md#what-ci-runs)). `mcp` is the one group/extra that stays out unless you ask for it explicitly with `--extra mcp`. See [Code style](code-style.md) before reaching -for `uv add black` or similar — it is not as simple as it sounds in this repository. +for `uv add black` or similar. It is not as simple as it sounds in this repository. ## Frontend @@ -69,11 +69,11 @@ pnpm run dev ``` `pnpm run dev` starts the Vite dev server on `http://localhost:5173`. `frontend/vite.config.js` -configures no dev-server proxy — there is no `server.proxy` entry in it — so the browser calls the +configures no dev-server proxy, there is no `server.proxy` entry in it, so the browser calls the Django API directly, cross-origin: `frontend/src/lib/api.js` builds its base URL from `PUBLIC_DJANGO_API_URL`, defaulting to `http://localhost:8000/api` when that variable is unset. That means your backend's `CORS_ALLOWED_ORIGINS` has to include `http://localhost:5173` (it does by -default in `backend/.env.example` — see [Manual setup](../getting-started/manual-setup.md)) or every +default in `backend/.env.example`: see [Manual setup](../getting-started/manual-setup.md)) or every request from the dev server fails as a CORS error, not a 404 or connection-refused, which is easy to misdiagnose as a backend problem. @@ -86,15 +86,15 @@ flutter run ``` That gets the app compiling and running against whatever emulator or device is attached, but **it -will not talk to your local backend until you edit a compile-time constant first** — see +will not talk to your local backend until you edit a compile-time constant first**. See [Build and configure](../mobile/build-and-configure.md) and [Connect to a self-hosted backend](../mobile/connect-to-self-hosted.md) before running it if you intend to exercise real API calls rather than just confirm the app builds. ## Running everything -The fastest way to bring up the full stack — PostgreSQL, Redis, the Django API, a Celery worker, a -Celery beat scheduler, and the frontend dev server — together is Docker Compose, from the repo +The fastest way to bring up the full stack: PostgreSQL, Redis, the Django API, a Celery worker, a +Celery beat scheduler, and the frontend dev server. Together is Docker Compose, from the repo root: ```bash @@ -106,18 +106,18 @@ the pieces natively instead, for faster iteration on the backend or frontend, me PostgreSQL and Redis yourself and then running each of these in its own terminal: ```bash -# Terminal 1 — API +# Terminal 1: API cd backend && uv run python manage.py runserver -# Terminal 2 — background jobs (recurring invoices, SLA scans, notification emails, ...) +# Terminal 2: background jobs (recurring invoices, SLA scans, notification emails, ...) cd backend && uv run celery -A crm worker --loglevel=INFO -# Terminal 3 — frontend +# Terminal 3: frontend cd frontend && pnpm run dev ``` The Celery worker is optional for exercising most CRUD endpoints, but several features send email or -run scheduled work through it — see [Background jobs](../architecture/background-jobs.md) for what +run scheduled work through it. See [Background jobs](../architecture/background-jobs.md) for what runs where, and note that `celery -A crm beat` (the scheduler that actually fires the ten recurring jobs described there) is a fourth, separate process that Docker Compose starts for you but the native flow above does not. diff --git a/docs/contributing/pull-requests.md b/docs/contributing/pull-requests.md index a4f8bd83..6aa3d013 100644 --- a/docs/contributing/pull-requests.md +++ b/docs/contributing/pull-requests.md @@ -8,12 +8,12 @@ practical, and update documentation when behavior, setup, or a public API change is worth re-reading right before you open a PR: - The change is focused and does not include unrelated edits. -- Relevant backend and frontend checks pass — see [Development setup](development-setup.md) and +- Relevant backend and frontend checks pass. See [Development setup](development-setup.md) and [Testing](testing.md) for what to run locally before pushing. -- Tests cover new or changed behavior where practical — see +- Tests cover new or changed behavior where practical. See [Security rules](security-rules.md#prove-permission-checks-can-fail) specifically if the change touches a permission check. -- Required migrations are included — see [What CI runs](#what-ci-runs) below for what happens if +- Required migrations are included. See [What CI runs](#what-ci-runs) below for what happens if one is missing. - Documentation is updated. - No credentials, tokens, or private data are committed. @@ -23,15 +23,15 @@ screenshots for any visible UI change. ## What CI runs -`.github/workflows/tests.yml` defines three jobs — `backend-tests`, `frontend-checks`, and -`docs-build` — triggered by two events, both scoped to the same path list (`backend/`, `frontend/`, +`.github/workflows/tests.yml` defines three jobs, `backend-tests`, `frontend-checks`, and +`docs-build`: triggered by two events, both scoped to the same path list (`backend/`, `frontend/`, `docs/`, `mkdocs.yml`, `.readthedocs.yaml`, `.coveragerc`, or the workflow file itself): a pull request against `master`, or a **push to `master` specifically** (`branches: [master]` on the `push` -trigger). Pushing a feature branch on its own doesn't run any of this — you'll only see these jobs +trigger). Pushing a feature branch on its own doesn't run any of this. You'll only see these jobs once you open the PR against `master`, so don't expect CI feedback from `git push` alone. **`backend-tests`** installs WeasyPrint's system libraries, runs `uv sync --frozen` (installing -exactly what's locked in `uv.lock` — no re-resolution), then two steps in order: +exactly what's locked in `uv.lock`, no re-resolution), then two steps in order: ```bash uv run python manage.py makemigrations --check --dry-run @@ -39,15 +39,15 @@ uv run pytest -m "not postgres_only" --tb=short -v ``` **A model change without a matching migration fails the first step**, before the test suite even -runs — `makemigrations --check --dry-run` exits non-zero if Django's autodetector finds any model +runs; `makemigrations --check --dry-run` exits non-zero if Django's autodetector finds any model change that isn't already captured in a migration file. Generate and commit the migration (`uv run python manage.py makemigrations`) before you push. See [Testing](testing.md#what-ci-runs) -for what the test step does and doesn't cover — notably, this job runs on SQLite, so it exercises no +for what the test step does and doesn't cover. Notably, this job runs on SQLite, so it exercises no RLS policy regardless of what you changed. On a push to `master` (not on pull requests) this job additionally regenerates and commits a coverage badge. **`frontend-checks`** installs with `pnpm install --frozen-lockfile`, then runs a Prettier check (a -formatting difference is reported as a warning, not a failure — see +formatting difference is reported as a warning, not a failure: see [Code style](code-style.md#frontend)), `pnpm eslint .` (this one does fail the job), `pnpm check` (svelte-check), and finally `pnpm build`. @@ -59,14 +59,14 @@ uv run mkdocs build --strict -f ../mkdocs.yml -d site `--strict` fails the build on a broken nav entry or a broken internal link target that doesn't exist. A second step then runs `common/tests/test_docs_build_excludes_internal.py`, which rebuilds -the site again and asserts that nothing under `docs/superpowers/` or `docs/design/` — internal specs -and plans, excluded via `exclude_docs` in `mkdocs.yml` — ends up published in the output. If you add +the site again and asserts that nothing under `docs/superpowers/` or `docs/design/`, internal specs +and plans, excluded via `exclude_docs` in `mkdocs.yml`. Ends up published in the output. If you add a documentation page, `mkdocs build --strict` is the same command you can run locally first; see [Development setup](development-setup.md#backend) for the `uv run --group docs` invocation. ## Review expectations -Backend Python style is not enforced by CI (see [Code style](code-style.md#python) for why) — a +Backend Python style is not enforced by CI (see [Code style](code-style.md#python) for why). A reviewer may still ask you to match the surrounding file's formatting even though no bot will flag it automatically. Frontend style mostly is enforced (ESLint failures and type errors block the build; Prettier differences only warn). Flutter has no CI at all, so `dart format .` and `flutter @@ -74,8 +74,8 @@ analyze` before opening a PR are the only check that happens for mobile changes. Beyond style, expect review to focus on the two things this codebase treats as non-negotiable: every new or changed queryset filtering by `org=request.profile.org` (or being covered by RLS with a test -proving it — see [Adding an org-scoped model](adding-an-org-scoped-model.md)), and every new or -changed permission check being provable in both directions — see +proving it. See [Adding an org-scoped model](adding-an-org-scoped-model.md)), and every new or +changed permission check being provable in both directions. See [Security rules](security-rules.md#prove-permission-checks-can-fail). A PR that adds an endpoint without a test for the rejected/forbidden path, not just the happy path, is the single most common thing to get asked to add here. diff --git a/docs/contributing/repo-layout.md b/docs/contributing/repo-layout.md index 7d567c78..7dfe72a3 100644 --- a/docs/contributing/repo-layout.md +++ b/docs/contributing/repo-layout.md @@ -1,31 +1,31 @@ # Repository layout -This page is a map, not a tutorial — it tells you where to look, and links to the pages that explain +This page is a map, not a tutorial. It tells you where to look, and links to the pages that explain *why* each part is shaped the way it is. ## Top level ``` Django-CRM/ -├── backend/ # Django REST Framework API — see Backend apps below -├── frontend/ # SvelteKit web app — see Frontend below -├── mobile/ # Flutter app — see Mobile below +├── backend/ # Django REST Framework API. See Backend apps below +├── frontend/ # SvelteKit web app. See Frontend below +├── mobile/ # Flutter app. See Mobile below ├── docs/ # This documentation site (MkDocs Material) ├── docker/ # Docker-specific config: docker/postgres/init-rls-user.sql (creates the -│ # non-superuser RLS role) and docker/backend/entrypoint.sh — that's the +│ # non-superuser RLS role) and docker/backend/entrypoint.sh. That's the │ # whole directory, no Redis config lives here -├── mcp_server/ # The hosted MCP server (backend's optional `mcp` extra) — see +├── mcp_server/ # The hosted MCP server (backend's optional `mcp` extra). See │ # Integrations → MCP server ├── mkdocs.yml # Docs site config and nav -├── docker-compose.yml # Full local stack — see Self-hosting → Docker +├── docker-compose.yml # Full local stack. See Self-hosting → Docker ├── CONTRIBUTING.md # The short version of this Contributing section -├── RLS_SETUP.md # Stubbed pointer to PostgreSQL and RLS — kept only so links made before +├── RLS_SETUP.md # Stubbed pointer to PostgreSQL and RLS, kept only so links made before │ # this documentation site existed still resolve; not a setup guide │ # itself anymore -└── schema.yml # Generated OpenAPI schema (drf-spectacular) — not hand-maintained +└── schema.yml # Generated OpenAPI schema (drf-spectacular), not hand-maintained ``` -`docs/` also holds internal specs and plans under `docs/superpowers/` and `docs/design/` — these are +`docs/` also holds internal specs and plans under `docs/superpowers/` and `docs/design/`. These are excluded from the built site (`exclude_docs` in `mkdocs.yml`) and are not part of the published documentation. @@ -43,25 +43,25 @@ Django/DRF/third-party entries): | `leads` | Leads, lead pipelines/stages, lead-to-account conversion | | `opportunity` | The sales pipeline: `Opportunity`, line items, stage aging, sales goals/quotas | | `tasks` | Task management and Kanban boards (`Board`/`BoardColumn`/`BoardTask`) | -| `invoices` | Billing — invoices, estimates, recurring invoices, products, payments — and the anonymous client-portal views | +| `invoices` | Billing (invoices, estimates, recurring invoices, products, payments), and the anonymous client-portal views | | `orders` | Sales orders and order line items | | `business_hours` | Per-org working-hours calendars that SLA timers honor | | `macros` | Canned responses / saved replies | `common` is the one app every other app depends on for tenancy: it owns `BaseModel` and -`BaseOrgModel` (`common/base.py` — the abstract base classes every org-scoped model builds on, see +`BaseOrgModel` (`common/base.py`: the abstract base classes every org-scoped model builds on, see [Adding an org-scoped model](adding-an-org-scoped-model.md)), the tenancy middleware (`common/middleware/get_company.py`'s `GetProfileAndOrg` and `common/middleware/rls_context.py`'s `RequireOrgContext`), and the RLS utilities -(`common/rls/__init__.py` — `ORG_SCOPED_TABLES`, `get_enable_policy_sql`, and the rest of the SQL +(`common/rls/__init__.py`; `ORG_SCOPED_TABLES`, `get_enable_policy_sql`, and the rest of the SQL generators). See [Architecture overview](../architecture/overview.md#backend-layout) for how these pieces run together on a request, and [Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md) for the tenancy model itself. Each domain app follows a similar internal shape: `models.py` (or a `models/` package for larger apps), `serializer.py`, one or more view modules, `migrations/`, and `tests/`. `common` is larger and -less uniform than the others because it also holds cross-cutting views — the dashboard, global -search, tag usage counts — that pull in models from every domain app; see +less uniform than the others because it also holds cross-cutting views. The dashboard, global +search, tag usage counts. That pull in models from every domain app; see [Architecture overview](../architecture/overview.md#backend-layout) for why that one-directional model-layer rule doesn't extend to views. @@ -77,14 +77,14 @@ frontend/src/ │ ├── stores/ # Svelte stores │ └── v2/ # Shared v2 design-system pieces (components, styles, formatters) └── routes/ - ├── (app)/ # Authenticated routes — leads, accounts, contacts, opportunities + ├── (app)/ # Authenticated routes, leads, accounts, contacts, opportunities │ # (as "pipeline"), tasks, tickets ("support"), invoices, and more - ├── (no-layout)/ # Public routes — login and the rest of the pre-auth flow + ├── (no-layout)/ # Public routes, login and the rest of the pre-auth flow └── api/ # SvelteKit server routes (not the Django API) ``` Every page under `routes/(app)/` reads and writes the Django API described in the rest of this -site — there is no separate frontend-only data model. +site. There is no separate frontend-only data model. ## Mobile diff --git a/docs/contributing/security-rules.md b/docs/contributing/security-rules.md index 8a975ddf..6edf6611 100644 --- a/docs/contributing/security-rules.md +++ b/docs/contributing/security-rules.md @@ -1,9 +1,9 @@ # Security rules BottleCRM is a multi-tenant application where every org's data sits in the same database, the same -tables, next to every other org's. The rules on this page exist because the two ways that goes wrong -— one tenant reading or writing another tenant's data, and a non-admin reaching admin- or -platform-level actions — are the two outcomes every change touching auth, permissions, or +tables, next to every other org's. The rules on this page exist because the two ways that goes wrong, +one tenant reading or writing another tenant's data, and a non-admin reaching admin- or +platform-level actions, are the two outcomes every change touching auth, permissions, or serializers has to rule out before it merges. ## The trust boundary @@ -12,7 +12,7 @@ The backend (`backend/`) is the only trust boundary in this system. [Architecture overview](../architecture/overview.md#the-three-clients) describes why that has to be true structurally, not just as policy: a SvelteKit web app, a Flutter mobile app, and any third-party integration all talk to the *same* Django REST Framework API over the same JSON -endpoints — there is no separate, more-trusted internal API. A rule enforced only in the SvelteKit +endpoints. There is no separate, more-trusted internal API. A rule enforced only in the SvelteKit UI (disabling a button, hiding a form field, client-side validation) protects none of the three clients, because a browser DevTools console, a modified mobile build, or a plain `curl` request reaches the same endpoint directly, skipping whatever the UI would have prevented. Every constraint @@ -21,9 +21,9 @@ that matters has to be enforced in the DRF view or serializer, full stop. ## Identity is server-derived Who is making a request, which org they're acting in, and what role they hold are facts the server -establishes and re-validates on every request — never values read from the request body, a query +establishes and re-validates on every request, never values read from the request body, a query string, or a custom header. [Authentication](../architecture/authentication.md#claims) shows the -concrete mechanism: the `org_id` claim inside a caller's signed JWT is not simply trusted — the +concrete mechanism: the `org_id` claim inside a caller's signed JWT is not simply trusted, the middleware that resolves it re-checks it against an active `Profile` row on every single request: ```python @@ -32,17 +32,17 @@ profile = Profile.objects.select_related("org").get( ) ``` -If that lookup fails — the membership was revoked, the profile was deactivated — the request gets a +If that lookup fails (the membership was revoked, the profile was deactivated) the request gets a 403 asking the caller to sign in again, rather than proceeding on a claim that's gone stale. The practical rule for any view or serializer you write: identity comes from `request.user` and `request.profile`, both set by middleware before your code runs (see -[Overview](../architecture/overview.md#request-lifecycle)) — never from a field the client sent. +[Overview](../architecture/overview.md#request-lifecycle)), never from a field the client sent. `role`, in particular, is a display convenience when it appears in a JWT payload; every real permission check reads `request.profile.role` fresh from the database, not from the token. ## Tenant scoping -Pair every permission check with an explicit org filter — the two are independent controls, and +Pair every permission check with an explicit org filter. The two are independent controls, and neither substitutes for the other. `HasOrgContext` (`backend/common/permissions.py`) is the standard permission class on nearly every org-scoped view: @@ -58,33 +58,33 @@ class HasOrgContext(permissions.BasePermission): return True ``` -That establishes "a real, active member of this org is making this request" — it does not filter +That establishes "a real, active member of this org is making this request". It does not filter which rows the view returns. Every queryset still has to add `Model.objects.filter(org=request.profile.org)` itself, and every `create()` / `serializer.save()` still has to pass `org=request.profile.org` explicitly, never accept it from the request body. RLS (PostgreSQL Row-Level Security) backs this up at the database layer, but it's a safety net for when -the filter is missing, not a substitute for writing it — see +the filter is missing, not a substitute for writing it. See [The two-layer contract](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract) for why both layers are required, and [Adding an org-scoped model](adding-an-org-scoped-model.md) for the full checklist when the view is for a brand-new model. ## Server-owned fields -Any field the server is supposed to derive — `org`, `created_by`, `profile`, a publish/visibility -flag — must never be settable from `request.data`. There are two real mechanisms in this codebase +Any field the server is supposed to derive: `org`, `created_by`, `profile`, a publish/visibility +flag, must never be settable from `request.data`. There are two real mechanisms in this codebase for that, not one: **Declare the field `read_only=True` or list it in `read_only_fields`.** `TeamCreateSerializer` in `backend/common/serializer.py` declares `read_only_fields = ("created_at", "created_by", "org")`; `leads/serializer.py` sets `read_only_fields = ("id", "created_at", "updated_at", "org")` on `LeadStageSerializer` and `LeadPipelineSerializer`; and `TimeEntrySerializer` -(`backend/cases/serializer.py`) declares the field itself read-only — `profile = -ProfileSerializer(read_only=True)` — for exactly the reason this section opens with: `profile` is +(`backend/cases/serializer.py`) declares the field itself read-only; `profile = +ProfileSerializer(read_only=True)`: for exactly the reason this section opens with: `profile` is who logged the time entry, and it has to be a fact the server sets, not one a client requests. **Or don't accept it through the serializer at all, and set it explicitly at the call site.** `TimeEntryCreateSerializer`'s own docstring states this directly: "`case`, `profile`, and `org` are -injected by the view and not accepted from the client" — and indeed none of the three appear in its +injected by the view and not accepted from the client", and indeed none of the three appear in its `Meta.fields`. The view (`backend/cases/time_views.py`) backs that up: ```python @@ -97,11 +97,11 @@ entry = TimeEntry.objects.create( ``` `serializer.validated_data` can only ever contain the fields the serializer declared in the first -place, so there's nothing for a client to override here even by guessing field names — the +place, so there's nothing for a client to override here even by guessing field names, the equivalent of popping a key out of `request.data` before `save()`, but enforced by what the serializer's `fields` tuple omits rather than by a runtime pop. -The sharpest example of *why* this matters is `is_sample` — a flag shared, via one imported +The sharpest example of *why* this matters is `is_sample`, a flag shared, via one imported `SAMPLE_DATA_HELP_TEXT` constant, by six models: `Lead`, `Account`, `Contact`, `Case`, `Opportunity`, and `Task` (`grep -rl SAMPLE_DATA_HELP_TEXT backend/` finds it in each app's `models.py`, plus `common/base.py` where it's defined once). `leads/serializer.py` declares @@ -112,7 +112,7 @@ than documentation: ```python 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." @@ -120,25 +120,25 @@ SAMPLE_DATA_HELP_TEXT = ( ``` If a field like this were writable, a hostile request wouldn't need a bug in the delete logic at -all — it would just flip the flag on someone else's real record and let the legitimate "clear sample +all. It would just flip the flag on someone else's real record and let the legitimate "clear sample data" action do the deleting. When you add a field the server is meant to own, ask what a client could achieve by setting it directly, not just whether the happy path needs it to be read-only. ## Prove permission checks can fail -A permission check that always evaluates to the same answer isn't a check — it's dead code wearing +A permission check that always evaluates to the same answer isn't a check. It's dead code wearing a check's shape, and this codebase has a well-documented history of exactly that happening by accident. `Case.created_by` (like nearly every model's `created_by`) is a foreign key to `User`, not -to `Profile`. Comparing a `Profile` instance directly against it — +to `Profile`. Comparing a `Profile` instance directly against it, ```python request.profile == self.object.created_by # always False: Profile compared to a User FK ``` -— type-checks in Python and never raises, but is never `True`, which silently turned an intended +Type-checks in Python and never raises, but is never `True`, which silently turned an intended "creator or admin" check into "admin only" with no error anywhere. This isn't hypothetical in this codebase: `git log -L` on `CaseAttachmentView.delete` in `backend/cases/views.py` shows a commit -(`c9250dd`) whose diff replaces exactly this — +(`c9250dd`) whose diff replaces exactly this, ```diff - request.profile.role == "ADMIN" @@ -148,19 +148,19 @@ codebase: `git log -L` on `CaseAttachmentView.delete` in `backend/cases/views.py + or request.profile.user_id == self.object.created_by_id ``` -— confirming from the actual commit history, not from a comment describing it, that the +Confirming from the actual commit history, not from a comment describing it, that the `Profile`-vs-`User` comparison was live code before it was replaced with the type-correct `request.profile.user_id == self.object.created_by_id`. [Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks) documents this -pattern in depth, including which apps had it fixed and which — as of that page's own last check -against source — still don't; read it before writing a new `created_by` comparison, and compare +pattern in depth, including which apps had it fixed and which, as of that page's own last check +against source, still don't; read it before writing a new `created_by` comparison, and compare `profile.user_id` to `obj.created_by_id`, never `profile` to `obj.created_by` directly. The only way to know a check can fail is to write a test that makes it fail. `leads/tests/ test_lead_access_and_totals.py` states the rule its own tests follow: "Every permission test asserts both directions. A check that can only return False is indistinguishable from a check that is never -reached." In practice that means a pair of tests, not one — for example +reached." In practice that means a pair of tests, not one, for example `opportunity/tests/test_sales_goals.py`: ```python diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 70f88e31..72579eab 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -8,9 +8,9 @@ From `backend/`: uv run pytest ``` -`backend/pytest.ini` sets `addopts` to include coverage by default — +`backend/pytest.ini` sets `addopts` to include coverage by default, `--cov=. --cov-config=../.coveragerc --cov-report=term-missing --cov-report=html:htmlcov ---cov-report=xml:coverage.xml` — alongside `--strict-markers --tb=short -v`. So a plain `uv run +--cov-report=xml:coverage.xml`, alongside `--strict-markers --tb=short -v`. So a plain `uv run pytest` always computes coverage and writes `backend/htmlcov/` and `backend/coverage.xml`, even for a single test file. @@ -19,7 +19,7 @@ Scope a run to one app, one file, or a keyword the same way you would with any p ```bash uv run pytest accounts/tests/ # one app's test package uv run pytest leads/tests/test_leads_kanban.py # one file -uv run pytest -k "test_org_isolation" # by keyword — matches the cross-tenant +uv run pytest -k "test_org_isolation" # by keyword: matches the cross-tenant # isolation test present in most API test # modules (leads, cases, contacts, ...) ``` @@ -34,9 +34,9 @@ uv run pytest --no-cov -x ``` `--strict-markers` (also set in `addopts`) means a typo'd **custom** marker fails the run immediately -rather than being silently ignored — every custom marker you use has to be declared in `pytest.ini`'s +rather than being silently ignored. Every custom marker you use has to be declared in `pytest.ini`'s `markers` list. It doesn't touch markers a plugin registers itself, like `@pytest.mark.django_db` -(from `pytest-django`) — those work without appearing in `pytest.ini` at all, which is why every +(from `pytest-django`). Those work without appearing in `pytest.ini` at all, which is why every example on this page uses `django_db` freely alongside the two markers that actually are declared. ## Markers @@ -49,14 +49,14 @@ markers = postgres_only: marks tests that require PostgreSQL ``` -`postgres_only` is in active use — exactly six tests carry it, confirmed by collecting the marker +`postgres_only` is in active use. Exactly six tests carry it, confirmed by collecting the marker directly (`uv run pytest -m postgres_only --collect-only` → `6/2776 tests collected`), across four files: `common/tests/test_pat_auth.py` (one test), `common/tests/test_pat_model.py` (one), `common/tests/test_rls_pipeline_tables.py` (two), and `invoices/tests/test_portal_rls.py` (two). (`common/tests/test_pat_integration.py` mentions `postgres_only` only in a comment explaining that -its own tests deliberately *don't* carry the marker and run on SQLite instead — it contributes zero +its own tests deliberately *don't* carry the marker and run on SQLite instead. It contributes zero of the six.) Four of the six assert RLS policy state directly (`pg_policies`, -`pg_class.relrowsecurity`) — the two in `test_rls_pipeline_tables.py`, plus one each in +`pg_class.relrowsecurity`): the two in `test_rls_pipeline_tables.py`, plus one each in `test_pat_model.py` and `test_portal_rls.py` confirming `personal_access_token` and `portal_access_token` deliberately carry *no* policy. The other two assert isolation *behaviour* under a real, RLS-enforcing role instead of reading policy metadata: @@ -65,16 +65,16 @@ endpoint and checks a foreign org's lead never appears, and `test_portal_rls.py::test_resolution_reads_estimate_that_empty_context_hides` exercises the full portal-token resolution path. `slow` is declared but, as of this writing, applied to nothing (`grep -rn "pytest.mark.slow" --include='*.py' .`, excluding -`venv/`, returns no matches in application code) — it's reserved for future use, not a marker you'll +`venv/`, returns no matches in application code). It's reserved for future use, not a marker you'll see filtering anything today. ## The SQLite caveat `crm.test_settings` (`backend/crm/test_settings.py`) is the settings module `pytest.ini` points at, -and its `DATABASES` is hardcoded to an in-memory SQLite database — not read from an environment +and its `DATABASES` is hardcoded to an in-memory SQLite database, not read from an environment variable, not conditional on anything. Its own module docstring states why: "RLS (Row-Level Security) is PostgreSQL-only and is skipped on SQLite." That means **the default `uv run pytest` -run never exercises a single RLS policy** — every test runs against a database engine that has no +run never exercises a single RLS policy**. Every test runs against a database engine that has no concept of row security at all. Everything you'd normally rely on RLS to catch, an org-filter bug included, is invisible to this default suite; only the explicit `Model.objects.filter(org=...)` in application code is being tested. See [Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract) @@ -83,22 +83,22 @@ for why that ORM filter has to be correct on its own merits, independent of RLS. The `postgres_only`-marked tests exist to close part of that gap, and they self-skip safely rather than fail when there's no PostgreSQL connection: each one checks `connection.vendor != "postgresql"` and calls `pytest.skip(...)` if so. Under the SQLite settings this repository ships, that check is -always true, so these six tests always skip — they don't fail, and a green `uv run pytest` run tells -you nothing about whether they'd pass. One of them — `test_portal_rls.py`'s module docstring — +always true, so these six tests always skip. They don't fail, and a green `uv run pytest` run tells +you nothing about whether they'd pass. One of them, `test_portal_rls.py`'s module docstring, describes an intended way to actually run them for real, against a non-superuser PostgreSQL role: `RLS_ENFORCE=1`, pointed at a `crm.test_settings_postgres` module. A comment in `backend/.env` -(gitignored — `.gitignore:20` — and absent from `.env.example`, so a fresh clone has neither the +(gitignored, `.gitignore:20`, and absent from `.env.example`, so a fresh clone has neither the file nor the reference) names the same two pieces. Neither source is authoritative, because that settings module does not exist anywhere in this checkout as of this writing, so this documented path does not currently work. If you need to prove RLS isolation holds against real data rather than just reading the policy SQL, use `manage.py manage_rls --test` against a real, non-superuser-configured PostgreSQL -database instead — see [PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md#verifying-isolation) +database instead. See [PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md#verifying-isolation) for exactly what it proves and how to set up that role. ## What CI runs `.github/workflows/tests.yml`'s `backend-tests` job runs on the same SQLite settings described -above — there's no PostgreSQL service in that job — so `postgres_only` tests skip there too, and +above, there's no PostgreSQL service in that job, so `postgres_only` tests skip there too, and CI itself has no path that runs them for real. Two steps matter, in this order: ```bash @@ -106,8 +106,8 @@ uv run python manage.py makemigrations --check --dry-run uv run pytest -m "not postgres_only" --tb=short -v ``` -The migration check runs first and fails the build if any model change is missing its migration — -see [Pull requests](pull-requests.md#what-ci-runs) for what that means for your workflow. The +The migration check runs first and fails the build if any model change is missing its migration. +See [Pull requests](pull-requests.md#what-ci-runs) for what that means for your workflow. The `-m "not postgres_only"` filter on the test step is redundant with the tests self-skipping, but makes the exclusion explicit in the command itself rather than relying on every future `postgres_only` test remembering its own skip guard. diff --git a/docs/design/README.md b/docs/design/README.md index b68a4fbe..4592484b 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -1,9 +1,9 @@ # v2 design reference -Two self-contained HTML files. No build, no dependencies, no network calls — +Two self-contained HTML files. No build, no dependencies, no network calls, open either one directly in a browser. -## clearing-reference.html — **the reference** +## clearing-reference.html, **the reference** The "Clearing" direction developed across 16 screens. This is the agreed reference for **layout, information architecture, component behaviour and @@ -24,8 +24,8 @@ it addresses from the 2026-07-27 audit. > > These mocks predate the palette decision and still use an exploratory > "bottle glass" scheme (green / cobalt / amber / oxide). The v2 app uses the -> agreed palette instead — Ember `#EA580C`, Ink `#1C1917`, Slate `#78716C`, -> Paper `#FAFAF9`, Clay `#B45309`, Rust `#9A3412`, Moss `#3F6212` — where +> agreed palette instead, Ember `#EA580C`, Ink `#1C1917`, Slate `#78716C`, +> Paper `#FAFAF9`, Clay `#B45309`, Rust `#9A3412`, Moss `#3F6212`, where > **Ember marks only "something you can act on, or something that needs you"**: > never a heading, never an icon tint, never active chrome. > @@ -35,7 +35,7 @@ it addresses from the 2026-07-27 audit. **Type and metrics do come from this file.** On 2026-07-28 v2 was realigned to the reference's numbers: 14.4px base, 21.9px/640 titles, 11px sans sentence-case table headers, 10px sans section labels, 44px rows, 22px page padding. That -supersedes the earlier "Page title 19px / Label 9.5px mono" note — mono is now +supersedes the earlier "Page title 19px / Label 9.5px mono" note. Mono is now reserved for numerals only. Colour was not touched. ### Coverage @@ -55,7 +55,7 @@ Two differences are deliberate: v2 also has screens the reference does not. Beyond the sixteen: an Accounts list, Contacts, Tasks and a Knowledge base (the reference's sidebar advertised the last two without drawing them), and then a second batch covering the parts -of v1 the reference never reached — +of v1 the reference never reached, - **Serve**: ticket approvals and service analytics, as tabs under Tickets. - **Sell**: goals, with attainment read against elapsed period rather than on @@ -65,7 +65,7 @@ of v1 the reference never reached — - **Run**: team and access, a settings hub, API tokens, business hours, and your own profile. -A third batch finished the administrative surface — +A third batch finished the administrative surface, - **Settings**: ticket routing, escalation, reopen policy, approval rules, inbound email, macros, tags, custom fields, and the organization profile. @@ -78,7 +78,7 @@ the two authoring forms. - **Customer-facing** (`src/routes/v2/(public)/`): the invoice portal, the estimate portal, and the CSAT survey. These use `+layout@.svelte` to escape - the app shell entirely — no sidebar, no nav counts, no command palette. + the app shell entirely, no sidebar, no nav counts, no command palette. Someone who receives an invoice is not a user of your CRM and must not be shown its furniture. - **Authoring**: a new-invoice builder and a new-article form. @@ -88,8 +88,8 @@ unlike the real portal. That is deliberate: `hooks.server.js` denies by default, and a design preview is the weakest possible reason to punch a hole in it. Do not add `/v2` to `PUBLIC_ROUTES`. -Second-level navigation is a tab strip (`SectionTabs`), not a nested sidebar — -the sidebar stays one level deep. Tab sets live in `src/lib/v2/tabs.js`. +Second-level navigation is a tab strip (`SectionTabs`), not a nested sidebar. +The sidebar stays one level deep. Tab sets live in `src/lib/v2/tabs.js`. **Settings deliberately has no tab set**: twelve pages are not peers you flip between, so the hub at `/v2/settings` is the index and each page carries a `SettingsCrumb` back to it. @@ -98,15 +98,15 @@ v2 covers every one of v1's app routes except Salesforce, plus three public ones, plus two screens v1 never had (Documents, Notifications). What is still v1-only, and why: -- **Salesforce** (`settings/salesforce`, `settings/salesforce/import`) — +- **Salesforce** (`settings/salesforce`, `settings/salesforce/import`), deliberately skipped. `/api/salesforce/status|credentials|connect/` all 404; there is no Salesforce app, model or URL registration in the backend, and `simple-salesforce` is an installed dependency with no caller. The v1 pages are a UI over an API that was never written, so a v2 copy would be a redesign of nothing. -- **`tasks/calendar`** — a redirect stub in v1, no v2 equivalent by design. +- **`tasks/calendar`**: a redirect stub in v1, no v2 equivalent by design. - **The auth and org-selection flow** (`login`, `login/verify`, `org`, - `org/new`, `bounce`) — out of scope. It is a security surface, not a design + `org/new`, `bounce`). Out of scope. It is a security surface, not a design one, and mocking a login screen is how you end up with a login screen that accepts mock credentials. @@ -115,7 +115,7 @@ v1-only, and why: The chrome was responsive before the content was. Measured at 390px, every list table scrolled sideways: contacts was 1083px wide and hid 693px of itself behind a swipe, tickets hid 622px, accounts 467px. Nothing overflowed the page, so it -read as working — the `.v2-table-wrap` scroller absorbed it — but the column you +read as working, the `.v2-table-wrap` scroller absorbed it, but the column you scan for a name went off-screen along with everything else, and lists are the way into every other screen. @@ -123,13 +123,13 @@ Below 768px each row is now a card: the identifying cell on the first line, the status pinned to the top right, the rest wrapping underneath as a meta line. The rules are in `v2.css`, in the `max-width: 768px` block, and the defaults carry every table without it being touched. Pages opt into refinements with `data-m` -on a cell — `hide`, `title`, `tag`, `lead`, `bar` — plus `data-l` for a label. +on a cell (`hide`, `title`, `tag`, `lead`, `bar`), plus `data-l` for a label. Three things are worth knowing before changing it: - **Columns are dropped, not shrunk.** A phone is not a narrow desktop. Six of - the lists were curated by hand — leads, pipeline, accounts, contacts, tickets, - tasks — on the question of what you do with a phone in your hand: check + the lists were curated by hand, leads, pipeline, accounts, contacts, tickets, + tasks: on the question of what you do with a phone in your hand: check today, look something up before a call, log a note, move a stage, answer a ticket, approve something. The rest of the list pages get the defaults and are better than they were, but were not curated. @@ -139,8 +139,8 @@ Three things are worth knowing before changing it: can place the call. Hiding the column would have hidden the warning. - **The layout is blocks and a gutter, not flexbox.** Flex was tried first and abandoned. It picks line breaks from each item's content width *before* it - grows anything, so a long title either claimed a line of its own or — with a - zero basis — let the meta cells crowd onto line one and squeeze it into a + grows anything, so a long title either claimed a line of its own or. With a + zero basis. Let the meta cells crowd onto line one and squeeze it into a column three words wide. Which one you got depended on the length of the data, which is the worst property a layout can have. @@ -150,14 +150,14 @@ selector, and the `lead` cell has to reset the right padding the title rule reserves for the tag, or it inherits 96px and shoves the title most of a screen to the right. -Touch targets were raised in the same pass — buttons and view chips to a 40px +Touch targets were raised in the same pass, buttons and view chips to a 40px minimum, since icon-only buttons cannot get there on padding alone, and the filter chip's remove button from 12×14px, which was smaller than the fingertip aiming at it and sat inside a chip that does something else when missed. ### The two screens v1 never had -Both are models with a complete API and no interface anywhere — not in the +Both are models with a complete API and no interface anywhere, not in the SvelteKit app, not in Flutter. Designing against them turned up four backend defects, each measured against a running server rather than read off the code. **All four are now fixed**, with tests; the pages are drawn against the @@ -171,17 +171,17 @@ corrected rules. to `created_by`, a **User** FK, so they were always False and **the uploader could not open or delete their own document**; and `Document.teams` was written by POST/PUT and read by no query, so **sharing with a team granted - nothing**. Fixed in `common/views/document_views.py` — one `_visible_to()` + nothing**. Fixed in `common/views/document_views.py`, one `_visible_to()` predicate shared by the list query and the object checks, so they cannot - drift apart again — and covered in both directions by + drift apart again, and covered in both directions by `common/tests/test_documents_access.py`. Deletion stays narrower than reading: admin or creator only, because a share hands someone a copy to work with, not the right to remove it from everyone else. - **Notifications** (`/v2/notifications`). v1 has a bell and a 360px dropdown, which is fine for two items and useless for twenty. The producer wrote - `link=f"/cases/{case.id}"` and **no client has ever served `/cases`** — - tickets are at `/tickets/[id]` — so v1's panel, which assigns `n.link` + `link=f"/cases/{case.id}"` and **no client has ever served `/cases`**. + Tickets are at `/tickets/[id]`, so v1's panel, which assigns `n.link` straight to `window.location.href`, sent every notification to a 404. Fixed at the producer in `cases.notifications.case_link()`, shared with the merge redirect that had the same bug; `api.js` still rewrites the dead prefix @@ -202,8 +202,8 @@ form names the ones that are: required (`Lead.clean()` and `LeadCreateSerializer.__init__` both enforce it) and creates an Account, a Contact and an Opportunity with no endpoint that undoes it. `leads/workflow.py` declared that rule and **was imported by - nothing** — its sibling `cases/workflow.py` is used by two modules, the leads - one by none — so the API accepted both a repeat conversion, which built a + nothing**. Its sibling `cases/workflow.py` is used by two modules, the leads + one by none, so the API accepted both a repeat conversion, which built a *second* Opportunity because `LeadDetailView.put` reran the service on every "converted" PUT, and a move back out of it, which orphaned everything conversion had made. Both are now refused by @@ -217,7 +217,7 @@ form names the ones that are: link to the line items. - **Changing a stage resets a clock someone else is watching.** `Opportunity.save()` sets `stage_changed_at = now()` on any stage change, and - that is what the board's aging colour is computed from — so moving a stage + that is what the board's aging colour is computed from, so moving a stage turns a stalled deal green without anything improving. The form shows the aging pill that is about to be discarded. @@ -227,7 +227,7 @@ Each one shows the current state and what it costs, not just the fields: - **Ticket routing** marks rules that can never run. Rules are evaluated in order and stop on the first match, so anything below an unconditional - stopping rule is dead — and "matched 0 times" on its own just looks like a + stopping rule is dead, and "matched 0 times" on its own just looks like a quiet month. - **Escalation** reports which policies resolve to no recipient. `reassign` with a null target, `notify` with neither a person nor a team, and @@ -245,13 +245,13 @@ no component can render one by accident. ### What the customer-facing pages are for -The same principle, pointed outward — say what is true, and what it costs: +The same principle, pointed outward. Say what is true, and what it costs: - **The invoice portal** answers one question (do I owe anything, how much, by when) and never shows `status`. `Sent` / `Viewed` / `Partially_Paid` are facts about our workflow, and one is actively misleading: `Viewed` is set by the customer's own page load. It also draws no "Pay now" button, because - there is no payment processor in this product — `Payment` rows are entered + there is no payment processor in this product; `Payment` rows are entered by hand after money arrives. - **The estimate portal** makes accept a two-step decision. It is the only irreversible thing a customer can do, there is no undo endpoint, and v1 @@ -263,7 +263,7 @@ Two backend gaps are named on the pages themselves rather than papered over: the accept endpoint never reads `expiry_date`, and `Estimate` records `accepted_at` with no record of *who* accepted. -## four-directions-explorer.html — provenance +## four-directions-explorer.html. Provenance The four directions that were compared before Clearing was chosen: Clearing (evolution), Workbench (dense operator tool), Dispatch (editorial), Room @@ -276,7 +276,7 @@ The four directions that were compared before Clearing was chosen: Clearing Most modules still read fixture data through `frontend/src/lib/v2/api.js`. **Leads, pipeline, accounts, contacts, tickets, the knowledge base, tasks, invoices, team-and-access and the API-tokens settings page are wired to the -real API** — see below. `src/lib/v2/migrated.js` is the list the shell banner +real API**. See below. `src/lib/v2/migrated.js` is the list the shell banner reads, and it is the one place that says which is which. ## Wiring a module @@ -288,7 +288,7 @@ eleventh, the goals page twelfth, the documents page thirteenth, the products catalogue (the first of the invoices sub-pages) fourteenth, the estimates worklist fifteenth, the recurring-invoice schedules sixteenth, the invoice reports seventeenth, the invoice templates eighteenth, the from-scratch invoice -builder nineteenth — which finished the invoices cluster: list, detail, +builder nineteenth, which finished the invoices cluster: list, detail, lifecycle, and every sub-page now read and write the real API. The service analytics dashboard (`/v2/tickets/analytics`) went twentieth and the approvals queue (`/v2/tickets/approvals`) twenty-first, the first two tickets sub-pages. @@ -300,13 +300,13 @@ obvious: every deal to `/v2/accounts/{uuid}` while accounts was still fixtures keyed by slugs like `acc-northwind`, so every one of those links answered 404. A module is not finished when its own pages work; it is finished when the pages it points -at do. Before declaring one done, follow its outbound links — +at do. Before declaring one done, follow its outbound links, `grep -rn 'href="/v2/' src/routes/v2/` and check what is on the other end. **Where the module it points at is not wired yet, do not link.** Accounts shipped its tickets and invoices rails pointing at `/v2/tickets/{uuid}` and -`/v2/invoices/{uuid}`, both still fixtures — the same mistake pipeline made, +`/v2/invoices/{uuid}`, both still fixtures, the same mistake pipeline made, repeated one module later. Those rails are plain rows now and become links again in the change that wires those modules. A link that 404s is worse than text: it teaches people the record is missing rather than the page. @@ -316,26 +316,26 @@ pages showed people and none of them could link to one, so the account's people rail and the deal's contact rail were written as dead text. Both are links now. **A rail you had to leave dead is the module to wire next.** Tickets went fifth for exactly that reason: the account and contact pages both listed a customer's -tickets as plain text. Invoices went eighth and last for the same reason — the +tickets as plain text. Invoices went eighth and last for the same reason. The account page had rendered a real Invoices rail since accounts were wired, actual rows deliberately left as plain `
`s with a "View all" pointing at the fixture list, because there was no invoice page to open. Every customer rail on the account page is a link now. **When no rail is dead, pick on risk.** Team-and-access went ninth on different -grounds. Nothing dead-ends into it — it is a nav destination, not a rail — so +grounds. Nothing dead-ends into it (it is a nav destination, not a rail), so the usual "wire the page the others point at" heuristic said nothing. It was chosen because it is the access-control surface: who is an admin, who was deactivated, whose API tokens still authenticate. Wiring it meant driving `/api/user//` for real, and that is where the highest-severity finding of -any module so far was hiding — a plain member could `PATCH` their own profile to +any module so far was hiding. A plain member could `PATCH` their own profile to `role="ADMIN"` and become an org admin (see the authorization section below). The lesson generalises: once the dead rails are gone, the module worth wiring -next is the one whose endpoints, if wrong, cost the most — not the one with the +next is the one whose endpoints, if wrong, cost the most, not the one with the most broken links. **Wiring one module makes a rail for the next.** The API-tokens settings page -went tenth because team-and-access — wired ninth — links straight to it: the +went tenth because team-and-access, wired ninth, links straight to it: the team page counts each member's live tokens and points the number at `/v2/settings/api-tokens`. So the tenth pick was a dead rail after all, one the ninth module created. It is the same access-control family (a token @@ -343,12 +343,12 @@ authenticates as its owner and inherits their whole role and org), so it was the right next step on risk as well as on links. Wiring it needed a backend that did not exist: the only token endpoint, `/api/profile/tokens/`, is deliberately self-scoped ("a user manages ONLY their own"), and this page is org-wide -oversight. The answer was a **separate, admin-only** surface — -`GET /api/org/tokens/` and `DELETE /api/org/tokens//` — added *beside* the +oversight. The answer was a **separate, admin-only** surface; +`GET /api/org/tokens/` and `DELETE /api/org/tokens//`, added *beside* the self-scoped one rather than by widening it, so the existing guard was never relaxed. `personal_access_token` carries no RLS (it is looked up by hash before any tenant context exists), so on those new endpoints the explicit -`org=request.profile.org` filter is the only tenant barrier — load-bearing, and +`org=request.profile.org` filter is the only tenant barrier, load-bearing, and tested both ways. Create was left on the self-scoped endpoint: a token can only ever be minted for yourself, so there is no "create on behalf of" for an admin to misuse. @@ -356,19 +356,19 @@ to misuse. That new list route also walked straight into a URL-ordering trap the codebase had already solved once: `path("org//", …)` sits above it and its `` swallows the literal `tokens`, so `GET /api/org/tokens/` resolved to -`OrgUpdateView` (a 403) until the literal route was moved above the catch-all — +`OrgUpdateView` (a 403) until the literal route was moved above the catch-all, exactly the fix the `org/api-key/` line already carries a comment about. A literal path added below a `` route is dead on arrival; the tests caught it because the list 403'd while the two-segment revoke route (which the pk rule cannot match) passed. **A page can show a column the model has and no endpoint writes.** The -organization settings page went eleventh, on risk again — it is the settings -flagship, admin-only, and sits next to the org API key — with no dead rail +organization settings page went eleventh, on risk again. It is the settings +flagship, admin-only, and sits next to the org API key, with no dead rail pointing at it. Wiring it exposed a gap that reads like the silent-drop family but is quieter than any of them: `csat_enabled` and `auto_close_children_on_parent_close` are real `Org` columns the page has always -displayed, yet **no serializer made them writable** — turning off org-wide CSAT +displayed, yet **no serializer made them writable**. Turning off org-wide CSAT was simply not expressible through the API. Adding them to `OrgSettingsSerializer` (an allow-list that still excludes `api_key` and `is_active`) is the whole backend change; there was no auth hole, only an edit path that did not exist. @@ -379,19 +379,19 @@ there loses the company profile it thought it saved. The wiring deliberately use `/api/org/settings/` (serializer-based, org taken from `request.profile`, so no id in the URL to get an IDOR wrong) and leaves the older view flagged, not widened. The rule: when a settings page renders a field, confirm a write path -*reaches the column* — a value that round-trips through the read serializer is +*reaches the column*, a value that round-trips through the read serializer is not proof anything can change it. **A writable relation is a cross-tenant hole until its queryset is org-scoped.** -The goals page went twelfth, on risk — a sidebar dead rail whose "New goal" +The goals page went twelfth, on risk, a sidebar dead rail whose "New goal" button wrote nowhere, and `SalesGoal` is the one place a quota points at a *person*. `SalesGoalCreateSerializer` exposed `assigned_to` (a `Profile`) and -`team` with DRF's default queryset — every row in the table — and validated only +`team` with DRF's default queryset, every row in the table, and validated only the dates and the target. `common_profile` carries no RLS (it is looked up before any tenant context exists), so nothing downstream caught it: an admin could `POST`/`PUT` a goal assigned to a Profile in another org, and that person's name and email then rendered in this org's goal detail and leaderboard. The fix scopes -both fields to `request.profile.org` in the serializer — the trust boundary — and +both fields to `request.profile.org` in the serializer, the trust boundary, and the frontend's pickers only ever offer in-org targets, but that is UX, not the guard. The rule: a `PrimaryKeyRelatedField` the client can set is an IDOR unless its queryset is filtered to the caller's org; DRF's default is every row, and RLS @@ -399,7 +399,7 @@ only saves you on the tables that have it. **RLS in the database is not RLS in source.** Wiring goals also surfaced that `sales_goal` was never in `ORG_SCOPED_TABLES` and no *current* migration stamps -its policy — the live DB carries `org_isolation`/`org_insert_check` on the table, +its policy. The live DB carries `org_isolation`/`org_insert_check` on the table, but only because a migration (`0012_rls_opportunity_config`) that has since been deleted from source once applied them; the ledger still records it, the file is gone. A fresh `migrate` from this tree ships the table with **no** RLS, tenant @@ -408,30 +408,30 @@ table and adds an idempotent source migration (`get_enable_policy_sql`, which drops-then-creates, so it is safe on the databases that already have the policy). Flagged, not fixed: `stage_aging_config` and `opportunity_line_item` sit in the same deleted-migration gap. The rule: `pg_policies` proving a table is protected -today says nothing about a rebuild — check that an org-scoped model is in +today says nothing about a rebuild. Check that an org-scoped model is in `ORG_SCOPED_TABLES` *and* has a policy migration on disk. **Write must be at least as narrow as delete.** The documents page went -thirteenth, on risk — `Document` is a first-class model with a full API that no +thirteenth, on risk; `Document` is a first-class model with a full API that no client (v1, Flutter, or v2) had ever rendered, so its access rules had only ever been exercised by tests. `DocumentDetailView` already gated `delete` narrowly, with a written rationale: a share "hands someone a copy to work with, not the right to remove it from everyone else," so delete is the creator or an admin -only. But `put`/`patch` authorised on `_may_read` — creator, anyone in +only. But `put`/`patch` authorised on `_may_read`: creator, anyone in `shared_to`, any member of a shared team, or an admin. So anyone a document was merely *shared with* could overwrite its file, rename it, flip its status to -`inactive` (hiding it from the org), and — through `PUT`, which clears the M2M -before re-adding — wipe the entire share list and lock everyone else out. +`inactive` (hiding it from the org), and, through `PUT`, which clears the M2M +before re-adding, wipe the entire share list and lock everyone else out. Overwriting the bytes behind a title is at least as destructive as deleting the row. The fix adds `_may_write = _may_delete` (creator or admin) and uses it on both verbs; reading stays broad on purpose. The rule: whenever a "read" grant is -wider than the "delete" grant, the write path has to match delete, not read — +wider than the "delete" grant, the write path has to match delete, not read, being shown a record and being allowed to rewrite it for everyone are different privileges. **Do not enforce in the UI a rule the server does not have.** The first cut of the documents frontend gated *uploading* on admin, copying the goals pattern. But -`DocumentListView.post` checks only `(IsAuthenticated, HasOrgContext)` — any org +`DocumentListView.post` checks only `(IsAuthenticated, HasOrgContext)`. Any org member may add a document, and the mock's own notes never restricted create. An "admins only" upload screen would have been the [[v2-team-wired]] mistake in reverse: a UI inventing a rule the backend does not enforce, so a rep who curled @@ -440,19 +440,19 @@ action (attaching a discovery note) would be blocked for no reason the server agrees with. Uploading was relaxed to match the backend; the narrow writes (edit, delete) stayed gated because those the server *does* enforce. Wiring the list also exposed a plain bug the endpoint had carried unhit: the inactive -pagination envelope computed its offset from `results_documents_active[-1]` — a -copy-paste from the active block — so an org with archived documents but no +pagination envelope computed its offset from `results_documents_active[-1]`, a +copy-paste from the active block, so an org with archived documents but no active ones (archive the only document and reload) hit `[][-1]` → `IndexError` → 500. No client had ever rendered the list, so the crash sat there for the life of the model. The rule: a screen may hide an action, but the server decides who may -take it — build the UI to the backend's real rules, not to a tidier story you +take it, build the UI to the backend's real rules, not to a tidier story you wish it told. -**Shared config reads wide and writes narrow — and that gate has to be added, +**Shared config reads wide and writes narrow, and that gate has to be added, not assumed.** The products catalogue went fourteenth, the first of the invoices sub-pages, and its `Product` model had a full CRUD API that no client had drawn, so every write test had used an admin. But the view gated `POST`/`PUT`/`DELETE` -on `(IsAuthenticated, HasOrgContext)` only — any member, `USER` role included, +on `(IsAuthenticated, HasOrgContext)` only: any member, `USER` role included, could rewrite a list price, retire a product, or hard-delete one for the whole org. The catalogue is org-wide config: its prices flow onto every rep's invoices and estimates, so it is the same shape as Organization and Team settings, which @@ -460,8 +460,8 @@ are admin-gated. The fix gates all three writes to an admin and leaves *reading* open to every member, because a rep needs the catalogue to build line items. This is the mirror of the documents lesson above: there, a write path had silently widened to match read and had to be *narrowed*; here, no write gate -existed at all and one had to be *added*. Both come from the same question — -"who may change this, versus who may see it?" — asked of a surface that had only +existed at all and one had to be *added*. Both come from the same question, +"who may change this, versus who may see it?". Asked of a surface that had only ever been tested by an admin. Note the reasonable-looking wrong turn avoided: it is tempting to also gate *reading* on admin ("it's a settings page"), which would break the line-item picker for every rep. Read wide, write narrow. @@ -469,25 +469,25 @@ break the line-item picker for every rep. Read wide, write narrow. **The load and the page share a data-shape contract, and renaming a field breaks it silently.** The fixture loader returned `{ products, totals }`; the real server load returned the data layer's `{ results, totals }` verbatim, and the page reads -`data.products`. So `data.products` was `undefined`, `{#each}` iterated nothing — -actually threw during SSR — and the page 500'd before a single row rendered, even +`data.products`. So `data.products` was `undefined`, `{#each}` iterated nothing, +actually threw during SSR, and the page 500'd before a single row rendered, even though the API behind it answered `200` with twenty products. The backend was never the problem. The rule: when a `+page.js` becomes a `+page.server.js`, the -new load must return the exact shape the `.svelte` already reads — the field +new load must return the exact shape the `.svelte` already reads. The field names are an interface, and the compiler cannot see across it. Diagnose a migrated-page 500 by checking that contract before suspecting the API. **A worklist can only sort by facts the list row actually carries.** The estimates page went fifteenth, and its whole reason to exist is to separate "accepted but not yet billed" (money agreed, nobody invoiced it) from "already -billed" — the decision is `status == 'Accepted' and not converted_to_invoice`. +billed". The decision is `status == 'Accepted' and not converted_to_invoice`. But `EstimateListSerializer` returned neither `converted_to_invoice` nor `opportunity`; the fixtures had faked both. Read against the real API, every accepted estimate would have looked unbilled, so the page would have offered to -raise a *second* invoice for one that already had one — a duplicate, produced by +raise a *second* invoice for one that already had one, a duplicate, produced by the one button the page is built around. This is not an authorization hole (the -estimate endpoints were already the best-authorized of the invoices cluster — -object-level `get_estimate_or_error` plus non-admin list scoping); it is a +estimate endpoints were already the best-authorized of the invoices cluster. +Object-level `get_estimate_or_error` plus non-admin list scoping); it is a correctness gap the wiring exposed. The fix put both fields on the list row, trimmed to id-plus-one-label each, and a test asserts the row flips from "unbilled" to carrying its invoice reference the moment it is converted. The @@ -496,40 +496,40 @@ the action's guard reads is present in the list payload, not just in the detail. **When a page's create surface is a builder, defer it rather than half-build it.** The estimates page has a "New estimate" button, but creating an estimate -means a header, three foreign-key pickers and a line-item table — the same class +means a header, three foreign-key pickers and a line-item table, the same class of surface as the invoice builder that is still a fixture. Wiring the list and the one-click `convert` is the sub-page's real work; a full create form is its own task. So "New estimate" points at the pipeline, which is where an estimate is -raised from a deal — exactly what the page's empty state has always said — rather +raised from a deal, exactly what the page's empty state has always said, rather than at a form this change would only get halfway through. A dead-ended button repointed at the honest origin beats a live button wired to a half-built form. -**When an owned record has no object-level auth, match its siblings — don't +**When an owned record has no object-level auth, match its siblings. Don't invent a model.** Recurring invoices went sixteenth, and every one of their endpoints filtered on `org` only: no object check on detail/update/delete/toggle, -and no non-admin list scoping — so any member could read, edit, delete or +and no non-admin list scoping, so any member could read, edit, delete or pause/resume every schedule in the org. The tempting fix is the products one (admin-only writes, "it's config"), but a recurring invoice is not org-wide -config like the catalogue; it is an *owned billing record* — `AssignableMixin` + +config like the catalogue; it is an *owned billing record*, `AssignableMixin` + `created_by`, the exact shape of the Invoice and Estimate sitting beside it. So the fix is theirs, not the catalogue's: non-admins are scoped to what they created or are assigned to, admins see all, and the same `get_recurring_or_error` helper the estimates use now guards the detail and toggle. The deciding question -is what *kind* of thing the resource is — shared configuration everyone uses, or -an owned record with a creator and assignees — and the answer is already encoded +is what *kind* of thing the resource is. Shared configuration everyone uses, or +an owned record with a creator and assignees, and the answer is already encoded in its base classes. Reuse the sibling's guard rather than writing a third one; the centralised `invoices/permissions.py` is where that reuse lives. -**Some surfaces are admin-only by their nature, not by a missing check — +**Some surfaces are admin-only by their nature, not by a missing check: recognise which.** The invoice reports went seventeenth: an org-wide financial -dashboard — total invoiced and collected, revenue by month, AR aging, who owes +dashboard: total invoiced and collected, revenue by month, AR aging, who owes what across every account. The three endpoints carried only `(IsAuthenticated, HasOrgContext)`, so any member saw the whole org's money. This is not the owned-record case (recurring): there is no per-user version of "the org's revenue", and scoping the aggregates to a rep's own invoices would be a different, lesser feature, not a fix. The right answer is that a whole-org -financial roll-up is a *management* surface, admin-only — the same bar as org and -team settings — and especially so once the invoice and estimate lists scope reps +financial roll-up is a *management* surface, admin-only, the same bar as org and +team settings, and especially so once the invoice and estimate lists scope reps to their own records, which makes an unscoped report the exact asymmetry those scopes exist to close. So the fix gated all three on admin and the page shows a plain "admins only" state (a `can_view` flag from the data layer, an empty but @@ -537,35 +537,35 @@ valid shape so the deriveds do not trip) rather than a broken dashboard. The judgment: before scoping a surface per-user, ask whether the surface even *has* a per-user meaning; when it is inherently org-level, the answer is a role gate, not a filter. Wiring it also surfaced the UI-with-no-backend pattern in -miniature — the page drew three things no endpoint returned (average time to pay, -invoiced-by-month beside paid, overdue grouped by account) — and, as with every +miniature, the page drew three things no endpoint returned (average time to pay, +invoiced-by-month beside paid, overdue grouped by account), and, as with every module before it, those were *added to the real endpoints*, not faked in the data layer: an aggregate the UI needs is a query to write, not a number to invent. **A stored field that a server later renders is an injection sink, and the -render is the trust boundary — not the page that never shows it.** The invoice +render is the trust boundary, not the page that never shows it.** The invoice templates went eighteenth, the highest-risk sub-page, and carried two findings at once. The first was the by-now-familiar shape: templates are org-wide shared config (how every invoice looks to a customer), and both endpoints were -`(IsAuthenticated, HasOrgContext)` only — any member could create, edit, delete, +`(IsAuthenticated, HasOrgContext)` only. Any member could create, edit, delete, or re-point the org default. Fixed the shared-config way: admin-gate the writes, leave the read open. But the second finding lived nowhere the page looked. A template carries `template_html`/`template_css`, org-authored HTML/CSS that WeasyPrint renders into a PDF with `base_url=BASE_DIR` and no URL-fetcher -restriction — reachable *unauthenticated* through the public invoice-PDF link. So +restriction, reachable *unauthenticated* through the public invoice-PDF link. So an `` or `url(http://169.254.169.254/…)` in that markup is a local-file read of the app's secrets and an SSRF to cloud metadata, generated on the server, handed back as a PDF. The page is a red herring here: it is scrupulous about never rendering the markup (no `{@html}`, -ever), which protects the *browser* — but the danger was never the browser, it +ever), which protects the *browser*, but the danger was never the browser, it was the *server-side* render the page doesn't touch. The fix is a deny-by-default -`url_fetcher` that permits only what a real invoice loads — `data:` URIs, a file -strictly inside `MEDIA_ROOT` (the logo), HTTPS to the one configured S3 host — +`url_fetcher` that permits only what a real invoice loads: `data:` URIs, a file +strictly inside `MEDIA_ROOT` (the logo), HTTPS to the one configured S3 host, and blocks every other scheme, host, and path (`invoices/pdf.py`). Two lessons compounded: (1) when a field is *stored now, rendered later*, secure the renderer, and note that "the UI never displays it" is not a defense when a different, unauthenticated path renders it; (2) the same discipline that keeps -markup out of the DOM should keep it off the wire — the list serializer now +markup out of the DOM should keep it off the wire, the list serializer now strips `template_html`/`template_css` entirely (a `has_custom_html` flag and a byte count stand in), so the raw markup is write-only across the whole API and the frontend's old boundary-strip became belt on top of braces. @@ -575,49 +575,49 @@ from-scratch invoice builder went nineteenth and finished the cluster. Because i had never posted, nobody had looked hard at `InvoiceCreateSerializer`, and it trusted three things it should not have: `status` was a plain writable field (POST `status="Paid"` and you mint an invoice that reports as paid with no -Payment behind it — `amount_paid` stays 0, `amount_due` the full total, and AR, +Payment behind it: `amount_paid` stays 0, `amount_due` the full total, and AR, aging and revenue all lie), and both `template_id` and each line item's `product` -were unscoped FKs (attach another org's template or product — a cross-tenant +were unscoped FKs (attach another org's template or product, a cross-tenant IDOR), while account/contact/opportunity beside them were already org-validated. All three sit in one serializer and were closed together: `status` made read-only (a new invoice is always Draft; it advances only through the send/mark-paid/cancel endpoints, which is where the state machine belongs), plus a `validate_template_id` and a line-item product-org check mirroring the existing `validate_account_id`. -The lesson: "it doesn't write yet" is exactly why the write path rots — the moment +The lesson: "it doesn't write yet" is exactly why the write path rots, the moment you wire the form, prove every FK is org-scoped and every status/flag the client can set is one it is allowed to set. **Driving the real form finds the mismatch a fixture can hide.** The builder's own -UI labelled Title *optional* — true for a page that never called the API, and +UI labelled Title *optional*: true for a page that never called the API, and false the instant it did: `Invoice.invoice_title` is not blank, so the create 400s without it. A fixture can claim any contract; only the real POST tells you -which one the server keeps. The fix ran the honest direction — the backend is +which one the server keeps. The fix ran the honest direction. The backend is authoritative, so the UI conformed to it (Title made required, `ready` gates submit on it), not the reverse. And the builder had a second gap the serializer exposed: it collected no contact at all, yet `contact_id` is required and -cross-validated against the account — so wiring it meant *adding* a contact +cross-validated against the account, so wiring it meant *adding* a contact picker, scoped to the chosen account, before the form could ever succeed. **A fixture can model a different product than the backend built, and then "wiring" is a fork.** The service analytics dashboard went twentieth, the first -tickets sub-page — and the only module so far where the fixture and the API did +tickets sub-page, and the only module so far where the fixture and the API did not describe the same thing. The page is a service-desk view: opened/closed volume per day, first-response met/missed *per priority* (it refuses on purpose to average four different promises into one number), a case-type mix, and a per-agent "open now / closed this week" table. The backend `cases/analytics` -module computes a different model — FRT/MTTR/SLA-breach with drilldown and CSV -export — and several of the page's cards had no source at all in it. That is not +module computes a different model, FRT/MTTR/SLA-breach with drilldown and CSV +export, and several of the page's cards had no source at all in it. That is not a wiring job with a defect to fix; it is a choice, and the choice changes both what you build and how much new attack surface you add, so it belonged to the user, not to a silent default. They chose to extend the backend to the page: one new admin-only `analytics/service/` endpoint assembling the whole shape from the org-scoped queryset (per-day volume, per-priority attainment against the -`workflow.py` targets, case-type counts, an agent table with its own time bases — +`workflow.py` targets, case-type counts, an agent table with its own time bases; `open` point-in-time, `closed_this_week` a trailing week, FRT over the window), plus the business-hours calendar name pulled from `business_hours`. The general rule this draws out: when the mock over-specifies past what the server computes, -name the fork out loud — reshape the page to the backend, or extend the backend -to the page — rather than quietly picking one and calling it wiring. +name the fork out loud, reshape the page to the backend, or extend the backend +to the page, rather than quietly picking one and calling it wiring. **An org aggregate is admin-only, and the read gate is a policy call, not a missing check.** The seven per-metric analytics endpoints already existed and @@ -625,23 +625,23 @@ were clean: org-scoped, and deliberately narrowing a *non-admin* to their own cases so a rep can see their own FRT. But the dashboard reads as a whole-organisation management surface ("who is carrying it"), and showing a rep that page scoped to their own slice under org-wide headings would misrepresent -it. So the new endpoint 403s a non-admin outright — the same shape as the +it. So the new endpoint 403s a non-admin outright, the same shape as the invoices reports page, and the same reasoning: a figure that only means anything across the whole org has no honest per-user rendering. Adding that gate tightens -access; it weakens nothing. The classification is the tool — shared config reads +access; it weakens nothing. The classification is the tool. Shared config reads wide and writes narrow, an owned record scopes per user, an org aggregate is -admin-only — and analytics, like reports, is the third kind. +admin-only, and analytics, like reports, is the third kind. **Wiring the button is how you find the missing check behind it.** The approvals queue went twenty-first, and it carried the sharpest security finding of the set: `ApprovalApproveView` checked that you were in the rule's approver pool and -nothing else, so an admin — who defaults into every rule's pool — could approve a +nothing else, so an admin, who defaults into every rule's pool, could approve a close request they had filed themselves. The old page even had a banner admitting it (`is_own_request` was shown, but the Approve button stayed live, because -disabling it client-side changes nothing — the endpoint accepted the POST +disabling it client-side changes nothing, the endpoint accepted the POST regardless). Separation of duties belongs in the view, not the page: the fix is a Profile-to-Profile guard (`approval.requested_by_id == request.profile.id → 403`) -in both approve and reject, with no admin exception — the whole point is that the +in both approve and reject, with no admin exception. The whole point is that the requester, admin or not, cannot be the decider. Both fields are `Profile` FKs, so the comparison is `Profile.id` to `Profile.id`; comparing against `request.profile.user` would be the always-False `created_by` type-mismatch this @@ -652,24 +652,24 @@ and a dedicated test asserts the 403. **A button guard is a claim about the server; make the server the one source.** The queue shows Approve/Reject only where the viewer may actually act, and the -page refuses to recompute that — pool membership and identity live server-side, so +page refuses to recompute that. Pool membership and identity live server-side, so guessing yes for someone the API says no to means a button that 403s. That guard is a serializer field, `can_act`, and the moment the self-approval rule landed it had to fold the same rule in: `can_act = pending AND in-pool AND not-the-requester`, computed from `request` in serializer context. Now the button the page offers and the decision the endpoint makes come from one function, so they cannot drift. The -own-request row loses its Approve/Reject and keeps only Withdraw — which is the -one thing a requester *may* do to their own request — and the banner explains why +own-request row loses its Approve/Reject and keeps only Withdraw, which is the +one thing a requester *may* do to their own request, and the banner explains why rather than tempting a click that would fail. **The rail you did not have to leave dead is the one that catches you out.** -Tickets shipped a "Linked articles" rail built from real `Case.solutions` rows -— an `` pointing at a page whose fixtures were +Tickets shipped a "Linked articles" rail built from real `Case.solutions` rows, +an `` pointing at a page whose fixtures were keyed `SOL-01`. It was written as a link because the data was real, and every one of those links was a 404 waiting for the first org to file an article against a ticket. **The crawl did not catch it**: no seeded case has a linked solution and no seeded org has an article at all, so the link never rendered. -That is the whole failure mode — a crawler only sees the links the seed data +That is the whole failure mode, a crawler only sees the links the seed data happens to produce, and the knowledge base had never been seeded. When a rail renders conditionally, check the empty case by *creating* the data, not by loading the page. The knowledge base went sixth to close it. @@ -680,23 +680,23 @@ contacts were wired, as plain text, because there was no task page to point at. Nothing was broken and no crawler could have complained; the rows were simply a dead end for three sessions. Tasks went seventh to close it. The general form: after wiring a module, list not only the links that 404 but the rails that -*should* be links and are not — `grep -n 'v2-label' src/routes/v2/*/\[id\]/+page.svelte` +*should* be links and are not: `grep -n 'v2-label' src/routes/v2/*/\[id\]/+page.svelte` and ask, for each heading, whether its rows go anywhere. **The fixture search is a rail too, and it was missed for four modules.** The command palette searched `mock/*.js` and linked to fixture ids, so the moment a -module went live every result it offered became a 404 — `acc-bluepeak`, `421`. +module went live every result it offered became a 404: `acc-bluepeak`, `421`. That went unnoticed through leads, pipeline, accounts and contacts because nothing points *at* the palette to check. Its blocks for the five wired modules are gone; delete a module's block in the same change that adds its prefix, until there is a real search endpoint. **Grep for the fixture arrays, not just the -routes** — `grep -n '\.\.\.' src/lib/v2/api.js`. +routes**, `grep -n '\.\.\.' src/lib/v2/api.js`. **A prefix can over-claim.** `/v2/tickets` is the first migrated prefix with sub-pages that did not come with it: `analytics` and `approvals` sit under it on different endpoints. A prefix match alone hangs "Live data" over both. `migrated.js` grew a `NOT_MIGRATED_ROUTES` list of exact paths for that, checked -first — exact, so a real `/v2/tickets/` cannot be caught by an entry meant +first, exact, so a real `/v2/tickets/` cannot be caught by an entry meant for a sibling. Both tickets sub-pages (`analytics`, `approvals`) are now live and off that list; only `tasks/board` remains on it. Before adding a prefix, list what is under it; when a sub-page goes live, delete its exact-path entry in the @@ -720,26 +720,26 @@ telling somebody a page is a sandbox while they edit a real customer. behind a form that still pretends to save is not migrated. **Add a line to `LIVE_COUNTS` in `routes/v2/+layout.server.js`** in the same -change — *if the module has a badge at all*. Otherwise the sidebar badge keeps +change, *if the module has a badge at all*. Otherwise the sidebar badge keeps its fixture and contradicts the page it links to. The counts run concurrently; each falls back to its fixture rather than failing the shell. -Accounts deliberately has no entry. These badges count work — leads to call, -deals to move, tickets to answer — and "Accounts 10" counts inventory: it never +Accounts deliberately has no entry. These badges count work. Leads to call, +deals to move, tickets to answer, and "Accounts 10" counts inventory: it never goes down and nothing about it is ever waiting on you. A badge you learn to ignore teaches you to ignore the ones beside it. The count was added, seen to render nothing, and removed rather than given a nav slot it had not earned. **A cleared checkbox submits nothing, which is what "leave this alone" looks like.** The whole PATCH scheme rests on absent-means-unchanged, and that makes an -unticked box indistinguishable from a field the form does not own — so "do not +unticked box indistinguishable from a field the form does not own, so "do not call" could be switched on and never off. Each flag on the contacts form posts a hidden `_present` partner that is always sent, and the action reads the flag only when its partner arrived. Any boolean on a PATCH form needs this; test it by turning the thing off, not on. **A multi-select needs the same marker for the opposite reason.** Deselecting -everything in a `` submits *nothing*: identical, over the wire, to a field the form does not own. The ticket form's "people affected" posts a hidden `contacts_present` so "remove the last person" is expressible at all. Same shape as the checkbox rule, same test: try to empty it, not fill it. @@ -752,18 +752,18 @@ list from one value. A deal with two people on it quietly lost one whenever anybody edited the description. All three forms now post a hidden `assigned_to_original` and the action only forwards the field when it actually changed. This was found by -saving a real two-assignee record and counting afterwards — not by reading the -code — and it is the check to repeat on every module: save something with more +saving a real two-assignee record and counting afterwards, not by reading the +code, and it is the check to repeat on every module: save something with more than one of everything, then count the relations. **Check what a verb *other* than the one you are wiring does.** In four of the five modules the object-level check was wrong in `get`/`put`/`patch`/`post` and right in `delete` and in the list filter. Where verbs disagree, the majority is -not automatically the policy — read both and work out which one somebody meant. +not automatically the policy. Read both and work out which one somebody meant. The knowledge base is the sixth and it had nothing to disagree with: `permission_classes = [IsAuthenticated]` on all four views and no object-level -check anywhere. **Absence is easy to read past** — five modules of practice at +check anywhere. **Absence is easy to read past**, five modules of practice at spotting a *wrong* check gives no help at all against a missing one, and the file looks tidy. Ask the question the other way round: for each verb, name the person who may not do this. On `Solution` there was no such person. A plain @@ -771,8 +771,8 @@ person who may not do this. On `Solution` there was no such person. A plain 200 and 204. Tasks is the seventh, and it is the `created_by` type mismatch in its purest -form: the *list* spelled the creator clause correctly — -`Q(created_by=profile.user)` — and every other verb compared `request.profile` +form: the *list* spelled the creator clause correctly, +`Q(created_by=profile.user)`, and every other verb compared `request.profile` to it, which is never equal. So a member could create a task, watch it appear in their own list, and then get a 500 opening it and a 403 on every write. **The one place a check is written correctly is the best evidence the others are @@ -782,7 +782,7 @@ for the check. **A dead permission clause and a writable field that feeds it are one bug, not two.** `created_by` was writable on the task serializer and accepted any `User` id in the database, including another org's. That was a data-integrity bug only -while nothing read the field — the moment the creator clause starts returning +while nothing read the field. The moment the creator clause starts returning `True`, an assignee can PATCH themselves into being the creator and gain the right to delete. Fixing the comparison without fixing the field would have converted a dormant bug into an escalation. When you repair a check, look at @@ -795,7 +795,7 @@ the detail view dropped that clause; delete dropped the assignee as well. So a watcher's queue listed exactly one ticket and opening it answered 403. **When you consolidate divergent checks, do not consolidate them into the widest -one.** The obvious fix — one `assert_case_access` matching the list — would have +one.** The obvious fix, one `assert_case_access` matching the list, would have handed every watcher the right to edit and reply, which nothing ever claimed. Watching is subscribing, not being handed the keys. `cases/access.py` is three rules on three lines instead: read is admin/creator/assignee/watcher, write drops @@ -804,9 +804,9 @@ widening write access would have been a privilege escalation shipped as a cleanup. `cases/kb_access.py` is the same shape for a different reason. Writing an -article and *approving* one are not degrees of the same permission — the whole +article and *approving* one are not degrees of the same permission. The whole point of a review workflow is that somebody other than the author says the -answer is right — so `write` is author-or-admin and `release` (approve, +answer is right, so `write` is author-or-admin and `release` (approve, publish, unpublish) is admin, and `release` takes no article argument at all, because being its author is the one fact that must not grant it. Where two verbs legitimately differ, keep two functions whose names say so. @@ -815,35 +815,35 @@ verbs legitimately differ, keep two functions whose names say so. like the control; `PATCH {"is_published": true}` was the same act through the door beside it, and `PATCH {"status": "approved"}` was self-approval with no endpoint involved. The view checks whether a request *moves* either field -before demanding an admin — a transition, not a value, so an author fixing a +before demanding an admin: a transition, not a value, so an author fixing a typo on their own already-approved article is not stopped by a gate meant for the person who approved it. Contacts is the clearest case of the four, because the two defects compound: the `created_by` comparison refused the creator, and the `users_mention` line under it raised `AttributeError` for everyone else. A role="USER" profile could open -**no contact at all** — 403 on the ones they entered, 500 on the ones assigned to -them — while deleting the first kind worked. Four tests in `test_contacts_api.py` +**no contact at all**: 403 on the ones they entered, 500 on the ones assigned to +them, while deleting the first kind worked. Four tests in `test_contacts_api.py` pinned that behaviour rather than reporting it, with names like `test_add_comment_with_serializer_fields_hits_save_bug` and docstrings saying "exercises the branch and verifies the bug". **A test written to reach a line is not a test of anything.** They assert the fixed behaviour now. -Invoices is the eighth, and the invoice itself was already right — every verb +Invoices is the eighth, and the invoice itself was already right, every verb routed through `invoices/permissions.py::get_invoice_or_error`, which compares `created_by` against `request.user` correctly and which a note in the file says the other apps should copy. The bug was that the *estimate* endpoints beside it never did. **A centralised, correct check is only worth as much as the callers -that use it** — the audit's job was to find the sibling entity that reached +that use it**. The audit's job was to find the sibling entity that reached around it. Estimates had two shapes of the same failure at once. The detail, convert and send views carried **no object check at all**, only an org filter, -so any member could read, edit, delete, convert or send any estimate in the org -— the `Solution` shape from the knowledge base, a missing check rather than a +so any member could read, edit, delete, convert or send any estimate in the org, +the `Solution` shape from the knowledge base, a missing check rather than a wrong one. And the list carried the `created_by` Profile-vs-User mismatch in its loudest form: `Q(created_by=request.profile)` does not silently return `False` the way an `==` does, it reaches the query layer as `Q(created_by=)` and **raises `ValueError`**, so every non-admin's estimate list was a 500, not a -short list. Same root cause as the dead `==`, opposite symptom — one hides, the +short list. Same root cause as the dead `==`, opposite symptom. One hides, the other shouts. The fix generalised the invoice helper to a shared `has_object_access` and routed the estimate views through `get_estimate_or_error`, and the estimate PDF's creator-lockout (the familiar @@ -851,58 +851,58 @@ other shouts. The fix generalised the invoice helper to a shared **Flagged, not fixed, because it needs a decision:** any org member can `PUT` an `InvoiceTemplate` whose `template_html`/`template_css` WeasyPrint renders with -`base_url=BASE_DIR` — arbitrary HTML to a local-file/SSRF sink, no role gate. +`base_url=BASE_DIR`: arbitrary HTML to a local-file/SSRF sink, no role gate. Restricting template writes to admins narrows it but does not close it (an admin can still inject); sanitising the template is the real fix and a larger change. It stays open. Team-and-access is the ninth, and it carried the most severe finding of the -series — an org-admin escalation, live. `UserDetailView` lets a member edit +series. An org-admin escalation, live. `UserDetailView` lets a member edit their **own** profile (the guard passes `self.request.profile.id == profile.id` through), and the update runs through `CreateProfileSerializer`, which exposed `role` and `is_organization_admin` as writable. So a plain `USER` could `PATCH /api/user//` with `{"role": "ADMIN"}` and become an org -admin — proven end to end: after the PATCH, the same token opened the admin-only +admin. Proven end to end: after the PATCH, the same token opened the admin-only roster it had been 403'd from a moment earlier. This is **mass assignment**, not -the `created_by` type mismatch — a different class with the same shape as every +the `created_by` type mismatch, a different class with the same shape as every "server-derived field is client-writable" bug: the field that decides permissions was in the serializer's `fields` and the self-edit path had no -reason to withhold it. The mock had it right and the backend did not — the +reason to withhold it. The mock had it right and the backend did not, the page's own header comment claimed "the server refuses to let anyone change their own role," a rule that did not exist. The fix makes the privileged fields (`role`, `is_organization_admin`, `has_sales_access`, `has_marketing_access`) read-only unless the actor is an admin acting on **someone other than -themselves** — one predicate that closes both the member's self-promotion and an +themselves**, one predicate that closes both the member's self-promotion and an admin's self-demotion (which would be the other way to strand the org without an admin). A last-active-admin guard on the deactivate endpoint closes the only remaining lockout path. **When you repair who may edit a record, look at what -the serializer lets them write in the same change** — the same lesson tasks +the serializer lets them write in the same change**, the same lesson tasks taught with `created_by`, one severity higher. The API-tokens page is the tenth, and its finding runs the other way: **the mock -cried wolf about a hole the backend had already closed.** The page's headline — -and the matching alert on the team page — was "a token whose owner was +cried wolf about a hole the backend had already closed.** The page's headline, +and the matching alert on the team page, was "a token whose owner was deactivated keeps working with their old role." It does not. `resolve_valid_pat` rejects any token whose `profile.is_active` is false, and deactivating an account sets exactly that flag; `test_pat_auth.py::test_inactive_profile_raises` has -proven it all along. So an "owner deactivated" token is **dormant** — refused at -login today, revived only if the account is reactivated — not a live credential. +proven it all along. So an "owner deactivated" token is **dormant**, refused at +login today, revived only if the account is reactivated, not a live credential. Wiring the page truthfully meant *downgrading* a security alarm, which is only safe once you have proven the backend is actually safe: the reframe ships with a new end-to-end test (`test_pat_org_api.py::test_deactivated_owner_token_is_ rejected_at_the_api`) alongside the existing unit one, and the false copy was corrected on both this page and the team page (rust → clay: a loose end to clear on offboarding, not a breach). **A fixture that asserts a protection is a claim -to verify against the backend, in either direction — it can be more careful than +to verify against the backend, in either direction. It can be more careful than the server (the team escalation) or less (this one).** **Flagged, not fixed, because it changes an auth chokepoint:** `resolve_valid_pat` checks `profile.is_active` and `org.is_active` but **not** `profile.user.is_active`. Deactivation in this app is profile-level, so the common offboarding path is -covered — but if a `User` account is globally disabled (e.g. by a superadmin) +covered, but if a `User` account is globally disabled (e.g. by a superadmin) while a profile stays active, that user's PATs keep authenticating. It is defense-in-depth, not a live exploit in the normal flow, and adding -`or not pat.profile.user.is_active` is a one-line change — but it alters the auth +`or not pat.profile.user.is_active` is a one-line change, but it alters the auth path every PAT request runs through, so it is left for an explicit decision rather than slipped into a page migration. @@ -911,37 +911,37 @@ rather than slipped into a page migration. Fixtures are written to make a design look right, so wiring one up is also the first honest audit of it. Leads lost three panels: -- **The qualification checklist** — "decision maker identified", "budget +- **The qualification checklist**: "decision maker identified", "budget confirmed", four ticks gating a Convert button. No model records any of it. It rendered identically on every lead, so the count was decoration and the gate gated nothing. -- **The always-on duplicate warning** — "Another lead shares this website", true +- **The always-on duplicate warning**: "Another lead shares this website", true of every lead the mock ever drew. It is a real query now and renders only when there is an answer. A warning that is always on is one people learn to skip. -- **`last_activity_at`** — a field Lead does not have. The mock file said so in +- **`last_activity_at`**: a field Lead does not have. The mock file said so in its own header: aging (`StageAgingConfig`, `get_aging_status()`) is Opportunity-only. The column reads `last_contacted` now and says "Not - contacted" where it is null, rather than substituting `updated_at` — an edit + contacted" where it is null, rather than substituting `updated_at`. An edit is not a conversation. Real data also broke two things fixtures never could: the rail's `grid-template-columns: 100px 1fr` scrolled sideways on a long email (a grid -track's implicit minimum is its content's min-width — it wants +track's implicit minimum is its content's min-width: it wants `minmax(0, 1fr)`), and 11 of 20 seeded leads carry phone numbers with `x123` extensions that the model's own `flexible_phone_validator` rejects. Accounts lost two more, and the second was the most confident invention yet: -- **`renewal_note`** — "Renews every August", "Prospect — no contract yet", +- **`renewal_note`** ("Renews every August", "Prospect) no contract yet", filled in for all fifteen fixture accounts and shown in a table column. There is no contract, subscription or renewal model anywhere in the backend, and nothing to derive one from. It read as a fact somebody would plan a quarter around. -- **`customer_since`** — a stored date on a model that has no such field. It +- **`customer_since`**: a stored date on a model that has no such field. It comes back as `first_won_on`: the close date of the first deal won against the company, which the CRM does know. The page still says "customer since"; the field name says where the date came from. When an invented field has an honest - derivation, derive it and name it after the derivation — not after the claim. + derivation, derive it and name it after the derivation, not after the claim. The fixture also got one thing exactly right, and it is worth copying. Its header refused to store `lifetime_value` / `open_pipeline` / `overdue_amount`, @@ -952,48 +952,48 @@ that looks finished.** The phone problem is wider than leads: **7 of 9 seeded accounts, 7 of 15 contacts and 11 of 20 leads** carry phones the validator rejects. On accounts it -is now visible rather than mysterious — the edit form mirrors the exact regex, +is now visible rather than mysterious, the edit form mirrors the exact regex, so the offending field is marked instead of the whole save failing with no field named. The underlying mismatch still needs a decision: widen the regex, or fix the seeder. The pipeline lost three of its own: -- **The "next action" card** — "Call Kavi about the legal review". `Opportunity` +- **The "next action" card**: "Call Kavi about the legal review". `Opportunity` has no such field and nothing derives one. A suggestion the system invented is worse than no suggestion, because people act on it. - **`last_activity_at`** again, in the rail. The row reads "Stage since" now, - from `stage_changed_at` — a narrower claim, and the one the board is actually + from `stage_changed_at`: a narrower claim, and the one the board is actually coloured by. -- **A contact's `relationship`** — "Champion", "Blocker". Contact has `title` and +- **A contact's `relationship`**: "Champion", "Blocker". Contact has `title` and `department`. The **Attached** rail of related invoices and tickets went too: both are real models, but neither is on the opportunity response, so filling that panel costs two cross-module requests per page load. The board also stopped claiming "Drag a card to move a stage". There is a real -move endpoint, but nothing is draggable — naming a gesture that does not exist +move endpoint, but nothing is draggable. Naming a gesture that does not exist is the habit this redesign is meant to break. Contacts lost the same two fields a fourth and a third time, and gained a distinction nobody had noticed: -- **`relationship`** — "Champion", "Blocker" — for the fourth time. On this page +- **`relationship`** ("Champion", "Blocker") for the fourth time. On this page it was load-bearing: a table column, a rail row, the subtitle under every colleague, and half the headline sentence. Contact has `title` and `department`. - **`last_activity_at`** again, and here it took the page's whole argument with it. The mock sorted the list by it, coloured a column by it, and built the banner on "nobody has spoken to X in 47 days". Nothing in the CRM records when - anyone last spoke to a contact — Lead has `last_contacted`, Contact has no + anyone last spoke to a contact. Lead has `last_contacted`, Contact has no equivalent, and there is no activity table to derive one from. The column reads `updated_at` under the label **Updated**, which is a fact about the record rather than about the relationship, and the banner now only fires on things the data can carry: they have left, nobody can reach them, or money is riding on a record with no owner. - **One account, where there are two.** The fixture gave each contact an - `account_id`. A Contact is joined to an Account twice over — the `account` FK + `account_id`. A Contact is joined to an Account twice over: the `account` FK the model documents as "primary", and membership of `Account.contacts`, which - is many-to-many — and the two are independent. In the seeded org **not one of + is many-to-many, and the two are independent. In the seeded org **not one of the fifteen contacts has the FK set**, while twelve are in the M2M; the account page builds its people list from the M2M, so the account field on a contact form wrote to a column no page read. Setting the primary account now records @@ -1004,34 +1004,34 @@ distinction nobody had noticed: `organization` survives that but is demoted. It is free text, and across the seeded org it routinely names a *different* company from the account the person -is attached to — so it appears only where there is no account link to show, and +is attached to, so it appears only where there is no account link to show, and is labelled "Company typed in" rather than presented as the account. Tickets lost the thing the whole page was arranged around: -- **`#421`** — a ticket number. `Case` has a UUID and a subject and nothing else +- **`#421`**: a ticket number. `Case` has a UUID and a subject and nothing else to call itself by. The "#" column, the crumb and every row in the "also open here" rail were printing a field that does not exist. The subject is the identifier now. -- **`first_response_target_minutes`** — and the fixture's own note about it had +- **`first_response_target_minutes`**, and the fixture's own note about it had the schema backwards. It said the field "models the escalation policy"; but `EscalationPolicy.first_response_target` is a **Profile to notify** on a breach, not a duration. The target is `Case.sla_first_response_hours`, and the deadline derived from it walks the org's business calendar and adds any time the ticket spent Pending. The page takes that deadline from the server rather than doing wall-clock arithmetic beside it. **Check a fixture's comments - against the model, not just its fields** — this one was cited as evidence. -- **`next_action`** — invented for the fifth time. The page states what is true + against the model, not just its fields**. This one was cited as evidence. +- **`next_action`**: invented for the fifth time. The page states what is true (unanswered, waiting on the customer, nobody assigned) and gives no advice. -- **"Suggested articles"** — the rail is the articles *linked* to the ticket. +- **"Suggested articles"**: the rail is the articles *linked* to the ticket. Suggestions are a different endpoint doing keyword matching, and labelling a filed link as a guess misreads what the rail is for. The sharper find was on the other side. Two fields the design leaned on are real columns that **nothing in the codebase ever wrote**: `first_response_at` had no writer at all, and `resolved_at` had exactly one, in -`close_with_children`. Four things read the first — the breach property, the -queue column, the escalation scan, the FRT dashboard — so every ticket in every +`close_with_children`. Four things read the first: the breach property, the +queue column, the escalation scan, the FRT dashboard, so every ticket in every org was permanently first-response breached and the escalation task re-fired on tickets answered hours ago. Both are stamped in `cases/signals.py` now. **A field existing is not evidence anybody fills it in**; grep for an assignment @@ -1039,18 +1039,18 @@ before building a column on one. The knowledge base lost one panel and one sentence: -- **"Related articles"** — three other articles under a heading that never said +- **"Related articles"**: three other articles under a heading that never said what the relation was. There are no tags, no embeddings and no "customers also read"; nothing in the schema computes relatedness and nothing could. What the database does hold is the M2M to `Case`, so the rail is now the - **tickets this article is filed against** — the same link the ticket page + **tickets this article is filed against**: the same link the ticket page already draws, pointing back. An invented relation replaced by a real one is a better trade than an invented relation deleted. - **"That is allowed, and sometimes right."** The new-article form offered `status` and `is_published` as free switches and told the writer a published draft was a deliberate choice. It is not a choice: `Solution.publish()` has always refused it. The mock could say that because it never submitted - anything, which is the general hazard — **a form that does not post can + anything, which is the general hazard. **a form that does not post can contradict the model indefinitely and read as considered while it does.** `use_count` survived under a new name (`case_count`, and it is real), and @@ -1063,18 +1063,18 @@ Tasks lost a relation and a stat card: - **A task attached to an invoice.** The fixture had `related: { kind: 'invoice', … }` linking to `/v2/invoices/INV-2025-0142`, and "Chase the Bluepeak invoice" is exactly the task a real rep writes. `Task` has four - optional parents — account, opportunity, case, lead — and no invoice FK. + optional parents (account, opportunity, case, lead), and no invoice FK. Nothing would have populated it. **A relation that reads as obviously necessary is not evidence of a column**; check the model before designing the rail on it. -- **"Done this week."** `Task` has no `completed_at` — only `BoardTask`, the - other table, does — so nothing anywhere records *when* a task was finished. +- **"Done this week."** `Task` has no `completed_at`, only `BoardTask`, the + other table, does, so nothing anywhere records *when* a task was finished. The number could only have been invented. It is **"no due date"** now, which is real and is the better card: a task with no due date is never overdue and never due this week, so it appears in no count and in front of nobody, which is the one thing about it worth being told. -The fixture's sort — priority, then due date, nulls last — did not survive +The fixture's sort (priority, then due date, nulls last) did not survive either, and that is fine: it was a comparator expressing "no date means whenever, not now", and the same observation is a stat card now, where it can be acted on rather than quietly determining scroll position. @@ -1088,7 +1088,7 @@ Invoices lost a reminder log and a way of computing a summary: about the client opening anything. The "they opened it" line was pure invention. The rail shows the real counters now. The same gap runs through the actions: the button says "Send a reminder" but the only endpoint is - `POST /send/`, which re-mails the invoice and stamps `sent_at` — it does not + `POST /send/`, which re-mails the invoice and stamps `sent_at`. It does not touch `reminder_count`, because reminders are the automated task's job. The button re-sends; the counters describe the schedule; the page says both plainly rather than pretending the click bumped the count. @@ -1107,10 +1107,10 @@ than fields it does. - **Two rules the fixture and the page both claimed the server enforced.** The mock's own comment said "the endpoint refuses to let anyone change their own role or remove the last admin, and the UI mirrors those two rules as hints." - Neither rule existed server-side — the first was the escalation above, the + Neither rule existed server-side: the first was the escalation above, the second let an admin deactivate the last admin and strand the org. **When a fixture asserts a protection as fact, that is a claim to verify, not a spec to - trust** — here the claim was aspirational and the backend had to be made to + trust**. Here the claim was aspirational and the backend had to be made to match it. - **Names, teams and token counts the serializer does not carry.** The fixture gave every person a `first_name`/`last_name`, a `teams` array and an @@ -1118,10 +1118,10 @@ than fields it does. person teams and no token count. Names fall back to the single field (and to the email when someone was invited and has not set one); teams are derived from `/api/teams/`, whose serializer nests each team's members, so there is - one source of truth rather than a second field to drift; and the token count — - a live signal on this page, since a not-yet-revoked token on a deactivated + one source of truth rather than a second field to drift; and the token count. + A live signal on this page, since a not-yet-revoked token on a deactivated account is a dormant liability (stopped at login today, revived if the account - is reactivated; see the tokens entry below) — was added to the users list as a + is reactivated; see the tokens entry below), was added to the users list as a real per-profile count, computed once in the view rather than on the shared serializer every endpoint pays for. **Nothing was invented to fill a column**; the ones the API could not back were derived, sourced, or added, not faked. @@ -1129,8 +1129,8 @@ than fields it does. "will appear here once they sign in"; the endpoint creates their Profile immediately and active, so they show up at once as a member who has "never" signed in. The copy says that instead. Team CRUD (creating a team, editing - membership) stayed a fixture — a separate write surface with its own picker, - left as estimates were left beside invoices — so the Teams list renders + membership) stayed a fixture, a separate write surface with its own picker, + left as estimates were left beside invoices, so the Teams list renders read-only and says so. The API-tokens page is the sharpest version of "verify the claim, don't trust @@ -1154,16 +1154,16 @@ made the page say something reassuring where the mock had raised an alarm. "value shown once" rule was only a caption. Create posts to the self-scoped `/api/profile/tokens/`, the raw value comes back in that one response and is shown once behind a Copy button; the list has only the 13-char prefix - thereafter, and the server keeps a hash. **The page is admin-only** — it lists - every owner's tokens — so a non-admin gets the same clean "Admins only" state + thereafter, and the server keeps a hash. **The page is admin-only**. It lists + every owner's tokens, so a non-admin gets the same clean "Admins only" state team uses, and no token metadata leaks to a member at all. ### PATCH, not PUT **All five** of `LeadDetailView.put`, `OpportunityDetailView.put`, `AccountDetailView.put`, `ContactDetailView.put` and `CaseDetailView.put` call -`.clear()` on their M2M fields unconditionally — `assigned_to`, `tags`, -`contacts`, `teams` — and re-add only what the body carried. A form that owns the +`.clear()` on their M2M fields unconditionally: `assigned_to`, `tags`, +`contacts`, `teams`, and re-add only what the body carried. A form that owns the scalar fields would therefore strip every tag each time somebody corrected a phone number; on an account it would strip every contact, off the record whose whole purpose is the people. `patch` guards each with `if "" in params`. @@ -1179,7 +1179,7 @@ gate**: a PUT sends every field on every save, so it always looks like a request to set `status`, and an author correcting a typo on their own approved article would be told they may not approve it. Where a gate reads transitions, sending unchanged fields manufactures transitions. The edit form posts a hidden -`status_original` and forwards `status` only when it actually moved — the same +`status_original` and forwards `status` only when it actually moved, the same marker pattern as `assigned_to_original`, guarding an authorization check rather than a relation. @@ -1191,15 +1191,15 @@ second one. The task edit form posts `parent_kind_original` and `parent_id_original` and only sends the parents when they actually moved. That last one has a wrinkle the other markers do not: when the parent *does* -change kind, both columns have to travel in the **same request** — the old one -set to `null` alongside the new one — because sent one at a time they are, quite +change kind, both columns have to travel in the **same request**. The old one +set to `null` alongside the new one, because sent one at a time they are, quite correctly, two parents. The rule refusing that is the rule working; the form has to speak in whole states rather than in steps. Absent also has to stay absent all the way through. On a converted lead the status select is disabled and submits nothing; sending its current value anyway trips `validate_status`, which reads `value == current` on an irreversible -status as a repeat conversion and rejects the whole save — including the job +status as a repeat conversion and rejects the whole save, including the job title you were actually trying to fix. ### Totals are the API's job @@ -1212,20 +1212,20 @@ list total is the specific bug this redesign exists to fix. The pipeline adds `amount_sum`, `weighted_sum` (SUM of `amount * probability / 100`) and `stalled_count`. `stalled_count` shares one `stalled_filter()` helper with the `?rotten=true` query, so the number in the header and the red pills on -the rows underneath it cannot mean two different things — +the rows underneath it cannot mean two different things, `test_agrees_with_the_pill_on_the_row` asserts exactly that. The same view gained `?open=true`. The existing `stage` filter is a `contains` match and cannot express "not closed", so the chip reading "Stage is not Closed" was previously satisfied by fetching everything and dropping the closed ones -client-side — correct only until the first page boundary. +client-side, correct only until the first page boundary. Accounts needed a different shape: not one total per list, but seven figures per -*row* — `won_amount`, `won_count`, `open_pipeline`, `open_deal_count`, +*row*: `won_amount`, `won_count`, `open_pipeline`, `open_deal_count`, `overdue_amount`, `open_tickets`, `first_won_on`. `annotate_rollups` in `accounts/views.py` attaches them to both the list and the detail queryset, so a number cannot change meaning depending on which page you read it from, and the -serializer emits them under `rollups` — `null`, not a row of zeroes, anywhere +serializer emits them under `rollups`: `null`, not a row of zeroes, anywhere that did not annotate. Two things about that annotation are easy to get wrong: @@ -1244,7 +1244,7 @@ Where a figure exists elsewhere, it is defined once and imported: open stages come from `opportunity.workflow.CLOSED_STAGES`, open tickets from `cases.workflow.TERMINAL_STATUSES`, unpaid invoices from a new `invoices.models.UNPAID_STATUSES` that the AR aging report now shares. Overdue -money is keyed off `due_date`, not off `status == "Overdue"` — that status is +money is keyed off `due_date`, not off `status == "Overdue"`. That status is set by a nightly Celery task, so anything reading it alone reports nothing on a day the task has not run. On the seeded database that is worth $47,384.40 on one account. @@ -1258,8 +1258,8 @@ answers all seven in one query and took the list from 18 queries to 12. `distinct=True` is not optional there: a non-admin's queryset joins the assignee M2M, so without it a task with two assignees counts twice. -The list also gained the form catalogues the other modules already publish — -`status`, `priority`, `users`, `accounts_list`, `contacts_list` — which is what +The list also gained the form catalogues the other modules already publish: +`status`, `priority`, `users`, `accounts_list`, `contacts_list`, which is what the third of the three red tests in `tasks/` had been asking for since it was written. They are **119 KB of the 128 KB response** on the seeded org, so `?slim=true` omits them; the list pages send it and only the forms do not. @@ -1269,36 +1269,36 @@ written. They are **119 KB of the 128 KB response** on the seeded org, so The board does **not** group the paginated list. Lanes built from one page are wrong in the way that looks right: every column renders, each is just short. `/opportunities/kanban/` returns each column with its true `item_count` and the -same RBAC scoping, capped at 100 cards per column — the lane says so when it is +same RBAC scoping, capped at 100 cards per column. The lane says so when it is truncated rather than stopping silently. ### Still missing -**Leads** — no create, import, kanban, bulk actions, delete, or **Convert** (the +**Leads**, no create, import, kanban, bulk actions, delete, or **Convert** (the button states the consequence and is disabled). -**Pipeline** — has create. Missing: delete, bulk actions, line-item editing, +**Pipeline**, has create. Missing: delete, bulk actions, line-item editing, drag-to-move, and comments (the timeline reads them, nothing posts one). -**Accounts** — has create and edit. Missing: delete, bulk actions, attaching +**Accounts**, has create and edit. Missing: delete, bulk actions, attaching contacts/teams/tags (the form states that it does not touch them), comments, and the activity timeline. The list has no filter controls: the loader forwards `search`, `name`, `city`, `industry`, `assigned_to` and `tags` if a URL carries -them, but nothing on the page sets one — a filter chip that changes the URL and +them, but nothing on the page sets one, a filter chip that changes the URL and nothing else is worse than no chip. -**Contacts** — has create and edit, including the active/inactive split, which +**Contacts**, has create and edit, including the active/inactive split, which is a real API filter now (`?is_active=`) rather than rows discarded after they -arrive. Missing: delete, bulk actions, import (the endpoints exist — +arrive. Missing: delete, bulk actions, import (the endpoints exist: `/contacts/import/preview/` and `/import/commit/`), attaching teams/tags, removing somebody from an account, and posting a comment. Comments *are* now -recordable — the endpoint could never save one before — and the detail page +recordable (the endpoint could never save one before) and the detail page reads them, but nothing writes one yet. The account picker holds 200 accounts and says so when there are more; past that it wants a search rather than a select. The list forwards `search`, `name`, `city`, `email`, `phone`, `assigned_to` and `tags` but, as with accounts, nothing on the page sets them. -**Tickets** — has create, edit, replying (public or internal note) and status +**Tickets**, has create, edit, replying (public or internal note) and status changes including close and reopen. Missing: delete, bulk actions, import, attaching files, merging, watch/unwatch, linking a knowledge-base article, parent/child, and time entries. `/v2/tickets/analytics` and @@ -1307,12 +1307,12 @@ banner honest about them. The queue forwards `search`, `priority`, `case_type`, `account` and `assigned_to` and sets `status` itself; as with the others, nothing on the page offers those controls yet. -**Knowledge base** — has create, edit, the review workflow (send for review, +**Knowledge base**, has create, edit, the review workflow (send for review, approve) and publish/unpublish. Missing: delete (the API has it, gated to author-or-admin; no page offers it), linking an article to a ticket from either page, and the customer-facing KB site the `kb_views` docstring records as a deliberate cut. The list forwards `search`, `status` and a `visibility` chip -that maps to `?is_published=`, and — as with every other module — nothing on +that maps to `?is_published=`, and, as with every other module, nothing on the page sets them yet. Worth knowing before building on `is_published`: with the customer-facing site @@ -1320,20 +1320,20 @@ cut, publishing today gates exactly one thing, the agent suggester behind the reply composer (`/cases//solution-suggestions/`). The flag is not currently a public-exposure switch. It is still worth gating as one, because that is what it will be the day the site ships and because the review workflow is an -integrity control in its own right — but the severity of the hole it left is +integrity control in its own right, but the severity of the hole it left is "anybody could approve their own answer", not "anybody could publish to the internet". -**Tasks** — has create, edit, delete (admin or creator), the per-row tick, +**Tasks**, has create, edit, delete (admin or creator), the per-row tick, reassigning, and comments including posting one. Missing: bulk actions, attaching files, tags and teams (the API takes them; no form offers them), -editing or deleting a comment, and any filter control — the loader forwards +editing or deleting a comment, and any filter control, the loader forwards `search` and `priority` and nothing on the page sets either. `/v2/tasks/board` stays on fixtures and is listed in `NOT_MIGRATED_ROUTES` for a sharper reason than "not done yet": a card is a `tasks.BoardTask`, a **different table** from the `tasks.Task` the list writes. They share a word and nothing -else — a card never appears in the task list and a task never appears on a +else, a card never appears in the task list and a task never appears on a board. Wiring one did not wire the other, and a prefix match would have claimed it did. Whether the product wants two task systems is a question for somebody else; until it is answered, the banner should not imply they are one. @@ -1350,8 +1350,8 @@ remove something people use. **Ticket subjects must be unique per org, case-insensitively.** `CaseCreateSerializer.validate_name` enforces it. For a helpdesk that is a -strange rule — "Cannot log in" is a subject three customers will send this week -— and email-to-ticket does not go through this serializer, so the constraint +strange rule, "Cannot log in" is a subject three customers will send this week, +and email-to-ticket does not go through this serializer, so the constraint binds the UI and not the inbox. Left as found; the create form warns about it up front so the refusal is at least legible. @@ -1364,7 +1364,7 @@ drive-by. ### Two things about the knowledge base somebody has to decide **Existing published drafts.** Any org that used these endpoints before this -change can hold articles that are `is_published` and not `approved` — the +change can hold articles that are `is_published` and not `approved`, the create path allowed it. The serializer now enforces the invariant only on requests that *move* one of the two fields, deliberately: judging the stored combination on every request would make those rows uneditable, refusing a @@ -1375,7 +1375,7 @@ unpublishes them outright is the other answer and it is a decision, not a cleanup: it removes articles from the suggester without telling anybody. **Nothing seeds the knowledge base.** `seed_data` creates leads, accounts, -contacts, deals, tickets and invoices, and zero articles — which is why the +contacts, deals, tickets and invoices, and zero articles, which is why the module sat unaudited and why the ticket rail pointing at it was never seen to break. A few seeded articles, one of them filed against a seeded ticket, would have caught this a session earlier and would make the crawl meaningful for @@ -1387,7 +1387,7 @@ whatever links here next. the "one parent entity" rule reached the database at all, and it is also why breaking that rule used to be a 500: Django's `ValidationError` is not DRF's, so nothing translated it. The view catches it now and answers 400 with the model's -own message, which is the right handling — but the underlying arrangement means +own message, which is the right handling, but the underlying arrangement means **every future constraint on `Task` will arrive as an exception from `save()` rather than from a serializer**, on every write path including Celery tasks and the admin. Either the other models should do the same and the API should @@ -1400,7 +1400,7 @@ state that produced this bug. **The invoice template is an HTML-injection sink and nobody guards it.** Any org member can `PUT /api/invoices/templates//` with arbitrary `template_html` and `template_css`, and WeasyPrint renders them with `base_url=BASE_DIR` when a -PDF is generated — a local-file read and SSRF primitive, with no role check. The +PDF is generated. A local-file read and SSRF primitive, with no role check. The same is true of `Product` and `RecurringInvoice` writes: org-shared billing config a plain member can rewrite. Gating those writes to admins is a strict improvement and removes the non-admin path to the sink, but it does not close @@ -1409,7 +1409,7 @@ in a sandbox or run through a sanitiser. This session flagged it and left it; it is a real decision, not an oversight. **`/v2/invoices/new` is still a preview, on purpose.** The list, the detail and -the lifecycle writes — send, mark-paid, cancel, duplicate — are live, and +the lifecycle writes (send, mark-paid, cancel, duplicate) are live, and Duplicate is itself a real create path that lands on a fresh draft. But the from-scratch builder, with its line-item editor and product/account pickers, is a larger surface than the read-plus-lifecycle scope this change covers, so it @@ -1418,7 +1418,7 @@ stays on fixtures in `NOT_MIGRATED_ROUTES`. Whoever picks it up wires waiting on it. **"Send a reminder" is `POST /send/`, which is not quite a reminder.** There is -no manual-reminder endpoint that bumps `reminder_count` — that counter belongs +no manual-reminder endpoint that bumps `reminder_count`. That counter belongs to the automated `send_invoice_reminders` task. The button re-mails the invoice and updates `sent_at`, which is the closest real action, and the page is honest about the split (the rail's counters describe the schedule; the button says @@ -1430,7 +1430,7 @@ wanted, it is a new endpoint, not a frontend change. **A globally-disabled `User` keeps their PATs.** `resolve_valid_pat` checks `profile.is_active` and `org.is_active` but not `profile.user.is_active`. The app's normal offboarding path (the team page's Deactivate) is profile-level, so -it is covered — a deactivated member's tokens are rejected. But if a superadmin +it is covered. A deactivated member's tokens are rejected. But if a superadmin disables the underlying `User` account while a profile stays active, that user's tokens keep authenticating. Adding `or not pat.profile.user.is_active` to the check is one line, but it sits on the auth path every PAT request runs through, @@ -1438,11 +1438,11 @@ so it wants an explicit decision rather than being folded into a page migration. Flagged, not fixed. **Self-service token management is not on this page.** `/v2/settings/api-tokens` -is the admin *oversight* view — every token in the org, read and revoke, gated +is the admin *oversight* view: every token in the org, read and revoke, gated to admins. A regular member has no v2 surface to create or see their own tokens; the self-scoped `/api/profile/tokens/` endpoint exists and is wired for create, but there is no member-facing page around it yet. That is deliberate scope, the -same way team CRUD was left beside team — a "your tokens" page for the profile +same way team CRUD was left beside team, a "your tokens" page for the profile area is the follow-on, not a gap in this one. ### Two things about organization settings somebody has to decide @@ -1453,34 +1453,34 @@ other field in the request body is silently dropped. `OrgSettingsView` (`PATCH /api/org/settings/`) is the serializer-based one the settings page uses, and it takes the org from `request.profile` rather than a URL id. The v1 org settings page already sends `domain` and `description` to `/api/org/settings/`, -neither of which is a field on the serializer — so those are dropped too. The two +neither of which is a field on the serializer, so those are dropped too. The two should converge on one endpoint (the settings one), and the half-built `OrgUpdateView` should either learn the full field set or be retired, so no future caller "saves the org" through the path that keeps only its name. Flagged, -not fixed — retiring an endpoint is a decision, not a page migration. +not fixed. Retiring an endpoint is a decision, not a page migration. **Reading org settings is open to every member; only editing is admin-gated.** -`GET /api/org/settings/` has no role check — any member sees the company profile, +`GET /api/org/settings/` has no role check: any member sees the company profile, tax ID, contact details and locale. That is intentional (a member legitimately sees their own org's address and currency, and the same values are printed on invoices they already handle), and the v2 page renders read-only for a non-admin with no edit affordance. If the tax ID or billing email is ever considered -admin-only, the gate goes on the GET, not on the page — the page is not the +admin-only, the gate goes on the GET, not on the page. The page is not the boundary. Worth a deliberate call rather than an assumption either way. -## The kanban board — /v2/tasks/board (twenty-second module, last sub-page) +## The kanban board: /v2/tasks/board (twenty-second module, last sub-page) This finishes the sub-pages: every page under a migrated prefix now reads and writes the real org, and `NOT_MIGRATED_ROUTES` is empty. A board card is a -`tasks.BoardTask` — a different table from the `tasks.Task` the list page writes -— so a prefix match never covered it, which is exactly why it was the one route +`tasks.BoardTask`: a different table from the `tasks.Task` the list page writes, +so a prefix match never covered it, which is exactly why it was the one route still opted out. **Wiring the drag is what found the missing check behind it.** The page had no interactivity at all: `svelte-dnd-action` was a dependency with zero uses, and the "New card" / board-picker buttons had no handlers. The board reads two calls -now — `GET /boards/?archived=false` for the picker and the active board, then -`GET /boards//columns/`, which nests each column's cards — flattened into the +now; `GET /boards/?archived=false` for the picker and the active board, then +`GET /boards//columns/`, which nests each column's cards: flattened into the `{ board, boards, columns, tasks }` shape the lanes join by `column_id`. The one write is the move: on drop, the destination lane POSTs `PUT /boards/tasks//` with the new `column` and `order`. Lanes are optimistic; a rejection reloads and @@ -1489,11 +1489,11 @@ the card snaps back, so the board never shows a move the backend didn't make. **The backend said it could move a card and it couldn't.** `BoardTaskDetailView.put` is documented "including moving to a different column," but `column` was `read_only` on `BoardTaskSerializer`, so DRF stripped it from `validated_data` and -every cross-column PUT returned 200 with the card still in its old lane — a +every cross-column PUT returned 200 with the card still in its old lane, a silent no-op, and the kanban's whole point. Fixed in the view, not by making `column` freely writable: the target is resolved and validated to a **sibling column of the same board** (`BoardColumn.objects.filter(pk=..., board=board)`), -so a card can't be relocated to another board's — or another org's — column by +so a card can't be relocated to another board's, or another org's, column by mass assignment. A bad or foreign column id is a 400, not a no-op. The move and any reorder then resequence the touched lane(s) to a contiguous `0..n-1` so the drop lands exactly where it was released and reloads are deterministic (the @@ -1503,12 +1503,12 @@ none was added. **A serializer with `fields = "__all__"` is a write surface until you name it.** The same edit narrowed the rest of `BoardTaskSerializer` to exactly a card's own -content — title, description, order, priority, due_date, and assignees via the +content: title, description, order, priority, due_date, and assignees via the write-only `assigned_to_ids`. `created_by`/`updated_by` are now read-only (they FK `common.User`, which RLS does **not** org-scope, so they were the one live -cross-org mass-assignment here — a client could stamp any user's id onto a card); +cross-org mass-assignment here. A client could stamp any user's id onto a card); the CRM-link FKs `account`/`contact`/`opportunity` are render-only (RLS covers -cross-org in prod, but the querysets were unfiltered — the same hole +cross-org in prod, but the querysets were unfiltered, the same hole `TaskCreateSerializer` hardens). `account` renders as `{id, name}` for the card chip. Seven tests, both ways: the move persists and lands at its index; a cross-board and a nonexistent column both 400 and leave the card put; a reorder @@ -1516,28 +1516,28 @@ resequences with no duplicate `order`; the source lane recompacts; `created_by` can't be overwritten. **Drag was the deliberate call, against the pipeline precedent.** The pipeline -board — the same lanes-of-cards shape — was built *without* drag on purpose ("an +board, the same lanes-of-cards shape, was built *without* drag on purpose ("an interface that names an action it can't do is worse than one that doesn't"), and changes a deal's stage on its detail view. A board card has no detail page: the card *is* the interaction, and the fixture framed the drag as its core. Asked and confirmed drag rather than assume it; the backend fix is identical either way, so only the page's mechanism differed. `svelte-dnd-action` brings keyboard support (tab to a card, space to lift), which a bespoke drag would not have. The board -picker is a native GET form (no `goto`), and a non-member sees "No boards yet" — +picker is a native GET form (no `goto`), and a non-member sees "No boards yet"; `GET /boards/` scopes to owner-or-member, so the empty-state branch is a real authorization outcome, not just a fallback. -## Your account — /v2/profile (twenty-third module, first standalone) +## Your account: /v2/profile (twenty-third module, first standalone) -The first module that is not under a migrated prefix — the sub-pages plan is +The first module that is not under a migrated prefix: the sub-pages plan is finished, so this begins the standalone routes (profile, notifications, the dashboard home, the settings cluster, the public portal). It reads three calls into one `$lib/server/v2/profile.js`: `GET /profile/` (the current profile, own -only — `request.profile` — extended with the caller's team names), +only, `request.profile`, extended with the caller's team names), `GET /org/` (every org the caller is a member of, scoped to -`Profile.objects.filter(user=request.user)` — their memberships, not a directory +`Profile.objects.filter(user=request.user)`: their memberships, not a directory of orgs), and `GET /profile/tokens/` (counted here into the "active" number). -Two page fields the fixture invented — job title and time zone — have no column +Two page fields the fixture invented, job title and time zone, have no column on the Profile model, so they were dropped rather than faked into two new columns; teams, which the fixture also showed, *is* real (`Profile.user_teams`) and was surfaced instead. @@ -1545,8 +1545,8 @@ and was surfaced instead. **The self-edit door had to be checked for the escalation the /api/user/ door already closed.** `/v2/profile` posts to `PATCH /profile/` (`ProfileView`), a different endpoint from the `/api/user//` one where a USER could once PATCH -their own `role` to ADMIN. This view was already safe by construction — it -hand-read only `phone` and `name` from the body — but "safe because the code +their own `role` to ADMIN. This view was already safe by construction. It +hand-read only `phone` and `name` from the body, but "safe because the code happens not to read the field" is one refactor away from a hole. It now validates through `ProfileSelfUpdateSerializer`, which *names* only `name` and `phone`: role, org, and the access flags are not fields there, so no request body can @@ -1561,11 +1561,11 @@ wrote `request.data["phone"]` straight onto the column, so any string landed; 400, not an unvalidated string in the DB (blank is still allowed and clears it). The view also 400s without org context instead of serializing `None`. -**Switching org is a real action, so it is wired — as an action, not a field.** +**Switching org is a real action, so it is wired, as an action, not a field.** The page shows a per-org Switch button (against the pipeline "don't name an action you can't do" rule, this one *can* be done). Its `switchOrg` action POSTs to -`/auth/switch-org/` — which issues a token only for an org where you have an -active profile, else 403 — and swaps the httpOnly `jwt_access`/`jwt_refresh`/`org` +`/auth/switch-org/`, which issues a token only for an org where you have an +active profile, else 403, and swaps the httpOnly `jwt_access`/`jwt_refresh`/`org` cookies the shell reads, mirroring the v1 `/org` picker. Verified end to end: an admin who belongs to two orgs switched between them and the current-org flag followed; the httpOnly cookies the switch set could not be overwritten from @@ -1574,14 +1574,14 @@ followed; the httpOnly cookies the switch set could not be overwritten from **Editing surfaced the seed-phone trap and closed it the leads way.** Some seeded numbers carry an extension (`...x655`) the validator rejects. The edit form offers name and phone together, so a plain name change would have re-sent an untouched -bad phone and been blocked by it — so the form only sends a field the person +bad phone and been blocked by it, so the form only sends a field the person actually changed (the PATCH leaves an absent field alone), the same rule the leads owner-select uses. A USER with an invalid seeded phone can rename themselves without first fixing a number they never touched. -## Notifications — /v2/notifications (twenty-fourth module) +## Notifications: /v2/notifications (twenty-fourth module) -The backend here was already right — every endpoint (`GET /notifications/`, +The backend here was already right. Every endpoint (`GET /notifications/`, `POST //read/`, `POST /read-all/`, `DELETE //`) scopes to `recipient=request.profile`, and the both-ways tests already exist (`test_cannot_mark_read_other_users_notification`, `..._does_not_touch_other_users`, @@ -1589,18 +1589,18 @@ The backend here was already right — every endpoint (`GET /notifications/`, the findings were both on the page. **The buttons were fictions.** The fixture page's `markRead`/`markAllRead` only -mutated local state — "nothing is persisted yet" said the comment — so the +mutated local state, "nothing is persisted yet" said the comment, so the Mark-read and Mark-all-read controls looked like they worked and never told the server. They now POST to `?/read` / `?/readAll` actions (which call the recipient-scoped endpoints) while keeping the optimistic local update for instant feedback, and revert if the write is refused. The read fetch is `keepalive` so -clicking a notification — which both opens the ticket and marks it read — does not +clicking a notification, which both opens the ticket and marks it read, does not lose the mark to the navigation it triggers. Verified end to end: mark-one and mark-all both survived a reload (the count came back from the API, not the page). **The link was rewritten wrong, and it is a field the page navigates to.** `Notification.link` is a stored *client* path, and a reader's browser goes there. -The fixture's rewrite was `link.replace(/^\/cases\//, '/v2/tickets/')` — which +The fixture's rewrite was `link.replace(/^\/cases\//, '/v2/tickets/')`, which handled the old broken prefix but left a correctly-produced `/tickets/` link pointing at the *v1* route (out of the v2 app), and passed any other stored value straight through as a live `href`. `resolvedLink` now rebuilds a fresh @@ -1614,24 +1614,24 @@ fix" footnote counting the one old row. modules' fixture counts with real ones; notifications was still on `notificationTotals.unread` (a fixture 3). It now calls `countUnread` (a cheap `limit=1` call returning the API's `unread_count`), so the sidebar badge matches -the page — and drops as you read, since unread is work, not inventory. +the page, and drops as you read, since unread is work, not inventory. -## Today — /v2 (twenty-fifth module, the home screen) +## Today: /v2 (twenty-fifth module, the home screen) The `/v2` home ("Today") was the hardest kind of wiring to classify: the page is -a single prioritised, cross-model **action queue** — quiet deals, tickets past a -first response, overdue invoices, tasks due — while the only backend at +a single prioritised, cross-model **action queue**: quiet deals, tickets past a +first response, overdue invoices, tasks due, while the only backend at `/api/dashboard/` (`ApiHomeView`) is a v1 **KPI dashboard**: counts, entity lists, pipeline-by-stage, revenue metrics. Two different products. There was no Today-queue endpoint, and the dashboard computes nothing for the fixture's -overdue-invoice, ticket-SLA, deal-aging or "cleared yesterday" items — the +overdue-invoice, ticket-SLA, deal-aging or "cleared yesterday" items, the [[fixture-backend-product-mismatch]] fork, brought to the user rather than defaulted. The user chose to **build the queue endpoint** (as with analytics), preserving the flagship UX. -**The new endpoint — `GET /api/dashboard/today/` (`ApiTodayView`).** Org-scoped +**The new endpoint: `GET /api/dashboard/today/` (`ApiTodayView`).** Org-scoped on every query (org from the JWT via middleware, never the client) and, for a -member, restricted to rows assigned to or created by them — the same visibility +member, restricted to rows assigned to or created by them. The same visibility `ApiHomeView` applies; admins see the whole org. It is guarded by `(IsAuthenticated, HasOrgContext)`, which also closes a None-guard gap the older `ApiHomeView` still has (that view is `IsAuthenticated`-only and would @@ -1646,31 +1646,31 @@ seven days. 15 tests, both ways: each source lands in the right bucket admin sees the same colleague-owned row, cross-tenant isolation, and unauth → 403. -**The page needed no reshape — only honest empty states.** The fixture was +**The page needed no reshape, only honest empty states.** The fixture was already built to the `{ queue, summary, later }` contract, so `getToday` in `$lib/server/v2/today.js` just reads the endpoint. But fed real (often sparse) data, the hard-coded header copy read wrong: the sub-line now adapts ("Nothing -needs you right now" at zero; the "gone quiet — those are first" clause only when +needs you right now" at zero; the "gone quiet. Those are first" clause only when there are quiet deals), an "Inbox zero for today" card replaces the empty queue, and the "Later this week" / "cleared yesterday" blocks hide when empty. **One migrated-list subtlety.** `/v2` is the dashboard *root*, so adding it to `MIGRATED_ROUTES` (which matches by prefix) would hang "Live data" over every -still-fixture page under it — settings/*, timesheet, the portal. It is registered +still-fixture page under it: settings/*, timesheet, the portal. It is registered in a new `MIGRATED_EXACT` list instead, matched as an exact path and never as a stem. This module is read-only (the home links out to each item's own module for writes), so there was no DB to restore. Browser-verified admin (39 urgent, 7 quiet worth $5.29M) and member (a scoped 13 / 2, no admin rows leaked) plus mobile 400px. -## Macros — /v2/settings/macros (twenty-sixth module, first settings sub-page) +## Macros: /v2/settings/macros (twenty-sixth module, first settings sub-page) The lightest of the settings cluster and a pure read: the page lists canned replies (org-shared and personal), marks which placeholders are typoed, and shows the seven tokens the server actually expands. The only mutating control is a "New macro" builder, which is not wired (deferred, like the other settings create forms). `getMacros` in `$lib/server/v2/macros.js` reads `GET /macros/`; -the one reshape is `owner` — the API returns a Profile id plus a separate +the one reshape is `owner`. The API returns a Profile id plus a separate `owner_name` (the owner's email; `User` has no display name), folded into the `{ id, name }` the card prints. @@ -1684,17 +1684,17 @@ colleague's private macro, and independently of the `active`/`search` filters so `with_unknown_placeholders` folds in Python because it depends on `find_unknown_placeholders`, the same server check the per-row field uses. `placeholders` is served straight from `macros/render.py`, which is now the -single source of the supported set — `SUPPORTED_TOKENS` (used by the renderer and +single source of the supported set, `SUPPORTED_TOKENS` (used by the renderer and the typo detector) is *derived from* an ordered `SUPPORTED_PLACEHOLDERS` tuple that pairs each token with its description, so the page's reference card cannot drift from the tokens the server expands. -**The finding the wiring surfaced — two authorization gaps.** (1) All three -macro views were `(IsAuthenticated,)` only, missing `HasOrgContext` — the same +**The finding the wiring surfaced: two authorization gaps.** (1) All three +macro views were `(IsAuthenticated,)` only, missing `HasOrgContext`, the same profileless-token `AttributeError` risk `ApiHomeView` still carries; brought in line with the documented standard. (2) A **personal-macro existence leak**: `GET` of a personal macro you don't own returns 404 (invisible), but the write -verbs (`PUT`/`PATCH`/`DELETE`) returned **403** for the same row — so the id +verbs (`PUT`/`PATCH`/`DELETE`) returned **403** for the same row, so the id space leaked which rows were somebody else's private macros. Fixed to 404 on the write path too, matching the GET; an *org* macro edited by a non-admin stays 403 because it *is* visible (authorization refusal, not a hidden object). 8 new @@ -1703,29 +1703,29 @@ cross-org isolation / the server-owned placeholder set, and every verb on another user's personal macro → 404 while the org-macro case stays 403. Read-only, so the fixture and `listMacros` facade stay in `api.js`/`vocabulary.js` -purely to feed the not-yet-wired settings **index hub** its count — the same way +purely to feed the not-yet-wired settings **index hub** its count. The same way the already-live api-tokens and organization fixtures still do. Browser-verified admin and member (personal isolation holds both directions; the typoed macro flags "Broken placeholder" with the wavy underline and "sent 12 times" warning) plus mobile 400px. Seeded macros were deleted afterward; the page writes nothing. -## Tags — /v2/settings/tags (twenty-seventh module, second settings sub-page) +## Tags: /v2/settings/tags (twenty-seventh module, second settings sub-page) Another read-only settings page. It lists every label the org shares (across accounts, leads, deals, tickets), each with per-model usage counts, flags the active ones applied to nothing, and pairs up near-duplicate names. `getTags` in -`$lib/server/v2/tags.js` reads `GET /tags/?include_archived=true` — archived is +`$lib/server/v2/tags.js` reads `GET /tags/?include_archived=true`. Archived is requested so the page can show the "Off" ones an admin has retired. Writes ("New tag", the duplicate "Merge") are deferred builders. -**What the list endpoint was missing — the whole analytics layer.** `GET /tags/` +**What the list endpoint was missing. The whole analytics layer.** `GET /tags/` returned bare tag rows: no usage, no totals. Added both. Per-tag `usage` `{ accounts, leads, opportunities, cases }` is computed as **one correlated -subquery per relation** (`_usage_subquery`), not four `Count`s on a single query -— joining all four M2M through-tables at once multiplies rows (the subquery- +subquery per relation** (`_usage_subquery`), not four `Count`s on a single query, +joining all four M2M through-tables at once multiplies rows (the subquery- rollup rule). Each subquery is **explicitly filtered by org**, not left to RLS: usage is a per-org fact, and RLS is inert for the app's DB role in dev/test, so -the ORM filter is the actual guard — a test attaches the same tag row to a +the ORM filter is the actual guard, a test attaches the same tag row to a foreign-org account and asserts the count stays 1, not 2. `totals` `{ count, active, unused }` is computed over the *whole* org tag set, independent of the list's active/name filters, so the stat cards stay correct @@ -1733,41 +1733,41 @@ when the list is filtered; `unused` = active tags whose four counts are all zero **The authz finding** was the same class as macros: all three tag views (`TagsListView`, `TagsDetailView`, `TagsRestoreView`) were `(IsAuthenticated,)` -only — added `HasOrgContext`. Reads stay open to any member (tags are shared +only. Added `HasOrgContext`. Reads stay open to any member (tags are shared config every record editor needs for pickers); writes were already admin-gated. 6 new tests (usage per model, unused-is-zero, totals count/active/unused, cross-org usage isolation, cross-org totals isolation) plus a corrected pre-existing `test_unauthenticated` that asserted `pytest.raises` where DRF now -returns a 401/403 Response — full tags suite 33 pass. +returns a 401/403 Response. Full tags suite 33 pass. Read-only, so `listTags` + the `tags`/`tagTotals` fixtures stay for the index hub (#63), same as macros. Browser-verified admin **and** member (reads open, so -both see the same org-shared tags — no per-user scoping) against real seed data: +both see the same org-shared tags, no per-user scoping) against real seed data: 5 seeded tags with genuine usage, plus a seeded near-dup / unused / archived to light up the duplicate banner, the "Unused" pill and the "Turned off" count; mobile 400px reflows the table to cards. Seeded rows deleted afterward. -## Custom fields — /v2/settings/custom-fields (twenty-eighth module, third settings sub-page) +## Custom fields: /v2/settings/custom-fields (twenty-eighth module, third settings sub-page) Read-only. Lists the schema an org added to its records, grouped by the record type each field extends, and surfaces the number that matters: `records_missing_value`. `getCustomFields` in `$lib/server/v2/custom-fields.js` reads `GET /custom-fields/` -(no filter — the page dims the turned-off fields, so it needs them). "New field" +(no filter, the page dims the turned-off fields, so it needs them). "New field" is a deferred builder. -**Unlike macros and tags, the authz was already right** — the three +**Unlike macros and tags, the authz was already right**: the three custom-field views already carry `(IsAuthenticated, HasOrgContext)` and gate writes on admin, with reads open to members (the record editor needs the schema). So this module's work was purely the analytics, and the finding is a computed one, not an auth hole. -**`records_missing_value` — counting the records that predate a rule.** Custom +**`records_missing_value`: counting the records that predate a rule.** Custom values live in each entity's `custom_fields` JSONField, and the value pipeline never stores an empty one, so a record "has a value" for a field iff its JSON carries the key: `missing = − ` (`custom_fields__has_key`, which works on both Postgres and the SQLite test DB). `_TARGET_MODELS` maps each of the nine `SUPPORTED_TARGETS` to its model; every -count is **explicitly `org=`-scoped** — RLS is inert for the app's dev/test DB +count is **explicitly `org=`-scoped**. RLS is inert for the app's dev/test DB role, so without the filter the number would fold in (and leak the existence of) another org's records. A test seeds three foreign-org cases and asserts the count stays 1. `totals` `{ count, active, models_extended, required_with_gaps }` is @@ -1778,32 +1778,32 @@ isolation, non-admin still sees analytics); 34 custom-fields tests pass. Read-only, so `listCustomFields` + the fixtures stay for the index hub (#63). Browser-verified admin and member (reads open) against seeded definitions on Case -and Lead (a required dropdown with a partial gap — one case filled, four not — a +and Lead (a required dropdown with a partial gap (one case filled, four not) a required Lead field missing on all 20, an optional textarea, and a turned-off checkbox): the gap banner reads "Severity tier is missing on 4 tickets; Budget band is missing on 20 leads", the stat cards and Required/Off pills and the filter icon all render, and mobile 400px collapses the two-column grid to one. Seeded definitions deleted and the one modified case restored afterward. -## Reopen policy — /v2/settings/reopen (twenty-ninth module, fourth settings sub-page) +## Reopen policy: /v2/settings/reopen (twenty-ninth module, fourth settings sub-page) Read-only. A four-field admin singleton (reopen-on-reply, window, comes-back-as, notify) plus three 30-day metrics. `getReopenPolicy` reads `GET /cases/reopen-policy/`; the whole flat response is the page's `policy`. Authz was already right (`ReopenPolicyView` GET+PUT both `HasOrgContext` + -admin-only), so — like custom-fields — the work was the analytics, and "Edit +admin-only), so, like custom-fields, the work was the analytics, and "Edit policy" is a deferred builder. **This was the first page where the fixture's clean metrics didn't map onto a -clean backend source** — a [[fixture-backend-product-mismatch]], surfaced as an +clean backend source**, a [[fixture-backend-product-mismatch]], surfaced as an AskUserQuestion. The user chose **full honest compute**. The three metrics have three different provenances in the reopen audit trail (`cases/signals.py`): -- `reopened_last_30d` — count of `REOPENED` Activities (one per reopen). -- `replies_after_window_30d` — the signal flags the `COMMENT` Activity +- `reopened_last_30d`: count of `REOPENED` Activities (one per reopen). +- `replies_after_window_30d`: the signal flags the `COMMENT` Activity `out_of_reopen_window=True` when a reply lands too late; counting that flag is the authoritative "reopened nothing" number, judged against the window in force at the time (a recompute here couldn't reproduce a since-changed window). -- `median_days_to_reply` — assembled from two stores because a reopen sets +- `median_days_to_reply`: assembled from two stores because a reopen sets `closed_on = None`: reopened replies keep their delta only in the `REOPENED` metadata's `days_since_close`, while replies that didn't reopen leave their case Closed, so the delta is `commented_on − closed_on` (a hand-rolled join from the @@ -1811,29 +1811,29 @@ three different provenances in the reopen audit trail (`cases/signals.py`): since a reopened case is no longer Closed and drops out of the comment side. Every query is explicitly `org=`-scoped (RLS inert in dev/test). 5 new tests -(`TestReopenAnalytics`) drive the real signal path — a within-window reply +(`TestReopenAnalytics`) drive the real signal path: a within-window reply reopens, an out-of-window reply flags, the median unions a delta-2 reopen with a delta-10 stale reply to 6, plus org isolation and an all-zero baseline; 19 reopen tests pass. Browser-verified against a throwaway scenario (one reopened + one out-of-window case) showing 1 / 1 / 6d and the "1 replies arrived too late" banner; mobile 400px reflows. The throwaway cases, comments and activities were deleted afterward (activities reference cases by `entity_id`, not FK, so they -don't cascade — deleted by hand). +don't cascade, deleted by hand). -## Escalation — /v2/settings/escalation (thirtieth module, fifth settings sub-page) +## Escalation: /v2/settings/escalation (thirtieth module, fifth settings sub-page) Read-only. One admin policy per priority; each card shows the two halves (missed -first response / missed resolution), what each does, and — the reason the page -exists — how many breaches went to a policy that does nothing. `getEscalationPolicies` +first response / missed resolution), what each does, and. The reason the page +exists, how many breaches went to a policy that does nothing. `getEscalationPolicies` reads `GET /cases/escalation-policies/`. Authz was already right (`HasOrgContext` -on both views; POST/PUT/DELETE gate on `_is_admin`) — no finding, like the three +on both views; POST/PUT/DELETE gate on `_is_admin`), no finding, like the three sub-pages before it. Read-wide/write-narrow shared config: a member GETs 200, POSTs 403. The only computed add is per-policy `breaches_last_30d {first_response, resolution}`, and it was the cluster's second fixture-backend fork. The clean -event source — `Activity(action="ESCALATED")` written by -`scan_for_breached_cases` — is the wrong source here: the scanner records an +event source, `Activity(action="ESCALATED")` written by +`scan_for_breached_cases`, is the wrong source here: the scanner records an activity *only when a policy acts* (active, with a target set), so a dead policy (off, or reassign-to-nobody) leaves no event and would read as zero breaches. That is exactly the population the page is built to expose ("breaches told @@ -1860,30 +1860,30 @@ policy off 1/1) → "5 breaches told nobody, 1 of 3 policies do nothing"; mobile 400px stacks the two halves. Seed policies, cases, activities and team deleted afterward. -## Ticket routing — /v2/settings/routing (thirty-first module, sixth settings sub-page) +## Ticket routing: /v2/settings/routing (thirty-first module, sixth settings sub-page) -Read-only. Rules in evaluation order (the order IS the behaviour — the engine +Read-only. Rules in evaluation order (the order IS the behaviour. The engine takes the first match), each read as the sentence it performs. `getRoutingRules` reads `GET /cases/routing-rules/`. Authz already right (`HasOrgContext` on all -views; POST/PUT/DELETE gate `_is_admin`) — no finding. Member GETs 200, POSTs 403. +views; POST/PUT/DELETE gate `_is_admin`), no finding. Member GETs 200, POSTs 403. -This was the fork test #58 taught us to run — and it came out the other way. The +This was the fork test #58 taught us to run, and it came out the other way. The page wants per-rule `matched_last_30d` and org `unrouted_last_30d`, and there IS a clean event source: `cases.routing._apply` writes an `Activity(action="ROUTED")` for every rule that fires on a case, *including* a rule that matched but had an empty pool. So unlike escalation's ESCALATED log (which skips the dead policies -the page is about), the ROUTED log records the whole population — no fork. +the page is about), the ROUTED log records the whole population, no fork. `_routing_analytics(org)` reads that log once: `matched_last_30d` is a count per `rule_id`; `unrouted_last_30d` is the window's cases (opened in 30d, `is_active`) -whose id appears in *no* ROUTED row — literally "no rule matched", which is what +whose id appears in *no* ROUTED row. Literally "no rule matched", which is what "nobody was assigned" means on the card. A rule that matched but could not assign is deliberately NOT unrouted. The view also attaches each round-robin rule's `RoutingRuleState.last_assigned_index` as `state`, so the page can name who is next, and totals `count`/`active`. The SvelteKit server layer flattens `target_assignees` (full profiles) to -`{ id, name, is_active }` — the page flags any assignee whose `is_active` is -false as "deactivated and still in the rotation" — and `target_team` to +`{ id, name, is_active }`: the page flags any assignee whose `is_active` is +false as "deactivated and still in the rotation", and `target_team` to `{ id, name }`. Eight new tests (`TestRoutingAnalytics`) drive the real create-signal path: a firing rule counts matched, an unmatched case is unrouted, **a matched-but-empty-pool case is matched not unrouted** (the key distinction), @@ -1894,7 +1894,7 @@ Incident→round-robin 2 matched with a deactivated agent flagged, 2 cases match nothing → Unrouted 2); mobile 400px turns the match count into a footnote. Seed rules, cases, activities and throwaway profiles deleted afterward. -## Inbound email — /v2/settings/inbound-email (thirty-second module, seventh settings sub-page) +## Inbound email: /v2/settings/inbound-email (thirty-second module, seventh settings sub-page) Read-only. The addresses that turn email into tickets, each with its defaults and two metrics: `cases_last_30d` (tickets opened from the address in 30 days) @@ -1908,22 +1908,22 @@ had `org`/`case`/headers but no mailbox link; `Case` got only a transient alias / forwarding miss it). Surfaced as an AskUserQuestion (proxy vs exact); the user chose **exact**. So a nullable `mailbox` FK was added to `EmailMessage`, set at ingest in `_record_email_message` (the mailbox is already in scope there), -with a migration that backfills historical rows via the same To-header match — +with a migration that backfills historical rows via the same To-header match, so history is best-effort but all new mail is attributed exactly. `SET_NULL` keeps the audit row if a mailbox is later deleted. `_mailbox_analytics(org)` reads that FK: `cases_last_30d` per mailbox = distinct cases *created* in the window (anchored on `case.created_at`, so a reply to an old ticket is not a new one) with an inbound message through the mailbox; -`last_received_at` = newest inbound `received_at` (any message, dropped included -— the address still received mail); org `cases_last_30d` = distinct such cases +`last_received_at` = newest inbound `received_at` (any message, dropped included, +the address still received mail); org `cases_last_30d` = distinct such cases across mailboxes (not a sum, so no double count). NO SECRETS reach the page. The backend still returns `webhook_secret` to admins (the #52 fix strips it only for non-admins), but the SvelteKit server layer rebuilds each row from a fixed allowlist that omits `webhook_secret` and the -`imap_*` columns, so the credential never lands in the browser payload for anyone -— verified by fetching the SSR HTML and asserting the seeded secret value, the +`imap_*` columns, so the credential never lands in the browser payload for anyone: +verified by fetching the SSR HTML and asserting the seeded secret value, the `webhook_secret` key, and `imap_password` are all absent while the mailbox data is present. `default_assignee` (a full profile) is flattened to `{ id, name }`. @@ -1937,7 +1937,7 @@ against a throwaway scenario (support 2 / alerts 1 / a deactivated old address 1 the last firing the "accepts mail and creates nothing" banner); mobile 400px stacks. Seed mailboxes, cases, emails, activities and contacts deleted afterward. -## Approval rules — /v2/settings/ticket-approvals (thirty-third module, eighth settings sub-page) +## Approval rules: /v2/settings/ticket-approvals (thirty-third module, eighth settings sub-page) Read-only. The rules that gate a ticket close and who can clear them. The queue at /v2/tickets/approvals answers "what is waiting on me"; this answers "what will @@ -1945,7 +1945,7 @@ be gated next, and by whom". `getApprovalRules` reads `GET /cases/approval-rules Authz already right (`HasOrgContext`; POST/PUT/DELETE gate `_is_admin`; the #53 cross-tenant PUT hole is closed). Member GETs 200, POSTs 403. -The backend metric was a clean compute — `pending_count` per rule = `Approval` +The backend metric was a clean compute; `pending_count` per rule = `Approval` rows in state `pending` bound to it (the model indexes `(org, state)`), and totals carry `active` + the org's total `pending`. The serializer returns `approvers` as `[{id, email}]`; the page renders them as a sentence, so the @@ -1953,8 +1953,8 @@ server layer flattens to the email strings. `match_team` already arrives as `{ id, name }`. **The finding was on the page, not the backend: a stale security warning.** The -page carried a prominent banner — "an approval can be granted by the person who -asked for it" — asserting that `ApprovalApproveView` never compares requester to +page carried a prominent banner; "an approval can be granted by the person who +asked for it", asserting that `ApprovalApproveView` never compares requester to approver, on every ADMIN-role rule with no named approvers. That gap was real when the page was written but was **closed in #50**: the view now returns 403 when `approval.requested_by_id == request.profile.id`, unconditionally (no admin @@ -1962,26 +1962,26 @@ exception), and the inbox already excludes self-requested rows. Leaving the warning would tell admins a separation-of-duties hole exists when the API enforces exactly that. So the `selfClearable` derivation, its per-rule flag, and the bottom banner were removed, and the file's doc-comment rewritten to record -that the view closed it. The genuinely-still-true trap — approver_role MANAGER +that the view closed it. The genuinely-still-true trap, approver_role MANAGER with no named approvers, which matches nobody because `Profile.role` has no -MANAGER — is kept ("Nobody can clear"). +MANAGER, is kept ("Nobody can clear"). Four new tests (`TestApprovalRuleAnalytics`) cover pending_count per rule, only `pending` state counting (approved/rejected/cancelled excluded), totals count/active, and cross-org isolation; 31 approval tests pass. Browser-verified against a throwaway scenario (named-approver 1 waiting / MANAGER trap flagged -"Nobody can clear" / any-admin 2 waiting) — and confirmed the removed +"Nobody can clear" / any-admin 2 waiting), and confirmed the removed self-approval banner does not render for the any-admin no-approvers rule that would previously have triggered it. Mobile 400px reflows. Seed rules, approvals, cases and activities deleted afterward. -## Business hours — /v2/settings/business-hours (thirty-fourth module, ninth settings sub-page) +## Business hours: /v2/settings/business-hours (thirty-fourth module, ninth settings sub-page) -Read-only. The calendar every SLA target is measured against — the reason +Read-only. The calendar every SLA target is measured against. The reason "answered in 4h" means anything. `getBusinessHours` reads `GET /business-hours/calendar/` (the org's default, created on first read). -No analytics — this was a **reshape**. The backend stores the week as fourteen +No analytics: this was a **reshape**. The backend stores the week as fourteen flat TimeFields (`monday_open`/`monday_close` … `sunday_open`/`sunday_close`, a null pair = "closed") serialized as "HH:MM:SS"; the page reads an ordered `days: [{ day, open, close }]` Monday-first with "HH:MM" times, so the server @@ -1991,7 +1991,7 @@ builders (the backend has admin PUT/POST/DELETE, but no form is wired), so this is reads-only like the rest of the cluster. The finding was the cluster's recurring one: all three `business_hours` views ran -`permission_classes = (IsAuthenticated,)` — **missing `HasOrgContext`** (like +`permission_classes = (IsAuthenticated,)`: **missing `HasOrgContext`** (like macros/tags). Org-scoping was present everywhere (`filter(org=request.profile.org)`), so no cross-tenant leak, but an authenticated-but-org-less token would reach `request.profile.org` on a null profile and 500 instead of getting a clean 403, @@ -1999,15 +1999,15 @@ and it deviated from the documented standard. Added `HasOrgContext` to all three One new test (`TestOrgContextRequired`) proves the fix behaviorally: a force-authenticated request with no `request.profile` (the direct-call path skips -the middleware) now returns 403, not 500 — verified it fails (500) with the guard +the middleware) now returns 403, not 500. Verified it fails (500) with the guard removed and passes (403) with it, so it is not a tautology. The existing 17 tests (authed GET/PUT/holiday CRUD, cross-org 404, validation) all still pass; 18 total. -Browser-verified against a throwaway America/New_York calendar (Mon–Thu 09:00–17:30, -Fri 09:00–17:00, weekend Closed → 42 hours a week, Thursday marked "today", two +Browser-verified against a throwaway America/New_York calendar (Mon-Thu 09:00-17:30, +Fri 09:00-17:00, weekend Closed → 42 hours a week, Thursday marked "today", two holidays with relative dates); mobile 400px stacks the split. Seed calendar + holidays deleted afterward (org had none, restoring the pristine state). -## Settings hub — /v2/settings (thirty-fifth module, closes the settings cluster) +## Settings hub: /v2/settings (thirty-fifth module, closes the settings cluster) The settings landing: it summarises every destination with the current value beside it and a warning where something needs attention. The last page of the @@ -2015,50 +2015,50 @@ cluster, and the one that lets the other nine drop their fixtures. **Fan-out, not a summary endpoint.** The hub needs a number from each destination. `$lib/server/v2/settings.js::getSettingsHub` calls the same server -layers each sub-page uses — concurrently — and assembles their totals, rather +layers each sub-page uses, concurrently, and assembles their totals, rather than a bespoke `/settings/summary/` that would have to reproduce, in Python across eight apps, the per-destination rollups the pages derive. Two of those rollups the endpoints don't return and are computed here from the arrays they do: escalation's `breaches_unhandled_30d` (counted exactly as the escalation page's `breachesGoingNowhere`, so the hub's warning matches the page it links to) and inbound's `silently_dropping`. Added `/v2/settings` to `MIGRATED_EXACT`, -never as a prefix — the sub-pages carry their own prefixes, and a prefix here +never as a prefix. The sub-pages carry their own prefixes, and a prefix here would re-cover them. **Graceful degrade for members.** Three destinations are admin-only: people (`/users/`), API tokens (`/org/tokens/`) and the reopen policy (`/cases/reopen-policy/`) all 403 a member. `listTeam`/`listOrgTokens` fold that into a `forbidden` sentinel; the reopen fetch throws, so it is caught to null -(the first cut 500'd the member hub — the reopen GET being admin-only, unlike +(the first cut 500'd the member hub. The reopen GET being admin-only, unlike the other read-wide settings, was the missed case). Each of the three rows then renders for a member with the destination but no value it is not allowed to -compute — the same choice the shell makes omitting the team badge a member +compute. The same choice the shell makes omitting the team badge a member cannot count. The other nine destinations are member-readable. Browser-verified both roles: admin sees "4 people · 1 admins", "1 live" (⚠), "Within 7 days"; member sees those three rows valueless and no warning count, every other row populated, no 500. Desktop + mobile 400px. -**Closing the cluster.** Each sub-page (#54–#62) had kept its `api.js` facade and +**Closing the cluster.** Each sub-page (#54-#62) had kept its `api.js` facade and `mock/*` fixture *because this hub fanned out to them*. With the hub on the real layers, the eleven hub facades were deleted from `api.js` (listPeople, getOrgSettings, listApiTokens, listRoutingRules, listEscalationPolicies, getReopenPolicy, listMailboxes, listMacros, listTags, listCustomFields, listApprovalRules), their now-orphaned fixture imports removed, and the three fixture files left with zero importers deleted (`mock/team.js`, `mock/handling.js`, -`mock/vocabulary.js`) — plus two pre-existing unused imports tidied in passing. +`mock/vocabulary.js`), plus two pre-existing unused imports tidied in passing. svelte-check 0 errors, eslint clean on `api.js`. No backend change; nothing -committed. This is the last page of the nine-page settings cluster (#52–#63). +committed. This is the last page of the nine-page settings cluster (#52-#63). --- -## The customer portal — v2 becomes the real thing (2026-07-31) +## The customer portal: v2 becomes the real thing (2026-07-31) -The public pages a *customer* sees — the invoice and estimate links emailed to -them, and the CSAT survey — were the last fixtures. The v2 versions under +The public pages a *customer* sees: the invoice and estimate links emailed to +them, and the CSAT survey, were the last fixtures. The v2 versions under `v2/(public)/*` turned out to be design **previews** behind the `/v2` auth guard; the actual customer portal already existed at `(no-layout)/portal/{estimate,invoice}/[token]` and `(no-layout)/csat/[token]` -(v1 design, real `/api/public/` endpoints — the emailed link is `/portal/...`, +(v1 design, real `/api/public/` endpoints. The emailed link is `/portal/...`, from `Estimate.public_url`). The decision was to **make the v2 redesign the real portal** and retire the v1 design, rather than wire throwaway previews. @@ -2068,7 +2068,7 @@ end. Three layers each denied the anonymous customer, and all three are fixed: 1. **DRF middleware** exempt list (already fixed 2026-07-28). 2. **RLS.** An anonymous request runs with `app.current_org` empty, so under a correctly-configured non-superuser role the isolation policy hid the invoice - / estimate / survey row — a 404 for every customer, masked in dev by the + / estimate / survey row, a 404 for every customer, masked in dev by the superuser DB user. Fixed with the `docs/PORTAL_RLS.md` **Option 1**: an unscoped `PortalAccessToken` table mapping `sha256(url_token) → org` (absent from `ORG_SCOPED_TABLES`, no policy), registered when each token is @@ -2078,13 +2078,13 @@ end. Three layers each denied the anonymous customer, and all three are fixed: resolved context reveals it, a bogus context still returns zero. 3. **The SvelteKit guard.** `hooks.server.js` `PUBLIC_ROUTES` listed only `/login`, `/logout`, `/bounce`, so it redirected every anonymous visitor to - `/portal/*` and `/csat/*` to `/login`. Added `/portal` and `/csat` — they + `/portal/*` and `/csat/*` to `/login`. Added `/portal` and `/csat`. They read nothing but token-scoped public endpoints. **The accept endpoint** also stopped taking a quote on trust. Accepting an estimate now enforces `expiry_date` server-side and **requires the acceptor's name and email**, recorded with the request IP and user-agent on new `Estimate` -fields — acceptance authorises an invoice, and the record of who gave it is now +fields, acceptance authorises an invoice, and the record of who gave it is now more than "whoever held the link". The real portal collects name+email in the two-step confirm and forwards the customer's real IP/UA to Django. diff --git a/docs/design/clearing-reference.html b/docs/design/clearing-reference.html index 56dbe7f7..236c06a0 100644 --- a/docs/design/clearing-reference.html +++ b/docs/design/clearing-reference.html @@ -3,7 +3,7 @@ -Clearing — BottleCRM mock set +Clearing. BottleCRM mock set