Management commands¶
Every command below is BottleCRM's own. The entire common/management/commands/ package holds
exactly these five files. Run uv run python manage.py help from backend/ and everything
outside the [common] section comes from Django itself or an installed third-party package
(django, auth, contenttypes, sessions, staticfiles, django_ses, drf_spectacular,
rest_framework, token_blacklist), none of it is project-specific, so it isn't repeated here.
Run any command from backend/ with uv run python manage.py <command> (or, against the Docker
stack, docker compose exec backend python manage.py <command>).
manage_rls¶
Read-only diagnostics for Row-Level Security. None of its three flags modify the database. RLS
policies are enabled and disabled by Django migrations, calling get_enable_policy_sql(), never by
this command (the module's own docstring says so explicitly). It checks the tables listed in
ORG_SCOPED_TABLES (common/rls/__init__.py), which as of this writing has 61 entries. The
repository's CLAUDE.md still says 24; that figure is out of date, and the count is deliberately
not repeated anywhere outside ORG_SCOPED_TABLES itself so it cannot drift again.
uv run python manage.py manage_rls --status
uv run python manage.py manage_rls --test
uv run python manage.py manage_rls --verify-user
| Flag | What it does |
|---|---|
--status |
For every table in ORG_SCOPED_TABLES, queries pg_class.relrowsecurity/relforcerowsecurity and prints ENABLED (forced), disabled, or TABLE NOT FOUND, plus whether the connected role is a superuser. This is also the default, running manage_rls with no flags at all runs --status. |
--test |
Picks two organizations (preferring ones that actually have Lead rows), sets the RLS context to each in turn and counts visible leads, then sets an empty context and counts again. Zero rows with no context and nonzero with a real context means isolation is working; nonzero rows with no context is a warning, not a failure, since some policies deliberately allow an empty context. |
--verify-user |
Queries pg_user for usesuper/usecreatedb on the connected role and prints an explicit WARNING: Superusers bypass RLS! with remediation SQL if the role is a superuser. |
This only covers what the command itself does. For why a superuser role defeats every policy silently, how to create a correctly-restricted role, and what each flag's output actually proves, see PostgreSQL and RLS.
seed_data¶
Generates a large, randomized dataset, accounts, contacts, leads, opportunities, cases, tasks,
invoices and more, for one or more organizations, entirely from curated fictional name/company
pools and reserved demo domains (example.com/.example) so nothing it creates resembles a real
person or business. The first organization created (--orgs index 0) is always named
MicroPyramid, specifically so local-dev workflows have a stable org name to sign in against with
devlogin --org MicroPyramid (see below). For the default, single-org run, re-running the command
with the same --email reuses that existing user, org, and profile rather than creating duplicates;
create_org does an Org.objects.get_or_create(name="MicroPyramid", ...). That reuse guarantee
is specific to index 0: any organization beyond the first (--orgs 2 or higher) gets a name drawn
randomly from a company-name pool each run, so re-running with --orgs 2 typically accumulates
additional organizations rather than reusing the second one from the previous run.
uv run python manage.py seed_data --email you@example.com
uv run python manage.py seed_data --email you@example.com --orgs 2 --leads 100 --seed 42
uv run python manage.py seed_data --email you@example.com --clear --no-input
| Flag | Default | What it does |
|---|---|---|
--email |
aswin.1231@gmail.com |
Email of the admin user to create (or reuse) as the seeded org's admin profile. |
--orgs |
1 |
Number of organizations to create. |
--users-per-org |
3 |
Additional non-admin users created per org. |
--leads |
20 |
Leads per org. |
--accounts |
10 |
Accounts per org. |
--contacts |
15 |
Contacts per org. |
--opportunities |
10 |
Opportunities per org. |
--cases |
5 |
Cases per org. |
--tasks |
10 |
Tasks per org. |
--goals |
8 |
Sales goals per org. |
--teams |
2 |
Teams per org. |
--tags |
5 |
Tags per org. |
--products |
20 |
Products per org. |
--invoices |
50 |
Invoices per org. |
--estimates |
15 |
Estimates per org. |
--recurring-invoices |
5 |
Recurring invoices per org. |
--invoice-templates |
3 |
Invoice templates per org. |
--currency |
USD |
Default org currency; restricted to CURRENCY_CODES choices. |
--country |
US |
Default org country code; also selects the Faker locale used for generated names/addresses (falls back to en_US for an unmapped code). |
--seed |
none | Random seed, for a reproducible run. |
--password |
testpass123 |
Password set on newly created users. |
--clear |
off (flag) | Deletes existing CRM data before seeding, not users, orgs, or profiles. Prompts for confirmation unless --no-input is also given. This is a separate, broader operation from a vertical pack's own sample-data clearing (see Demo data and packs) and isn't scoped to pack-created records specifically. |
--no-input |
off (flag) | Skips the --clear confirmation prompt. |
See Demo data and packs for the narrative walkthrough, including what a full seeded org looks like and how vertical packs layer on top of it.
devlogin¶
Mints a JWT access/refresh pair for an existing user directly from the command line, no OAuth
provider, no outbound email, no browser round-trip. Refuses to run unless settings.DEBUG is
True, raising a CommandError as the very first thing handle() does. That means it prints an
error and exits non-zero in any environment where DEBUG=False, not that it silently does nothing.
uv run python manage.py devlogin <email> --org <name-or-uuid>
uv run python manage.py devlogin <email> --create
| Argument | What it does |
|---|---|
email (positional, required) |
The user to mint a token for. |
--org |
Optional org name or UUID. Resolves id first, then name: it tries Org.objects.get(id=org_arg) first, and only falls back to matching on Org.name if that lookup fails (not found, or org_arg isn't a valid UUID at all). More than one org sharing that name is refused outright rather than guessed at, pass an id instead. The target user must already have an active Profile in the resolved org. This command does not create one, and raises a CommandError naming the missing user/org pair if none exists. |
--create |
Creates the user (with a random, discarded password) if no user with that email exists yet. This only creates the User row. It has no effect on the Profile requirement above; a freshly created user still needs a Profile in the target org before --org will succeed for them. |
On success it prints an access token and a refresh token, the org's UUID if --org was given (a
token minted this way already carries the org_id claim, so no follow-up org-switch call is
needed), and a localStorage.setItem(...) snippet for a browser console. That snippet alone does
not sign you into the SvelteKit app itself; see
Using the tokens in the browser
for the cookies hooks.server.js actually checks. See
First sign-in: Local development
for the full walkthrough, including what happens with no --org at all.
create_default_admin¶
Creates a Django superuser, but only if none already exists; User.objects.filter(is_superuser=
True).exists() short-circuits the whole command to a no-op if one is found, so it's safe to run on
every startup. It takes no arguments at all (no add_arguments override). It reads ADMIN_EMAIL
and ADMIN_PASSWORD directly with os.environ.get, independently of crm/settings.py (ADMIN_EMAIL
happens to share a name and a default with the copy settings.py also reads; ADMIN_PASSWORD is
read nowhere else). If ADMIN_PASSWORD is unset or empty, the command falls back to the literal
password admin and prints a warning. It does not refuse to create the account. This command
runs automatically on every Docker container start (docker/backend/entrypoint.sh, immediately
after migrate), so in the Docker quick start it never needs to be invoked directly, and an unset
ADMIN_PASSWORD in a real deployment means the bootstrap superuser's password really is admin
until it's changed by hand.
audit_org_fields¶
A one-time Phase 1 multi-tenancy hardening tool, not a general or self-updating auditor: it checks
a hardcoded pair of Python lists inside the command itself, models_with_nullable_org
(currently just common.Address and common.Tags) and models_with_required_org (thirteen named
models), not every org-scoped model that exists today. A model added to the codebase since this
command was last edited is invisible to it either way.
uv run python manage.py audit_org_fields
uv run python manage.py audit_org_fields --fix
uv run python manage.py audit_org_fields --verbose
The audit always runs; there is no way to suppress it short of not invoking the command. A
--check flag used to be accepted by the parser and never read in handle(), so passing it or
omitting it produced identical output; it has been removed rather than left as a flag that means
nothing.
| Flag | What it does |
|---|---|
--fix |
For common.Address rows with a null org, attempts to backfill it from a related Profile. For common.Tags, --fix does not fix anything. It prints a message asking for manual review instead. For any other model that turns up with a null org, it also just prints "Automatic fix not implemented." |
--verbose |
Two effects, one per pass: in the nullable-org pass it prints each model's field configuration (null, blank, related_query_name); in the required-org pass it's what makes a "Model not found" warning print at all. Without --verbose, a LookupError there is swallowed silently. |
Output uses ✅/❌/⚠️ markers per model and ends with a summary and a suggested next-steps block
(create a migration, run makemigrations/migrate): it never modifies the schema itself, only
data, and only when --fix is passed.