diff --git a/django_app/htsh/middleware.py b/django_app/htsh/middleware.py index f875fd4..a43fd6f 100644 --- a/django_app/htsh/middleware.py +++ b/django_app/htsh/middleware.py @@ -32,6 +32,10 @@ def _is_auth_flow(self, path: str) -> bool: def process_request(self, request): path = request.path + if path in ("/favourites", "/favourites/"): + if not request.user.is_authenticated and request.session.get("campaign_code"): + return None + # 1. Auth-required paths need fully authenticated HTSH user if self._requires_auth(path): if not request.user.is_authenticated: diff --git a/django_app/htsh/tests/test_middleware.py b/django_app/htsh/tests/test_middleware.py index 34ce5b1..6d46efb 100644 --- a/django_app/htsh/tests/test_middleware.py +++ b/django_app/htsh/tests/test_middleware.py @@ -287,6 +287,20 @@ def test_unauthenticated_favourites_trailing_slash_redirects(self): self.assertEqual(response.status_code, 302) self.assertIn("/landing/", response.url) + def test_campaign_session_favourites_pass_through(self): + request = self.factory.get("/favourites") + request.user = AnonymousUser() + request.session = {"campaign_code": "123456"} + response = self.middleware.process_request(request) + self.assertIsNone(response) + + def test_campaign_session_favourites_trailing_slash_pass_through(self): + request = self.factory.get("/favourites/") + request.user = AnonymousUser() + request.session = {"campaign_code": "123456"} + response = self.middleware.process_request(request) + self.assertIsNone(response) + def test_htsh_flow_no_cache_headers(self): inner_response = HttpResponse("OK") middleware = HtshAccessMiddleware(get_response=lambda r: inner_response) diff --git a/django_app/htsh/tests/test_views_signup.py b/django_app/htsh/tests/test_views_signup.py index cab2e81..6cc09d1 100644 --- a/django_app/htsh/tests/test_views_signup.py +++ b/django_app/htsh/tests/test_views_signup.py @@ -9,7 +9,7 @@ from django.urls import reverse from django.utils import timezone -from htsh.models import MagicLink, UserProfile +from htsh.models import FavouriteService, MagicLink, UserProfile from htsh.services.tokens import generate_otp, hash_token from testing.helpers import ( make_campaign, @@ -78,7 +78,7 @@ def test_landing_valid_campaign_restarts_authenticated_user_journey(self): response = self.client.get(reverse("home")) self.assertEqual(response.status_code, 200) - self.assertIn(b"details-postcode", response.content) + self.assertIn(b"questionnaire-intro", response.content) self.assertNotIn("_auth_user_id", self.client.session) self.assertEqual(self.client.session.get("campaign_code"), campaign.campaign_code) @@ -95,6 +95,19 @@ def test_landing_valid_campaign_sets_no_store_headers(self): self.assertEqual(response["Pragma"], "no-cache") self.assertEqual(response["Expires"], "0") + def test_landing_valid_campaign_clears_anonymous_session_favourites(self): + """Valid campaign entry clears anonymous favourites session state.""" + session = self.client.session + session["anonymous_favourite_service_ids"] = [42, 43] + session.save() + + campaign = make_campaign(campaign_code="111222") + response = self.client.get(reverse("landing"), {"cc": campaign.campaign_code}) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") + self.assertNotIn("anonymous_favourite_service_ids", self.client.session) + def test_landing_invalid_campaign_sets_no_store_headers(self): """Campaign code error pages should not be edge-cached either.""" response = self.client.get(reverse("landing"), {"cc": "BADCODE"}) @@ -380,13 +393,15 @@ def setUp(self): expires_at=timezone.now() + timedelta(minutes=15), ) - def _set_otp_session(self, flow="login"): + def _set_otp_session(self, flow="login", **extra): """Set OTP session keys.""" session = self.client.session session["otp_user_id"] = self.user.id session["otp_flow"] = flow session["otp_contact"] = self.profile.email session["otp_is_email"] = True + for key, value in extra.items(): + session[key] = value session.save() def test_otp_verify_get_renders_form(self, mock_timing): @@ -475,6 +490,30 @@ def test_otp_verify_signup_flow_sets_disclaimer(self, mock_timing): self.profile.refresh_from_db() self.assertIsNotNone(self.profile.disclaimer_accepted_at) + def test_otp_verify_login_imports_anonymous_favourites(self, mock_timing): + """OTP login imports valid anonymous favourites and clears the session key.""" + self._set_otp_session( + anonymous_favourite_service_ids=[7, "8", "invalid", 7] + ) + + response = self.client.post( + reverse("htsh:otp_verify"), + {"otp": self.otp}, + ) + + self.assertEqual(response.status_code, 302) + self.assertTrue( + FavouriteService.objects.filter(user=self.user, service_id=7).exists() + ) + self.assertTrue( + FavouriteService.objects.filter(user=self.user, service_id=8).exists() + ) + self.assertEqual( + FavouriteService.objects.filter(user=self.user).count(), + 2, + ) + self.assertNotIn("anonymous_favourite_service_ids", self.client.session) + @patch("htsh.views._ensure_min_response_time") class InterstitialSignupSessionMigrationTests(TestCase): @@ -550,3 +589,42 @@ def test_standard_signup_redirects_to_success(self, mock_timing): ) self.assertEqual(response.status_code, 302) self.assertIn("/success", response.url) + + def test_signup_imports_anonymous_favourites(self, mock_timing): + """OTP signup imports valid anonymous favourites into FavouriteService rows.""" + self._set_otp_session(anonymous_favourite_service_ids=[42, "43", "bad"]) + + response = self.client.post( + reverse("htsh:otp_verify"), + {"otp": self.otp}, + ) + + self.assertEqual(response.status_code, 302) + self.assertTrue( + FavouriteService.objects.filter(user=self.user, service_id=42).exists() + ) + self.assertTrue( + FavouriteService.objects.filter(user=self.user, service_id=43).exists() + ) + self.assertEqual(FavouriteService.objects.filter(user=self.user).count(), 2) + self.assertNotIn("anonymous_favourite_service_ids", self.client.session) + + def test_signup_import_deduplicates_existing_favourites(self, mock_timing): + """OTP signup does not duplicate favourites already saved on the account.""" + FavouriteService.objects.create(user=self.user, service_id=42) + self._set_otp_session(anonymous_favourite_service_ids=[42, "42", 43]) + + response = self.client.post( + reverse("htsh:otp_verify"), + {"otp": self.otp}, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual( + FavouriteService.objects.filter(user=self.user, service_id=42).count(), + 1, + ) + self.assertTrue( + FavouriteService.objects.filter(user=self.user, service_id=43).exists() + ) + self.assertEqual(FavouriteService.objects.filter(user=self.user).count(), 2) diff --git a/django_app/htsh/views.py b/django_app/htsh/views.py index e8875ef..306b269 100644 --- a/django_app/htsh/views.py +++ b/django_app/htsh/views.py @@ -27,7 +27,14 @@ DeleteAccountForm, ReturningForm, ) -from .models import UserProfile, MagicLink, UserFilter, Campaign, generate_username +from .models import ( + UserProfile, + MagicLink, + UserFilter, + Campaign, + FavouriteService, + generate_username, +) from .services.tokens import generate_otp, hash_token from .services.sender import get_email_sender, get_sms_sender @@ -128,6 +135,29 @@ def _normalize_phone(phone: str) -> str: return re.sub(r"[\s\-\(\)]+", "", phone) +def _import_anonymous_favourites(request: HttpRequest, user) -> None: + """Move anonymous session favourites into persistent account favourites.""" + from web.views import SESSION_KEY_ANONYMOUS_FAVOURITES + + raw_values = request.session.get(SESSION_KEY_ANONYMOUS_FAVOURITES, []) + if not isinstance(raw_values, list): + raw_values = [] + + normalized_ids: set[int] = set() + for value in raw_values: + try: + normalized_ids.add(int(value)) + except (TypeError, ValueError): + continue + + for service_id in normalized_ids: + FavouriteService.objects.get_or_create(user=user, service_id=service_id) + + if SESSION_KEY_ANONYMOUS_FAVOURITES in request.session: + request.session.pop(SESSION_KEY_ANONYMOUS_FAVOURITES, None) + request.session.modified = True + + def magic_link_request(request: HttpRequest) -> HttpResponse: """Request an OTP to be sent to a registered user via email or phone.""" start_time = time.time() @@ -351,6 +381,8 @@ def otp_verify(request: HttpRequest) -> HttpResponse: request.session.pop("campaign_code", None) request.session.pop("disclaimer_accepted", None) + _import_anonymous_favourites(request, user) + if otp_flow == "signup": from web.views import ( QUESTIONNAIRE_SESSION_KEYS, @@ -454,7 +486,11 @@ def _set_no_store(resp: HttpResponse) -> HttpResponse: if request.user.is_authenticated: logout(request) - from web.views import QUESTIONNAIRE_SESSION_KEYS, PERSISTED_SESSION_KEYS + from web.views import ( + QUESTIONNAIRE_SESSION_KEYS, + PERSISTED_SESSION_KEYS, + SESSION_KEY_ANONYMOUS_FAVOURITES, + ) for key in QUESTIONNAIRE_SESSION_KEYS + PERSISTED_SESSION_KEYS: request.session.pop(key, None) @@ -463,6 +499,7 @@ def _set_no_store(resp: HttpResponse) -> HttpResponse: "entry_flow", "disclaimer_accepted", "preferred_contact_method", + SESSION_KEY_ANONYMOUS_FAVOURITES, ]: request.session.pop(key, None) diff --git a/django_app/templates/jinja2/htsh/disclaimer.jinja b/django_app/templates/jinja2/htsh/disclaimer.jinja index 05f78d6..2f12216 100644 --- a/django_app/templates/jinja2/htsh/disclaimer.jinja +++ b/django_app/templates/jinja2/htsh/disclaimer.jinja @@ -18,16 +18,16 @@ ] }) }} {% endif %} -

