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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions django_app/htsh/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions django_app/htsh/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
84 changes: 81 additions & 3 deletions django_app/htsh/tests/test_views_signup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand All @@ -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"})
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
41 changes: 39 additions & 2 deletions django_app/htsh/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down
14 changes: 7 additions & 7 deletions django_app/templates/jinja2/htsh/disclaimer.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@
]
}) }}
{% endif %}
<h1>Can we use your basic information?</h1>
<p>We need to know a few details about you so we can:</p>
<h1>Sign up so we can help you along the way</h1>
<p>We’re going to ask you for either your email address or mobile number so we can:</p>
<ul>
<li>make suggestions that might work for you</li>
<li>check in with you to help you keep going - we'll ask you if you're OK with this later</li>
<li>make sure only people who have been invited can use this service</li>
<li>check in with you to see how you’re doing</li>
<li>encourage you to keep going</li>
<li>offer suggestions for other things to try</li>
</ul>
<p>You can opt out of this service at any time, and anything you've told us will be deleted</p>
<p>You can opt out at any time, and anything you've told us will be deleted.</p>
<p>
To find out more about how we'll use your information, <a href="/" target="_blank" rel="noopener noreferrer">read our privacy policy / terms (opens in a new tab)</a>
To find out more about how we'll use your information, <a href="/" target="_blank" rel="noopener noreferrer">read our privacy policy.</a>
</p>
<form method="post">
{{ csrf_input }}
Expand Down
23 changes: 12 additions & 11 deletions django_app/templates/jinja2/htsh/landing.jinja
Original file line number Diff line number Diff line change
@@ -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 %}
Expand All @@ -10,7 +10,7 @@
<div class="nhsuk-width-container">
<div class="nhsuk-grid-row">
<div class="nhsuk-grid-column-two-thirds">
<h1 class="nhsuk-heading-xl">Get help to make healthy lifestyle changes that last</h1>
<h1 class="nhsuk-heading-xl">Get help to create healthy habits that work for you</h1>
<p class="nhsuk-lede-text">
From improving your mental health to upping your energy levels, exercising and eating well can make a big difference to your quality of life.
</p>
Expand All @@ -33,15 +33,16 @@
<div class="nhsuk-width-container">
<div class="nhsuk-grid-row">
<div class="nhsuk-grid-column-two-thirds">
<h2>Find new ways to eat well and move more</h2>
<p>Choose from:</p>
<h2>Get help to create healthy habits that work for you</h2>
<p>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.</p>
<h2>Find local, national and online activities and groups</h2>
<p>Here's how it works:</p>
<ul class="nhsuk-u-padding-bottom-3">
<li>exercise programmes, like Couch to Fitness</li>
<li>weight management programmes, like the NHS Weight Loss Plan</li>
<li>walking programmes, like NHS Active 10</li>
<li>tell us a bit about yourself</li>
<li>we'll match you with activities that best meet your needs</li>
<li>you'll see details on how to get involved</li>
<li>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</li>
</ul>
<h2>We’ll show you the options that best meet your needs</h2>
<p>Tell us a bit about yourself and we'll match you to the activities we think will work best for you.</p>
</div>
</div>
</div>
Expand All @@ -53,8 +54,8 @@
<div class="app-robo-mecc"
style="background-image: url('/static/assets/images/bot/avatar-standard.svg')">
<div>
<h2>Well help you keep going</h2>
<p>We’ll check in with you to ask how you’re doing and encourage you to carry on.</p>
<h2>We'll stay with you throughout</h2>
<p>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.</p>
</div>
</div>
</div>
Expand Down
6 changes: 5 additions & 1 deletion django_app/templates/jinja2/web/layout/base.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
<form method="post">
{{ csrf_input }}

<p class="nhsuk-caption-l">6/7</p>

{{ radios({
"idPrefix": "confidence_readiness",
"name": "confidence_readiness",
Expand Down
2 changes: 2 additions & 0 deletions django_app/templates/jinja2/web/pages/current-barriers.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
<form method="post">
{{ csrf_input }}

<p class="nhsuk-caption-l">5/7</p>

{{ checkboxes({
"idPrefix": "current_barriers",
"name": "current_barriers",
Expand Down
7 changes: 6 additions & 1 deletion django_app/templates/jinja2/web/pages/details-postcode.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
{% set pageName = "What is your home postcode?" %}

{% block beforeContent %}
{{ back_link("/") }}
{{ back_link("/questionnaire-intro") }}
{% endblock %}

{% block content %}
Expand All @@ -28,12 +28,17 @@
<form method="post">
{{ csrf_input }}

<p class="nhsuk-caption-l">1/7</p>

{{ 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",
Expand Down
2 changes: 2 additions & 0 deletions django_app/templates/jinja2/web/pages/enablers.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
<form method="post">
{{ csrf_input }}

<p class="nhsuk-caption-l">7/7</p>

{{ checkboxes({
"idPrefix": "enablers",
"name": "enablers",
Expand Down
Loading
Loading