Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e97ce16
feat: O3 -> 04 Add Banner on first login attempt
joybytes Jun 19, 2025
2290d56
feat: O4 -> O5 Add Choose Organisation
joybytes Jun 19, 2025
def971c
feat: O5 -> O6 Enter Service Name
joybytes Jun 19, 2025
b130ad9
feat: O6 -> O7 Test Mode
joybytes Jun 19, 2025
f31cfec
feat: O9 Replace Trial Mode with Test Mode
joybytes Jul 7, 2025
994bf80
feat: O13 -> O14 Choose From Name
joybytes Jul 9, 2025
554cdab
feat: O14 -> O15a Let recipients reply to your emails
joybytes Jul 9, 2025
aa2d140
wip
joybytes Jul 23, 2025
00e3d86
fix: trial mode string send.py
joybytes Jul 23, 2025
d1d0372
Add new / updated pages to do with estimated volumes
klssmith Jul 24, 2025
fbffebf
chore: uncomment verify code for platform admin
joybytes Jul 24, 2025
13a61ea
Stop logging out platform admin users after 30 mins
klssmith Jan 7, 2025
e274411
Add test mode inset text to more pages
klssmith Jul 24, 2025
86102b7
Preselect the users org when adding a service and choosing org type
klssmith Jul 24, 2025
b3d23c0
O16: preselect "enter a single address" radio button
klssmith Jul 25, 2025
0429ebb
O19: Add "add a from name" to the task list
klssmith Jul 28, 2025
2e2c077
Remove O16: preselect enter a single address radio button changes
joybytes Jul 29, 2025
060b219
Add sender email journey backlinks
joybytes Jul 29, 2025
034843d
Bypass email verification wait screen
joybytes Jul 29, 2025
6b773d7
Remove test mode inset from tour
joybytes Jul 29, 2025
5f5b46d
Add template type check so text messages are not affected
joybytes Jul 29, 2025
b16359b
06 and 020 - Remove web application or back office system message
joybytes Jul 31, 2025
2714949
Refacor template button to: Save and Preview
joybytes Aug 15, 2025
0a5c97a
Merge remote-tracking branch 'origin/main' into feat/onboarding-proto…
joybytes Aug 18, 2025
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
12 changes: 12 additions & 0 deletions app/assets/javascripts/notificationBanner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
(function(window) {
"use strict";

// Based on GOVUK.ErrorBanner
window.GOVUK.NotificationBanner = {
hideBanner: () => $('.govuk-notification-banner').addClass('govuk-!-display-none'),
showBanner: () =>
$('.govuk-notification-banner')
.removeClass('govuk-!-display-none')
.trigger('focus')
};
})(window);
1 change: 1 addition & 0 deletions app/assets/stylesheets/govuk-frontend/_all.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ $govuk-assets-path: "/static/";
@import "govuk/components/button/index";
@import "govuk/components/details/index";
@import "govuk/components/error-summary/index";
@import "govuk/components/notification-banner/index";
@import "govuk/components/radios/index";
@import "govuk/components/checkboxes/index";
@import "govuk/components/input/index";
Expand Down
98 changes: 90 additions & 8 deletions app/main/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1298,7 +1298,7 @@ def populate(self, domains_list):

