From fe03bef35dec99b90fc16881c6d7c245674c510d Mon Sep 17 00:00:00 2001 From: Jamie Falcus <50366804+jamiefalcus@users.noreply.github.com> Date: Tue, 30 Dec 2025 12:01:08 +0000 Subject: [PATCH 1/2] PPHA-477: Adding cancer diagnosis page and tests --- .../helpers/user_interaction_helpers.py | 101 ------------------ .../questions/forms/cancer_diagnosis_form.py | 23 ++++ .../questions/jinja2/cancer_diagnosis.jinja | 9 ++ ..._haveyoueversmokedresponse_response_set.py | 19 ++++ .../0035_cancerdiagnosisresponse.py | 27 +++++ .../questions/models/__init__.py | 10 -- .../models/cancer_diagnosis_response.py | 9 ++ .../presenters/response_set_presenter.py | 7 ++ .../cancer_diagnosis_response_factory.py | 12 +++ .../unit/forms/test_cancer_diagnosis_form.py | 54 ++++++++++ .../models/test_cancer_diagnosis_response.py | 34 ++++++ .../presenters/test_response_set_presenter.py | 14 +++ .../tests/unit/views/test_cancer_diagnosis.py | 12 ++- .../questions/views/cancer_diagnosis.py | 38 ++++++- scripts/tests/unit.sh | 12 ++- tests/features/questionnaire.feature | 2 +- tests/features/steps/form_steps.py | 5 + 17 files changed, 266 insertions(+), 122 deletions(-) delete mode 100644 lung_cancer_screening/core/tests/acceptance/helpers/user_interaction_helpers.py create mode 100644 lung_cancer_screening/questions/forms/cancer_diagnosis_form.py create mode 100644 lung_cancer_screening/questions/jinja2/cancer_diagnosis.jinja create mode 100644 lung_cancer_screening/questions/migrations/0034_alter_haveyoueversmokedresponse_response_set.py create mode 100644 lung_cancer_screening/questions/migrations/0035_cancerdiagnosisresponse.py create mode 100644 lung_cancer_screening/questions/models/cancer_diagnosis_response.py create mode 100644 lung_cancer_screening/questions/tests/factories/cancer_diagnosis_response_factory.py create mode 100644 lung_cancer_screening/questions/tests/unit/forms/test_cancer_diagnosis_form.py create mode 100644 lung_cancer_screening/questions/tests/unit/models/test_cancer_diagnosis_response.py diff --git a/lung_cancer_screening/core/tests/acceptance/helpers/user_interaction_helpers.py b/lung_cancer_screening/core/tests/acceptance/helpers/user_interaction_helpers.py deleted file mode 100644 index 3fa57dfe..00000000 --- a/lung_cancer_screening/core/tests/acceptance/helpers/user_interaction_helpers.py +++ /dev/null @@ -1,101 +0,0 @@ -from playwright.sync_api import expect -from .test_helpers import check_labels - -def setup_user(page, live_server_url): - user_id = 'abc123' - page.goto(f"{live_server_url}/start") - fill_in_and_submit_user_id(page, user_id) - -def fill_in_and_submit_user_id(page, user_id): - page.fill("input[name='user_id']", user_id) - page.click('text=Start now') - - -def fill_in_and_submit_smoking_eligibility(page, smoking_status): - expect(page.locator("legend")).to_have_text( - "Have you ever smoked?") - - page.get_by_label(smoking_status).check() - - page.click("text=Continue") - -def fill_in_and_submit_date_of_birth(page, age): - expect(page.locator("legend")).to_have_text( - "What is your date of birth?") - - page.get_by_label("Day").fill(str(age.day)) - page.get_by_label("Month").fill(str(age.month)) - page.get_by_label("Year").fill(str(age.year)) - - page.click("text=Continue") - -def fill_in_and_submit_height_metric(page, height): - expect(page.locator("h1")).to_have_text("What is your height?") - - page.get_by_label("Centimetre").fill(str(height)) - - page.click("text=Continue") - -def fill_in_and_submit_height_imperial(page, feet, inches): - expect(page.locator("h1")).to_have_text("What is your height?") - - page.get_by_label("Feet").fill(str(feet)) - page.get_by_label("Inches").fill(str(inches)) - - page.click("text=Continue") - -def fill_in_and_submit_weight_metric(page, kilograms): - expect(page.locator("h1")).to_have_text("Enter your weight") - - page.get_by_label("Kilograms").fill(str(kilograms)) - - page.click("text=Continue") - -def fill_in_and_submit_weight_imperial(page, stone, pounds): - expect(page.locator("h1")).to_have_text("Enter your weight") - - page.get_by_label("Stone").fill(str(stone)) - page.get_by_label("Pounds").fill(str(pounds)) - - page.click("text=Continue") - -def fill_in_and_submit_sex_at_birth(page, sex): - expect(page.locator("legend")).to_have_text( - "What was your sex at birth?") - - page.get_by_label(sex, exact=True).check() - - page.click("text=Continue") - -def fill_in_and_submit_gender(page, gender): - expect(page.locator("legend")).to_have_text( - "Which of these best describes you?") - - page.get_by_label(gender, exact=True).check() - - page.click("text=Continue") - -def fill_in_and_submit_ethnicity(page, ethnicity): - expect(page.locator("legend")).to_have_text( - "What is your ethnic background?") - - page.get_by_label(ethnicity, exact=True).check() - - page.click("text=Continue") - -def fill_in_and_submit_asbestos_exposure(page, answer): - expect(page.locator("legend")).to_have_text( - "Have you ever worked in a job where you might have been exposed to asbestos?") - - page.get_by_label(answer, exact=True).check() - - page.click("text=Continue") - -def fill_in_and_submit_respiratory_conditions(page, answer): - expect(page.locator("legend")).to_have_text( - "Have you ever been diagnosed with any of the following respiratory conditions?") - - check_labels(page, answer) - - page.click("text=Continue") - diff --git a/lung_cancer_screening/questions/forms/cancer_diagnosis_form.py b/lung_cancer_screening/questions/forms/cancer_diagnosis_form.py new file mode 100644 index 00000000..1c99e28a --- /dev/null +++ b/lung_cancer_screening/questions/forms/cancer_diagnosis_form.py @@ -0,0 +1,23 @@ +from django import forms +from ...nhsuk_forms.typed_choice_field import TypedChoiceField +from ..models.cancer_diagnosis_response import CancerDiagnosisResponse + + +class CancerDiagnosisForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["value"] = TypedChoiceField( + choices=[(True, 'Yes'), (False, 'No')], + widget=forms.RadioSelect, + label="If you have ever been diagnosed with cancer", + label_classes="nhsuk-fieldset__legend--l", + coerce=lambda x: x == 'True', + error_messages={ + 'required': 'Select if you have been diagnosed with cancer' + } + ) + + class Meta: + model = CancerDiagnosisResponse + fields = ['value'] diff --git a/lung_cancer_screening/questions/jinja2/cancer_diagnosis.jinja b/lung_cancer_screening/questions/jinja2/cancer_diagnosis.jinja new file mode 100644 index 00000000..4f65964c --- /dev/null +++ b/lung_cancer_screening/questions/jinja2/cancer_diagnosis.jinja @@ -0,0 +1,9 @@ +{% extends 'question_form.jinja' %} + +{% block prelude %} + +
If you have ever been diagnosed with any type of cancer it may impact your chances of developing lung cancer. + +{% endblock prelude %} diff --git a/lung_cancer_screening/questions/migrations/0034_alter_haveyoueversmokedresponse_response_set.py b/lung_cancer_screening/questions/migrations/0034_alter_haveyoueversmokedresponse_response_set.py new file mode 100644 index 00000000..e66a2035 --- /dev/null +++ b/lung_cancer_screening/questions/migrations/0034_alter_haveyoueversmokedresponse_response_set.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2025-12-30 09:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('questions', '0033_weightresponse'), + ] + + operations = [ + migrations.AlterField( + model_name='haveyoueversmokedresponse', + name='response_set', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='have_you_ever_smoked_response', to='questions.responseset'), + ), + ] diff --git a/lung_cancer_screening/questions/migrations/0035_cancerdiagnosisresponse.py b/lung_cancer_screening/questions/migrations/0035_cancerdiagnosisresponse.py new file mode 100644 index 00000000..73f7d9a9 --- /dev/null +++ b/lung_cancer_screening/questions/migrations/0035_cancerdiagnosisresponse.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.9 on 2025-12-30 09:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('questions', '0034_alter_haveyoueversmokedresponse_response_set'), + ] + + operations = [ + migrations.CreateModel( + name='CancerDiagnosisResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.BooleanField()), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='cancer_diagnosis_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/lung_cancer_screening/questions/models/__init__.py b/lung_cancer_screening/questions/models/__init__.py index f9bf11a9..c0486157 100644 --- a/lung_cancer_screening/questions/models/__init__.py +++ b/lung_cancer_screening/questions/models/__init__.py @@ -1,11 +1 @@ -from .response_set import ResponseSet # noqa: F401 from .user import User # noqa: F401 -from .have_you_ever_smoked_response import HaveYouEverSmokedResponse # noqa: F401 -from .asbestos_exposure_response import AsbestosExposureResponse # noqa: F401 -from .date_of_birth_response import DateOfBirthResponse # noqa: F401 -from .ethnicity_response import EthnicityResponse # noqa: F401 -from .gender_response import GenderResponse # noqa: F401 -from .height_response import HeightResponse # noqa: F401 -from .respiratory_conditions_response import RespiratoryConditionsResponse # noqa: F401 -from .sex_at_birth_response import SexAtBirthResponse # noqa: F401 -from .weight_response import WeightResponse # noqa: F401 diff --git a/lung_cancer_screening/questions/models/cancer_diagnosis_response.py b/lung_cancer_screening/questions/models/cancer_diagnosis_response.py new file mode 100644 index 00000000..87c66455 --- /dev/null +++ b/lung_cancer_screening/questions/models/cancer_diagnosis_response.py @@ -0,0 +1,9 @@ +from django.db import models + +from .base import BaseModel +from .response_set import ResponseSet + + +class CancerDiagnosisResponse(BaseModel): + response_set = models.OneToOneField(ResponseSet, on_delete=models.CASCADE, related_name='cancer_diagnosis_response') + value = models.BooleanField() diff --git a/lung_cancer_screening/questions/presenters/response_set_presenter.py b/lung_cancer_screening/questions/presenters/response_set_presenter.py index dc088047..8c259ec0 100644 --- a/lung_cancer_screening/questions/presenters/response_set_presenter.py +++ b/lung_cancer_screening/questions/presenters/response_set_presenter.py @@ -76,6 +76,13 @@ def asbestos_exposure(self): return "Yes" if self.response_set.asbestos_exposure_response.value else "No" + @property + def cancer_diagnosis(self): + if not hasattr(self.response_set, 'cancer_diagnosis_response'): + return None + + return "Yes" if self.response_set.cancer_diagnosis_response.value else "No" + @property def respiratory_conditions(self): if not hasattr(self.response_set, 'respiratory_conditions_response'): diff --git a/lung_cancer_screening/questions/tests/factories/cancer_diagnosis_response_factory.py b/lung_cancer_screening/questions/tests/factories/cancer_diagnosis_response_factory.py new file mode 100644 index 00000000..5f775497 --- /dev/null +++ b/lung_cancer_screening/questions/tests/factories/cancer_diagnosis_response_factory.py @@ -0,0 +1,12 @@ +import factory + +from .response_set_factory import ResponseSetFactory +from ...models.cancer_diagnosis_response import CancerDiagnosisResponse + + +class CancerDiagnosisResponseFactory(factory.django.DjangoModelFactory): + class Meta: + model = CancerDiagnosisResponse + + response_set = factory.SubFactory(ResponseSetFactory) + value = factory.Faker('boolean') diff --git a/lung_cancer_screening/questions/tests/unit/forms/test_cancer_diagnosis_form.py b/lung_cancer_screening/questions/tests/unit/forms/test_cancer_diagnosis_form.py new file mode 100644 index 00000000..fbfc18da --- /dev/null +++ b/lung_cancer_screening/questions/tests/unit/forms/test_cancer_diagnosis_form.py @@ -0,0 +1,54 @@ +from django.test import TestCase, tag + +from ...factories.response_set_factory import ResponseSetFactory +from ....models.cancer_diagnosis_response import CancerDiagnosisResponse +from ....forms.cancer_diagnosis_form import CancerDiagnosisForm + +@tag("CancerDiagnosis") +class TestCancerDiagnosisForm(TestCase): + def setUp(self): + self.response_set = ResponseSetFactory() + self.response = CancerDiagnosisResponse.objects.create( + response_set=self.response_set, + value=False + ) + + + def test_is_valid_with_a_valid_value(self): + form = CancerDiagnosisForm( + instance=self.response, + data={ + "value": False + } + ) + self.assertTrue(form.is_valid()) + self.assertEqual( + form.cleaned_data["value"], + False + ) + + def test_is_invalid_with_an_invalid_value(self): + form = CancerDiagnosisForm( + instance=self.response, + data={ + "value": "invalid" + } + ) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors["value"], + ["Select a valid choice. invalid is not one of the available choices."] + ) + + def test_is_invalid_when_no_option_is_selected(self): + form = CancerDiagnosisForm( + instance=self.response, + data={ + "value": None + } + ) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors["value"], + ["Select if you have been diagnosed with cancer"] + ) diff --git a/lung_cancer_screening/questions/tests/unit/models/test_cancer_diagnosis_response.py b/lung_cancer_screening/questions/tests/unit/models/test_cancer_diagnosis_response.py new file mode 100644 index 00000000..b0099fd7 --- /dev/null +++ b/lung_cancer_screening/questions/tests/unit/models/test_cancer_diagnosis_response.py @@ -0,0 +1,34 @@ +from django.test import TestCase, tag + +from ...factories.response_set_factory import ResponseSetFactory +from ...factories.cancer_diagnosis_response_factory import CancerDiagnosisResponseFactory + +from ....models.cancer_diagnosis_response import CancerDiagnosisResponse + +@tag("CancerDiagnosis") +class TestCancerDiagnosisResponse(TestCase): + def setUp(self): + self.response_set = ResponseSetFactory() + + def test_has_a_valid_factory(self): + model = CancerDiagnosisResponseFactory.build(response_set=self.response_set) + model.full_clean() + + + def test_has_response_set_as_foreign_key(self): + response_set = ResponseSetFactory() + response = CancerDiagnosisResponse.objects.create( + response_set=response_set, + value=True + ) + + self.assertEqual(response.response_set, response_set) + + def test_has_value_as_bool(self): + response_set = ResponseSetFactory() + response = CancerDiagnosisResponse.objects.create( + response_set=response_set, + value=False + ) + + self.assertIsInstance(response.value, bool) diff --git a/lung_cancer_screening/questions/tests/unit/presenters/test_response_set_presenter.py b/lung_cancer_screening/questions/tests/unit/presenters/test_response_set_presenter.py index 6db3dda8..97e2ce78 100644 --- a/lung_cancer_screening/questions/tests/unit/presenters/test_response_set_presenter.py +++ b/lung_cancer_screening/questions/tests/unit/presenters/test_response_set_presenter.py @@ -10,6 +10,7 @@ from ...factories.ethnicity_response_factory import EthnicityResponseFactory from ...factories.asbestos_exposure_response_factory import AsbestosExposureResponseFactory from ...factories.respiratory_conditions_response_factory import RespiratoryConditionsResponseFactory +from ...factories.cancer_diagnosis_response_factory import CancerDiagnosisResponseFactory from ....models.have_you_ever_smoked_response import HaveYouEverSmokedValues from ....models.sex_at_birth_response import SexAtBirthValues @@ -164,3 +165,16 @@ def test_respiratory_conditions_with_value(self): ) presenter = ResponseSetPresenter(self.response_set) self.assertEqual(presenter.respiratory_conditions, RespiratoryConditionValues.COPD.label + " and " + RespiratoryConditionValues.BRONCHITIS.label) + + def test_cancer_diagnosis_with_no_value(self): + presenter = ResponseSetPresenter(self.response_set) + self.assertEqual(presenter.cancer_diagnosis, None) + + + def test_cancer_diagnosis_with_value(self): + CancerDiagnosisResponseFactory( + response_set=self.response_set, + value=True + ) + presenter = ResponseSetPresenter(self.response_set) + self.assertEqual(presenter.cancer_diagnosis, "Yes") diff --git a/lung_cancer_screening/questions/tests/unit/views/test_cancer_diagnosis.py b/lung_cancer_screening/questions/tests/unit/views/test_cancer_diagnosis.py index d435cc40..efa3ebb8 100644 --- a/lung_cancer_screening/questions/tests/unit/views/test_cancer_diagnosis.py +++ b/lung_cancer_screening/questions/tests/unit/views/test_cancer_diagnosis.py @@ -46,11 +46,14 @@ class TestPostCancerDiagnosis(TestCase): def setUp(self): self.user = login_user(self.client) + self.valid_params = {"value": True} + def test_post_redirects_if_the_user_is_not_logged_in(self): self.client.logout() response = self.client.post( - reverse("questions:cancer_diagnosis") + reverse("questions:cancer_diagnosis"), + self.valid_params ) self.assertRedirects( @@ -83,7 +86,7 @@ def test_post_updates_unsubmitted_response_set_when_one_exists(self): self.assertEqual(response_set.submitted_at, None) self.assertEqual(response_set.user, self.user) - def test_post_creates_new_unsubmitted_response_set_when_submitted_exists_over_year_ago( # noqa: E501 + def test_post_creates_new_unsubmitted_response_set_when_submitted_exists_over_year_ago( self ): self.user.responseset_set.create( @@ -101,7 +104,7 @@ def test_post_creates_new_unsubmitted_response_set_when_submitted_exists_over_ye self.assertEqual(response_set.submitted_at, None) self.assertEqual(response_set.user, self.user) - def test_post_redirects_when_submitted_response_set_exists_within_last_year( # noqa: E501 + def test_post_redirects_when_submitted_response_set_exists_within_last_year( self ): self.user.responseset_set.create( @@ -116,7 +119,8 @@ def test_post_redirects_when_submitted_response_set_exists_within_last_year( # def test_post_redirects_to_family_history_lung_cancer_path(self): response = self.client.post( - reverse("questions:cancer_diagnosis") + reverse("questions:cancer_diagnosis"), + self.valid_params ) self.assertRedirects( diff --git a/lung_cancer_screening/questions/views/cancer_diagnosis.py b/lung_cancer_screening/questions/views/cancer_diagnosis.py index bdf5d681..1c8c2668 100644 --- a/lung_cancer_screening/questions/views/cancer_diagnosis.py +++ b/lung_cancer_screening/questions/views/cancer_diagnosis.py @@ -5,20 +5,50 @@ from .mixins.ensure_response_set import EnsureResponseSet +from ..forms.cancer_diagnosis_form import CancerDiagnosisForm +from ..models.cancer_diagnosis_response import CancerDiagnosisResponse class CancerDiagnosisView(LoginRequiredMixin, EnsureResponseSet, View): +# def setup(self, request): +# super() +# self.response, _ = CancerDiagnosisResponse.objects.get_or_build( +# response_set=request.response_set +# ) + def get(self, request): - return render_template(request) + response, _ = CancerDiagnosisResponse.objects.get_or_build( + response_set=request.response_set + ) + return render_template(request, response) def post(self, request): - return redirect(reverse("questions:family_history_lung_cancer")) + response, _ = CancerDiagnosisResponse.objects.get_or_build( + response_set=request.response_set + ) + + form = CancerDiagnosisForm( + instance=response, + data=request.POST + ) + + if form.is_valid(): + response.value = form.cleaned_data["value"] + response.save() + return redirect(reverse("questions:family_history_lung_cancer")) + else: + return render_template(request, response, 422) + +def render_template(request, response, status=200): + #response, _ = CancerDiagnosisResponse.objects.get_or_build( + # response_set=request.response_set + #) -def render_template(request, status=200): return render( request, - "question_form.jinja", + "cancer_diagnosis.jinja", { + "form": CancerDiagnosisForm(instance=response), "back_link_url": reverse("questions:asbestos_exposure") }, status=status diff --git a/scripts/tests/unit.sh b/scripts/tests/unit.sh index 410a3998..bfba2d60 100755 --- a/scripts/tests/unit.sh +++ b/scripts/tests/unit.sh @@ -17,13 +17,21 @@ cd "$(git rev-parse --show-toplevel)" # tests from here. If you want to run other test suites, see the predefined # tasks in scripts/test.mk. +if [[ -n "${TAG:-}" ]]; then + TAG="--tag=$TAG" +else + TAG="" +fi + if [[ -n "${TEST_MODULE:-}" ]]; then if [[ "$TEST_MODULE" == *\/* ]]; then # Modify paths to point to modules TEST_MODULE="${TEST_MODULE%.py}" TEST_MODULE=${TEST_MODULE//\//\.} fi - docker compose run --rm --remove-orphans web poetry run python manage.py test $TEST_MODULE --settings=lung_cancer_screening.settings_test --exclude-tag=accessibility else - docker compose run --rm --remove-orphans web poetry run python manage.py test --settings=lung_cancer_screening.settings_test --exclude-tag=accessibility + TEST_MODULE="" fi + +docker compose run --rm --remove-orphans web poetry run python manage.py test $TEST_MODULE $TAG --settings=lung_cancer_screening.settings_test --exclude-tag=accessibility + diff --git a/tests/features/questionnaire.feature b/tests/features/questionnaire.feature index cfce956d..dd50c482 100644 --- a/tests/features/questionnaire.feature +++ b/tests/features/questionnaire.feature @@ -41,7 +41,7 @@ Feature: Questionnaire When I fill in and submit my asbestos exposure with "No" Then I am on "/cancer-diagnosis" And I see a back link to "/asbestos-exposure" - When I click "Continue" + When I fill in and submit my cancer diagnosis with "No" Then I am on "/family-history-lung-cancer" And I see a back link to "/cancer-diagnosis" When I click "Continue" diff --git a/tests/features/steps/form_steps.py b/tests/features/steps/form_steps.py index d853f225..c5d60988 100644 --- a/tests/features/steps/form_steps.py +++ b/tests/features/steps/form_steps.py @@ -74,6 +74,11 @@ def when_i_fill_in_and_submit_my_asbestos_exposure(context, asbestos_exposure): context.page.get_by_label(asbestos_exposure, exact=True).check() when_i_submit_the_form(context) +@when(u'I fill in and submit my cancer diagnosis with "{cancer_diagnosis}"') +def when_i_fill_in_and_submit_my_cancer_diagnosis(context, cancer_diagnosis): + context.page.get_by_label(cancer_diagnosis, exact=True).check() + when_i_submit_the_form(context) + @when('I submit the form') def when_i_submit_the_form(context): context.page.click("text=Continue") From 2e9f173809ff8bcbcf63b2d7dbf5bdf800713e83 Mon Sep 17 00:00:00 2001 From: Jamie Falcus <50366804+jamiefalcus@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:22:13 +0000 Subject: [PATCH 2/2] Fix linting issue --- .gitleaksignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitleaksignore b/.gitleaksignore index 8f8207fa..97b5079c 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -21,5 +21,5 @@ infrastructure/bootstrap/main.bicep:generic-api-key:30 infrastructure/bootstrap/main.bicep:generic-api-key:31 infrastructure/bootstrap/main.bicep:generic-api-key:32 infrastructure/bootstrap/main.bicep:generic-api-key:33 -infrastructure/bootstrap/modules/storage.bicep:generic-api-key:59 infrastructure/bootstrap/modules/keyVault.bicep:generic-api-key:10 +infrastructure/bootstrap/modules/storage.bicep:generic-api-key:59