agentleFS
MicroPyramidMicroPyramid/Django-CRM/CLAUDE.md

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
  1. Distinctive:

    Check by name for: org auto-join by email domain, IDOR on detail and nested routes, mass assignment, refresh tokens surviving a privilege …

    line 14
  2. Distinctive:

    Agents do not commit, stage or push: Leave changes unstaged for a human to review.

    line 26
  3. Very specific:

    Test fixtures: common.testing (org_a, org_b, admin_client, user_client, set_rls_context, ...), re-exported by conftest.py.

    line 38
  4. Very specific:

    Meta.ordering leaks into DISTINCT and GROUP BY: call .order_by() before .distinct() or .values().annotate().

    line 84
  5. Very specific:

    Never log a raw axios error in hooks.server.ts, since it carries the tokens: use describeError.

    line 100

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

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.profile and 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= on create() and serializer.save(). RLS is the safety net, not the contract.
  • Server-owned fields (org, created_by, profile, publish flags) are read_only=True or 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.md describes.

House rules

  • Web and mobile ship together. A user-facing change reaches frontend/src/ and mobile/lib/ in the same pass, whichever side it starts on. If one half genuinely cannot ship, add a row to the Backlog in mobile/docs/PARITY.md and 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.css turns .v2-table rows into cards below 768px (refined per cell with data-m: title, meta, lead, tag, bar, hide) and supplies .v2-tabbar and .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. version in backend/pyproject.toml is the only version, and backend/uv.lock repeats it on its django-crm line. 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 publishes ghcr.io/django-crm/bottlecrm-frontend:<version>; a mobile-only change does not. See docs/contributing/releasing.md.
  • A new Django app is two edits: INSTALLED_APPS and the hatch packages/include lists in backend/pyproject.toml. A missing entry imports fine from a checkout and fails after pip install; common/tests/test_packaging.py catches 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 compare role yourself. Permission classes HasOrgContext, IsOrgAdmin and IsSuperAdmin live 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's access.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 through common.validators (uuid_param, uuid_list_param, payload_id_list, date_param, decimal_param, choice_list_param).
  • RLS: common.tasks.set_rls_context and clear_rls_context (pair them in finally); get_enable_policy_sql and ORG_SCOPED_TABLES in common/rls/__init__.py.
  • Uploads: common.utils.validate_attachment before the first write, then create_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_url and api_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 by conftest.py.

Frontend:

  • Formatting: #lib/v2/format.js (money(n, currency), which defaults to USD, so pass data.org.currency; shortDate, longDate, relativeTime). The org's "today": todayIn(tz) from #lib/v2/dates.js with data.org.timezone.
  • Admin: isOrgAdmin from #lib/admin.js. Labels and tones: #lib/v2/enums.js. Classes: cn from #lib/utils.js. DRF errors to a sentence: readableError from #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: MentionTextarea and MentionBody in #lib/v2/components/, fed by mentionPeople() 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 root

CI (.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_by is a User FK; assigned_to holds Profiles. Compare profile.user_id == obj.created_by_id. Mixing the two is how permission checks went silently dead.
  • BaseModel.save stamps created_by/updated_by from the request user inside a request and keeps caller values outside one.
  • ATOMIC_REQUESTS is off: validate everything, uploads included, before the first write.
  • Task, Comment and Attachments call full_clean() in save(), so a model ValidationError arrives as a 500 unless the serializer checks first. Case.clean() never runs on save.
  • Meta.ordering leaks into DISTINCT and GROUP BY: call .order_by() before .distinct() or .values().annotate().
  • A read_only field 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_settings is in-memory SQLite, where the RLS helpers do nothing. Tenant isolation needs a postgres_only test.
  • Most core models predate BaseOrgModel and declare their own org FK. A new org-scoped model inherits BaseOrgModel, joins ORG_SCOPED_TABLES and gets an RLS migration; follow docs/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 .ts and every component uses <script lang="ts">. Imports and vi.mock calls keep the #lib/....js path (Vite, vitest and svelte-check resolve it to the .ts file). It is fully strict, with no any in app code (ESLint, plus src/lib/no-any.test.ts for an untyped $props(); tests may use it for doubles, arbitrary data is unknown). API types are generated into src/lib/api-schema.d.ts by pnpm api:types: regenerate with any serializer or endpoint change. apiRequest is generic, so annotate the variable it returns into as Schemas[...]; a body is its request type, or FormBody<Schemas[...]> when relayed from a form, and pnpm check ends with scripts/api-contract.ts, which holds every call to its endpoint's reply and request types (enterprise endpoints are in src/lib/enterprise-api-schema.d.ts); pages use PageProps from ./$types; a catch variable is unknown, read with apiStatus/apiErrorBody from #lib/api-errors.js (never from the mocked api-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: no export let, on:click, <slot>, createEventDispatcher or $app/stores. There is no Zod.
  • Data loads on the server. A route's +page.server.ts calls #lib/server/v2/<entity>.js, which calls apiRequest from #lib/api-helpers.js with the cookies; writes are form actions with use:enhance and fail(). There are no +page.js files, because the JWT is an httpOnly cookie the browser cannot read. Browser code talks to src/routes/api/*/+server.js proxies or ?/action, never to Django, and cannot import #lib/server/*.
  • A load's returned names are the page's contract. server/v2 returns { results, totals } and each load renames them. Pages type data with PageProps, so pnpm check catches 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 (use missingOptions from #lib/v2/pickers.js); an unchecked checkbox submits nothing, so send an explicit false.
  • There are no toasts and no toast library. Feedback is fail() plus form?.error or form?.message.
  • Env vars are declared in src/env.ts and read through $app/env/public or $app/env/private. In SvelteKit 3 a string starting with / is a route ID: link with resolve() from $app/paths, or asInternalPath for a built string.
  • Vitest runs in Node: it cannot compile .svelte or resolve $app/* beyond the env stubs, so a testable module imports neither. Tests sit beside the code as *.test.ts; run them from frontend/.
  • Never log a raw axios error in hooks.server.ts, since it carries the tokens: use describeError. /help (the signed-in user's tickets) is not /help-center (public) and stays out of PUBLIC_ROUTES.

Checking a page in the browser

  1. cd backend && uv run python manage.py devlogin <email> --org MicroPyramid. It only runs with DEBUG=True. With seeded data, <email> is seed_data's default --email. It prints both tokens and a devtools snippet.
  2. Open the app and run the snippet in the console (or evaluate_script). It sets the jwt_access, jwt_refresh and org cookies that hooks.server.ts reads, then reloads. Tokens in localStorage alone still redirect to /login.
  3. / is the dashboard; there is no /dashboard route. 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).

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.