class CreateServiceForm(StripWhitespaceForm):
name = GovukTextInputField(
"Service name",
"",
validators=[
DataRequired(message="Enter a service name"),
MustContainAlphanumericCharacters(),
Expand All @@ -1308,6 +1308,24 @@ class CreateServiceForm(StripWhitespaceForm):
organisation_type = OrganisationTypeField("Who runs this service?")


class ChooseOrganisationForm(StripWhitespaceForm):
choices = []

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.organisation.choices = [
("default", kwargs["default_organisation"]),
("other", "Other public sector body"),
]

organisation = GovukRadiosField(
"",
validators=[DataRequired()],
choices=choices,
thing="an organisation",
)


class CreateNhsServiceForm(CreateServiceForm):
organisation_type = OrganisationTypeField(
"Who runs this service?",
Expand Down Expand Up @@ -1744,6 +1762,29 @@ def validate(self, *args, **kwargs):
return super().validate(*args, **kwargs)


class ExpectToSendForm(StripWhitespaceForm):
message_types = GovukCheckboxesField(
"",
validators=[DataRequired(message="Select at least 1 option")],
choices=[
("emails", "Emails"),
("texts", "Text messages"),
("letters", "Letters"),
],
)


class EmailUsageForm(StripWhitespaceForm):
high_volume_emails = GovukRadiosField(
"",
choices=[
("yes", "Yes"),
("no", "No"),
("maybe", "Maybe"),
],
)


class AdminProviderRatioForm(OrderableFieldsForm):
def __init__(self, providers):
self._providers = providers
Expand Down Expand Up @@ -1935,19 +1976,23 @@ class ServiceEmailSenderForm(StripWhitespaceForm):
"alert",
}

use_custom_email_sender_name = OnOffField(
"Choose a sender name",
choices_for_error_message="same or custom",
CHOICE_CUSTOM = "custom"
CHOICE_ORGANISATION = "organisation"
CHOICE_SERVICE = "service"

use_custom_email_sender_name = GovukRadiosField(
"",
choices=[
(False, "Use the name of your service"),
(True, "Enter a custom sender name"),
(CHOICE_CUSTOM, "Enter a ‘from’ name"),
(CHOICE_ORGANISATION, "Use the name of your organisation"),
(CHOICE_SERVICE, "Use the name of your service"),
],
)

custom_email_sender_name = GovukTextInputField("Sender name", validators=[])

def validate(self, *args, **kwargs):
if self.use_custom_email_sender_name.data is True:
if self.use_custom_email_sender_name.data == self.CHOICE_CUSTOM:
self.custom_email_sender_name.validators = [
NotifyDataRequired(thing="a sender name"),
MustContainAlphanumericCharacters(thing="sender name"),
Expand All @@ -1961,7 +2006,7 @@ def validate_custom_email_sender_name(self, field):
Validate that the email from name ("Sender Name" <[email protected])
is under 320 characters (if it's over, SES will reject the email and we'll end up with technical errors)
"""
if self.use_custom_email_sender_name.data is not True:
if self.use_custom_email_sender_name.data != self.CHOICE_CUSTOM:
return

normalised_sender_name = make_string_safe_for_email_local_part(field.data)
Expand Down Expand Up @@ -2359,6 +2404,43 @@ def get_placeholder_form_instance(
return PlaceholderForm(placeholder_value=dict_to_populate_from.get(placeholder_name, ""))


class AddRecipientForm(StripWhitespaceForm):
CHOICE_UPLOAD_CSV = "upload_csv"
CHOICE_ENTER_SINGLE_EMAIL = "enter_single"
CHOICE_USE_OWN_EMAIL = "use_my_email"

ADD_RECIPIENT_CHOICES = [
(CHOICE_UPLOAD_CSV, "Upload a list of email addresses"),
(CHOICE_ENTER_SINGLE_EMAIL, "Enter a single email address"),
(CHOICE_USE_OWN_EMAIL, "Use my email address"),
]

add_recipient_method = GovukRadiosField(
"",
choices=ADD_RECIPIENT_CHOICES,
thing="how to add recipients",
validators=[DataRequired()],
)

enter_single_address = make_email_address_field(
gov_user=False,
thing="an email address",
label="Email address",
)

def validate(self, *args, **kwargs):
self.enter_single_address.validators = []

if self.add_recipient_method.data == self.CHOICE_ENTER_SINGLE_EMAIL:
self.enter_single_address.validators = [
NotifyDataRequired(thing="an email address"),
ValidEmail(),
ValidGovEmail(),
]

return super().validate(*args, **kwargs)


class SetSenderForm(StripWhitespaceForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand Down
68 changes: 38 additions & 30 deletions app/main/views/add_service.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
from flask import current_app, redirect, render_template, session, url_for
from flask import current_app, redirect, render_template, request, session, url_for
from flask_login import current_user
from notifications_python_client.errors import HTTPError

from app import service_api_client
from app.main import main
from app.main.forms import CreateNhsServiceForm, CreateServiceForm
from app.models.organisation import Organisation
from app.models.service import Service
from app.main.forms import ChooseOrganisationForm, CreateNhsServiceForm, CreateServiceForm
from app.utils.user import user_is_gov_user, user_is_logged_in


Expand Down Expand Up @@ -46,10 +44,10 @@ def _create_example_template(service_id):
return example_sms_template


@main.route("/add-service", methods=["GET", "POST"])
@main.route("/add-service/service-name", methods=["GET", "POST"])
@user_is_logged_in
@user_is_gov_user
def add_service():
def add_service_service_name():
default_organisation_type = current_user.default_organisation_type
if default_organisation_type == "nhs":
form = CreateNhsServiceForm()
Expand All @@ -68,30 +66,7 @@ def add_service():
if error:
return _render_add_service_page(form, default_organisation_type)

new_service = Service.from_id(service_id)

# GPs have a zero message limit (to prevent them sending messages while in trial mode)
if form.organisation_type.data == Organisation.TYPE_NHS_GP:
new_service.update(sms_message_limit=0)

# show the tour if the user doesn't have any other services. Never show for NHS GPs
show_tour = (
len(service_api_client.get_active_services({"user_id": session["user_id"]}).get("data", [])) <= 1
and form.organisation_type.data != Organisation.TYPE_NHS_GP
)

if show_tour:
example_sms_template = _create_example_template(service_id)

return redirect(
url_for("main.begin_tour", service_id=service_id, template_id=example_sms_template["data"]["id"])
)
else:
# if user has email auth, it makes sense that people they invite to their new service can have it too
if current_user.email_auth:
new_service.force_permission("email_auth", on=True)

return redirect(url_for("main.service_dashboard", service_id=service_id))
return redirect(url_for("main.add_service_test_mode"))

else:
return _render_add_service_page(form, default_organisation_type)
Expand All @@ -104,3 +79,36 @@ def _render_add_service_page(form, default_organisation_type):
default_organisation_type=default_organisation_type,
error_summary_enabled=True,
)


@main.route("/add-service/choose-organisation", methods=["GET", "POST"])
@user_is_logged_in
@user_is_gov_user
def add_service_choose_organisation():
default_org = current_user.default_organisation
form = ChooseOrganisationForm(default_organisation=default_org.name)

form.organisation.data = "default"

if form.validate_on_submit():
return redirect(url_for("main.add_service_service_name"))

return render_template("views/add-service-choose-organisation.html", form=form)


@main.route("/add-service/test-mode", methods=["GET", "POST"])
@user_is_logged_in
@user_is_gov_user
def add_service_test_mode():
service_id = session.get("service_id")

if not service_id:
return redirect(url_for("main.add_service_service_name"))

if request.method == "POST":
example_sms_template = _create_example_template(service_id)
return redirect(
url_for("main.begin_tour", service_id=service_id, template_id=example_sms_template["data"]["id"])
)

return render_template("views/add-service-test-mode.html")
2 changes: 1 addition & 1 deletion app/main/views/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def create_api_key(service_id):
"hint": {
"html": Markup(
"Not available because your service is in "
'<a class="govuk-link govuk-link--no-visited-state" href="/features/trial-mode">trial mode</a>'
'<a class="govuk-link govuk-link--no-visited-state" href="/features/trial-mode">test mode</a>'
)
},
}
Expand Down
75 changes: 75 additions & 0 deletions app/main/views/backlink_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from urllib.parse import urlparse

from flask import request, session, url_for

from app import Service


def get_backlink_email_sender(service: Service, template_id: str):
has_from_name = service.email_sender_name is not None
has_reply_to = bool(service.email_reply_to_addresses)
has_multiple_reply_to = len(service.email_reply_to_addresses) > 1

args = {"service_id": service.id, "template_id": template_id}

# Needs to choose from name
if not has_from_name:
base = ["view_template", "service_email_sender_change"]

# 1a. clicks do this later -> add recipients
# 1b. clicks add reply to email -> add recipients (endpoint will be added on the go)
if not has_reply_to:
expected_flow = base + ["service_email_reply_to"]
# 2. choose reply to email -> add recipients
elif has_multiple_reply_to:
expected_flow = base + ["set_sender"]
# 3. add recipients
else:
expected_flow = base
# Needs to choose reply to email
elif not has_reply_to:
expected_flow = ["view_template", "service_email_reply_to"]

# Has multiple reply to email
elif has_multiple_reply_to:
expected_flow = ["view_template", "set_sender"]
# Contains all necessary fields and single reply to email (DONE)
else:
expected_flow = ["view_template"]

return create_backlinks(expected_flow, **args)


def create_backlinks(routes, **kwargs):
return [url_for(f"main.{route}", **kwargs) for route in routes]


def get_previous_backlink(service_id, template_id):
if service_id is None or template_id is None:
return None

backlinks = session.get("email_sender_backlinks", [])

current_path = request.path # Just the path, no query string

parsed_paths = [urlparse(url).path for url in backlinks]

# Special journey check: user completed reply-to add and verify, now at step-0
# In this case we are redirecting it back to view_template as too many values
# have been set and we want to keep things consistent
has_add_reply_to = any("email-reply-to/add" in path for path in parsed_paths)
has_verify_reply_to = any("email-reply-to/" in path and "/verify" in path for path in parsed_paths)
is_on_step_0 = "/one-off/step-0" in current_path

if is_on_step_0 and has_add_reply_to and has_verify_reply_to:
return url_for("main.view_template", service_id=service_id, template_id=template_id)

try:
paths = [urlparse(url).path for url in backlinks]
current_index = paths.index(current_path)
if current_index > 0:
return backlinks[current_index - 1]
except ValueError:
pass

return url_for("main.view_template", service_id=service_id, template_id=template_id)
2 changes: 2 additions & 0 deletions app/main/views/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def old_service_dashboard(service_id):
@main.route("/services/<uuid:service_id>")
@user_has_permissions()
def service_dashboard(service_id):
session["from_sender_flow_check"] = False

if session.get("invited_user_id"):
session.pop("invited_user_id", None)
session["service_id"] = service_id
Expand Down
Loading