Can we use your basic information?

-

We need to know a few details about you so we can:

+

Sign up so we can help you along the way

+

We’re going to ask you for either your email address or mobile number so we can:

-

You can opt out of this service at any time, and anything you've told us will be deleted

+

You can opt out at any time, and anything you've told us will be deleted.

- To find out more about how we'll use your information, read our privacy policy / terms (opens in a new tab) + To find out more about how we'll use your information, read our privacy policy.

{{ csrf_input }} diff --git a/django_app/templates/jinja2/htsh/landing.jinja b/django_app/templates/jinja2/htsh/landing.jinja index e5f2aa5..50c6c09 100644 --- a/django_app/templates/jinja2/htsh/landing.jinja +++ b/django_app/templates/jinja2/htsh/landing.jinja @@ -1,7 +1,7 @@ {% extends "web/layout/base.jinja" %} {% from "web/macros/layout.jinja" import two_thirds_column_start, two_thirds_column_end %} {% from "web/macros/forms.jinja" import form_error_summary, continue_button, back_link %} -{% set pageName = "Get help to make healthy lifestyle changes that last" %} +{% set pageName = "Get help to create healthy habits that work for you" %} {% block pageTitle %}Help to Stay Healthy{% endblock %} {% block main %} {% if campaign %} @@ -10,7 +10,7 @@
-

Get help to make healthy lifestyle changes that last

+

Get help to create healthy habits that work for you

From improving your mental health to upping your energy levels, exercising and eating well can make a big difference to your quality of life.

@@ -33,15 +33,16 @@
-

Find new ways to eat well and move more

-

Choose from:

+

Get help to create healthy habits that work for you

+

Taking that first step towards healthier habits isn't always easy. Help to Stay Healthy is here to help you find the right support, and stay with you through the natural ups and downs of making changes.

+

Find local, national and online activities and groups

+

Here's how it works:

    -
  • exercise programmes, like Couch to Fitness
  • -
  • weight management programmes, like the NHS Weight Loss Plan
  • -
  • walking programmes, like NHS Active 10
  • +
  • tell us a bit about yourself
  • +
  • we'll match you with activities that best meet your needs
  • +
  • you'll see details on how to get involved
  • +
  • if you want us to, we'll check in with you to see how you're getting on, and offer suggestions and encouragement to keep going or try something else
-

We’ll show you the options that best meet your needs

-

Tell us a bit about yourself and we'll match you to the activities we think will work best for you.

@@ -53,8 +54,8 @@
-

We’ll help you keep going

-

We’ll check in with you to ask how you’re doing and encourage you to carry on.

+

We'll stay with you throughout

+

No judgement. No time limit. We'll be here when you need us to celebrate the wins and overcome the setbacks. You can choose to sign up for ongoing support once you've told us a bit about yourself.

diff --git a/django_app/templates/jinja2/web/layout/base.jinja b/django_app/templates/jinja2/web/layout/base.jinja index 69890f6..e36c6aa 100644 --- a/django_app/templates/jinja2/web/layout/base.jinja +++ b/django_app/templates/jinja2/web/layout/base.jinja @@ -28,7 +28,11 @@ { "text": "Edit my details", "icon": true, "href": url("htsh:account") }, { "text": "Sign out", "href": url("htsh:logout") }, ] - } if showPilotAccount else none) + } if showPilotAccount else ({ + "items": [ + { "text": "Sign up", "href": url("htsh:disclaimer") }, + ] + } if not request.user.is_authenticated else none)) }) }} {% endblock %} {% block footer %} diff --git a/django_app/templates/jinja2/web/pages/confidence-readiness.jinja b/django_app/templates/jinja2/web/pages/confidence-readiness.jinja index 24c8574..8f72218 100644 --- a/django_app/templates/jinja2/web/pages/confidence-readiness.jinja +++ b/django_app/templates/jinja2/web/pages/confidence-readiness.jinja @@ -27,6 +27,8 @@ {{ csrf_input }} +

6/7

+ {{ radios({ "idPrefix": "confidence_readiness", "name": "confidence_readiness", diff --git a/django_app/templates/jinja2/web/pages/current-barriers.jinja b/django_app/templates/jinja2/web/pages/current-barriers.jinja index 70a5569..c502e32 100644 --- a/django_app/templates/jinja2/web/pages/current-barriers.jinja +++ b/django_app/templates/jinja2/web/pages/current-barriers.jinja @@ -27,6 +27,8 @@ {{ csrf_input }} +

5/7

+ {{ checkboxes({ "idPrefix": "current_barriers", "name": "current_barriers", diff --git a/django_app/templates/jinja2/web/pages/details-postcode.jinja b/django_app/templates/jinja2/web/pages/details-postcode.jinja index 548a95b..a33ccb7 100644 --- a/django_app/templates/jinja2/web/pages/details-postcode.jinja +++ b/django_app/templates/jinja2/web/pages/details-postcode.jinja @@ -7,7 +7,7 @@ {% set pageName = "What is your home postcode?" %} {% block beforeContent %} - {{ back_link("/") }} + {{ back_link("/questionnaire-intro") }} {% endblock %} {% block content %} @@ -28,12 +28,17 @@ {{ csrf_input }} +

1/7

+ {{ input({ "label": { "text": "What is your home postcode?", "classes": "nhsuk-label--l", "isPageHeading": "true" }, + "hint": { + "text": "We’ll use this to show you activities and services near you. If you’re filling this in on behalf of someone else, please ask them if it’s okay to use their postcode." + }, "id": "details-postcode", "name": "details-postcode", "classes": "nhsuk-input--width-10", diff --git a/django_app/templates/jinja2/web/pages/enablers.jinja b/django_app/templates/jinja2/web/pages/enablers.jinja index b5e9bae..dcd34b2 100644 --- a/django_app/templates/jinja2/web/pages/enablers.jinja +++ b/django_app/templates/jinja2/web/pages/enablers.jinja @@ -27,6 +27,8 @@ {{ csrf_input }} +

7/7

+ {{ checkboxes({ "idPrefix": "enablers", "name": "enablers", diff --git a/django_app/templates/jinja2/web/pages/index.jinja b/django_app/templates/jinja2/web/pages/index.jinja index 0f4153a..4c91382 100644 --- a/django_app/templates/jinja2/web/pages/index.jinja +++ b/django_app/templates/jinja2/web/pages/index.jinja @@ -22,7 +22,7 @@
{% endmacro %} -{% set pageName = "Get help to make healthy lifestyle changes that last" %} +{% set pageName = "Get help to create healthy habits that work for you" %} {% if request.user.is_authenticated %} {% set endLinksHtml %}
@@ -48,9 +48,9 @@
{% endif %} {% endfor %} -

Get help to make healthy lifestyle changes that last

+

Get help to create healthy habits that work for you

- From improving your mental health to upping your energy levels, exercising and eating well can make a big difference to your quality of life. + Taking that first step towards healthier habits isn't always easy. Help to Stay Healthy is here to help you find the right support, and stay with you through the natural ups and downs of making changes.

@@ -60,7 +60,8 @@
-

How you'd like to stay healthy

+

Here’s what you told us

+

We’ll use this to find activities and support that fit your life. You can change your answers at any time.

{% for message in messages %}

{{ message }}

@@ -68,29 +69,29 @@ {% endfor %}
- {{ summary_row("What prompted you to look for support", - (user_fields.motivation if user_fields.motivation else "Not set").replace("_", " ").capitalize(), + {{ summary_row("What made you look for support", + user_fields_display.motivation, url("motivation") ~ "?mode=edit" ) }}
{{ summary_row("Biggest difference to your health", - (user_fields.priority_behaviour if user_fields.priority_behaviour else "Not set").replace("_", " ").capitalize(), + user_fields_display.priority_behaviour, url("priority_behaviour") ~ "?mode=edit") }}
{{ summary_row("Past barriers", - user_fields.past_barriers | map("replace", "_", " ") | map("capitalize") if user_fields.past_barriers else ["Not set"], + user_fields_display.past_barriers, url("past_barriers") ~ "?mode=edit") }}
{{ summary_row("Current barriers", - user_fields.current_barriers | map("replace", "_", " ") | map("capitalize") if user_fields.current_barriers else ["Not set"], + user_fields_display.current_barriers, url("current_barriers") ~ "?mode=edit") }}
{{ summary_row("Confidence and readiness", - (user_fields.confidence_readiness if user_fields.confidence_readiness else "Not set").replace("_", " ").capitalize(), + user_fields_display.confidence_readiness, url("confidence_readiness") ~ "?mode=edit") }}
- {{ summary_row("What helps you stay on track", - user_fields.enablers | map("replace", "_", " ") | map("capitalize") if user_fields.enablers else ["Not set"], + {{ summary_row("What would make the biggest difference to you", + user_fields_display.enablers, url("enablers") ~ "?mode=edit") }}

@@ -105,7 +106,6 @@
- {% if request.user.is_authenticated %}
@@ -138,7 +138,6 @@
- {% endif %}
@@ -151,15 +150,14 @@
-

Find new ways to eat well and move more

-

Choose from:

+

Find local, national and online activities and groups

+

Here's how it works:

    -
  • exercise programmes, like Couch to Fitness
  • -
  • weight management programmes, like the NHS Weight Loss Plan
  • -
  • walking programmes, like NHS Active 10
  • +
  • tell us a bit about yourself
  • +
  • we'll match you with activities that best meet your needs
  • +
  • you'll see details on how to get involved
  • +
  • if you want us to, we'll check in with you to see how you're getting on, and offer suggestions and encouragement to keep going or try something else
-

We’ll show you the options that best meet your needs

-

Tell us a bit about yourself and we'll match you to the activities we think will work best for you.

@@ -171,8 +169,8 @@
-

We’ll help you keep going

-

We’ll check in with you to ask how you’re doing and encourage you to carry on.

+

We'll stay with you throughout

+

No judgement. No time limit. We'll be here when you need us to celebrate the wins and overcome the setbacks. You can choose to sign up for ongoing support once you've told us a bit about yourself.

@@ -196,4 +194,4 @@
{% endif %} -{% endblock main %} +{% endblock main %} \ No newline at end of file diff --git a/django_app/templates/jinja2/web/pages/listing.jinja b/django_app/templates/jinja2/web/pages/listing.jinja index a952557..08c4ffa 100644 --- a/django_app/templates/jinja2/web/pages/listing.jinja +++ b/django_app/templates/jinja2/web/pages/listing.jinja @@ -277,7 +277,7 @@
- {% if request.user.is_authenticated %} + {% if request.user.is_authenticated or favourite_ids %} Your saved services {% endif %} @@ -291,7 +291,11 @@ {% else %}

+ {% if request.user.is_authenticated %} We have some options for you + {% else %} + If you’d like us to help you along the way, don’t forget to sign up - it only takes a minute. + {% endif %}

{% if pagination.total_results > 0 %} diff --git a/django_app/templates/jinja2/web/pages/motivation.jinja b/django_app/templates/jinja2/web/pages/motivation.jinja index 034f223..1296901 100644 --- a/django_app/templates/jinja2/web/pages/motivation.jinja +++ b/django_app/templates/jinja2/web/pages/motivation.jinja @@ -3,7 +3,7 @@ {% from "web/macros/layout.jinja" import two_thirds_column_start, two_thirds_column_end %} {% from "web/macros/forms.jinja" import form_error_summary, continue_button, back_link %} -{% set pageName = "What prompted you to look for support with your health today?" %} +{% set pageName = "What made you look for support today?" %} {% block beforeContent %} {{ back_link(back_href) }} @@ -27,6 +27,8 @@ {{ csrf_input }} +

2/7

+ {{ radios({ "idPrefix": "motivation", "name": "motivation", @@ -35,7 +37,7 @@ } if error else undefined, "fieldset": { "legend": { - "text": "What prompted you to look for support with your health today?", + "text": "What made you look for support today?", "classes": "nhsuk-fieldset__legend--l", "isPageHeading": true } @@ -47,7 +49,7 @@ }, { "value": "motivation.noticed_changes", - "text": "I've noticed changes in my weight or fitness" + "text": "I've noticed changes in my weight or movement" }, { "value": "motivation.health_professional", @@ -75,7 +77,7 @@ }, { "value": "motivation.just_exploring", - "text": "I'm not sure — I'm just exploring" + "text": "I'm just exploring" } ], "value": data.motivation if data.motivation else undefined diff --git a/django_app/templates/jinja2/web/pages/past-barriers.jinja b/django_app/templates/jinja2/web/pages/past-barriers.jinja index c326345..13e4cb6 100644 --- a/django_app/templates/jinja2/web/pages/past-barriers.jinja +++ b/django_app/templates/jinja2/web/pages/past-barriers.jinja @@ -27,6 +27,8 @@ {{ csrf_input }} +

4/7

+ {{ checkboxes({ "idPrefix": "past_barriers", "name": "past_barriers", diff --git a/django_app/templates/jinja2/web/pages/priority-behaviour.jinja b/django_app/templates/jinja2/web/pages/priority-behaviour.jinja index caa563e..172a931 100644 --- a/django_app/templates/jinja2/web/pages/priority-behaviour.jinja +++ b/django_app/templates/jinja2/web/pages/priority-behaviour.jinja @@ -3,7 +3,7 @@ {% from "web/macros/layout.jinja" import two_thirds_column_start, two_thirds_column_end %} {% from "web/macros/forms.jinja" import form_error_summary, continue_button, back_link %} -{% set pageName = "Which of the following would make the biggest difference to your health and wellbeing right now?" %} +{% set pageName = "Which of these would make the biggest difference to you right now?" %} {% block beforeContent %} {{ back_link(back_href) }} @@ -27,6 +27,8 @@ {{ csrf_input }} +

3/7

+ {{ radios({ "idPrefix": "priority_behaviour", "name": "priority_behaviour", @@ -35,7 +37,7 @@ } if error else undefined, "fieldset": { "legend": { - "text": "Which of the following would make the biggest difference to your health and wellbeing right now?", + "text": "Which of these would make the biggest difference to you right now?", "classes": "nhsuk-fieldset__legend--l", "isPageHeading": true } diff --git a/django_app/templates/jinja2/web/pages/questionnaire-intro.jinja b/django_app/templates/jinja2/web/pages/questionnaire-intro.jinja new file mode 100644 index 0000000..bbb069b --- /dev/null +++ b/django_app/templates/jinja2/web/pages/questionnaire-intro.jinja @@ -0,0 +1,26 @@ +{% extends "web/layout/base.jinja" %} + +{% from "web/macros/layout.jinja" import two_thirds_column_start, two_thirds_column_end %} +{% from "web/macros/forms.jinja" import continue_button, back_link %} + +{% set pageName = "Before you start the questions" %} + +{% block beforeContent %} + {{ back_link("/") }} +{% endblock beforeContent %} + +{% block content %} + {{ two_thirds_column_start() }} + +

Finding what works for you

+

We’re going to ask you 7 short questions about what you're hoping to change, what's got in the way before, and what would help you this time.

+

Your answers help us show you activities and support that fit your life. Not just a list of everything on offer.

+

There are no right or wrong answers, and you can update them any time.

+ + + {{ csrf_input }} + {{ continue_button("Start questions") }} + + + {{ two_thirds_column_end() }} +{% endblock content %} diff --git a/django_app/templates/jinja2/web/pages/success.jinja b/django_app/templates/jinja2/web/pages/success.jinja index 1e9b6bf..2233923 100644 --- a/django_app/templates/jinja2/web/pages/success.jinja +++ b/django_app/templates/jinja2/web/pages/success.jinja @@ -11,7 +11,7 @@

Thank you

We hope you find a service that works for you.

{% endif %} - {{ button({"text": "Continue", "href": "/details-postcode"}) }} + {{ button({"text": "Continue", "href": "/"}) }} {% endset %} {% block content %} {{ two_thirds_column_start() }} diff --git a/django_app/web/tests/test_views_favourites.py b/django_app/web/tests/test_views_favourites.py index dfc2868..f606455 100644 --- a/django_app/web/tests/test_views_favourites.py +++ b/django_app/web/tests/test_views_favourites.py @@ -8,6 +8,7 @@ from htsh.models import FavouriteService from testing.helpers import make_favourite_service, make_profile, make_user +from web.views import SESSION_KEY_ANONYMOUS_FAVOURITES class _AuthenticatedTestCase(TestCase): @@ -44,8 +45,8 @@ def test_remove_favourite(self): FavouriteService.objects.filter(user=self.user, service_id=42).exists() ) - def test_unauthenticated_redirects_to_detail_prompt(self): - """POST to toggle while unauthenticated redirects to detail page (account prompt).""" + def test_unauthenticated_with_campaign_toggles_session_and_redirects_to_listing(self): + """POST to toggle while unauthenticated with campaign mutates session and redirects to listing.""" self.client.logout() session = self.client.session session["campaign_code"] = "123456" @@ -53,9 +54,19 @@ def test_unauthenticated_redirects_to_detail_prompt(self): url = reverse("toggle_favourite", args=[42]) response = self.client.post(url) self.assertEqual(response.status_code, 302) - self.assertIn("/detail/42", response.url) + self.assertIn("/listing", response.url) + session = self.client.session + self.assertEqual(session.get(SESSION_KEY_ANONYMOUS_FAVOURITES), [42]) self.assertFalse(FavouriteService.objects.filter(service_id=42).exists()) + def test_unauthenticated_without_campaign_redirects_to_landing(self): + """POST to toggle while unauthenticated without campaign redirects to landing.""" + self.client.logout() + url = reverse("toggle_favourite", args=[42]) + response = self.client.post(url) + self.assertEqual(response.status_code, 302) + self.assertIn("/landing", response.url) + def test_get_returns_405(self): """GET request to toggle endpoint returns 405 Method Not Allowed.""" url = reverse("toggle_favourite", args=[42]) @@ -82,19 +93,19 @@ def test_redirect_ignores_external_referer(self): class AnonymousTogglePromptTests(TestCase): - """Tests for anonymous star toggle redirecting to account prompt.""" + """Tests for anonymous star toggle session behavior.""" def setUp(self): session = self.client.session session["campaign_code"] = "123456" session.save() - def test_anonymous_toggle_redirects_to_detail(self): - """POST /favourite/toggle/42 as anonymous → 302 to /detail/42.""" + def test_anonymous_toggle_redirects_to_listing_without_referer(self): + """POST /favourite/toggle/42 as anonymous without referer → 302 to /listing.""" url = reverse("toggle_favourite", args=[42]) response = self.client.post(url) self.assertEqual(response.status_code, 302) - self.assertIn("/detail/42", response.url) + self.assertIn("/listing", response.url) def test_anonymous_toggle_no_favourite_created(self): """POST /favourite/toggle/42 as anonymous → no FavouriteService row.""" @@ -102,12 +113,21 @@ def test_anonymous_toggle_no_favourite_created(self): self.client.post(url) self.assertFalse(FavouriteService.objects.filter(service_id=42).exists()) - def test_anonymous_toggle_ignores_referer(self): - """POST /favourite/toggle/42 with HTTP_REFERER=/listing as anonymous → still 302 to /detail/42.""" + def test_anonymous_toggle_uses_same_host_referer(self): + """POST /favourite/toggle/42 with same-host referer redirects back to referer.""" url = reverse("toggle_favourite", args=[42]) - response = self.client.post(url, HTTP_REFERER="http://testserver/listing") + response = self.client.post(url, HTTP_REFERER="http://testserver/detail/42") self.assertEqual(response.status_code, 302) - self.assertIn("/detail/42", response.url) + self.assertEqual(response.url, "http://testserver/detail/42") + + def test_anonymous_toggle_is_idempotent_add_then_remove(self): + """Repeated anonymous toggle adds then removes service ID from session list.""" + url = reverse("toggle_favourite", args=[42]) + self.client.post(url) + self.assertEqual(self.client.session.get(SESSION_KEY_ANONYMOUS_FAVOURITES), [42]) + + self.client.post(url) + self.assertEqual(self.client.session.get(SESSION_KEY_ANONYMOUS_FAVOURITES), []) def _mock_response(data, status=200): @@ -175,6 +195,23 @@ def test_listing_anonymous_shows_grey_stars(self, mock_post): self.assertIn('stroke="#768692"', content) self.assertNotIn('fill="#FFB800"', content) + @patch("web.views.requests.post") + def test_listing_anonymous_shows_gold_star_from_session_favourites(self, mock_post): + """Anonymous listing star renders gold when service ID is favourited in session.""" + self.client.logout() + session = self.client.session + session["campaign_code"] = "123456" + session["details-postcode"] = "SW1A 1AA" + session[SESSION_KEY_ANONYMOUS_FAVOURITES] = [1] + session.save() + mock_post.return_value = _mock_response({ + "total": 1, + "results": [{"id": 1, "serviceName": "Service A"}], + }) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertIn('fill="#FFB800"', response.content.decode()) + @override_settings(SERVICE_API_BASE_URL="http://testserver") class DetailStarTests(_AuthenticatedTestCase): @@ -211,6 +248,25 @@ def test_detail_shows_grey_star_when_not_favourited(self, mock_get): content = response.content.decode() self.assertIn('stroke="#768692"', content) + @patch("web.views.requests.get") + def test_detail_anonymous_shows_gold_star_from_session_favourites(self, mock_get): + """Anonymous detail star renders gold when service ID is favourited in session.""" + self.client.logout() + session = self.client.session + session["campaign_code"] = "123456" + session[SESSION_KEY_ANONYMOUS_FAVOURITES] = [7] + session.save() + mock_get.return_value = _mock_response({ + "id": 7, + "serviceName": "Anon Fav Detail", + "description": "Desc", + "logoImage": "logo.png", + }) + url = reverse("detail", args=[7]) + response = self.client.get(url + "?skip_prompt=1") + self.assertEqual(response.status_code, 200) + self.assertIn('fill="#FFB800"', response.content.decode()) + class FavouritesPageTests(_AuthenticatedTestCase): """Tests for the /favourites page.""" @@ -276,6 +332,54 @@ def test_favourites_page_has_remove_form(self): self.assertIn("favourite/toggle/", response.content.decode()) +class AnonymousFavouritesPageTests(TestCase): + """Tests for anonymous session-backed /favourites access.""" + + def setUp(self): + session = self.client.session + session["campaign_code"] = "123456" + session.save() + + def test_anonymous_favourites_page_shows_session_saved_services(self): + """Anonymous campaign user can open /favourites and see session-backed services.""" + from api.models_v3 import V3_Service + + service = V3_Service.objects.create( + name="Anon Saved Service", + description="Desc", + cost_text="Free", + sort_order=1.0, + ) + session = self.client.session + session[SESSION_KEY_ANONYMOUS_FAVOURITES] = [service.id] + session.save() + + response = self.client.get("/favourites") + self.assertEqual(response.status_code, 200) + self.assertIn("Anon Saved Service", response.content.decode()) + + def test_anonymous_favourites_page_empty_state_without_session_favourites(self): + """Anonymous campaign user without favourites sees the empty state.""" + response = self.client.get("/favourites") + self.assertEqual(response.status_code, 200) + self.assertIn("saved any services yet", response.content.decode()) + + def test_anonymous_favourites_page_has_remove_form(self): + """Anonymous session-backed favourites page also renders the toggle form.""" + from api.models_v3 import V3_Service + + svc = V3_Service.objects.create( + name="Remove Svc", description="Desc", cost_text="Free", sort_order=1.0 + ) + session = self.client.session + session[SESSION_KEY_ANONYMOUS_FAVOURITES] = [svc.id] + session.save() + + response = self.client.get("/favourites") + self.assertEqual(response.status_code, 200) + self.assertIn("favourite/toggle/", response.content.decode()) + + @override_settings(SERVICE_API_BASE_URL="http://testserver") class ListingFavouritesButtonTests(_AuthenticatedTestCase): """Tests for the favourites button on the listing page.""" @@ -298,7 +402,7 @@ def test_authenticated_user_sees_favourites_button(self, mock_post): @patch("web.views.requests.post") def test_anonymous_user_does_not_see_favourites_button(self, mock_post): - """Anonymous user does not see the favourites button on listing.""" + """Anonymous user without session favourites does not see the favourites button.""" self.client.logout() session = self.client.session session["campaign_code"] = "123456" @@ -308,3 +412,21 @@ def test_anonymous_user_does_not_see_favourites_button(self, mock_post): response = self.client.get(reverse("listing")) self.assertEqual(response.status_code, 200) self.assertNotIn("Your saved services", response.content.decode()) + + @patch("web.views.requests.post") + def test_anonymous_user_with_session_favourites_sees_favourites_button(self, mock_post): + """Anonymous user with session favourites sees the favourites button on listing.""" + self.client.logout() + session = self.client.session + session["campaign_code"] = "123456" + session["details-postcode"] = "SW1A 1AA" + session[SESSION_KEY_ANONYMOUS_FAVOURITES] = [42] + session.save() + mock_post.return_value = _mock_response({"total": 0, "results": []}) + + response = self.client.get(reverse("listing")) + + self.assertEqual(response.status_code, 200) + content = response.content.decode() + self.assertIn("Your saved services", content) + self.assertIn('href="/favourites"', content) diff --git a/django_app/web/tests/test_views_listing.py b/django_app/web/tests/test_views_listing.py index 817a368..4309517 100644 --- a/django_app/web/tests/test_views_listing.py +++ b/django_app/web/tests/test_views_listing.py @@ -268,113 +268,64 @@ class AnonymousDetailViewTests(_AnonymousCampaignTestCase): @patch("web.views.requests.get") def test_valid_service(self, mock_get): - """GET /detail/1 as anonymous with dismissed prompt → 200.""" + """GET /detail/1 as anonymous → 200.""" mock_get.return_value = _mock_response({ "id": 1, "serviceName": "Test Service", "description": "A test service", }) - session = self.client.session - session["account_prompt_dismissed"] = True - session.save() response = self.client.get(reverse("detail", kwargs={"service_id": 1})) self.assertEqual(response.status_code, 200) @override_settings(SERVICE_API_BASE_URL="http://testserver") -class AccountPromptInterstitialTests(_AnonymousCampaignTestCase): - """Tests for the account creation interstitial on first service detail visit.""" - - def test_anonymous_first_visit_shows_prompt(self): - """GET /detail/1 without dismissed flag → 200, shows interstitial.""" - response = self.client.get(reverse("detail", kwargs={"service_id": 1})) - self.assertEqual(response.status_code, 200) - self.assertIn(b"nhsuk-panel", response.content) - self.assertIn(b"Create an account", response.content) - self.assertIn(b"Skip", response.content) +class AnonymousDetailNoPromptTests(_AnonymousCampaignTestCase): + """Anonymous users should go straight to service details without prompt.""" @patch("web.views.requests.get") - def test_skip_dismisses_and_shows_detail(self, mock_get): - """GET /detail/1?skip_prompt=1 → dismisses prompt, shows detail.""" + def test_anonymous_first_visit_shows_detail(self, mock_get): + """GET /detail/1 as anonymous → 200, shows detail content.""" mock_get.return_value = _mock_response({ "id": 1, "serviceName": "Test Service", "description": "A test", }) - response = self.client.get( - reverse("detail", kwargs={"service_id": 1}) + "?skip_prompt=1" - ) + response = self.client.get(reverse("detail", kwargs={"service_id": 1})) self.assertEqual(response.status_code, 200) self.assertIn(b"Test Service", response.content) - self.assertTrue(self.client.session.get("account_prompt_dismissed")) - - def test_skip_prompt_with_safe_next_redirects(self): - """GET /detail/42?skip_prompt=1&next=/listing → 302 to /listing.""" - response = self.client.get( - reverse("detail", kwargs={"service_id": 42}) + "?skip_prompt=1&next=/listing" - ) - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, "/listing") - self.assertTrue(self.client.session.get("account_prompt_dismissed")) + self.assertNotIn(b"Create an account", response.content) - def test_skip_prompt_with_unsafe_next_ignores(self): - """GET /detail/42?skip_prompt=1&next=http://evil.com → does not redirect to evil.com.""" - mock_get_patcher = patch("web.views.requests.get") - mock_get = mock_get_patcher.start() + @patch("web.views.requests.get") + def test_skip_prompt_query_is_ignored(self, mock_get): + """GET /detail/1?skip_prompt=1 shows detail and does not set session flags.""" mock_get.return_value = _mock_response({ - "id": 42, + "id": 1, "serviceName": "Test Service", "description": "A test", }) response = self.client.get( - reverse("detail", kwargs={"service_id": 42}) + "?skip_prompt=1&next=http://evil.com" + reverse("detail", kwargs={"service_id": 1}) + "?skip_prompt=1" ) - mock_get_patcher.stop() - # Should NOT redirect to evil.com — renders detail instead self.assertEqual(response.status_code, 200) - self.assertTrue(self.client.session.get("account_prompt_dismissed")) + self.assertIn(b"Test Service", response.content) + self.assertIsNone(self.client.session.get("account_prompt_dismissed")) @patch("web.views.requests.get") - def test_dismissed_shows_detail_directly(self, mock_get): - """GET /detail/1 with dismissed flag → 200, shows detail, no panel.""" + def test_create_account_post_redirects_to_disclaimer(self, mock_get): + """POST /detail/1 action=create_account redirects to disclaimer and stores prompt session.""" mock_get.return_value = _mock_response({ "id": 1, "serviceName": "Test Service", "description": "A test", }) - session = self.client.session - session["account_prompt_dismissed"] = True - session.save() - response = self.client.get(reverse("detail", kwargs={"service_id": 1})) - self.assertEqual(response.status_code, 200) - self.assertIn(b"Test Service", response.content) - self.assertNotIn(b"nhsuk-panel", response.content) - - def test_create_account_redirects_to_disclaimer(self): - """POST /detail/1 action=create_account → 302 to /disclaimer/.""" response = self.client.post( reverse("detail", kwargs={"service_id": 1}), {"action": "create_account"}, ) self.assertEqual(response.status_code, 302) - self.assertIn("/disclaimer/", response.url) + self.assertEqual(response.url, reverse("htsh:disclaimer")) self.assertEqual(self.client.session.get("account_prompt_service_id"), 1) - @patch("web.views.requests.get") - def test_prompt_not_shown_after_dismiss_on_different_service(self, mock_get): - """Dismiss on service 1, visit service 99 → no interstitial.""" - mock_get.return_value = _mock_response({ - "id": 99, - "serviceName": "Other Service", - "description": "Another", - }) - session = self.client.session - session["account_prompt_dismissed"] = True - session.save() - response = self.client.get(reverse("detail", kwargs={"service_id": 99})) - self.assertEqual(response.status_code, 200) - self.assertNotIn(b"nhsuk-panel", response.content) - @override_settings(SERVICE_API_BASE_URL="http://testserver") class AuthenticatedDetailBypassTests(_AuthenticatedTestCase): diff --git a/django_app/web/tests/test_views_returning.py b/django_app/web/tests/test_views_returning.py index 51981e4..a4e9e0e 100644 --- a/django_app/web/tests/test_views_returning.py +++ b/django_app/web/tests/test_views_returning.py @@ -20,11 +20,11 @@ def setUp(self): session["campaign_code"] = self.campaign.campaign_code session.save() - def test_anonymous_with_campaign_routes_to_postcode(self): - """Fresh anonymous session with campaign_code routes start_href to details-postcode.""" + def test_anonymous_with_campaign_routes_to_questionnaire_intro(self): + """Fresh anonymous session with campaign_code routes start_href to questionnaire-intro.""" response = self.client.get(reverse("home")) self.assertEqual(response.status_code, 200) - self.assertIn(b"details-postcode", response.content) + self.assertIn(b"questionnaire-intro", response.content) def test_anonymous_with_campaign_has_no_persisted_keys(self): """Fresh anonymous session with campaign_code has no PERSISTED_SESSION_KEYS values.""" diff --git a/django_app/web/tests/test_views_wizard.py b/django_app/web/tests/test_views_wizard.py index f40757f..0b6a83d 100644 --- a/django_app/web/tests/test_views_wizard.py +++ b/django_app/web/tests/test_views_wizard.py @@ -56,6 +56,26 @@ def test_start_authenticated_with_questionnaire_response(self): self.assertEqual(response.status_code, 200) self.assertIn(b"listing", response.content) + def test_start_shows_human_readable_past_barriers_summary(self): + """GET / shows selected past barrier text, not the stored enum-like value.""" + user = make_user() + make_profile(user=user, disclaimer_accepted_at=timezone.now()) + QuestionnaireResponse.objects.create( + user=user, + motivation="motivation.want_to_feel_better", + past_barriers=["past_barriers.didnt_know_where_to_start"], + ) + self.client.force_login(user) + + session = self.client.session + session["onboarding_complete"] = True + session.save() + + response = self.client.get(reverse("home")) + self.assertEqual(response.status_code, 200) + self.assertNotIn(b"past_barriers.didnt_know_where_to_start", response.content) + self.assertIn(b"I didn't know what to do or where to start", response.content) + def test_start_authenticated_with_filter_data(self): """GET / with user who has UserFilter data routes to listing.""" user = make_user() @@ -102,7 +122,7 @@ def test_post_valid_email_preference(self): "preferred_contact_method": UserProfile.CONTACT_EMAIL, }) self.assertRedirects( - response, reverse("details_postcode"), fetch_redirect_response=False, + response, reverse("questionnaire_intro"), fetch_redirect_response=False, ) def test_post_valid_sms_preference(self): @@ -112,7 +132,7 @@ def test_post_valid_sms_preference(self): "preferred_contact_method": UserProfile.CONTACT_SMS, }) self.assertRedirects( - response, reverse("details_postcode"), fetch_redirect_response=False, + response, reverse("questionnaire_intro"), fetch_redirect_response=False, ) def test_post_invalid_no_preference(self): @@ -161,7 +181,7 @@ def test_get_renders_form(self): @mock_postcodes_io(is_valid=True) def test_post_valid_postcode(self, mock_get): - """POST with valid postcode redirects to motivation (Q1).""" + """POST with valid postcode redirects to motivation page.""" response = self.client.post(self.url, {"details-postcode": "SW1A 1AA"}) self.assertRedirects( response, reverse("motivation"), fetch_redirect_response=False @@ -182,6 +202,20 @@ def test_post_api_invalid(self, mock_get): # --------------------------------------------------------------------------- +class QuestionnaireIntroViewTests(_AuthenticatedTestCase): + """Tests for pre-postcode context screen before Q1.""" + + def test_get_renders(self): + response = self.client.get(reverse("questionnaire_intro")) + self.assertEqual(response.status_code, 200) + + def test_post_redirects_to_details_postcode(self): + response = self.client.post(reverse("questionnaire_intro")) + self.assertRedirects( + response, reverse("details_postcode"), fetch_redirect_response=False + ) + + class MotivationViewTests(_AuthenticatedTestCase): """Tests for Q1 motivation (single-select radios).""" @@ -314,12 +348,12 @@ def test_get_renders(self): response = self.client.get(reverse("enablers")) self.assertEqual(response.status_code, 200) - def test_post_valid_redirects_to_allow_check_in(self): + def test_post_valid_redirects_to_home_summary(self): response = self.client.post( reverse("enablers"), {"enablers": ["enablers.affordable"]}, ) self.assertRedirects( - response, reverse("allow-check-in"), fetch_redirect_response=False + response, reverse("home"), fetch_redirect_response=False ) def test_post_empty_shows_error(self): @@ -385,10 +419,10 @@ def setUp(self): class AnonymousStartViewTests(_AnonymousCampaignTestCase): - def test_start_anonymous_with_campaign_shows_postcode_href(self): + def test_start_anonymous_with_campaign_shows_questionnaire_intro_href(self): response = self.client.get(reverse("home")) self.assertEqual(response.status_code, 200) - self.assertIn(b"details-postcode", response.content) + self.assertIn(b"questionnaire-intro", response.content) def test_start_anonymous_with_campaign_no_contact_details(self): response = self.client.get(reverse("home")) @@ -452,12 +486,12 @@ def test_post_valid_redirects_to_current_barriers(self): class AnonymousEnablersTests(_AnonymousCampaignTestCase): - def test_post_redirects_to_listing(self): + def test_post_redirects_to_home_summary(self): response = self.client.post( reverse("enablers"), {"enablers": ["enablers.affordable"]} ) self.assertRedirects( - response, reverse("listing"), fetch_redirect_response=False + response, reverse("home"), fetch_redirect_response=False ) def test_post_sets_onboarding_complete(self): @@ -475,10 +509,10 @@ def test_post_does_not_redirect_to_allow_check_in(self): class AuthenticatedEnablersTests(_AuthenticatedTestCase): - def test_post_redirects_to_allow_check_in(self): + def test_post_redirects_to_home_summary(self): response = self.client.post( reverse("enablers"), {"enablers": ["enablers.affordable"]} ) self.assertRedirects( - response, reverse("allow-check-in"), fetch_redirect_response=False + response, reverse("home"), fetch_redirect_response=False ) diff --git a/django_app/web/urls.py b/django_app/web/urls.py index d276954..15dc5f7 100644 --- a/django_app/web/urls.py +++ b/django_app/web/urls.py @@ -5,6 +5,7 @@ path('', views.start, name='home'), path('details-contact-details', views.details_contact_details, name='details_contact_details'), path('details-postcode', views.details_postcode, name='details_postcode'), + path('questionnaire-intro', views.questionnaire_intro, name='questionnaire_intro'), path('motivation', views.motivation, name='motivation'), path('priority-behaviour', views.priority_behaviour, name='priority_behaviour'), path('past-barriers', views.past_barriers, name='past_barriers'), diff --git a/django_app/web/views.py b/django_app/web/views.py index 4b29fb6..b9a3c07 100644 --- a/django_app/web/views.py +++ b/django_app/web/views.py @@ -24,14 +24,12 @@ import re from json import JSONDecodeError from typing import Any, Dict, List -from urllib.parse import urlparse import requests from django.conf import settings from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import redirect, render from django.urls import reverse -from django.utils.http import url_has_allowed_host_and_scheme from django.contrib import messages from django.views.decorators.http import require_POST @@ -60,6 +58,7 @@ SESSION_KEY_CONTACT = "contact" SESSION_KEY_EMAIL = "emailInput" SESSION_KEY_MOBILE = "mobileInput" +SESSION_KEY_ANONYMOUS_FAVOURITES = "anonymous_favourite_service_ids" POSTCODE_API_TEMPLATE = "https://api.postcodes.io/postcodes/{postcode}/validate" POSTCODE_API_TIMEOUT = 5 @@ -84,6 +83,116 @@ ] +QUESTION_OPTION_LABELS: Dict[str, str] = { + # Q1 motivation + "motivation.want_to_feel_better": "I want to feel better in myself", + "motivation.noticed_changes": "I've noticed changes in my weight or fitness", + "motivation.health_professional": "A health professional suggested it", + "motivation.social_encouragement": "A friend or family member encouraged me", + "motivation.health_scare": "I've had a health scare or diagnosis", + "motivation.setting_example": "I want to set a good example for others", + "motivation.tried_before": "I've tried before and want to try again", + "motivation.life_transition": "I'm at a point in my life where I want to make a change", + "motivation.just_exploring": "I'm not sure - I'm just exploring", + # Q2 priority behaviour + "priority_behaviour.more_physically_active": "Becoming more physically active", + "priority_behaviour.eating_drinking": "Improving what I eat or drink", + "priority_behaviour.managing_weight": "Managing my weight", + "priority_behaviour.mental_wellbeing": "Improving my mental wellbeing (e.g. stress, mood, sleep)", + "priority_behaviour.energy_stamina": "Building my energy and stamina", + "priority_behaviour.managing_condition": "Managing a health condition better", + "priority_behaviour.body_confidence": "Feeling more confident in my body", + # Q3 past barriers + "past_barriers.no_time": "I didn't have enough time", + "past_barriers.too_expensive": "It was too expensive", + "past_barriers.not_physically_able": "I didn't feel physically able or well enough", + "past_barriers.didnt_know_where_to_start": "I didn't know what to do or where to start", + "past_barriers.lost_motivation": "I lost motivation or interest over time", + "past_barriers.no_one_to_do_it_with": "I didn't have anyone to do it with", + "past_barriers.nothing_nearby": "There were no suitable options near me", + "past_barriers.life_pressures": "Life got in the way (stress, work, family)", + "past_barriers.lack_of_confidence": "I didn't feel confident enough", + # Q4 current barriers + "current_barriers.short_on_time": "I'm short on time", + "current_barriers.cant_afford_it": "I can't afford it", + "current_barriers.health_condition": "My health or a physical condition limits what I can do", + "current_barriers.not_sure_what_works": "I'm not sure what would actually work for me", + "current_barriers.low_motivation": "I don't feel motivated right now", + "current_barriers.self_conscious": "I feel self-conscious or anxious about trying something new", + "current_barriers.practical_barriers": "I don't have practical support (e.g. childcare, transport)", + "current_barriers.routine": "I find it hard to make things a routine", + "current_barriers.low_perceived_need": "I feel fine and don't see an urgent need to change", + # Q5 confidence and readiness + "confidence_readiness.ready_and_confident": "I feel ready and confident - I just need the right option", + "confidence_readiness.keen_but_worried": "I'm keen but not sure I can stick to it", + "confidence_readiness.want_to_but_barriers": "I want to but I'm worried about what might get in the way", + "confidence_readiness.not_quite_ready": "I'm thinking about it but not quite ready to commit", + "confidence_readiness.change_out_of_reach": "I'm not sure change is possible for me right now", + # Q6 enablers + "enablers.wont_take_too_much_time": "Knowing it won't take up too much time", + "enablers.affordable": "Finding something affordable", + "enablers.support_from_others": "Having support from other people", + "enablers.start_slowly": "Doing something I can start slowly and build up", + "enablers.suitable_for_me": "Knowing it's suitable for someone like me", + "enablers.home_online": "Being able to do it from home or online", + "enablers.clear_guidance": "Having clear guidance on what to do", + "enablers.will_make_a_difference": "Knowing it will actually make a difference", +} + + +def _format_answer_label(value: Any) -> str: + """Return human-readable text for a stored questionnaire answer value.""" + if not value: + return "Not set" + if not isinstance(value, str): + return str(value) + + mapped = QUESTION_OPTION_LABELS.get(value) + if mapped: + return mapped + + # Fallback for unknown values: drop question prefix and prettify token. + code = value.split(".", 1)[-1] + return code.replace("_", " ").capitalize() + + +def _format_answer_list(values: Any) -> List[str]: + """Return human-readable text for multi-select questionnaire answers.""" + if not values: + return ["Not set"] + if isinstance(values, list): + return [_format_answer_label(v) for v in values] + return [_format_answer_label(values)] + + +def _get_anonymous_favourite_ids(session) -> set[int]: + """Return normalized anonymous favourite service IDs from session.""" + raw_values = session.get(SESSION_KEY_ANONYMOUS_FAVOURITES, []) + if not isinstance(raw_values, list): + raw_values = [] + + normalized: set[int] = set() + for value in raw_values: + try: + normalized.add(int(value)) + except (TypeError, ValueError): + # Ignore stale/invalid values without blocking UX. + continue + + canonical = sorted(normalized) + if raw_values != canonical: + session[SESSION_KEY_ANONYMOUS_FAVOURITES] = canonical + session.modified = True + + return normalized + + +def _set_anonymous_favourite_ids(session, service_ids: set[int]) -> None: + """Persist canonical anonymous favourite IDs in session.""" + session[SESSION_KEY_ANONYMOUS_FAVOURITES] = sorted(service_ids) + session.modified = True + + PERSISTED_SESSION_KEYS: List[str] = [ SESSION_KEY_DETAILS_POSTCODE, # Listing filters (set/edited on listing page) @@ -204,7 +313,7 @@ def start(request: HttpRequest) -> HttpResponse: pass else: # Anonymous campaign user — skip contact details, start at postcode - start_href = "details-postcode" + start_href = "questionnaire-intro" # Populate user_fields from session so the summary renders correctly if onboarding_complete: for key in QUESTIONNAIRE_SESSION_KEYS + PERSISTED_SESSION_KEYS: @@ -215,6 +324,15 @@ def start(request: HttpRequest) -> HttpResponse: if request.session.get("entry_flow") == "magiclink": start_href = "listing" + user_fields_display = { + "motivation": _format_answer_label(user_fields.get("motivation")), + "priority_behaviour": _format_answer_label(user_fields.get("priority_behaviour")), + "past_barriers": _format_answer_list(user_fields.get("past_barriers")), + "current_barriers": _format_answer_list(user_fields.get("current_barriers")), + "confidence_readiness": _format_answer_label(user_fields.get("confidence_readiness")), + "enablers": _format_answer_list(user_fields.get("enablers")), + } + return render( request, "web/pages/index.jinja", @@ -224,6 +342,7 @@ def start(request: HttpRequest) -> HttpResponse: "onboarding_complete": onboarding_complete, "opted_in": opted_in, "user_fields": user_fields, + "user_fields_display": user_fields_display, }, ) @@ -345,7 +464,7 @@ def details_contact_details(request: HttpRequest) -> HttpResponse: profile.preferred_contact_method = pref profile.save(update_fields=["phone", "preferred_contact_method"]) - return redirect("details_postcode") + return redirect("questionnaire_intro") return render( request, @@ -405,7 +524,7 @@ def details_postcode(request: HttpRequest) -> HttpResponse: def motivation(request: HttpRequest) -> HttpResponse: """Q1: What prompted you to look for support with your health today?""" mode = request.GET.get("mode") - back_href = "/" if mode == "edit" else "details-postcode" + back_href = "/" if mode == "edit" else "/details-postcode" if request.method == "POST": value = request.POST.get("motivation") if value in (None, "", CHECKBOX_UNCHECKED_VALUE): @@ -549,11 +668,8 @@ def enablers(request: HttpRequest) -> HttpResponse: if mode == "edit": messages.success(request, "Your data has been updated.") return redirect("/") - elif not request.user.is_authenticated: - request.session["onboarding_complete"] = True - return redirect("listing") - else: - return redirect("allow-check-in") + request.session["onboarding_complete"] = True + return redirect("home") return render( request, @@ -561,6 +677,17 @@ def enablers(request: HttpRequest) -> HttpResponse: {"data": request.session, "back_href": back_href}, ) + +def questionnaire_intro(request: HttpRequest) -> HttpResponse: + """Provide context before users begin the questionnaire.""" + if request.method == "POST": + return redirect("details_postcode") + + return render( + request, + "web/pages/questionnaire-intro.jinja", + ) + # --------------------------------------------------------------------------- # Journey: option to allow check-in # --------------------------------------------------------------------------- @@ -801,6 +928,8 @@ def listing(request: HttpRequest) -> HttpResponse: FavouriteService.objects.filter(user=request.user) .values_list("service_id", flat=True) ) + else: + favourite_ids = _get_anonymous_favourite_ids(request.session) return render( request, @@ -857,31 +986,15 @@ def _get_page_range(current_page: int, total_pages: int, window: int = 2) -> Lis def detail(request: HttpRequest, service_id: int) -> HttpResponse: """Show a single service's details, fetched from the API.""" - if request.GET.get("skip_prompt"): - request.session["account_prompt_dismissed"] = True - next_url = request.GET.get("next", "") - if next_url and url_has_allowed_host_and_scheme( - next_url, allowed_hosts={request.get_host()} - ): - return redirect(next_url) - - if (not request.user.is_authenticated - and not request.session.get("account_prompt_dismissed")): - if request.method == "POST" and request.POST.get("action") == "create_account": - request.session["account_prompt_service_id"] = service_id - return redirect("htsh:disclaimer") - if not request.GET.get("skip_prompt"): - prompt_next = "" - referer = request.META.get("HTTP_REFERER", "") - if referer and url_has_allowed_host_and_scheme( - referer, allowed_hosts={request.get_host()} - ): - prompt_next = urlparse(referer).path - return render(request, "web/pages/account-prompt.jinja", { - "service_id": service_id, - "prompt_next": prompt_next, - }) - # --- End interstitial gate --- + if ( + request.method == "POST" + and not request.user.is_authenticated + and request.session.get("campaign_code") + and request.POST.get("action") == "create_account" + ): + request.session["account_prompt_service_id"] = service_id + request.session.modified = True + return redirect("htsh:disclaimer") api_url = _build_internal_api_url( reverse("v3:service-detail", kwargs={"id": service_id}) @@ -902,6 +1015,8 @@ def detail(request: HttpRequest, service_id: int) -> HttpResponse: is_favourited = FavouriteService.objects.filter( user=request.user, service_id=service_id ).exists() + else: + is_favourited = service_id in _get_anonymous_favourite_ids(request.session) return render( request, @@ -914,8 +1029,20 @@ def detail(request: HttpRequest, service_id: int) -> HttpResponse: def toggle_favourite(request: HttpRequest, service_id: int) -> HttpResponse: """Toggle a service in the user's favourites. POST only, redirects back.""" if not request.user.is_authenticated: - request.session.pop("account_prompt_dismissed", None) - return redirect(reverse("detail", kwargs={"service_id": service_id})) + if not request.session.get("campaign_code"): + return redirect("landing") + + anonymous_favourites = _get_anonymous_favourite_ids(request.session) + if service_id in anonymous_favourites: + anonymous_favourites.remove(service_id) + else: + anonymous_favourites.add(service_id) + _set_anonymous_favourite_ids(request.session, anonymous_favourites) + + referer = request.META.get("HTTP_REFERER", "") + if referer and request.get_host() in referer: + return redirect(referer) + return redirect(reverse("listing")) from htsh.models import FavouriteService @@ -934,14 +1061,19 @@ def toggle_favourite(request: HttpRequest, service_id: int) -> HttpResponse: def favourites_list(request: HttpRequest) -> HttpResponse: """Show the user's saved (favourited) services as cards.""" - from htsh.models import FavouriteService from api.models_v3 import V3_Service from api.v3.serializers import V3_ServiceSummarySerializer - service_ids = list( - FavouriteService.objects.filter(user=request.user) - .values_list("service_id", flat=True) - ) + if request.user.is_authenticated: + from htsh.models import FavouriteService + + service_ids = list( + FavouriteService.objects.filter(user=request.user) + .values_list("service_id", flat=True) + ) + else: + service_ids = sorted(_get_anonymous_favourite_ids(request.session)) + services = V3_Service.objects.filter(id__in=service_ids).select_related("service_type") serialized = V3_ServiceSummarySerializer(services, many=True).data