Django-CRM
CLAUDE.md2.5k starsChanged 5 days agoMITView on GitHub
BottleCRM community edition, a multi-tenant CRM. backend/ is a Django 6.1 + DRF API managed with uv and published to PyPI as django-crm. frontend/ is SvelteKit 3 with Svelte 5 runes, Tailwind 4, shadcn-svelte and pnpm. mobile/ is a Flutter app with its own mobile/CLAUDE.md. Both clients use the same API. Tenants are isolated by PostgreSQL row-level security underneath explicit ORM filters. This is a production multi-tenant app under active attack. The two targets are account takeover and superadmin escalation:…
- Installs packages
We split this CLAUDE.md into the rules it gives an agent
5 rules. About 0 are common, 2 are distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 14
Check by name for: org auto-join by email domain, IDOR on detail and nested routes, mass assignment, refresh tokens surviving a privilege …
- Distinctive: line 26
Agents do not commit, stage or push: Leave changes unstaged for a human to review.
- Very specific: line 38
Test fixtures: common.testing (org_a, org_b, admin_client, user_client, set_rls_context, ...), re-exported by conftest.py.
- Very specific: line 84
Meta.orderingleaks intoDISTINCTandGROUP BY: call .order_by() before .distinct() or .values().annotate(). - Very specific: line 100
Never log a raw axios error in hooks.server.ts, since it carries the tokens: use describeError.
≈ “Common” is an estimate: at least 1 in 5 of the 25,680 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md
BottleCRM community edition, a multi-tenant CRM. backend/ is a Django 6.1 + DRF API managed with uv and published to PyPI as django-crm. frontend/ is SvelteKit 3 with Svelte 5 runes, Tailwind 4, shadcn-svelte and pnpm. mobile/ is a Flutter app with its own mobile/CLAUDE.md. Both clients use the same API. Tenants are isolated by PostgreSQL row-level security underneath explicit ORM filters.
Security first
This is a production multi-tenant app under active attack. The two targets are account takeover and superadmin escalation: assume every change is probed for a way to act as another user, read another org's data, or reach is_superuser. Full rules: docs/contributing/security-rules.md.
- The backend is the only trust boundary. Web and mobile validation are UX; every rule must also live in the DRF serializer or view.
- Identity, org and role are server-derived from
request.profileand the JWT, never the request body. Login, OAuth, JWT claims, token refresh and rotation, email or password change, invitations, membership and role assignment are high-risk by default. - Every view gets
permission_classes = (IsAuthenticated, HasOrgContext)plus its object or role check. When you touch a check, prove it can return both true and false, with a test for each: comparing mismatched types makes a check silently always-false. - Scope every query by org, then check visibility (see Reuse below), and pass
org=oncreate()andserializer.save(). RLS is the safety net, not the contract. - Server-owned fields (
org,created_by,profile, publish flags) areread_only=Trueor popped before save. State transitions validate the source state, not just the target. - Check by name for: org auto-join by email domain, IDOR on detail and nested routes, mass assignment, refresh tokens surviving a privilege change, SSRF, path traversal, stored-markdown XSS, and secrets or another tenant's data in responses and logs.
- Never weaken a protection (RLS policy, permission class, org filter, validator, CSRF or auth check) to make something work: surface it and ask. Flag vulnerabilities you notice, but never in a public issue, PR or commit message: they are reported as
SECURITY.mddescribes.
House rules
- Web and mobile ship together. A user-facing change reaches
frontend/src/andmobile/lib/in the same pass, whichever side it starts on. If one half genuinely cannot ship, add a row to the Backlog inmobile/docs/PARITY.mdand say so. Parity covers behaviour too: validators, status sets, role gates, filter params, currency and timezone rules. Change one client, grep the other. - Mobile first. A web page is done when it works at 390px: no horizontal scroll, no stranded action, tap targets about 44px.
frontend/src/lib/v2/styles/v2.cssturns.v2-tablerows into cards below 768px (refined per cell withdata-m:title,meta,lead,tag,bar,hide) and supplies.v2-tabbarand.v2-fab. Bespoke markup opts out of all of it. 768px is the phone breakpoint; other one-off values in route files are drift. - Bump the version with the change.
versioninbackend/pyproject.tomlis the only version, andbackend/uv.lockrepeats it on itsdjango-crmline. Feature or endpoint: minor. Fix, docs or packaging: patch. Major only for a REST break or a migration that cannot be applied forward. A frontend change bumps too, since every release publishesghcr.io/django-crm/bottlecrm-frontend:<version>; a mobile-only change does not. Seedocs/contributing/releasing.md. - A new Django app is two edits:
INSTALLED_APPSand the hatchpackages/includelists inbackend/pyproject.toml. A missing entry imports fine from a checkout and fails afterpip install;common/tests/test_packaging.pycatches the drift. - Build what was asked, and reuse before adding. No config knob or abstraction without a second real caller. A feature is done when its failure paths, malformed input and wrong-role caller are tested. When you fix a block, check its other branches; no dead code, and no docstring describing behaviour the code lacks.
- Comment sparingly. Comment only what the code cannot say: a non-obvious reason, a workaround, a subtle invariant, a deliberate departure from the obvious approach. Never narrate what it does. Two or three lines, five at most; a longer reason belongs in a docstring or
docs/, with a one-line pointer. State a fact once, where a reader meets it first. Much of this code predates the rule, so the file you are editing is not the standard. This overrides matching the surrounding comment density. - No em dashes in anything: code, comments, docstrings, UI copy, DRF error strings, docs, test names, seed data. Rewrite with a comma, colon, parentheses or two sentences; an en dash or spaced hyphen in the same role is not a fix. A bare
'—'rendered as an empty-value placeholder is a glyph and stays. - Agents do not commit, stage or push. Leave changes unstaged for a human to review.
Reuse before writing
Backend:
- Admin:
common.permissions.is_org_admin(profile). Never compareroleyourself. Permission classesHasOrgContext,IsOrgAdminandIsSuperAdminlive in the same module. - One record:
common.lookups.get_scoped_or_404(model, pk, org)(404 for missing, cross-tenant and malformed ids); a comment or attachment on a visible parent:get_on_visible_record_or_404. Visibility rules live in each module'saccess.py(visible_leads_qs,visible_cases_qs,has_*_access,may_delete_*). - Routes use
<uid:pk>(common.converters.UUIDLikeConverter), never<uuid:pk>or<str:pk>. Query params and payload ids go throughcommon.validators(uuid_param,uuid_list_param,payload_id_list,date_param,decimal_param,choice_list_param). - RLS:
common.tasks.set_rls_contextandclear_rls_context(pair them infinally);get_enable_policy_sqlandORG_SCOPED_TABLESincommon/rls/__init__.py. - Uploads:
common.utils.validate_attachmentbefore the first write, thencreate_attachment. - Money:
common.money(org_currency,currency_block, which never adds two currencies). Time:common.org_time(org_timezone,activate_org_timezone). Links in emails:common.links.frontend_urlandapi_url. Audit:common.audit_log.audit_log. Custom fields:common.custom_fields.validate_payload. - Test fixtures:
common.testing(org_a,org_b,admin_client,user_client,set_rls_context, ...), re-exported byconftest.py.
Frontend:
- Formatting:
#lib/v2/format.js(money(n, currency), which defaults to USD, so passdata.org.currency;shortDate,longDate,relativeTime). The org's "today":todayIn(tz)from#lib/v2/dates.jswithdata.org.timezone. - Admin:
isOrgAdminfrom#lib/admin.js. Labels and tones:#lib/v2/enums.js. Classes:cnfrom#lib/utils.js. DRF errors to a sentence:readableErrorfrom#lib/server/v2/form-errors.js. - Lists and filters:
#lib/v2/filters.js,#lib/server/v2/filter-params.js,#lib/server/v2/list-queries.js,#lib/server/v2/saved-views.js. Custom fields:#lib/server/v2/custom-fields.js. @mentions:MentionTextareaandMentionBodyin#lib/v2/components/, fed bymentionPeople()from#lib/v2/mentions.js. - Components:
#lib/v2/components/<Name>.svelte(PageHeader,Pill,EmptyState,StatCard,ConfirmAction,FilterBar, ...).
Commands
# backend/
uv sync
uv run python manage.py migrate
uv run python manage.py runserver # :8000
uv run celery -A crm worker --loglevel=INFO
uv run pytest leads/tests/test_leads_kanban.py --no-cov # one file (preferred)
uv run pytest --no-cov -x # whole suite, in-memory SQLite
DJANGO_SETTINGS_MODULE=crm.test_settings_postgres DBUSER=<non-superuser role> \
uv run pytest --no-cov -m postgres_only # RLS tests; DB* env vars as in CI
uv run ruff check . && uv run ruff format . # ruff only: no black, no isort
uv run python manage.py makemigrations --check --dry-run
uv run python manage.py manage_rls --status # also --verify-user, --test
# frontend/
pnpm install
pnpm run dev # :5181
pnpm test # vitest
pnpm run check # svelte-kit sync + svelte-check
pnpm run lint # prettier --check + eslint
pnpm run format
docker compose up --build # full stack, from the repo rootCI (.github/workflows/tests.yml) blocks on makemigrations --check, ruff check, ruff format --check, pytest on SQLite and on Postgres, the postgres_only tests as a non-superuser role, a wheel install check, eslint, pnpm check, pnpm test, pnpm build and mkdocs --strict. Prettier only warns. Nothing runs for mobile. Markers, the Postgres role and the three CI runs: docs/contributing/testing.md.
Gotchas
Backend:
created_byis aUserFK;assigned_toholdsProfiles. Compareprofile.user_id == obj.created_by_id. Mixing the two is how permission checks went silently dead.BaseModel.savestampscreated_by/updated_byfrom the request user inside a request and keeps caller values outside one.ATOMIC_REQUESTSis off: validate everything, uploads included, before the first write.Task,CommentandAttachmentscallfull_clean()insave(), so a modelValidationErrorarrives as a 500 unless the serializer checks first.Case.clean()never runs on save.Meta.orderingleaks intoDISTINCTandGROUP BY: call.order_by()before.distinct()or.values().annotate().- A
read_onlyfield is dropped silently: the write returns 200 and nothing changes. Privilege fields turn read-only for callers who may not grant them. - The detail route carries comments and attachments.
/api/<module>/<uid:pk>/takes GET (record plus comments and attachments), POST (adds a comment or attachment), PUT, PATCH and DELETE./comment/<uid:pk>/is PUT, PATCH, DELETE by comment id;/attachment/<uid:pk>/is DELETE only. Deals are/api/opportunities/. Swagger:/swagger-ui/. - SQLite tests cannot see RLS:
crm.test_settingsis in-memory SQLite, where the RLS helpers do nothing. Tenant isolation needs apostgres_onlytest. - Most core models predate
BaseOrgModeland declare their ownorgFK. A new org-scoped model inheritsBaseOrgModel, joinsORG_SCOPED_TABLESand gets an RLS migration; followdocs/contributing/adding-an-org-scoped-model.md. - Celery workers have no middleware: call
set_rls_context(org_id)before any query.
Frontend:
- TypeScript throughout: every module and test is
.tsand every component uses<script lang="ts">. Imports andvi.mockcalls keep the#lib/....jspath (Vite, vitest and svelte-check resolve it to the.tsfile). It is fully strict, with noanyin app code (ESLint, plussrc/lib/no-any.test.tsfor an untyped$props(); tests may use it for doubles, arbitrary data isunknown). API types are generated intosrc/lib/api-schema.d.tsbypnpm api:types: regenerate with any serializer or endpoint change.apiRequestis generic, so annotate the variable it returns into asSchemas[...]; a body is its request type, orFormBody<Schemas[...]>when relayed from a form, andpnpm checkends withscripts/api-contract.ts, which holds every call to its endpoint's reply and request types (enterprise endpoints are insrc/lib/enterprise-api-schema.d.ts); pages usePagePropsfrom./$types; acatchvariable isunknown, read withapiStatus/apiErrorBodyfrom#lib/api-errors.js(never from the mockedapi-helpers.js). A wrong generated type is a backend schema bug: the backend suite checks every response against the schema (common/schema_contract.py, no allowlist), so fix the@extend_schema, never the view. Imports use the#lib/...subpath with the full file (#lib/components/ui/button/index.js), never$lib. Runes only: noexport let,on:click,<slot>,createEventDispatcheror$app/stores. There is no Zod. - Data loads on the server. A route's
+page.server.tscalls#lib/server/v2/<entity>.js, which callsapiRequestfrom#lib/api-helpers.jswith the cookies; writes are form actions withuse:enhanceandfail(). There are no+page.jsfiles, because the JWT is an httpOnly cookie the browser cannot read. Browser code talks tosrc/routes/api/*/+server.jsproxies or?/action, never to Django, and cannot import#lib/server/*. - A load's returned names are the page's contract.
server/v2returns{ results, totals }and each load renames them. Pages typedatawithPageProps, sopnpm checkcatches a page reading a name its load does not return; it cannot catch a schema that misdescribes the API, which shows as an SSR 500 while the API answers 200. Load the page. - Forms drop values silently. A
<select>with no option matching the stored value submits nothing and clears it on save (usemissingOptionsfrom#lib/v2/pickers.js); an unchecked checkbox submits nothing, so send an explicitfalse. - There are no toasts and no toast library. Feedback is
fail()plusform?.errororform?.message. - Env vars are declared in
src/env.tsand read through$app/env/publicor$app/env/private. In SvelteKit 3 a string starting with/is a route ID: link withresolve()from$app/paths, orasInternalPathfor a built string. - Vitest runs in Node: it cannot compile
.svelteor resolve$app/*beyond the env stubs, so a testable module imports neither. Tests sit beside the code as*.test.ts; run them fromfrontend/. - Never log a raw axios error in
hooks.server.ts, since it carries the tokens: usedescribeError./help(the signed-in user's tickets) is not/help-center(public) and stays out ofPUBLIC_ROUTES.
Checking a page in the browser
cd backend && uv run python manage.py devlogin <email> --org MicroPyramid. It only runs withDEBUG=True. With seeded data,<email>isseed_data's default--email. It prints both tokens and a devtools snippet.- Open the app and run the snippet in the console (or
evaluate_script). It sets thejwt_access,jwt_refreshandorgcookies thathooks.server.tsreads, then reloads. Tokens inlocalStoragealone still redirect to/login. /is the dashboard; there is no/dashboardroute. Check narrow layouts with device emulation at 390px, since a resized window may not go below about 500px.
--org takes an id or a name, and the user needs an active profile in that org (seed_data creates one).
In this repositoryOne other file it gives its agents.
- mobile/CLAUDE.mdCLAUDE.md
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

