Feature Flags¶
EarthRanger has two current per-tenant flagging systems, supplied by the Tenant Management Service and distinguished by how long the flag lives, plus one truly legacy artifact you should leave alone. The choice between the two is by longevity and purpose — see Choosing where a new flag goes.
Typed feature flags — long-lived, strongly-typed per-tenant capability configuration: which subsystems and features a site sees (
events_enabled,subjects_enabled,analyzers_enabled,alerts_enabled, …). These are durable product configuration, not transitional, so they’re worth a typed, TMS-validated field. Fields on theFeatureFlagsdataclass indas/utils/tenant/dataclass.py, deserialized from thefeatureFlagsblock. See Typed feature flags.Release toggles — short-lived rollout gates. Declared in an ER-side registry and read from a
releaseTogglesdict on the tenant payload; a global override lets you force one on (or off) for every tenant in a single ER change. The name follows Martin Fowler’s feature-toggle taxonomy: a release toggle keeps a latent feature dark in mainline, is flipped on as it rolls out, and is removed once the rollout is complete.Gating Django admin pages builds on a release toggle plus its global override — a single per-tenant gate, no separate Django-settings kill switch. See Gating Django admin pages.
For where a plain Django setting belongs (env-driven vs. Docker-only vs. test-only), see Settings & Configuration. Toggles are runtime gates; settings are deploy-time configuration.
features.tms.is_on()— legacy, do not extend
das/utils/features.pydefines a tiny in-process flag registry whose only entry isfeatures.tms(alwaysTrue). It is a relic of the migration to the Tenant Management Service: code paths that needed to behave differently before vs. after TMS landed were gated onfeatures.tms.is_on(). That migration is complete; multi-tenancy is unconditional andfeatures.tms.is_on()always returnsTrue.Existing call sites have not been ripped out — leaving them in place keeps diffs small and avoids surprises mid-flight. But:
Do not add new
features.tms.is_on()checks. New code should assume TMS is on. If you find yourself wanting to branch on it, you don’t.Do not extend the
Featuresenum. It is not a general-purpose flag system. For a new gate, use a typed feature flag or a release toggle as documented below.When you happen to be modifying a function that contains a
features.tms.is_on()check, you may inline the true branch and delete the import as a drive-by — but don’t make it a separate task.
Per-tenant flags from TMS¶
The Tenant Management Service ships two kinds of per-tenant flag to EarthRanger — typed feature flags and release toggles. They are complementary, not old-vs-new; pick by longevity.
Choosing where a new flag goes¶
Typed feature flag |
Release toggle |
|
|---|---|---|
Use for |
Durable per-site capability configuration — “what a site sees” (a subsystem on/off, an integration enabled) |
A transitional gate that lets an unfinished feature ship dark and roll out gradually |
Lifespan |
Indefinite; part of the product’s tenant-configuration surface |
Temporary; delete it once the feature is fully rolled out |
Declared in |
|
|
Cost to add |
Two coordinated changes — ER dataclass and TMS schema |
ER-only; no TMS schema change |
Typing |
Strongly typed and TMS-validated |
Stringly-typed values in a generic dict |
The asymmetry is deliberate: a permanent capability flag is worth the coordinated, strongly-typed change, while a throwaway rollout gate shouldn’t cost a TMS schema update. If you’re unsure whether a flag will outlive its rollout, start it as a release toggle — promoting it to a typed flag later is cheaper than carrying a permanent stringly-typed entry.
Typed feature flags¶
A long-lived set of flags live as fields on the FeatureFlags dataclass in das/utils/tenant/dataclass.py and are deserialized from the featureFlags block of the TMS payload. They configure which capabilities a site sees, so we sometimes turn them off to simplify a given deployment. Access them via the tenant settings:
from utils.tenant import get_tenant_settings
ts = get_tenant_settings()
if ts.feature_flags.alerts_enabled:
...
Field |
TMS field ( |
Default |
Notes |
|---|---|---|---|
|
|
|
Alerting subsystem |
|
|
|
Buoy API |
|
|
|
Daily report generation |
|
|
|
KML export |
|
|
|
Mapping features v2 |
|
|
|
Tableau integration |
|
|
|
Tableau site-id gating |
|
|
|
Track-length feature gating |
|
|
|
Events subsystem |
|
|
|
Subjects subsystem |
|
|
|
Spatial features |
|
|
|
Real-time analyzers |
|
|
|
Force IdP login for the tenant |
|
|
|
Auth0 / IdP org identifier (string, not boolean) |
Adding one costs two coordinated changes per flag — a field on the ER dataclass and a matching field in the TMS schema. That cost is worth paying for a durable, strongly-typed capability flag; for a short-lived rollout gate, reach for a release toggle instead. (idp_org_id is the only non-boolean entry in the table — a configuration value rather than an on/off flag.)
Release toggles¶
Short-lived rollout toggles are declared in a single ER-side registry — das/utils/tenant/release_toggles.py — and read from a generic releaseToggles dict on the tenant payload. Adding a toggle is an ER-only change; TMS does not need a schema update.
# das/utils/tenant/release_toggles.py
RELEASE_TOGGLES: dict[str, ReleaseToggle] = {
"community_input_admin_enabled": ReleaseToggle(
default=False,
description="Per-tenant gate for the Community Input Django admin page.",
),
# ...new toggles go here
}
Read a toggle with get_release_toggle(name):
from utils.tenant.release_toggles import get_release_toggle
if get_release_toggle("community_input_admin_enabled"):
...
Resolution precedence¶
get_release_toggle(name) resolves a value in this order:
global_override— if the registeredReleaseTogglesetsglobal_overrideto a non-Nonevalue, that value wins for every tenant, regardless of what TMS sent. See Flipping a toggle on for everyone below.Per-tenant value —
get_tenant_settings().release_toggles[name](thereleaseTogglesblock on the wire), set through TMS without a release.default— the registered fallback when the tenant hasn’t set a value.
Other semantics:
Asking for a toggle name that isn’t in
RELEASE_TOGGLESraisesUnknownReleaseToggle. This is intentional typo protection: every read site must correspond to a declared toggle.A toggle declared in code but not yet shipped by TMS will simply use its default — safe to merge ahead of TMS work.
An operator who sets a value in TMS for a name that ER doesn’t declare is silently ignored (the value lives in
release_togglesbut no one reads it).
Flipping a toggle on for everyone¶
When you decide a gated feature should be visible to all tenants at once — e.g. making the Community Input admin public — set global_override on the registered ReleaseToggle and ship it:
"community_input_admin_enabled": ReleaseToggle(
default=False,
global_override=True, # now on for every tenant; per-tenant releaseToggles ignored
description="...",
),
This is a one-line ER code change (PR + release) rather than touching releaseToggles on every tenant. global_override=False is the inverse — an emergency kill that forces the toggle off everywhere even for tenants that opted in. Leave it None (the default) to defer to per-tenant values. There is no Django-settings kill switch in front of this — the release toggle is the single gate. Once a feature is fully and permanently rolled out, the right end state is to delete the toggle and its checks, not to leave global_override=True forever.
Adding a new release toggle¶
Add an entry to
RELEASE_TOGGLESindas/utils/tenant/release_toggles.pywith a sensible default (almost alwaysFalse— opt-in) and a one-line description.Read it via
get_release_toggle("your_toggle_name")at the call site.No TMS schema change required. Coordinate with TMS only to (a) ensure the TMS admin UI has a way to set values in the generic
releaseTogglesdict, and (b) flip the toggle on for specific tenants when ready.
Enabling a toggle in local development¶
In production the per-tenant values arrive in the tenant payload’s releaseToggles block from TMS. Locally there’s no TMS, so seed release_toggles from your das/.env via the RELEASE_TOGGLES setting (parsed as JSON and applied by DjangoSettingsTenantBuilder):
# das/.env
RELEASE_TOGGLES={"community_input_admin_enabled": true}
This is the faithful local equivalent of TMS setting the toggle for your tenant — it flows through the same release_toggles → get_release_toggle path, so it honours resolution precedence (a global_override in the registry still wins over it). It applies when the DjangoSettingsClient builds the tenant (TMS_API_CLIENT=core.tms.DjangoSettingsClient); if you instead run the TestClient, set the same key in das/core/fixtures/tenant-response.json’s releaseToggles block. Don’t set global_override/default in the registry just to test locally — those are committed code.
Gating Django admin pages¶
When you ship a new feature that includes a Django admin surface, gate the admin behind a single per-tenant release toggle so it can be merged early but stays dark by default. The toggle’s three-level resolution precedence gives you everything the old two-layer scheme did, with one concept:
Merged but dark —
default=False, noglobal_override. The admin is registered but everyhas_*_permissionreturnsFalse, so it’s hidden from the index and inaccessible for all tenants (superusers included).Early access for one tenant — set
community_input_admin_enabled: truein that tenant’sreleaseTogglesvia TMS. No release.Public for everyone — set
global_override=Trueon the toggle (one ER PR). See Flipping a toggle on for everyone.
The API for the feature is not gated by this scheme — ship it independently. That’s the point of the design: the API can be public while the Django admin stays hidden until you flip the toggle.
No import-time kill switch. Earlier revisions paired this with an
AdminFeatureFlag(model, flag="<FEATURE>_ADMIN_ENABLED")decorator that read a Django setting and unregistered the model at import time. That layer has been removed for admin gating — the release toggle is the single source of truth. (AdminFeatureFlagitself still exists indas/core/common.pyfor feature-wide settings likePATROL_ENABLED; don’t use it for new admin-only gates.)
Pattern¶
Add the ReleaseToggledAdminMixin (das/core/common.py) to the ModelAdmin and set release_toggle to the name of an entry in RELEASE_TOGGLES.
from core.common import ReleaseToggledAdminMixin
@admin.register(models.CommunityInput)
class CommunityInputAdmin(ReleaseToggledAdminMixin, ModelAdminDisplayingManyToManyFieldMixin):
release_toggle = "community_input_admin_enabled"
...
ReleaseToggledAdminMixin overrides has_module_permission / has_view_permission / has_add_permission / has_change_permission / has_delete_permission to consult get_release_toggle(<release_toggle>) at request time. A typo’d name raises UnknownReleaseToggle at the first request that hits the admin, surfacing the mistake immediately.
Naming convention¶
Where |
Name |
|---|---|
|
|
The _admin_enabled suffix distinguishes admin-only gates from feature-wide settings like PATROL_ENABLED that affect more than the admin.
Steps to add a gate¶
Register
<feature>_admin_enabledinRELEASE_TOGGLES(das/utils/tenant/release_toggles.py) withdefault=Falseand a one-line description.Add
ReleaseToggledAdminMixinto theModelAdminand setrelease_toggle = "<feature>_admin_enabled"as shown above.Enable per tenant by setting the toggle in
releaseTogglesvia TMS (no release), or for everyone by settingglobal_override=Trueon the toggle (one ER PR). No TMS schema change is required either way.
Limitations¶
Neither mechanism supports per-user gating or staged percentage rollouts. Both the typed flags and release toggles are loaded once per request via get_tenant_settings(), so a value cannot vary mid-request, and a global_override change ships with an ER release. For anything user-scoped, use permissions; for staged percentage rollouts, reach for a dedicated remote flag service rather than extending the patterns here.