Conventions¶
Code Style¶
- Python: ruff for linting, formatting, and import sorting (line length 119, target py311)
- JS/CSS: prettier (tab-width 2, single-quote)
- Templates: djlint owns them, not prettier —
djlint-reformat-djangowill rewrite your formatting on commit.templates/prelogin/home.htmlis excluded from both djlint hooks - prek hooks enforce all of the above (reading
.pre-commit-config.yaml) plus django-upgrade (--target-version 5.2), djlint (templates) and eslint (commcare_connect/static/**/*.js); ruff's UP rules cover what pyupgrade used to - Django models should extend
BaseModelfromcommcare_connect/utils/db.py(providescreated_by,modified_by,date_created,date_modified) - Custom
Usermodel uses singlenamefield instead offirst_name/last_name - Single Responsibility: Functions should do one thing (or a few closely related things). If a function is doing too much, split it
- Newspaper metaphor: Order functions top-down — high-level/public functions at the top, helpers/details below
- Prefer class-based views for complex business logic, form handling, and views that switch on request method. Use function views only for simple cases. Note that most existing views are function views (
opportunity/views/is roughly 2:1 function to class) — they are legacy, not the pattern to copy - Use Django Forms over raw HTML forms for validation and rendering
- No inline HTML in Python: Keep templates in
.htmlfiles, not in Python strings - Keep JS in JS files: Don't inline JavaScript in templates; use separate
.jsfiles - Alpine.js for in-page interactivity, htmx for dynamic data loading from the server
- Searchable selects are opt-in: nothing enhances a
<select>by default. Adddata-tomselect="1"to the widget's attrs (or the tag) to havestatic/js/tomselect.jsturn it into a tom-select, and adddata-tomselect:no-remove-buttonon single selects or the chosen value renders as a removable chip. Extra TomSelect options go throughdata-tomselect:settingsas JSON; anything needing render functions gets its own module and must not carrydata-tomselect, or the generic initializer claims it first (seestatic/js/work_area_search.js). Use it for long or searchable lists, not short fixed choices. The page must also loadbundles/js/tomselect-bundle.jsandbundles/css/tomselect.cssor a marked field silently stays native — check they are there, and that they don't arrive by accident:opportunity/user_tasks.htmlgets them only from its permission-gated Create Task modal include, so its filters are searchable for some users and plain for others - Use predefined style classes for elements instead of raw Tailwind utility classes. The vocabulary lives in
tailwind/tailwind.css(button,button-outline-rounded,badge,card_bg,title,status-active, …);grep -E '^\s*\.[a-z][a-zA-Z0-9_-]*\s*\{' tailwind/tailwind.csslists all 76 of them. Plenty of templates still use raw utilities; that's legacy - Tables should fit without horizontal scrolling:
.base-tablesetswhitespace-nowrapon every header and cell, so each column is as wide as its longest label, andbase_table.htmlfalls back tooverflow-x-autowhen the columns don't fit. When you add a column, make room instead of relying on that scroll bar: let long headers wrap, and give narrow numeric columns thecol-statclass (seeSTAT_COLUMN_ATTRSinopportunity/tables/__init__.py). Only accept a horizontal scroll bar when there's an explicit reason, such as a wide data grid, and say why in a comment next to the table - App READMEs are part of the change:
opportunity/README.md,opportunity/PAYMENTS.md,form_receiver/README.md,flags/README.md,health/README.mdandtemplates/README.mddocument non-obvious behaviour (status rules, accrual, status codes and HQ retries, payload contract, constraints, Celery tasks). If your change alters something one of them states, update it in the same PR.
Commits¶
Use semantic commit messages (feat: add CSV export for visits, fix(opportunity): handle missing payment unit). Don't mix change types in one commit; split them instead.
Robustness¶
- Guard against missing environmental dependencies: Code must not hard-fail because the environment isn't fully set up. This covers settings/env values (
settings.MAPBOX_TOKEN, Twilio creds, API keys), DB records that act as configuration (SocialApp,Site,Currency/Country, waffleFlags), and unreachable external services (HQ, ConnectID, OCS) - Required vs optional: if only one feature needs it, degrade that feature alone — hide or disable the UI and say why (see
configured_providerincommcare_connect/users/templatetags/socialaccount_extras.pyandcommcare_connect/templates/ocs/_connect_prompt.html). If the app genuinely can't work without it, fail loudly and early in the code path that needs it rather than half-working. - Never render broken UI: don't emit a button, link or map that 500s or dead-ends when the config is absent
- Set an explicit timeout on outbound calls: all HTTP goes through httpx, which defaults to 5s — fine for quick calls, too short for bulk work, so pass a timeout sized to the operation (see
_make_requestincommcare_connect/connect_id_client/main.py:timeout=10default, 15–30s for bulk sends;commcare_connect/ocs_provider/views.py) - Dispatch Celery tasks after commit:
ATOMIC_REQUESTS = Truemeans a baretask.delay()in a request can run before the transaction commits, so the worker may not see the rows it was told about. Wrap the dispatch:transaction.on_commit(partial(task.delay, obj.pk))(seecommcare_connect/form_receiver/processor.py). Most views still call.delay()directly — don't copy that. - Celery tasks: skip vs retry: missing configuration won't fix itself — log and return (
commcare_connect/audit/tasks.py). A transient failure (unreachable service, lock contention) should retry withautoretry_for+retry_backoff(commcare_connect/opportunity/tasks.py) - Log, don't spam: use the module
logger(logger.exception/logger.error). Log at error level only when the config is expected in that environment; things that are legitimately absent in dev belong atinfo(e.g.audit/tasks.py) - Test the unconfigured path: add a test that runs with the setting unset or the record missing — an optional feature still renders/runs, a required one fails early with a clear error
Testing¶
- Framework: pytest + pytest-django + factory-boy
- Test location:
commcare_connect/<app>/tests/withfactories.py,test_*.py - Global fixtures in
commcare_connect/conftest.py:organization,user,opportunity,mobile_user,user_with_connectid_link,mobile_user_with_connect_link,paymentunit_options,org_user_member,org_user_admin,program_manager_org,managed_opportunity,program_manager_org_user_member,program_manager_org_user_admin,api_rf,api_client - autouse fixtures:
media_storage(redirects to tmpdir),ensure_currency_country_data(repopulates Currency/Country flushed between tests) - HTTP mocking:
pytest-httpxfor httpx calls - Prefer fixtures over factories to avoid duplication. Check
conftest.pyfiles (global and per-app) for existing fixtures before creating new factory instances - Use
pytest.mark.parametrizeinstead of writing multiple near-identical tests - Test functions, not view responses: When testing views, extract and test the underlying business logic functions rather than making HTTP requests. Extract helper functions from views if needed to make them testable
Gotchas¶
- PostGIS required everywhere (including tests). Local dev needs
gdal,geos,projsystem libs. On macOS, setGDAL_LIBRARY_PATHandGEOS_LIBRARY_PATHin.env .envleaks into test settings:base.pycallsenv.read_env(BASE_DIR / ".env"), so local.envvalues also apply underconfig.settings.test. An empty value overrides a code default with""rather than falling back —CONNECTID_URL=breaks the suite. CI has no.env, so these failures are local-only--reuse-db+ Currency/Country data: These models get flushed between tests. Theensure_currency_country_dataautouse fixture handles this — don't remove it- Secondary DB is opt-in:
DATABASE_ROUTERSis only installed whenSECONDARY_DATABASE_URLis set (config/settings/base.py:52-56). Without itSECONDARY_DB_ALIASisNone, routing is off andmigrate_multijust migratesdefault— so multidb routing behaviour can't be reproduced locally by default - API UUID transition: The
API_UUIDwaffle switch controls whether API endpoints accept integer PKs or UUIDs. Useget_object_or_list_by_uuid_or_int()fromutils/db.pyfor API lookups. - htmx settles attributes onto swapped-in content: when a response replaces content that held an element with the same tag and
id, htmx copies the old element's attributes onto the incoming one and restores the server-rendered values during the settle step. Anything JS applies to swapped-in elements at insertion time is therefore undone from the second swap onwards — enhance onhtmx:afterSettle, not inside the fragment (this is whystatic/js/tomselect.jslistens for it) - Alpine
<template x-if>/x-forcontent is inert: it is parsed into a separate fragment and is not in the document until Alpine mounts it, so page-load scans never see it and mounting fires no htmx event. Anything that enhances elements has to be handed the subtree on mount —x-init="htmx.process($el); initTomSelectElements()"(seetasks/new_task_modal.html, which usesx-ifdeliberately so the form resets on close). Options rendered into a select byx-forare a separate trap: tom-select snapshots the option list when it initializes, so later additions only reach it through the instance - CSRF via sessions:
CSRF_USE_SESSIONS = True. Templates usehx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'on<body>for htmx - Webpack output: Bundles are built to
commcare_connect/static/bundles/and referenced with plain{% static 'bundles/...' %}, served viaSTATICFILES_DIRS.webpack-stats.jsonis written but unused — django-webpack-loader is not installed - CI uses:
postgis/postgis:15-3.5image, Python 3.11, requiresgdal-bin libproj-devapt packages