Skip to content

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-django will rewrite your formatting on commit. templates/prelogin/home.html is 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 BaseModel from commcare_connect/utils/db.py (provides created_by, modified_by, date_created, date_modified)
  • Custom User model uses single name field instead of first_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 .html files, not in Python strings
  • Keep JS in JS files: Don't inline JavaScript in templates; use separate .js files
  • 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. Add data-tomselect="1" to the widget's attrs (or the tag) to have static/js/tomselect.js turn it into a tom-select, and add data-tomselect:no-remove-button on single selects or the chosen value renders as a removable chip. Extra TomSelect options go through data-tomselect:settings as JSON; anything needing render functions gets its own module and must not carry data-tomselect, or the generic initializer claims it first (see static/js/work_area_search.js). Use it for long or searchable lists, not short fixed choices. The page must also load bundles/js/tomselect-bundle.js and bundles/css/tomselect.css or a marked field silently stays native — check they are there, and that they don't arrive by accident: opportunity/user_tasks.html gets 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.css lists all 76 of them. Plenty of templates still use raw utilities; that's legacy
  • Tables should fit without horizontal scrolling: .base-table sets whitespace-nowrap on every header and cell, so each column is as wide as its longest label, and base_table.html falls back to overflow-x-auto when 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 the col-stat class (see STAT_COLUMN_ATTRS in opportunity/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.md and templates/README.md document 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, waffle Flags), 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_provider in commcare_connect/users/templatetags/socialaccount_extras.py and commcare_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_request in commcare_connect/connect_id_client/main.py: timeout=10 default, 15–30s for bulk sends; commcare_connect/ocs_provider/views.py)
  • Dispatch Celery tasks after commit: ATOMIC_REQUESTS = True means a bare task.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)) (see commcare_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 with autoretry_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 at info (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/ with factories.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-httpx for httpx calls
  • Prefer fixtures over factories to avoid duplication. Check conftest.py files (global and per-app) for existing fixtures before creating new factory instances
  • Use pytest.mark.parametrize instead 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, proj system libs. On macOS, set GDAL_LIBRARY_PATH and GEOS_LIBRARY_PATH in .env
  • .env leaks into test settings: base.py calls env.read_env(BASE_DIR / ".env"), so local .env values also apply under config.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. The ensure_currency_country_data autouse fixture handles this — don't remove it
  • Secondary DB is opt-in: DATABASE_ROUTERS is only installed when SECONDARY_DATABASE_URL is set (config/settings/base.py:52-56). Without it SECONDARY_DB_ALIAS is None, routing is off and migrate_multi just migrates default — so multidb routing behaviour can't be reproduced locally by default
  • API UUID transition: The API_UUID waffle switch controls whether API endpoints accept integer PKs or UUIDs. Use get_object_or_list_by_uuid_or_int() from utils/db.py for 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 on htmx:afterSettle, not inside the fragment (this is why static/js/tomselect.js listens for it)
  • Alpine <template x-if>/x-for content 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()" (see tasks/new_task_modal.html, which uses x-if deliberately so the form resets on close). Options rendered into a select by x-for are 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 use hx-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 via STATICFILES_DIRS. webpack-stats.json is written but unused — django-webpack-loader is not installed
  • CI uses: postgis/postgis:15-3.5 image, Python 3.11, requires gdal-bin libproj-dev apt packages