Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions docs/infrastructure/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions flags.dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
flags:
gateway_images: false
2 changes: 2 additions & 0 deletions flags.preprod.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
flags:
gateway_images: false
2 changes: 2 additions & 0 deletions flags.production.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
flags:
gateway_images: false
2 changes: 2 additions & 0 deletions flags.review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
flags:
gateway_images: false
2 changes: 2 additions & 0 deletions flags.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
flags:
gateway_images: false
1 change: 1 addition & 0 deletions infrastructure/environments/dev/variables.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions infrastructure/environments/preprod/variables.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions infrastructure/environments/prod/variables.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions infrastructure/environments/review/variables.yml
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
29 changes: 29 additions & 0 deletions manage_breast_screening/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions manage_breast_screening/core/apps.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
33 changes: 33 additions & 0 deletions manage_breast_screening/core/feature_flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from pathlib import Path

import yaml
from openfeature import api
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider

Comment thread
gpeng marked this conversation as resolved.

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))
55 changes: 55 additions & 0 deletions manage_breast_screening/core/tests/test_feature_flags.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
5 changes: 2 additions & 3 deletions manage_breast_screening/mammograms/views/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import os

import pytest
from django.urls import reverse
from playwright.sync_api import expect

Expand All @@ -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()
Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading