- {% if request.user.is_authenticated %}
+ {% if request.user.is_authenticated or favourite_ids %}
+ {% 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 @@
+
+ {{ 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