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 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.
The three clients¶
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 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,
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.
Request lifecycle¶
Tenancy is established in middleware, before any view code runs, in a fixed order defined in
MIDDLEWARE in backend/crm/settings.py:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
"crum.CurrentRequestUserMiddleware",
"common.middleware.get_company.GetProfileAndOrg",
"common.middleware.rls_context.RequireOrgContext",
]
The two that matter for multi-tenancy run last, in this order:
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. For a JWT-bearing request it decodes the access token, reads theorg_idclaim out of the signed payload (never out of a header or body. See Authentication), and looks up an activeProfilefor thatuser_id/org_idpair; only if that profile exists does it setrequest.profileandrequest.org. For an org-API-key request it resolves the key to the org's first activeADMINprofile. 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.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 ifrequest.orgwas never set by the step above, and, this is the step that actually enables tenant isolation, it sets the PostgreSQL session variableapp.current_orgto that org's id withSELECT 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 Multi-tenancy and RLS for how.
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
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
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,
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
Permissions and roles.
Backend layout¶
backend/
├── crm/ # Project config: settings.py, urls.py, celery.py
├── common/ # Cross-cutting: User/Org/Profile, middleware, RLS config, permissions,
│ # auth views, personal access tokens, notifications, tags, teams, custom
│ # fields, documents, packs, security audit log
├── accounts/ # Account (company/customer) records
├── contacts/ # Contact (person) records
├── leads/ # Leads, lead pipelines/stages, lead-to-account conversion
├── opportunity/ # Sales pipeline: Opportunity, line items, stage aging, sales goals
├── cases/ # Support tickets: Case, SLAs, CSAT, escalation/routing, inbound email
├── tasks/ # Task management and Kanban boards (Board/BoardColumn/BoardTask)
├── invoices/ # Billing: invoices, estimates, recurring invoices, products, payments,
│ # and the anonymous client-portal views (invoices.public_urls)
├── 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 itself: it owns the
User/Org/Profile models, the RLS configuration (common/rls/), the permission classes
(common/permissions.py), and every authentication path (common/views/auth_views.py,
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
(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
exactly what those views are for. Only the model layer is strictly one-directional.
The teams Django app referenced in some older documentation and comments no longer exists as a
separate app; Teams is a model in common/models.py, and INSTALLED_APPS in
backend/crm/settings.py has a commented-out # "teams", # Merged into common app line marking
where it used to be.