diff --git a/Dockerfile b/Dockerfile index f7b752abd..e21e288eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,6 +53,7 @@ COPY --from=python_builder --chown=${CONTAINER_USER}:${CONTAINER_GROUP} ${VIRTUA COPY --chown=${CONTAINER_USER}:${CONTAINER_GROUP} ./manage_breast_screening /app/manage_breast_screening COPY --from=node_builder --chown=${CONTAINER_USER}:${CONTAINER_GROUP} /app/manage_breast_screening/assets/compiled /app/manage_breast_screening/assets/compiled COPY --chown=${CONTAINER_USER}:${CONTAINER_GROUP} manage.py ./ +COPY --chown=${CONTAINER_USER}:${CONTAINER_GROUP} --chmod=444 flags*.yml ./ COPY --chown=root:root --chmod=755 scripts/docker/entrypoint.sh /app/entrypoint.sh # Run django commands diff --git a/docs/feature-flags.md b/docs/feature-flags.md new file mode 100644 index 000000000..9761e52d1 --- /dev/null +++ b/docs/feature-flags.md @@ -0,0 +1,68 @@ +# Feature flags + +Feature flags are implemented using [OpenFeature](https://openfeature.dev/) with an `InMemoryProvider`. Flags are declared in YAML files and loaded at startup. + +## Flag files + +Each environment has its own flag file. The application selects the file based on the `DEPLOYED_TO` environment variable, falling back to `flags.yml` for local development where `DEPLOYED_TO` is not set. + +| Environment | File | +| ---------------------------- | ---------------------- | +| dev | `flags.dev.yml` | +| review | `flags.review.yml` | +| preprod | `flags.preprod.yml` | +| production | `flags.production.yml` | +| local (no `DEPLOYED_TO` set) | `flags.yml` | + +## Adding a flag + +**1. Declare the flag in each environment's file.** + +Add an entry to all five flag files (`flags.yml`, `flags.dev.yml`, `flags.review.yml`, `flags.preprod.yml`, `flags.production.yml`). Set the value to `true` to enable or `false` to disable in that environment. + +```yaml +flags: + my_flag: false +``` + +**2. Check the flag in application code.** + +```python +from manage_breast_screening.core.feature_flags import FeatureFlag + +if FeatureFlag.is_enabled("my_flag"): + # feature is enabled +``` + +`FeatureFlag.is_enabled` returns `False` if the flag is missing or the provider is unavailable. If a flag name is not found in the YAML file, OpenFeature silently returns the fallback rather than raising an error — so a typo in a flag name will return `False` without any indication something is wrong. + +## Enabling a flag in tests + +Use the `with_flag_enabled` fixture to turn a flag on for the duration of a single test: + +```python +def test_something(with_flag_enabled): + with_flag_enabled("my_flag") + # flag is enabled for this test only +``` + +Multiple flags can be enabled by calling it more than once: + +```python +def test_something(with_flag_enabled): + with_flag_enabled("my_flag") + with_flag_enabled("another_flag") +``` + +In class-based tests the fixture must be relayed via an `autouse` fixture so it is accessible to helper methods: + +```python +@pytest.fixture(autouse=True) +def flags(self, with_flag_enabled): + self.with_flag_enabled = with_flag_enabled + +def and_my_flag_is_enabled(self): + self.with_flag_enabled("my_flag") +``` + +After each test the flags are reset to the defaults from the YAML file. diff --git a/docs/infrastructure/environment-variables.md b/docs/infrastructure/environment-variables.md index 4de5b7276..c6e9ae4d1 100644 --- a/docs/infrastructure/environment-variables.md +++ b/docs/infrastructure/environment-variables.md @@ -114,6 +114,17 @@ Postgres server port. Defaults to 5432. Required for review apps as each databas Database server admin user name. When using a managed identity, name of the managed identity. +## DEPLOYED_TO + +Describes the environment the application is running in. + +**Values:** + +- `dev` +- `review` +- `preprod` +- `production` + ## DJANGO_ENV Specifies the Django environment configuration. This variable controls environment-specific behaviour throughout the application. It's primarily for ensuring non-production code is not run or made available in production. diff --git a/flags.dev.yml b/flags.dev.yml new file mode 100644 index 000000000..f5887de57 --- /dev/null +++ b/flags.dev.yml @@ -0,0 +1,2 @@ +flags: + gateway_images: false diff --git a/flags.preprod.yml b/flags.preprod.yml new file mode 100644 index 000000000..f5887de57 --- /dev/null +++ b/flags.preprod.yml @@ -0,0 +1,2 @@ +flags: + gateway_images: false diff --git a/flags.production.yml b/flags.production.yml new file mode 100644 index 000000000..f5887de57 --- /dev/null +++ b/flags.production.yml @@ -0,0 +1,2 @@ +flags: + gateway_images: false diff --git a/flags.review.yml b/flags.review.yml new file mode 100644 index 000000000..f5887de57 --- /dev/null +++ b/flags.review.yml @@ -0,0 +1,2 @@ +flags: + gateway_images: false diff --git a/flags.yml b/flags.yml new file mode 100644 index 000000000..f5887de57 --- /dev/null +++ b/flags.yml @@ -0,0 +1,2 @@ +flags: + gateway_images: false diff --git a/infrastructure/environments/dev/variables.yml b/infrastructure/environments/dev/variables.yml index 55c20cbce..7fdbde032 100644 --- a/infrastructure/environments/dev/variables.yml +++ b/infrastructure/environments/dev/variables.yml @@ -1,3 +1,4 @@ +DEPLOYED_TO: dev BASE_URL: https://dev.manage-breast-screening.non-live.screening.nhs.uk BASIC_AUTH_ENABLED: True CIS2_SERVER_METADATA_URL: https://am.nhsint.auth-ptl.cis2.spineservices.nhs.uk/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare/.well-known/openid-configuration diff --git a/infrastructure/environments/preprod/variables.yml b/infrastructure/environments/preprod/variables.yml index 6d963192b..fbf804661 100644 --- a/infrastructure/environments/preprod/variables.yml +++ b/infrastructure/environments/preprod/variables.yml @@ -1,3 +1,4 @@ +DEPLOYED_TO: preprod BASE_URL: https://preprod.manage-breast-screening.nhs.uk BASIC_AUTH_ENABLED: True CIS2_SERVER_METADATA_URL: https://am.nhsint.auth-ptl.cis2.spineservices.nhs.uk/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare/.well-known/openid-configuration diff --git a/infrastructure/environments/prod/variables.yml b/infrastructure/environments/prod/variables.yml index 023204303..583f08f1b 100644 --- a/infrastructure/environments/prod/variables.yml +++ b/infrastructure/environments/prod/variables.yml @@ -1,3 +1,4 @@ +DEPLOYED_TO: production BASE_URL: https://manage-breast-screening.nhs.uk BASIC_AUTH_ENABLED: True CIS2_SERVER_METADATA_URL: https://am.nhsint.auth-ptl.cis2.spineservices.nhs.uk/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare/.well-known/openid-configuration diff --git a/infrastructure/environments/review/variables.yml b/infrastructure/environments/review/variables.yml index a61501063..3e2c69c61 100644 --- a/infrastructure/environments/review/variables.yml +++ b/infrastructure/environments/review/variables.yml @@ -1,3 +1,4 @@ +DEPLOYED_TO: review BASIC_AUTH_ENABLED: True PERSONAS_ENABLED: 1 CSRF_TRUSTED_ORIGINS: 'https://*.manage-breast-screening.non-live.screening.nhs.uk' diff --git a/manage_breast_screening/conftest.py b/manage_breast_screening/conftest.py index 59020fa3e..7e3645028 100644 --- a/manage_breast_screening/conftest.py +++ b/manage_breast_screening/conftest.py @@ -6,17 +6,46 @@ import pytest from django.test.client import Client from django.utils import timezone +from openfeature import api +from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider from manage_breast_screening.clinics.tests.factories import ( ProviderFactory, UserAssignmentFactory, ) +from manage_breast_screening.core.apps import _FLAGS_YAML +from manage_breast_screening.core.feature_flags import setup_feature_flags from manage_breast_screening.users.tests.factories import UserFactory # Show long diffs in failed test output TestCase.maxDiff = None +@pytest.fixture +def with_flag_enabled(): + """Enable a named boolean OpenFeature flag for the duration of a test. + + Usage:: + + def test_something(with_flag_enabled): + with_flag_enabled("my_flag") + ... + """ + + enabled_flags = {} + + def enable(flag_name: str): + enabled_flags[flag_name] = InMemoryFlag( + default_variant="on", + variants={"on": True, "off": False}, + ) + api.set_provider(InMemoryProvider(enabled_flags)) + + yield enable + + setup_feature_flags(_FLAGS_YAML) + + def force_mbs_login(client, user): """Log in a user and set login_time to satisfy SessionTimeoutMiddleware.""" client.force_login(user) diff --git a/manage_breast_screening/core/apps.py b/manage_breast_screening/core/apps.py index 5a62e8eee..9f633d2e9 100644 --- a/manage_breast_screening/core/apps.py +++ b/manage_breast_screening/core/apps.py @@ -1,9 +1,19 @@ +from os import environ +from pathlib import Path + from django.apps import AppConfig from manage_breast_screening.core.services.application_insights_logging import ( ApplicationInsightsLogging, ) +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_deployed_to = environ.get("DEPLOYED_TO") +_env_flags = _REPO_ROOT / f"flags.{_deployed_to}.yml" if _deployed_to else None +_FLAGS_YAML = ( + _env_flags if _env_flags and _env_flags.exists() else _REPO_ROOT / "flags.yml" +) + class CoreConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" @@ -12,3 +22,6 @@ class CoreConfig(AppConfig): def ready(self): super().ready() ApplicationInsightsLogging().configure_azure_monitor() + from manage_breast_screening.core.feature_flags import setup_feature_flags + + setup_feature_flags(_FLAGS_YAML) diff --git a/manage_breast_screening/core/feature_flags.py b/manage_breast_screening/core/feature_flags.py new file mode 100644 index 000000000..b361bcfe4 --- /dev/null +++ b/manage_breast_screening/core/feature_flags.py @@ -0,0 +1,33 @@ +from pathlib import Path + +import yaml +from openfeature import api +from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider + + +class FeatureFlag: + @staticmethod + def is_enabled(name: str) -> bool: + return api.get_client().get_boolean_value(name, False) + + +def setup_feature_flags(flags_yaml_path: Path) -> None: + """Initialise the OpenFeature InMemoryProvider from a YAML flags file. + + Call from AppConfig.ready(). The YAML file must follow the structure:: + + flags: + my_flag: true # true to enable, false to disable + """ + with flags_yaml_path.open() as f: + data = yaml.safe_load(f) + + raw_flags = data.get("flags", {}) + provider_flags = { + name: InMemoryFlag( + default_variant="on" if enabled else "off", + variants={"on": True, "off": False}, + ) + for name, enabled in raw_flags.items() + } + api.set_provider(InMemoryProvider(provider_flags)) diff --git a/manage_breast_screening/core/tests/test_feature_flags.py b/manage_breast_screening/core/tests/test_feature_flags.py new file mode 100644 index 000000000..30c1a00eb --- /dev/null +++ b/manage_breast_screening/core/tests/test_feature_flags.py @@ -0,0 +1,55 @@ +import pytest +from openfeature import api + +from manage_breast_screening.core.apps import _FLAGS_YAML +from manage_breast_screening.core.feature_flags import setup_feature_flags + + +class TestSetupFeatureFlags: + @pytest.fixture(autouse=True) + def reset_flags(self): + yield + setup_feature_flags(_FLAGS_YAML) + + def test_enabled_flag_returns_true(self, tmp_path): + flags_file = tmp_path / "flags.yml" + flags_file.write_text("flags:\n my_flag: true\n") + setup_feature_flags(flags_file) + + assert api.get_client().get_boolean_value("my_flag", False) is True + + def test_disabled_flag_returns_false(self, tmp_path): + flags_file = tmp_path / "flags.yml" + flags_file.write_text("flags:\n my_flag: false\n") + setup_feature_flags(flags_file) + + assert api.get_client().get_boolean_value("my_flag", False) is False + + def test_missing_flag_returns_fallback(self, tmp_path): + flags_file = tmp_path / "flags.yml" + flags_file.write_text("flags: {}\n") + setup_feature_flags(flags_file) + + assert api.get_client().get_boolean_value("nonexistent_flag", False) is False + + +class TestWithFlagEnabledFixture: + def test_enabled_flag_returns_true(self, with_flag_enabled): + with_flag_enabled("my_flag") + + assert api.get_client().get_boolean_value("my_flag", False) is True + + def test_unenabled_flag_returns_fallback(self, with_flag_enabled): + assert api.get_client().get_boolean_value("my_flag", False) is False + + def test_multiple_flags_can_be_enabled(self, with_flag_enabled): + with_flag_enabled("flag_one") + with_flag_enabled("flag_two") + + assert api.get_client().get_boolean_value("flag_one", False) is True + assert api.get_client().get_boolean_value("flag_two", False) is True + + def test_enabling_one_flag_does_not_enable_others(self, with_flag_enabled): + with_flag_enabled("flag_one") + + assert api.get_client().get_boolean_value("flag_two", False) is False diff --git a/manage_breast_screening/mammograms/tests/views/test_appointment_workflow_views.py b/manage_breast_screening/mammograms/tests/views/test_appointment_workflow_views.py index b2a10e8d4..ac800f65e 100644 --- a/manage_breast_screening/mammograms/tests/views/test_appointment_workflow_views.py +++ b/manage_breast_screening/mammograms/tests/views/test_appointment_workflow_views.py @@ -255,9 +255,9 @@ def test_creates_gateway_action( mock_send_action.assert_called_once_with(relay, action) def test_redirects_to_gateway_images_when_enabled( - self, clinical_user_client, monkeypatch, confirmed_identity_appointment + self, clinical_user_client, with_flag_enabled, confirmed_identity_appointment ): - monkeypatch.setenv("GATEWAY_IMAGES_ENABLED", "true") + with_flag_enabled("gateway_images") RelayFactory.create( setting=confirmed_identity_appointment.clinic_slot.clinic.setting ) diff --git a/manage_breast_screening/mammograms/views/__init__.py b/manage_breast_screening/mammograms/views/__init__.py index 98873588c..8d11ebb44 100644 --- a/manage_breast_screening/mammograms/views/__init__.py +++ b/manage_breast_screening/mammograms/views/__init__.py @@ -1,10 +1,9 @@ -import os - +from manage_breast_screening.core.feature_flags import FeatureFlag from manage_breast_screening.gateway.models import Relay def gateway_images_enabled(appointment): """Check if automatic gateway image retrieval is enabled.""" - if os.getenv("GATEWAY_IMAGES_ENABLED", "false").lower() == "true": + if FeatureFlag.is_enabled("gateway_images"): return Relay.for_appointment(appointment) is not None return False diff --git a/manage_breast_screening/tests/system/clinical/test_gateway_images.py b/manage_breast_screening/tests/system/clinical/test_gateway_images.py index 371a7e623..613ae9155 100644 --- a/manage_breast_screening/tests/system/clinical/test_gateway_images.py +++ b/manage_breast_screening/tests/system/clinical/test_gateway_images.py @@ -1,5 +1,4 @@ -import os - +import pytest from django.urls import reverse from playwright.sync_api import expect @@ -18,6 +17,10 @@ class TestGatewayImages(SystemTestCase): + @pytest.fixture(autouse=True) + def flags(self, with_flag_enabled): + self.with_flag_enabled = with_flag_enabled + def test_renders_no_images_content(self): self.given_i_am_logged_in_as_a_clinical_user() self.and_there_is_an_appointment() @@ -63,7 +66,7 @@ def and_there_is_an_appointment(self): ) def and_gateway_images_are_enabled(self): - os.environ["GATEWAY_IMAGES_ENABLED"] = "true" + self.with_flag_enabled("gateway_images") RelayFactory(setting=self.appointment.clinic_slot.clinic.setting) def when_i_visit_the_take_images_page(self): diff --git a/pyproject.toml b/pyproject.toml index e61a0227b..6551f5e8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "jsonschema>=4.26.0", "django-csp>=4.0", "opentelemetry-instrumentation-jinja2>=0.61b0", + "openfeature-sdk>=0.8.4,<0.9.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 99620206b..5a1cb79eb 100644 --- a/uv.lock +++ b/uv.lock @@ -859,6 +859,7 @@ dependencies = [ { name = "nhsuk-frontend-jinja" }, { name = "ninja-put-patch-file-upload-middleware" }, { name = "opentelemetry-instrumentation-jinja2" }, + { name = "openfeature-sdk" }, { name = "pandas" }, { name = "phonenumbers" }, { name = "pillow" }, @@ -916,6 +917,7 @@ requires-dist = [ { name = "nhsuk-frontend-jinja", specifier = "==0.7.2" }, { name = "ninja-put-patch-file-upload-middleware", specifier = ">=0.1.4" }, { name = "opentelemetry-instrumentation-jinja2", specifier = ">=0.61b0" }, + { name = "openfeature-sdk", specifier = ">=0.8.4,<0.9.0" }, { name = "pandas", specifier = ">=2.3.0,<4.0.0" }, { name = "phonenumbers", specifier = ">=9.0.22" }, { name = "pillow", specifier = ">=12.1.0" }, @@ -1117,6 +1119,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] +[[package]] +name = "openfeature-sdk" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/08/f6698d0614b8703170117b786bd77b7b0a04f3ee00f19fbe9b360d2dee69/openfeature_sdk-0.8.4.tar.gz", hash = "sha256:66abf71f928ec8c0db1111072bb0ef2635dfbd09510f77f4b548e5d0ea0e6c1a", size = 29676, upload-time = "2025-12-09T07:31:13.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/80/f6532778188c573cc83790b11abccde717d4c1442514e722d6bb6140e55c/openfeature_sdk-0.8.4-py3-none-any.whl", hash = "sha256:805ba090669798fc343ca9fdcbc56ff0f4b57bf6757533f0854d2021192e620a", size = 35986, upload-time = "2025-12-09T07:31:12.092Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.40.0"