2017-12-11 16:02:12 +00:00
|
|
|
|
import weakref
|
2016-08-07 09:17:49 +01:00
|
|
|
|
from datetime import datetime, timedelta
|
2017-12-11 16:22:37 +00:00
|
|
|
|
from itertools import chain
|
2017-08-07 11:30:25 +01:00
|
|
|
|
|
2018-02-20 11:22:17 +00:00
|
|
|
|
import pytz
|
2018-11-15 17:31:07 +00:00
|
|
|
|
from flask import request
|
2018-02-20 11:22:17 +00:00
|
|
|
|
from flask_wtf import FlaskForm as Form
|
|
|
|
|
|
from flask_wtf.file import FileAllowed
|
|
|
|
|
|
from flask_wtf.file import FileField as FileField_wtf
|
|
|
|
|
|
from notifications_utils.columns import Columns
|
2018-04-30 17:39:42 +01:00
|
|
|
|
from notifications_utils.formatters import strip_whitespace
|
2016-04-14 12:00:55 +01:00
|
|
|
|
from notifications_utils.recipients import (
|
2018-02-20 11:22:17 +00:00
|
|
|
|
InvalidPhoneError,
|
2018-08-09 11:59:05 +01:00
|
|
|
|
normalise_phone_number,
|
2016-03-31 15:17:05 +01:00
|
|
|
|
validate_phone_number,
|
|
|
|
|
|
)
|
2016-01-12 10:43:23 +00:00
|
|
|
|
from wtforms import (
|
2016-03-02 15:25:04 +00:00
|
|
|
|
BooleanField,
|
2018-02-20 11:22:17 +00:00
|
|
|
|
DateField,
|
|
|
|
|
|
FieldList,
|
|
|
|
|
|
FileField,
|
2016-05-11 09:43:55 +01:00
|
|
|
|
HiddenField,
|
2016-06-29 17:10:49 +01:00
|
|
|
|
IntegerField,
|
2018-02-20 11:22:17 +00:00
|
|
|
|
PasswordField,
|
2016-09-20 12:30:00 +01:00
|
|
|
|
RadioField,
|
2018-11-08 11:56:29 +00:00
|
|
|
|
SelectMultipleField,
|
2018-02-20 11:22:17 +00:00
|
|
|
|
StringField,
|
|
|
|
|
|
TextAreaField,
|
|
|
|
|
|
ValidationError,
|
|
|
|
|
|
validators,
|
|
|
|
|
|
widgets,
|
2017-10-18 14:51:26 +01:00
|
|
|
|
)
|
2018-02-20 11:22:17 +00:00
|
|
|
|
from wtforms.fields.html5 import EmailField, SearchField, TelField
|
2018-06-05 10:37:41 +01:00
|
|
|
|
from wtforms.validators import URL, DataRequired, Length, Optional, Regexp
|
2018-11-08 11:56:29 +00:00
|
|
|
|
from wtforms.widgets import CheckboxInput, ListWidget
|
2016-01-11 15:00:51 +00:00
|
|
|
|
|
2019-07-08 14:09:29 +01:00
|
|
|
|
from app import format_thousands
|
2018-01-31 10:26:37 +00:00
|
|
|
|
from app.main.validators import (
|
|
|
|
|
|
Blacklist,
|
|
|
|
|
|
CsvFileValidator,
|
2018-02-28 11:50:41 +00:00
|
|
|
|
DoesNotStartWithDoubleZero,
|
2018-02-20 11:22:17 +00:00
|
|
|
|
LettersNumbersAndFullStopsOnly,
|
2019-11-08 17:12:32 +00:00
|
|
|
|
MustContainAlphanumericCharacters,
|
2018-01-31 10:26:37 +00:00
|
|
|
|
NoCommasInPlaceHolders,
|
2019-05-03 15:13:39 +01:00
|
|
|
|
OnlySMSCharacters,
|
2018-02-20 11:22:17 +00:00
|
|
|
|
ValidEmail,
|
|
|
|
|
|
ValidGovEmail,
|
2018-01-31 10:26:37 +00:00
|
|
|
|
)
|
2019-08-27 15:52:42 +01:00
|
|
|
|
from app.models.organisation import Organisation
|
Make user API client return JSON, not a model
The data flow of other bits of our application looks like this:
```
API (returns JSON)
⬇
API client (returns a built in type, usually `dict`)
⬇
Model (returns an instance, eg of type `Service`)
⬇
View (returns HTML)
```
The user API client was architected weirdly, in that it returned a model
directly, like this:
```
API (returns JSON)
⬇
API client (returns a model, of type `User`, `InvitedUser`, etc)
⬇
View (returns HTML)
```
This mixing of different layers of the application is bad because it
makes it hard to write model code that doesn’t have circular
dependencies. As our application gets more complicated we will be
relying more on models to manage this complexity, so we should make it
easy, not hard to write them.
It also means that most of our mocking was of the User model, not just
the underlying JSON. So it would have been easy to introduce subtle bugs
to the user model, because it wasn’t being comprehensively tested. A lot
of the changed lines of code in this commit mean changing the tests to
mock only the JSON, which means that the model layer gets implicitly
tested.
For those reasons this commit changes the user API client to return
JSON, not an instance of `User` or other models.
2019-05-23 15:27:35 +01:00
|
|
|
|
from app.models.roles_and_permissions import permissions, roles
|
2019-04-04 11:09:09 +01:00
|
|
|
|
from app.utils import guess_name_from_email_address
|
2016-02-01 16:57:40 +00:00
|
|
|
|
|
2015-11-27 09:47:29 +00:00
|
|
|
|
|
2016-08-07 09:17:49 +01:00
|
|
|
|
def get_time_value_and_label(future_time):
|
|
|
|
|
|
return (
|
|
|
|
|
|
future_time.replace(tzinfo=None).isoformat(),
|
2016-10-11 14:11:10 +01:00
|
|
|
|
'{} at {}'.format(
|
|
|
|
|
|
get_human_day(future_time.astimezone(pytz.timezone('Europe/London'))),
|
|
|
|
|
|
get_human_time(future_time.astimezone(pytz.timezone('Europe/London')))
|
|
|
|
|
|
)
|
2016-08-07 09:17:49 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_human_time(time):
|
|
|
|
|
|
return {
|
2016-10-11 14:11:10 +01:00
|
|
|
|
'0': 'midnight',
|
|
|
|
|
|
'12': 'midday'
|
2016-08-07 09:17:49 +01:00
|
|
|
|
}.get(
|
|
|
|
|
|
time.strftime('%-H'),
|
|
|
|
|
|
time.strftime('%-I%p').lower()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2016-10-11 17:59:09 +01:00
|
|
|
|
def get_human_day(time, prefix_today_with='T'):
|
2016-10-11 14:11:10 +01:00
|
|
|
|
# Add 1 hour to get ‘midnight today’ instead of ‘midnight tomorrow’
|
|
|
|
|
|
time = (time - timedelta(hours=1)).strftime('%A')
|
|
|
|
|
|
if time == datetime.utcnow().strftime('%A'):
|
2016-10-11 17:59:09 +01:00
|
|
|
|
return '{}oday'.format(prefix_today_with)
|
2016-10-11 14:11:10 +01:00
|
|
|
|
if time == (datetime.utcnow() + timedelta(days=1)).strftime('%A'):
|
|
|
|
|
|
return 'Tomorrow'
|
|
|
|
|
|
return time
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_furthest_possible_scheduled_time():
|
|
|
|
|
|
return (datetime.utcnow() + timedelta(days=4)).replace(hour=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_next_hours_until(until):
|
|
|
|
|
|
now = datetime.utcnow()
|
|
|
|
|
|
hours = int((until - now).total_seconds() / (60 * 60))
|
2016-08-07 09:17:49 +01:00
|
|
|
|
return [
|
|
|
|
|
|
(now + timedelta(hours=i)).replace(minute=0, second=0).replace(tzinfo=pytz.utc)
|
|
|
|
|
|
for i in range(1, hours + 1)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2016-10-11 14:11:10 +01:00
|
|
|
|
def get_next_days_until(until):
|
|
|
|
|
|
now = datetime.utcnow()
|
|
|
|
|
|
days = int((until - now).total_seconds() / (60 * 60 * 24))
|
|
|
|
|
|
return [
|
2016-10-11 17:59:09 +01:00
|
|
|
|
get_human_day(
|
|
|
|
|
|
(now + timedelta(days=i)).replace(tzinfo=pytz.utc),
|
|
|
|
|
|
prefix_today_with='Later t'
|
|
|
|
|
|
)
|
2016-10-11 14:11:10 +01:00
|
|
|
|
for i in range(0, days + 1)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2018-11-08 11:56:29 +00:00
|
|
|
|
class MultiCheckboxField(SelectMultipleField):
|
|
|
|
|
|
widget = ListWidget(prefix_label=False)
|
|
|
|
|
|
option_widget = CheckboxInput()
|
|
|
|
|
|
|
|
|
|
|
|
|
2018-12-07 12:23:12 +00:00
|
|
|
|
def email_address(label='Email address', gov_user=True, required=True):
|
|
|
|
|
|
|
2016-10-26 14:01:01 +01:00
|
|
|
|
validators = [
|
2018-12-07 12:23:12 +00:00
|
|
|
|
ValidEmail(),
|
2016-10-26 14:01:01 +01:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
if gov_user:
|
2016-10-28 10:45:05 +01:00
|
|
|
|
validators.append(ValidGovEmail())
|
2018-12-07 12:23:12 +00:00
|
|
|
|
|
|
|
|
|
|
if required:
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators.append(DataRequired(message='Cannot be empty'))
|
2018-12-07 12:23:12 +00:00
|
|
|
|
|
2019-07-16 08:59:17 +01:00
|
|
|
|
return EmailField(label, validators, render_kw={'spellcheck': 'false'})
|
2016-01-08 12:00:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
2016-02-15 13:13:57 +00:00
|
|
|
|
class UKMobileNumber(TelField):
|
2016-01-12 18:10:16 +00:00
|
|
|
|
def pre_validate(self, form):
|
2016-02-01 16:57:40 +00:00
|
|
|
|
try:
|
2016-03-08 07:17:39 +00:00
|
|
|
|
validate_phone_number(self.data)
|
2016-02-01 16:57:40 +00:00
|
|
|
|
except InvalidPhoneError as e:
|
2016-12-20 14:38:34 +00:00
|
|
|
|
raise ValidationError(str(e))
|
2016-01-12 18:10:16 +00:00
|
|
|
|
|
|
|
|
|
|
|
2017-05-25 09:40:37 +01:00
|
|
|
|
class InternationalPhoneNumber(TelField):
|
|
|
|
|
|
def pre_validate(self, form):
|
|
|
|
|
|
try:
|
2017-11-10 12:35:21 +00:00
|
|
|
|
if self.data:
|
|
|
|
|
|
validate_phone_number(self.data, international=True)
|
2017-05-25 09:40:37 +01:00
|
|
|
|
except InvalidPhoneError as e:
|
|
|
|
|
|
raise ValidationError(str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-08-29 14:52:24 +01:00
|
|
|
|
def uk_mobile_number(label='Mobile number'):
|
2017-05-25 09:40:37 +01:00
|
|
|
|
return UKMobileNumber(label,
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators=[DataRequired(message='Cannot be empty')])
|
2016-01-08 12:00:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
2017-05-25 09:40:37 +01:00
|
|
|
|
def international_phone_number(label='Mobile number'):
|
|
|
|
|
|
return InternationalPhoneNumber(
|
|
|
|
|
|
label,
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators=[DataRequired(message='Cannot be empty')]
|
2017-05-25 09:40:37 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2016-04-07 14:12:40 +01:00
|
|
|
|
def password(label='Password'):
|
2016-01-12 11:25:46 +00:00
|
|
|
|
return PasswordField(label,
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators=[DataRequired(message='Cannot be empty'),
|
2016-09-26 09:29:50 +01:00
|
|
|
|
Length(8, 255, message='Must be at least 8 characters'),
|
2016-09-27 11:37:20 +01:00
|
|
|
|
Blacklist(message='Choose a password that’s harder to guess')])
|
2016-01-08 12:00:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
2018-05-07 22:26:24 +01:00
|
|
|
|
class SMSCode(StringField):
|
2018-05-07 22:57:18 +01:00
|
|
|
|
validators = [
|
2019-09-12 16:42:33 +01:00
|
|
|
|
DataRequired(message='Cannot be empty'),
|
2018-11-13 10:49:35 +00:00
|
|
|
|
Regexp(regex=r'^\d+$', message='Numbers only'),
|
2018-05-07 21:24:23 +01:00
|
|
|
|
Length(min=5, message='Not enough numbers'),
|
|
|
|
|
|
Length(max=5, message='Too many numbers'),
|
2018-05-07 22:57:18 +01:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
def __call__(self, **kwargs):
|
|
|
|
|
|
return super().__call__(type='tel', pattern='[0-9]*', **kwargs)
|
2016-01-08 12:00:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
2019-02-15 13:34:29 +00:00
|
|
|
|
class ForgivingIntegerField(StringField):
|
|
|
|
|
|
|
2019-02-27 13:02:55 +00:00
|
|
|
|
# Actual value is 2147483647 but this is a scary looking arbitrary number
|
|
|
|
|
|
POSTGRES_MAX_INT = 2000000000
|
2019-02-15 13:34:29 +00:00
|
|
|
|
|
2019-02-27 15:10:34 +00:00
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
label=None,
|
|
|
|
|
|
things='items',
|
|
|
|
|
|
format_error_suffix='',
|
|
|
|
|
|
**kwargs
|
|
|
|
|
|
):
|
2019-02-27 13:02:37 +00:00
|
|
|
|
self.things = things
|
2019-02-27 15:10:34 +00:00
|
|
|
|
self.format_error_suffix = format_error_suffix
|
2019-02-27 13:02:37 +00:00
|
|
|
|
super().__init__(label, **kwargs)
|
|
|
|
|
|
|
2019-02-15 13:34:29 +00:00
|
|
|
|
def process_formdata(self, valuelist):
|
|
|
|
|
|
|
|
|
|
|
|
if valuelist:
|
|
|
|
|
|
|
2019-02-27 12:05:28 +00:00
|
|
|
|
value = valuelist[0].replace(',', '').replace(' ', '')
|
2019-02-15 13:34:29 +00:00
|
|
|
|
|
|
|
|
|
|
try:
|
2019-02-27 12:05:28 +00:00
|
|
|
|
value = int(value)
|
2019-02-15 13:34:29 +00:00
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2019-02-27 12:05:28 +00:00
|
|
|
|
if value == '':
|
|
|
|
|
|
value = 0
|
2019-02-15 13:34:29 +00:00
|
|
|
|
|
2019-02-27 12:05:28 +00:00
|
|
|
|
return super().process_formdata([value])
|
2019-02-15 13:34:29 +00:00
|
|
|
|
|
|
|
|
|
|
def pre_validate(self, form):
|
|
|
|
|
|
|
|
|
|
|
|
if self.data:
|
|
|
|
|
|
error = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
if int(self.data) > self.POSTGRES_MAX_INT:
|
2019-07-08 14:09:29 +01:00
|
|
|
|
error = 'Number of {} must be {} or less'.format(
|
2019-02-27 13:02:37 +00:00
|
|
|
|
self.things,
|
2019-07-08 14:09:29 +01:00
|
|
|
|
format_thousands(self.POSTGRES_MAX_INT),
|
2019-02-27 13:02:37 +00:00
|
|
|
|
)
|
2019-02-15 13:34:29 +00:00
|
|
|
|
except ValueError:
|
2019-02-27 15:10:34 +00:00
|
|
|
|
error = 'Enter the number of {} {}'.format(
|
|
|
|
|
|
self.things,
|
|
|
|
|
|
self.format_error_suffix,
|
|
|
|
|
|
)
|
2019-02-15 13:34:29 +00:00
|
|
|
|
|
|
|
|
|
|
if error:
|
|
|
|
|
|
raise ValidationError(error)
|
|
|
|
|
|
|
|
|
|
|
|
return super().pre_validate(form)
|
|
|
|
|
|
|
|
|
|
|
|
def __call__(self, **kwargs):
|
|
|
|
|
|
|
2019-02-27 12:05:28 +00:00
|
|
|
|
if self.get_form().is_submitted() and not self.get_form().validate():
|
|
|
|
|
|
return super().__call__(
|
|
|
|
|
|
value=(self.raw_data or [None])[0],
|
|
|
|
|
|
**kwargs
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2019-02-15 13:34:29 +00:00
|
|
|
|
try:
|
|
|
|
|
|
value = int(self.data)
|
2019-07-08 14:09:29 +01:00
|
|
|
|
value = format_thousands(value)
|
2019-02-15 13:34:29 +00:00
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
value = self.data if self.data is not None else ''
|
|
|
|
|
|
|
|
|
|
|
|
return super().__call__(value=value, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-08-27 15:52:42 +01:00
|
|
|
|
class OrganisationTypeField(RadioField):
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*args,
|
|
|
|
|
|
include_only=None,
|
|
|
|
|
|
validators=None,
|
|
|
|
|
|
**kwargs
|
|
|
|
|
|
):
|
|
|
|
|
|
super().__init__(
|
|
|
|
|
|
*args,
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
(value, label) for value, label in Organisation.TYPES
|
|
|
|
|
|
if not include_only or value in include_only
|
|
|
|
|
|
],
|
|
|
|
|
|
validators=[DataRequired()] + (validators or []),
|
|
|
|
|
|
**kwargs
|
|
|
|
|
|
)
|
2017-10-05 12:06:40 +01:00
|
|
|
|
|
|
|
|
|
|
|
2019-02-25 16:17:48 +00:00
|
|
|
|
class FieldWithNoneOption():
|
|
|
|
|
|
|
|
|
|
|
|
# This is a special value that is specific to our forms. This is
|
|
|
|
|
|
# more expicit than casting `None` to a string `'None'` which can
|
|
|
|
|
|
# have unexpected edge cases
|
|
|
|
|
|
NONE_OPTION_VALUE = '__NONE__'
|
|
|
|
|
|
|
|
|
|
|
|
# When receiving Python data, eg when instantiating the form object
|
|
|
|
|
|
# we want to convert that data to our special value, so that it gets
|
|
|
|
|
|
# recognised as being one of the valid choices
|
|
|
|
|
|
def process_data(self, value):
|
|
|
|
|
|
self.data = self.NONE_OPTION_VALUE if value is None else value
|
|
|
|
|
|
|
|
|
|
|
|
# After validation we want to convert it back to a Python `None` for
|
|
|
|
|
|
# use elsewhere, eg posting to the API
|
|
|
|
|
|
def post_validate(self, form, validation_stopped):
|
|
|
|
|
|
if self.data == self.NONE_OPTION_VALUE and not validation_stopped:
|
|
|
|
|
|
self.data = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RadioFieldWithNoneOption(FieldWithNoneOption, RadioField):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NestedFieldMixin:
|
|
|
|
|
|
def children(self):
|
|
|
|
|
|
# start map with root option as a single child entry
|
|
|
|
|
|
child_map = {None: [option for option in self
|
|
|
|
|
|
if option.data == self.NONE_OPTION_VALUE]}
|
|
|
|
|
|
|
|
|
|
|
|
# add entries for all other children
|
|
|
|
|
|
for option in self:
|
|
|
|
|
|
if option.data == self.NONE_OPTION_VALUE:
|
|
|
|
|
|
child_ids = [
|
|
|
|
|
|
folder['id'] for folder in self.all_template_folders
|
|
|
|
|
|
if folder['parent_id'] is None]
|
|
|
|
|
|
key = self.NONE_OPTION_VALUE
|
|
|
|
|
|
else:
|
|
|
|
|
|
child_ids = [
|
|
|
|
|
|
folder['id'] for folder in self.all_template_folders
|
|
|
|
|
|
if folder['parent_id'] == option.data]
|
|
|
|
|
|
key = option.data
|
|
|
|
|
|
|
|
|
|
|
|
child_map[key] = [option for option in self if option.data in child_ids]
|
|
|
|
|
|
|
|
|
|
|
|
return child_map
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NestedRadioField(RadioFieldWithNoneOption, NestedFieldMixin):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NestedCheckboxesField(SelectMultipleField, NestedFieldMixin):
|
|
|
|
|
|
NONE_OPTION_VALUE = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class HiddenFieldWithNoneOption(FieldWithNoneOption, HiddenField):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RadioFieldWithRequiredMessage(RadioField):
|
|
|
|
|
|
def __init__(self, *args, required_message='Not a valid choice', **kwargs):
|
|
|
|
|
|
self.required_message = required_message
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
def pre_validate(self, form):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return super().pre_validate(form)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
raise ValueError(self.required_message)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class StripWhitespaceForm(Form):
|
|
|
|
|
|
class Meta:
|
|
|
|
|
|
def bind_field(self, form, unbound_field, options):
|
|
|
|
|
|
# FieldList simply doesn't support filters.
|
|
|
|
|
|
# @see: https://github.com/wtforms/wtforms/issues/148
|
|
|
|
|
|
no_filter_fields = (FieldList, PasswordField)
|
|
|
|
|
|
filters = [strip_whitespace] if not issubclass(unbound_field.field_class, no_filter_fields) else []
|
|
|
|
|
|
filters += unbound_field.kwargs.get('filters', [])
|
|
|
|
|
|
bound = unbound_field.bind(form=form, filters=filters, **options)
|
|
|
|
|
|
bound.get_form = weakref.ref(form) # GC won't collect the form if we don't use a weakref
|
|
|
|
|
|
return bound
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:22:37 +00:00
|
|
|
|
class StripWhitespaceStringField(StringField):
|
|
|
|
|
|
def __init__(self, label=None, **kwargs):
|
|
|
|
|
|
kwargs['filters'] = tuple(chain(
|
|
|
|
|
|
kwargs.get('filters', ()),
|
|
|
|
|
|
(
|
|
|
|
|
|
strip_whitespace,
|
|
|
|
|
|
),
|
|
|
|
|
|
))
|
|
|
|
|
|
super(StringField, self).__init__(label, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-09-05 12:10:24 +01:00
|
|
|
|
class OnOffField(RadioField):
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, label, choices=None, *args, **kwargs):
|
|
|
|
|
|
super().__init__(label, choices=choices or [
|
|
|
|
|
|
(True, 'On'),
|
|
|
|
|
|
(False, 'Off'),
|
|
|
|
|
|
], *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
def process_formdata(self, valuelist):
|
|
|
|
|
|
if valuelist:
|
|
|
|
|
|
value = valuelist[0]
|
|
|
|
|
|
self.data = (value == 'True') if value in ['True', 'False'] else value
|
|
|
|
|
|
|
|
|
|
|
|
def iter_choices(self):
|
|
|
|
|
|
for value, label in self.choices:
|
|
|
|
|
|
# This overrides WTForms default behaviour which is to check
|
|
|
|
|
|
# self.coerce(value) == self.data
|
|
|
|
|
|
# where self.coerce returns a string for a boolean input
|
|
|
|
|
|
yield (
|
|
|
|
|
|
value,
|
|
|
|
|
|
label,
|
|
|
|
|
|
(self.data in {value, self.coerce(value)})
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class LoginForm(StripWhitespaceForm):
|
2018-05-03 16:31:00 +01:00
|
|
|
|
email_address = EmailField('Email address', validators=[
|
2015-11-27 16:25:56 +00:00
|
|
|
|
Length(min=5, max=255),
|
2019-09-12 16:42:33 +01:00
|
|
|
|
DataRequired(message='Cannot be empty'),
|
2018-02-14 14:35:16 +00:00
|
|
|
|
ValidEmail()
|
2015-11-27 09:47:29 +00:00
|
|
|
|
])
|
2015-12-02 15:23:03 +00:00
|
|
|
|
password = PasswordField('Password', validators=[
|
2016-01-08 12:00:52 +00:00
|
|
|
|
DataRequired(message='Enter your password')
|
2015-11-27 09:47:29 +00:00
|
|
|
|
])
|
2015-12-01 13:23:54 +00:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class RegisterUserForm(StripWhitespaceForm):
|
2015-12-02 15:23:03 +00:00
|
|
|
|
name = StringField('Full name',
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators=[DataRequired(message='Cannot be empty')])
|
2016-01-08 12:00:52 +00:00
|
|
|
|
email_address = email_address()
|
2017-08-29 14:52:24 +01:00
|
|
|
|
mobile_number = international_phone_number()
|
2016-01-08 12:00:52 +00:00
|
|
|
|
password = password()
|
2017-11-13 13:39:31 +00:00
|
|
|
|
# always register as sms type
|
|
|
|
|
|
auth_type = HiddenField('auth_type', default='sms_auth')
|
2015-12-04 16:21:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
2018-07-02 09:08:21 +01:00
|
|
|
|
class RegisterUserFromInviteForm(RegisterUserForm):
|
2017-11-14 15:53:38 +00:00
|
|
|
|
def __init__(self, invited_user):
|
|
|
|
|
|
super().__init__(
|
Make user API client return JSON, not a model
The data flow of other bits of our application looks like this:
```
API (returns JSON)
⬇
API client (returns a built in type, usually `dict`)
⬇
Model (returns an instance, eg of type `Service`)
⬇
View (returns HTML)
```
The user API client was architected weirdly, in that it returned a model
directly, like this:
```
API (returns JSON)
⬇
API client (returns a model, of type `User`, `InvitedUser`, etc)
⬇
View (returns HTML)
```
This mixing of different layers of the application is bad because it
makes it hard to write model code that doesn’t have circular
dependencies. As our application gets more complicated we will be
relying more on models to manage this complexity, so we should make it
easy, not hard to write them.
It also means that most of our mocking was of the User model, not just
the underlying JSON. So it would have been easy to introduce subtle bugs
to the user model, because it wasn’t being comprehensively tested. A lot
of the changed lines of code in this commit mean changing the tests to
mock only the JSON, which means that the model layer gets implicitly
tested.
For those reasons this commit changes the user API client to return
JSON, not an instance of `User` or other models.
2019-05-23 15:27:35 +01:00
|
|
|
|
service=invited_user.service,
|
|
|
|
|
|
email_address=invited_user.email_address,
|
|
|
|
|
|
auth_type=invited_user.auth_type,
|
2018-07-02 09:08:21 +01:00
|
|
|
|
name=guess_name_from_email_address(
|
Make user API client return JSON, not a model
The data flow of other bits of our application looks like this:
```
API (returns JSON)
⬇
API client (returns a built in type, usually `dict`)
⬇
Model (returns an instance, eg of type `Service`)
⬇
View (returns HTML)
```
The user API client was architected weirdly, in that it returned a model
directly, like this:
```
API (returns JSON)
⬇
API client (returns a model, of type `User`, `InvitedUser`, etc)
⬇
View (returns HTML)
```
This mixing of different layers of the application is bad because it
makes it hard to write model code that doesn’t have circular
dependencies. As our application gets more complicated we will be
relying more on models to manage this complexity, so we should make it
easy, not hard to write them.
It also means that most of our mocking was of the User model, not just
the underlying JSON. So it would have been easy to introduce subtle bugs
to the user model, because it wasn’t being comprehensively tested. A lot
of the changed lines of code in this commit mean changing the tests to
mock only the JSON, which means that the model layer gets implicitly
tested.
For those reasons this commit changes the user API client to return
JSON, not an instance of `User` or other models.
2019-05-23 15:27:35 +01:00
|
|
|
|
invited_user.email_address
|
2018-07-02 09:08:21 +01:00
|
|
|
|
),
|
2017-11-14 15:53:38 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2017-11-10 12:35:21 +00:00
|
|
|
|
mobile_number = InternationalPhoneNumber('Mobile number', validators=[])
|
2016-03-02 15:25:04 +00:00
|
|
|
|
service = HiddenField('service')
|
2016-03-08 16:29:05 +00:00
|
|
|
|
email_address = HiddenField('email_address')
|
2017-11-13 13:39:31 +00:00
|
|
|
|
auth_type = HiddenField('auth_type', validators=[DataRequired()])
|
2016-03-02 15:25:04 +00:00
|
|
|
|
|
2017-11-10 12:35:21 +00:00
|
|
|
|
def validate_mobile_number(self, field):
|
2017-11-13 13:39:31 +00:00
|
|
|
|
if self.auth_type.data == 'sms_auth' and not field.data:
|
2019-09-12 16:42:33 +01:00
|
|
|
|
raise ValidationError('Cannot be empty')
|
2017-11-10 12:35:21 +00:00
|
|
|
|
|
2016-03-02 15:25:04 +00:00
|
|
|
|
|
2018-02-19 16:53:29 +00:00
|
|
|
|
class RegisterUserFromOrgInviteForm(StripWhitespaceForm):
|
|
|
|
|
|
def __init__(self, invited_org_user):
|
|
|
|
|
|
super().__init__(
|
Make user API client return JSON, not a model
The data flow of other bits of our application looks like this:
```
API (returns JSON)
⬇
API client (returns a built in type, usually `dict`)
⬇
Model (returns an instance, eg of type `Service`)
⬇
View (returns HTML)
```
The user API client was architected weirdly, in that it returned a model
directly, like this:
```
API (returns JSON)
⬇
API client (returns a model, of type `User`, `InvitedUser`, etc)
⬇
View (returns HTML)
```
This mixing of different layers of the application is bad because it
makes it hard to write model code that doesn’t have circular
dependencies. As our application gets more complicated we will be
relying more on models to manage this complexity, so we should make it
easy, not hard to write them.
It also means that most of our mocking was of the User model, not just
the underlying JSON. So it would have been easy to introduce subtle bugs
to the user model, because it wasn’t being comprehensively tested. A lot
of the changed lines of code in this commit mean changing the tests to
mock only the JSON, which means that the model layer gets implicitly
tested.
For those reasons this commit changes the user API client to return
JSON, not an instance of `User` or other models.
2019-05-23 15:27:35 +01:00
|
|
|
|
organisation=invited_org_user.organisation,
|
|
|
|
|
|
email_address=invited_org_user.email_address,
|
2018-02-19 16:53:29 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
name = StringField(
|
|
|
|
|
|
'Full name',
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators=[DataRequired(message='Cannot be empty')]
|
2018-02-19 16:53:29 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2019-09-12 16:42:33 +01:00
|
|
|
|
mobile_number = InternationalPhoneNumber('Mobile number', validators=[DataRequired(message='Cannot be empty')])
|
2018-02-19 16:53:29 +00:00
|
|
|
|
password = password()
|
|
|
|
|
|
organisation = HiddenField('organisation')
|
|
|
|
|
|
email_address = HiddenField('email_address')
|
|
|
|
|
|
auth_type = HiddenField('auth_type', validators=[DataRequired()])
|
|
|
|
|
|
|
|
|
|
|
|
|
2018-08-08 08:45:58 +01:00
|
|
|
|
PermissionsAbstract = type("PermissionsAbstract", (StripWhitespaceForm,), {
|
|
|
|
|
|
permission: BooleanField(label) for permission, label in permissions
|
|
|
|
|
|
})
|
2018-06-13 12:07:08 +01:00
|
|
|
|
|
2018-08-08 08:45:58 +01:00
|
|
|
|
|
|
|
|
|
|
class PermissionsForm(PermissionsAbstract):
|
2019-02-25 16:23:28 +00:00
|
|
|
|
def __init__(self, all_template_folders=None, *args, **kwargs):
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
2019-05-17 11:03:41 +01:00
|
|
|
|
self.folder_permissions.choices = []
|
2019-02-27 15:45:18 +00:00
|
|
|
|
if all_template_folders is not None:
|
2019-02-25 16:23:28 +00:00
|
|
|
|
self.folder_permissions.all_template_folders = all_template_folders
|
|
|
|
|
|
self.folder_permissions.choices = [
|
|
|
|
|
|
(item['id'], item['name']) for item in ([{'name': 'Templates', 'id': None}] + all_template_folders)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2019-03-05 11:45:50 +00:00
|
|
|
|
folder_permissions = NestedCheckboxesField('Folders this team member can see')
|
2018-06-13 12:07:08 +01:00
|
|
|
|
|
2017-11-01 15:36:27 +00:00
|
|
|
|
login_authentication = RadioField(
|
|
|
|
|
|
'Sign in using',
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
('sms_auth', 'Text message code'),
|
|
|
|
|
|
('email_auth', 'Email link'),
|
|
|
|
|
|
],
|
|
|
|
|
|
validators=[DataRequired()]
|
|
|
|
|
|
)
|
2016-03-03 13:00:12 +00:00
|
|
|
|
|
2018-06-13 12:07:08 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def permissions(self):
|
|
|
|
|
|
return {role for role in roles.keys() if self[role].data is True}
|
|
|
|
|
|
|
2018-08-08 08:45:58 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def permissions_fields(self):
|
|
|
|
|
|
return (getattr(self, permission) for permission, _ in permissions)
|
|
|
|
|
|
|
2018-08-06 11:10:37 +01:00
|
|
|
|
@classmethod
|
2019-02-25 16:23:28 +00:00
|
|
|
|
def from_user(cls, user, service_id, **kwargs):
|
2018-08-06 11:10:37 +01:00
|
|
|
|
return cls(
|
2019-02-25 16:23:28 +00:00
|
|
|
|
**kwargs,
|
2018-08-06 11:10:37 +01:00
|
|
|
|
**{
|
|
|
|
|
|
role: user.has_permission_for_service(service_id, role)
|
|
|
|
|
|
for role in roles.keys()
|
|
|
|
|
|
},
|
|
|
|
|
|
login_authentication=user.auth_type
|
|
|
|
|
|
)
|
2018-06-12 14:29:47 +01:00
|
|
|
|
|
|
|
|
|
|
|
2018-08-06 11:10:37 +01:00
|
|
|
|
class InviteUserForm(PermissionsForm):
|
2016-10-26 14:01:01 +01:00
|
|
|
|
email_address = email_address(gov_user=False)
|
2016-03-09 13:00:52 +00:00
|
|
|
|
|
|
|
|
|
|
def __init__(self, invalid_email_address, *args, **kwargs):
|
2018-06-12 11:51:37 +01:00
|
|
|
|
super().__init__(*args, **kwargs)
|
2016-03-09 13:00:52 +00:00
|
|
|
|
self.invalid_email_address = invalid_email_address.lower()
|
|
|
|
|
|
|
|
|
|
|
|
def validate_email_address(self, field):
|
|
|
|
|
|
if field.data.lower() == self.invalid_email_address:
|
2019-09-12 16:42:33 +01:00
|
|
|
|
raise ValidationError("You cannot send an invitation to yourself")
|
2016-03-09 13:00:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
2018-02-19 16:53:29 +00:00
|
|
|
|
class InviteOrgUserForm(StripWhitespaceForm):
|
|
|
|
|
|
email_address = email_address(gov_user=False)
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, invalid_email_address, *args, **kwargs):
|
|
|
|
|
|
super(InviteOrgUserForm, self).__init__(*args, **kwargs)
|
|
|
|
|
|
self.invalid_email_address = invalid_email_address.lower()
|
|
|
|
|
|
|
|
|
|
|
|
def validate_email_address(self, field):
|
|
|
|
|
|
if field.data.lower() == self.invalid_email_address:
|
2019-09-12 16:42:33 +01:00
|
|
|
|
raise ValidationError("You cannot send an invitation to yourself")
|
2018-02-19 16:53:29 +00:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class TwoFactorForm(StripWhitespaceForm):
|
2016-01-27 12:22:32 +00:00
|
|
|
|
def __init__(self, validate_code_func, *args, **kwargs):
|
2016-01-07 12:43:10 +00:00
|
|
|
|
'''
|
|
|
|
|
|
Keyword arguments:
|
2016-01-27 12:22:32 +00:00
|
|
|
|
validate_code_func -- Validates the code with the API.
|
2016-01-07 12:43:10 +00:00
|
|
|
|
'''
|
2016-01-27 12:22:32 +00:00
|
|
|
|
self.validate_code_func = validate_code_func
|
2016-01-07 12:43:10 +00:00
|
|
|
|
super(TwoFactorForm, self).__init__(*args, **kwargs)
|
|
|
|
|
|
|
2018-05-07 22:57:18 +01:00
|
|
|
|
sms_code = SMSCode('Text message code')
|
2015-12-08 12:36:54 +00:00
|
|
|
|
|
2018-05-07 21:24:23 +01:00
|
|
|
|
def validate(self):
|
|
|
|
|
|
|
|
|
|
|
|
if not self.sms_code.validate(self):
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
is_valid, reason = self.validate_code_func(self.sms_code.data)
|
|
|
|
|
|
|
2016-01-27 12:22:32 +00:00
|
|
|
|
if not is_valid:
|
2018-05-07 21:24:23 +01:00
|
|
|
|
self.sms_code.errors.append(reason)
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
return True
|
2016-01-27 12:22:32 +00:00
|
|
|
|
|
2015-12-07 16:56:11 +00:00
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class TextNotReceivedForm(StripWhitespaceForm):
|
2017-08-29 14:52:24 +01:00
|
|
|
|
mobile_number = international_phone_number()
|
2015-12-15 15:35:30 +00:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class RenameServiceForm(StripWhitespaceForm):
|
2016-03-10 14:29:31 +00:00
|
|
|
|
name = StringField(
|
2016-06-20 13:33:29 +01:00
|
|
|
|
u'Service name',
|
2016-03-10 14:29:31 +00:00
|
|
|
|
validators=[
|
2019-11-08 17:12:32 +00:00
|
|
|
|
DataRequired(message='Cannot be empty'),
|
|
|
|
|
|
MustContainAlphanumericCharacters()
|
2016-03-10 14:29:31 +00:00
|
|
|
|
])
|
|
|
|
|
|
|
2016-01-11 13:15:10 +00:00
|
|
|
|
|
2018-03-06 17:12:31 +00:00
|
|
|
|
class RenameOrganisationForm(StripWhitespaceForm):
|
|
|
|
|
|
name = StringField(
|
|
|
|
|
|
u'Organisation name',
|
|
|
|
|
|
validators=[
|
2019-11-08 17:12:32 +00:00
|
|
|
|
DataRequired(message='Cannot be empty'),
|
|
|
|
|
|
MustContainAlphanumericCharacters()
|
2018-03-06 17:12:31 +00:00
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-09-05 12:10:24 +01:00
|
|
|
|
class AddGPOrganisationForm(StripWhitespaceForm):
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, *args, service_name='unknown', **kwargs):
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
self.same_as_service_name.label.text = 'Is your GP practice called ‘{}’?'.format(service_name)
|
|
|
|
|
|
self.service_name = service_name
|
|
|
|
|
|
|
|
|
|
|
|
def get_organisation_name(self):
|
|
|
|
|
|
if self.same_as_service_name.data:
|
|
|
|
|
|
return self.service_name
|
|
|
|
|
|
return self.name.data
|
|
|
|
|
|
|
|
|
|
|
|
same_as_service_name = OnOffField(
|
|
|
|
|
|
'Is your GP practice called the same name as your service?',
|
|
|
|
|
|
choices=(
|
|
|
|
|
|
(True, 'Yes'),
|
|
|
|
|
|
(False, 'No'),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
name = StringField(
|
|
|
|
|
|
'What’s your practice called?',
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def validate_name(self, field):
|
|
|
|
|
|
if self.same_as_service_name.data is False:
|
|
|
|
|
|
if not field.data:
|
2019-09-12 16:42:33 +01:00
|
|
|
|
raise ValidationError('Cannot be empty')
|
2019-09-05 12:10:24 +01:00
|
|
|
|
else:
|
|
|
|
|
|
field.data = ''
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-09-05 16:18:02 +01:00
|
|
|
|
class AddNHSLocalOrganisationForm(StripWhitespaceForm):
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, *args, organisation_choices=None, **kwargs):
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
self.organisations.choices = organisation_choices
|
|
|
|
|
|
|
|
|
|
|
|
organisations = RadioField(
|
|
|
|
|
|
'Which NHS Trust or Clinical Commissioning Group do you work for?',
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-02-19 17:26:16 +00:00
|
|
|
|
class OrganisationOrganisationTypeForm(StripWhitespaceForm):
|
2019-08-27 15:52:42 +01:00
|
|
|
|
organisation_type = OrganisationTypeField('What type of organisation is this?')
|
2019-02-19 17:26:16 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OrganisationCrownStatusForm(StripWhitespaceForm):
|
|
|
|
|
|
crown_status = RadioField(
|
|
|
|
|
|
(
|
|
|
|
|
|
'Is this organisation a crown body?'
|
|
|
|
|
|
),
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
('crown', 'Yes'),
|
|
|
|
|
|
('non-crown', 'No'),
|
|
|
|
|
|
('unknown', 'Not sure'),
|
|
|
|
|
|
],
|
|
|
|
|
|
validators=[
|
2019-09-12 16:42:33 +01:00
|
|
|
|
DataRequired(message='Cannot be empty')
|
2019-02-19 17:26:16 +00:00
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OrganisationAgreementSignedForm(StripWhitespaceForm):
|
|
|
|
|
|
agreement_signed = RadioField(
|
|
|
|
|
|
(
|
|
|
|
|
|
'Has this organisation signed the agreement?'
|
|
|
|
|
|
),
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
('yes', 'Yes'),
|
|
|
|
|
|
('no', 'No'),
|
|
|
|
|
|
('unknown', 'No (but we have some service-specific agreements in place)'),
|
|
|
|
|
|
],
|
|
|
|
|
|
validators=[
|
2019-09-12 16:42:33 +01:00
|
|
|
|
DataRequired(message='Cannot be empty')
|
2019-02-19 17:26:16 +00:00
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OrganisationDomainsForm(StripWhitespaceForm):
|
|
|
|
|
|
|
|
|
|
|
|
def populate(self, domains_list):
|
|
|
|
|
|
for index, value in enumerate(domains_list):
|
|
|
|
|
|
self.domains[index].data = value
|
|
|
|
|
|
|
|
|
|
|
|
domains = FieldList(
|
|
|
|
|
|
StripWhitespaceStringField(
|
|
|
|
|
|
'',
|
|
|
|
|
|
validators=[
|
|
|
|
|
|
Optional(),
|
|
|
|
|
|
],
|
|
|
|
|
|
default=''
|
|
|
|
|
|
),
|
2019-03-22 16:27:30 +00:00
|
|
|
|
min_entries=20,
|
|
|
|
|
|
max_entries=20,
|
2019-02-19 17:26:16 +00:00
|
|
|
|
label="Domain names"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class CreateServiceForm(StripWhitespaceForm):
|
2017-10-04 11:49:32 +01:00
|
|
|
|
name = StringField(
|
2019-07-16 15:37:27 +01:00
|
|
|
|
"What’s your service called?",
|
2017-10-04 11:49:32 +01:00
|
|
|
|
validators=[
|
2019-11-08 17:12:32 +00:00
|
|
|
|
DataRequired(message='Cannot be empty'),
|
|
|
|
|
|
MustContainAlphanumericCharacters()
|
2017-10-04 11:49:32 +01:00
|
|
|
|
])
|
2019-08-27 15:52:42 +01:00
|
|
|
|
organisation_type = OrganisationTypeField('Who runs this service?')
|
2017-10-05 12:06:40 +01:00
|
|
|
|
|
|
|
|
|
|
|
2019-07-16 15:37:27 +01:00
|
|
|
|
class CreateNhsServiceForm(CreateServiceForm):
|
2019-08-27 15:52:42 +01:00
|
|
|
|
organisation_type = OrganisationTypeField(
|
|
|
|
|
|
'Who runs this service?',
|
2019-08-27 16:26:50 +01:00
|
|
|
|
include_only={'nhs_central', 'nhs_local', 'nhs_gp'},
|
2019-08-27 15:52:42 +01:00
|
|
|
|
)
|
2019-07-16 15:37:27 +01:00
|
|
|
|
|
|
|
|
|
|
|
2019-07-01 10:19:33 +01:00
|
|
|
|
class NewOrganisationForm(
|
|
|
|
|
|
RenameOrganisationForm,
|
2019-07-01 17:01:11 +01:00
|
|
|
|
OrganisationOrganisationTypeForm,
|
2019-07-01 10:19:33 +01:00
|
|
|
|
OrganisationCrownStatusForm,
|
|
|
|
|
|
):
|
2019-07-01 17:01:11 +01:00
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
# Don’t offer the ‘not sure’ choice
|
|
|
|
|
|
self.crown_status.choices = self.crown_status.choices[:-1]
|
2019-07-01 10:19:33 +01:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class FreeSMSAllowance(StripWhitespaceForm):
|
2017-10-05 13:49:43 +01:00
|
|
|
|
free_sms_allowance = IntegerField(
|
|
|
|
|
|
'Numbers of text message fragments per year',
|
|
|
|
|
|
validators=[
|
2019-09-12 16:42:33 +01:00
|
|
|
|
DataRequired(message='Cannot be empty')
|
2017-10-05 13:49:43 +01:00
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ConfirmPasswordForm(StripWhitespaceForm):
|
2016-01-22 16:34:36 +00:00
|
|
|
|
def __init__(self, validate_password_func, *args, **kwargs):
|
|
|
|
|
|
self.validate_password_func = validate_password_func
|
|
|
|
|
|
super(ConfirmPasswordForm, self).__init__(*args, **kwargs)
|
|
|
|
|
|
|
2016-01-11 13:15:10 +00:00
|
|
|
|
password = PasswordField(u'Enter password')
|
|
|
|
|
|
|
2016-01-22 16:34:36 +00:00
|
|
|
|
def validate_password(self, field):
|
|
|
|
|
|
if not self.validate_password_func(field.data):
|
|
|
|
|
|
raise ValidationError('Invalid password')
|
|
|
|
|
|
|
2016-01-11 13:15:10 +00:00
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class BaseTemplateForm(StripWhitespaceForm):
|
2016-01-19 15:54:12 +00:00
|
|
|
|
name = StringField(
|
|
|
|
|
|
u'Template name',
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators=[DataRequired(message="Cannot be empty")])
|
2016-01-22 12:19:15 +00:00
|
|
|
|
|
2016-01-22 12:15:47 +00:00
|
|
|
|
template_content = TextAreaField(
|
2016-06-20 11:37:42 +01:00
|
|
|
|
u'Message',
|
2016-04-07 16:02:06 +01:00
|
|
|
|
validators=[
|
2019-09-12 16:42:33 +01:00
|
|
|
|
DataRequired(message="Cannot be empty"),
|
2017-02-15 15:06:47 +00:00
|
|
|
|
NoCommasInPlaceHolders()
|
2016-04-07 16:02:06 +01:00
|
|
|
|
]
|
|
|
|
|
|
)
|
2017-01-19 09:35:34 +00:00
|
|
|
|
process_type = RadioField(
|
|
|
|
|
|
'Use priority queue?',
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
('priority', 'Yes'),
|
|
|
|
|
|
('normal', 'No'),
|
|
|
|
|
|
],
|
|
|
|
|
|
validators=[DataRequired()],
|
|
|
|
|
|
default='normal'
|
|
|
|
|
|
)
|
2016-01-11 13:15:10 +00:00
|
|
|
|
|
|
|
|
|
|
|
2017-02-15 15:06:47 +00:00
|
|
|
|
class SMSTemplateForm(BaseTemplateForm):
|
|
|
|
|
|
def validate_template_content(self, field):
|
2019-05-03 15:13:39 +01:00
|
|
|
|
OnlySMSCharacters()(None, field)
|
2017-02-15 15:06:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmailTemplateForm(BaseTemplateForm):
|
2016-04-14 14:04:41 +01:00
|
|
|
|
subject = TextAreaField(
|
2016-02-22 14:45:13 +00:00
|
|
|
|
u'Subject',
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators=[DataRequired(message="Cannot be empty")])
|
2016-02-22 14:45:13 +00:00
|
|
|
|
|
|
|
|
|
|
|
2016-11-08 13:12:07 +00:00
|
|
|
|
class LetterTemplateForm(EmailTemplateForm):
|
2017-03-13 10:55:15 +00:00
|
|
|
|
subject = TextAreaField(
|
2017-05-10 11:27:45 +01:00
|
|
|
|
u'Main heading',
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators=[DataRequired(message="Cannot be empty")])
|
2017-03-13 10:55:15 +00:00
|
|
|
|
|
|
|
|
|
|
template_content = TextAreaField(
|
|
|
|
|
|
u'Body',
|
|
|
|
|
|
validators=[
|
2019-09-12 16:42:33 +01:00
|
|
|
|
DataRequired(message="Cannot be empty"),
|
2017-03-13 10:55:15 +00:00
|
|
|
|
NoCommasInPlaceHolders()
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2016-11-08 13:12:07 +00:00
|
|
|
|
|
|
|
|
|
|
|
2019-01-29 15:43:59 +00:00
|
|
|
|
class LetterTemplatePostageForm(StripWhitespaceForm):
|
|
|
|
|
|
postage = RadioField(
|
|
|
|
|
|
'Choose the postage for this letter template',
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
('first', 'First class'),
|
|
|
|
|
|
('second', 'Second class'),
|
|
|
|
|
|
],
|
|
|
|
|
|
validators=[DataRequired()]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-10-09 11:23:02 +01:00
|
|
|
|
class LetterUploadPostageForm(StripWhitespaceForm):
|
|
|
|
|
|
postage = RadioField(
|
|
|
|
|
|
'Choose the postage for this letter',
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
('first', 'First class post'),
|
|
|
|
|
|
('second', 'Second class post'),
|
|
|
|
|
|
],
|
|
|
|
|
|
default='second',
|
|
|
|
|
|
validators=[DataRequired()]
|
|
|
|
|
|
)
|
2019-10-09 13:47:28 +01:00
|
|
|
|
file_id = HiddenField(
|
|
|
|
|
|
validators=[DataRequired()]
|
|
|
|
|
|
)
|
2019-10-09 11:23:02 +01:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ForgotPasswordForm(StripWhitespaceForm):
|
2016-10-26 14:01:01 +01:00
|
|
|
|
email_address = email_address(gov_user=False)
|
2016-01-04 14:00:39 +00:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class NewPasswordForm(StripWhitespaceForm):
|
2016-01-08 12:00:52 +00:00
|
|
|
|
new_password = password()
|
2016-01-11 15:00:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ChangePasswordForm(StripWhitespaceForm):
|
2016-01-27 12:22:32 +00:00
|
|
|
|
def __init__(self, validate_password_func, *args, **kwargs):
|
|
|
|
|
|
self.validate_password_func = validate_password_func
|
|
|
|
|
|
super(ChangePasswordForm, self).__init__(*args, **kwargs)
|
|
|
|
|
|
|
2016-01-12 11:25:46 +00:00
|
|
|
|
old_password = password('Current password')
|
|
|
|
|
|
new_password = password('New password')
|
|
|
|
|
|
|
2016-01-27 12:22:32 +00:00
|
|
|
|
def validate_old_password(self, field):
|
|
|
|
|
|
if not self.validate_password_func(field.data):
|
|
|
|
|
|
raise ValidationError('Invalid password')
|
|
|
|
|
|
|
2016-01-12 11:25:46 +00:00
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class CsvUploadForm(StripWhitespaceForm):
|
2016-02-04 12:20:24 +00:00
|
|
|
|
file = FileField('Add recipients', validators=[DataRequired(
|
2016-05-11 09:43:55 +01:00
|
|
|
|
message='Please pick a file'), CsvFileValidator()])
|
2016-01-12 10:28:14 +00:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ChangeNameForm(StripWhitespaceForm):
|
2016-01-12 10:28:14 +00:00
|
|
|
|
new_name = StringField(u'Your name')
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ChangeEmailForm(StripWhitespaceForm):
|
2016-01-27 12:22:32 +00:00
|
|
|
|
def __init__(self, validate_email_func, *args, **kwargs):
|
|
|
|
|
|
self.validate_email_func = validate_email_func
|
|
|
|
|
|
super(ChangeEmailForm, self).__init__(*args, **kwargs)
|
|
|
|
|
|
|
2016-01-12 10:28:14 +00:00
|
|
|
|
email_address = email_address()
|
|
|
|
|
|
|
2016-01-27 12:22:32 +00:00
|
|
|
|
def validate_email_address(self, field):
|
|
|
|
|
|
is_valid = self.validate_email_func(field.data)
|
2018-02-19 16:53:29 +00:00
|
|
|
|
if is_valid:
|
2016-01-27 12:22:32 +00:00
|
|
|
|
raise ValidationError("The email address is already in use")
|
|
|
|
|
|
|
2016-01-12 10:28:14 +00:00
|
|
|
|
|
2019-04-18 16:03:13 +01:00
|
|
|
|
class ChangeNonGovEmailForm(ChangeEmailForm):
|
|
|
|
|
|
email_address = email_address(gov_user=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ChangeMobileNumberForm(StripWhitespaceForm):
|
2017-08-29 14:52:24 +01:00
|
|
|
|
mobile_number = international_phone_number()
|
2016-01-12 10:28:14 +00:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ChooseTimeForm(StripWhitespaceForm):
|
2016-08-07 09:17:49 +01:00
|
|
|
|
|
|
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
|
|
super(ChooseTimeForm, self).__init__(*args, **kwargs)
|
|
|
|
|
|
self.scheduled_for.choices = [('', 'Now')] + [
|
2016-10-11 14:11:10 +01:00
|
|
|
|
get_time_value_and_label(hour) for hour in get_next_hours_until(
|
|
|
|
|
|
get_furthest_possible_scheduled_time()
|
|
|
|
|
|
)
|
2016-08-07 09:17:49 +01:00
|
|
|
|
]
|
2016-10-11 14:17:29 +01:00
|
|
|
|
self.scheduled_for.categories = get_next_days_until(get_furthest_possible_scheduled_time())
|
2016-08-07 09:17:49 +01:00
|
|
|
|
|
|
|
|
|
|
scheduled_for = RadioField(
|
|
|
|
|
|
'When should Notify send these messages?',
|
|
|
|
|
|
default='',
|
|
|
|
|
|
validators=[
|
|
|
|
|
|
DataRequired()
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class CreateKeyForm(StripWhitespaceForm):
|
2018-11-13 14:27:01 +00:00
|
|
|
|
def __init__(self, existing_keys, *args, **kwargs):
|
2018-11-13 09:57:17 +00:00
|
|
|
|
self.existing_key_names = [
|
|
|
|
|
|
key['name'].lower() for key in existing_keys
|
2019-03-04 19:50:00 +00:00
|
|
|
|
if not key['expiry_date']
|
2018-11-13 09:57:17 +00:00
|
|
|
|
]
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
2016-01-21 14:15:36 +00:00
|
|
|
|
|
2016-06-29 17:10:49 +01:00
|
|
|
|
key_type = RadioField(
|
2017-04-26 13:21:27 +01:00
|
|
|
|
'Type of key',
|
2016-06-29 17:10:49 +01:00
|
|
|
|
validators=[
|
|
|
|
|
|
DataRequired()
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2016-01-21 14:15:36 +00:00
|
|
|
|
key_name = StringField(u'Description of key', validators=[
|
|
|
|
|
|
DataRequired(message='You need to give the key a name')
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
def validate_key_name(self, key_name):
|
2016-01-21 16:52:01 +00:00
|
|
|
|
if key_name.data.lower() in self.existing_key_names:
|
2016-01-21 14:15:36 +00:00
|
|
|
|
raise ValidationError('A key with this name already exists')
|
2016-04-19 13:51:16 +01:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class SupportType(StripWhitespaceForm):
|
2016-12-12 11:25:43 +00:00
|
|
|
|
support_type = RadioField(
|
|
|
|
|
|
'How can we help you?',
|
|
|
|
|
|
choices=[
|
2017-11-28 11:59:11 +00:00
|
|
|
|
('report-problem', 'Report a problem'),
|
|
|
|
|
|
('ask-question-give-feedback', 'Ask a question or give feedback'),
|
2016-12-12 11:25:43 +00:00
|
|
|
|
],
|
|
|
|
|
|
validators=[DataRequired()]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class Feedback(StripWhitespaceForm):
|
2016-04-19 13:51:16 +01:00
|
|
|
|
name = StringField('Name')
|
2018-12-07 12:23:12 +00:00
|
|
|
|
email_address = email_address(label='Email address', gov_user=False, required=False)
|
2019-09-12 16:42:33 +01:00
|
|
|
|
feedback = TextAreaField('Your message', validators=[DataRequired(message="Cannot be empty")])
|
2016-04-26 13:31:57 +01:00
|
|
|
|
|
|
|
|
|
|
|
2016-12-21 14:55:49 +00:00
|
|
|
|
class Problem(Feedback):
|
|
|
|
|
|
email_address = email_address(label='Email address', gov_user=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class Triage(StripWhitespaceForm):
|
2016-12-12 11:44:11 +00:00
|
|
|
|
severe = RadioField(
|
|
|
|
|
|
'Is it an emergency?',
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
('yes', 'Yes'),
|
|
|
|
|
|
('no', 'No'),
|
|
|
|
|
|
],
|
|
|
|
|
|
validators=[DataRequired()]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-02-15 11:03:19 +00:00
|
|
|
|
class EstimateUsageForm(StripWhitespaceForm):
|
2019-02-15 13:34:29 +00:00
|
|
|
|
|
|
|
|
|
|
volume_email = ForgivingIntegerField(
|
2018-08-30 11:00:39 +01:00
|
|
|
|
'How many emails do you expect to send in the next year?',
|
2019-02-27 13:02:37 +00:00
|
|
|
|
things='emails',
|
2019-02-27 15:10:34 +00:00
|
|
|
|
format_error_suffix='you expect to send',
|
2016-10-24 15:46:22 +01:00
|
|
|
|
)
|
2019-02-15 13:34:29 +00:00
|
|
|
|
volume_sms = ForgivingIntegerField(
|
2018-08-30 11:00:39 +01:00
|
|
|
|
'How many text messages do you expect to send in the next year?',
|
2019-02-27 13:02:37 +00:00
|
|
|
|
things='text messages',
|
2019-02-27 15:10:34 +00:00
|
|
|
|
format_error_suffix='you expect to send',
|
2016-10-24 13:38:55 +01:00
|
|
|
|
)
|
2019-02-15 13:34:29 +00:00
|
|
|
|
volume_letter = ForgivingIntegerField(
|
2018-08-30 11:00:39 +01:00
|
|
|
|
'How many letters do you expect to send in the next year?',
|
2019-02-27 13:02:37 +00:00
|
|
|
|
things='letters',
|
2019-02-27 15:10:34 +00:00
|
|
|
|
format_error_suffix='you expect to send',
|
2016-10-24 13:38:55 +01:00
|
|
|
|
)
|
2019-02-15 11:03:19 +00:00
|
|
|
|
consent_to_research = RadioField(
|
2018-08-30 11:51:34 +01:00
|
|
|
|
'Can we contact you when we’re doing user research?',
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
('yes', 'Yes'),
|
|
|
|
|
|
('no', 'No'),
|
|
|
|
|
|
],
|
|
|
|
|
|
validators=[DataRequired()]
|
|
|
|
|
|
)
|
2016-05-11 09:43:55 +01:00
|
|
|
|
|
2019-02-15 13:34:29 +00:00
|
|
|
|
at_least_one_volume_filled = True
|
|
|
|
|
|
|
|
|
|
|
|
def validate(self, *args, **kwargs):
|
|
|
|
|
|
|
|
|
|
|
|
if self.volume_email.data == self.volume_sms.data == self.volume_letter.data == 0:
|
|
|
|
|
|
self.at_least_one_volume_filled = False
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
return super().validate(*args, **kwargs)
|
|
|
|
|
|
|
2016-05-11 09:43:55 +01:00
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ProviderForm(StripWhitespaceForm):
|
2016-05-11 09:43:55 +01:00
|
|
|
|
priority = IntegerField('Priority', [validators.NumberRange(min=1, max=100, message="Must be between 1 and 100")])
|
2016-05-16 13:09:58 +01:00
|
|
|
|
|
|
|
|
|
|
|
2018-08-09 11:59:05 +01:00
|
|
|
|
class ServiceContactDetailsForm(StripWhitespaceForm):
|
|
|
|
|
|
contact_details_type = RadioField(
|
|
|
|
|
|
'Type of contact details',
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
('url', 'Link'),
|
|
|
|
|
|
('email_address', 'Email address'),
|
|
|
|
|
|
('phone_number', 'Phone number'),
|
|
|
|
|
|
],
|
|
|
|
|
|
validators=[DataRequired()]
|
2018-06-05 10:37:41 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
2018-08-09 11:59:05 +01:00
|
|
|
|
url = StringField("URL")
|
|
|
|
|
|
email_address = EmailField("Email address")
|
|
|
|
|
|
phone_number = StringField("Phone number")
|
|
|
|
|
|
|
|
|
|
|
|
def validate(self):
|
|
|
|
|
|
|
|
|
|
|
|
if self.contact_details_type.data == 'url':
|
|
|
|
|
|
self.url.validators = [DataRequired(), URL(message='Must be a valid URL')]
|
|
|
|
|
|
|
|
|
|
|
|
elif self.contact_details_type.data == 'email_address':
|
|
|
|
|
|
self.email_address.validators = [DataRequired(), Length(min=5, max=255), ValidEmail()]
|
|
|
|
|
|
|
|
|
|
|
|
elif self.contact_details_type.data == 'phone_number':
|
|
|
|
|
|
# we can't use the existing phone number validation functions here since we want to allow landlines
|
|
|
|
|
|
def valid_phone_number(self, num):
|
|
|
|
|
|
try:
|
|
|
|
|
|
normalise_phone_number(num.data)
|
|
|
|
|
|
return True
|
|
|
|
|
|
except InvalidPhoneError:
|
|
|
|
|
|
raise ValidationError('Must be a valid phone number')
|
|
|
|
|
|
self.phone_number.validators = [DataRequired(), Length(min=5, max=20), valid_phone_number]
|
|
|
|
|
|
|
|
|
|
|
|
return super().validate()
|
|
|
|
|
|
|
2018-06-05 10:37:41 +01:00
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ServiceReplyToEmailForm(StripWhitespaceForm):
|
2019-07-09 16:47:11 +01:00
|
|
|
|
email_address = email_address(label='Reply-to email address', gov_user=False)
|
2017-09-26 15:34:19 +01:00
|
|
|
|
is_default = BooleanField("Make this email address the default")
|
2016-07-01 13:47:22 +01:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ServiceSmsSenderForm(StripWhitespaceForm):
|
2016-08-22 16:10:57 +01:00
|
|
|
|
sms_sender = StringField(
|
|
|
|
|
|
'Text message sender',
|
|
|
|
|
|
validators=[
|
2019-09-12 16:42:33 +01:00
|
|
|
|
DataRequired(message="Cannot be empty"),
|
2018-01-31 10:26:37 +00:00
|
|
|
|
Length(max=11, message="Enter 11 characters or fewer"),
|
2018-09-19 16:22:58 +01:00
|
|
|
|
Length(min=4, message="Enter 4 characters or more"),
|
2018-01-31 10:38:44 +00:00
|
|
|
|
LettersNumbersAndFullStopsOnly(),
|
2018-02-28 11:50:41 +00:00
|
|
|
|
DoesNotStartWithDoubleZero(),
|
2016-08-22 16:10:57 +01:00
|
|
|
|
]
|
|
|
|
|
|
)
|
2017-10-24 15:37:44 +01:00
|
|
|
|
is_default = BooleanField("Make this text message sender the default")
|
2016-07-01 13:47:22 +01:00
|
|
|
|
|
2016-08-08 10:28:40 +01:00
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ServiceEditInboundNumberForm(StripWhitespaceForm):
|
2017-10-30 14:30:43 +00:00
|
|
|
|
is_default = BooleanField("Make this text message sender the default")
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ServiceLetterContactBlockForm(StripWhitespaceForm):
|
2017-05-17 15:35:21 +01:00
|
|
|
|
letter_contact_block = TextAreaField(
|
|
|
|
|
|
validators=[
|
2019-09-12 16:42:33 +01:00
|
|
|
|
DataRequired(message="Cannot be empty"),
|
2017-05-17 15:35:21 +01:00
|
|
|
|
NoCommasInPlaceHolders()
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2017-10-05 15:47:12 +01:00
|
|
|
|
is_default = BooleanField("Set as your default address")
|
2017-03-02 15:56:28 +00:00
|
|
|
|
|
2017-10-27 10:56:03 +01:00
|
|
|
|
def validate_letter_contact_block(self, field):
|
2017-03-03 16:15:15 +00:00
|
|
|
|
line_count = field.data.strip().count('\n')
|
|
|
|
|
|
if line_count >= 10:
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
'Contains {} lines, maximum is 10'.format(line_count + 1)
|
|
|
|
|
|
)
|
2017-03-02 15:56:28 +00:00
|
|
|
|
|
|
|
|
|
|
|
2019-02-18 16:27:53 +00:00
|
|
|
|
class ServiceOnOffSettingForm(StripWhitespaceForm):
|
|
|
|
|
|
|
2019-03-25 14:46:42 +00:00
|
|
|
|
def __init__(self, name, *args, truthy='On', falsey='Off', **kwargs):
|
2019-01-29 14:51:31 +00:00
|
|
|
|
super().__init__(*args, **kwargs)
|
2019-02-18 16:27:53 +00:00
|
|
|
|
self.enabled.label.text = name
|
2019-03-25 14:46:42 +00:00
|
|
|
|
self.enabled.choices = [
|
|
|
|
|
|
(True, truthy),
|
|
|
|
|
|
(False, falsey),
|
|
|
|
|
|
]
|
2019-02-18 16:27:53 +00:00
|
|
|
|
|
|
|
|
|
|
enabled = OnOffField('Choices')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ServiceSwitchChannelForm(ServiceOnOffSettingForm):
|
|
|
|
|
|
def __init__(self, channel, *args, **kwargs):
|
|
|
|
|
|
name = 'Send {}'.format({
|
2019-01-29 14:51:31 +00:00
|
|
|
|
'email': 'emails',
|
|
|
|
|
|
'sms': 'text messages',
|
|
|
|
|
|
'letter': 'letters',
|
|
|
|
|
|
}.get(channel))
|
2018-01-19 13:45:54 +00:00
|
|
|
|
|
2019-02-18 16:27:53 +00:00
|
|
|
|
super().__init__(name, *args, **kwargs)
|
2018-01-19 13:45:54 +00:00
|
|
|
|
|
|
|
|
|
|
|
2019-02-19 17:26:16 +00:00
|
|
|
|
class SetEmailBranding(StripWhitespaceForm):
|
2016-08-08 10:28:40 +01:00
|
|
|
|
|
2018-11-08 14:46:18 +00:00
|
|
|
|
branding_style = RadioFieldWithNoneOption(
|
2018-02-07 10:30:49 +00:00
|
|
|
|
'Branding style',
|
2016-08-08 10:28:40 +01:00
|
|
|
|
validators=[
|
|
|
|
|
|
DataRequired()
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2016-09-20 12:30:00 +01:00
|
|
|
|
|
2018-11-12 09:04:39 +00:00
|
|
|
|
DEFAULT = (FieldWithNoneOption.NONE_OPTION_VALUE, 'GOV.UK')
|
2018-08-31 17:31:26 +01:00
|
|
|
|
|
2019-02-01 16:31:34 +00:00
|
|
|
|
def __init__(self, all_branding_options, current_branding):
|
2018-08-31 17:31:26 +01:00
|
|
|
|
|
2019-02-01 16:31:34 +00:00
|
|
|
|
super().__init__(branding_style=current_branding)
|
2018-08-31 17:31:26 +01:00
|
|
|
|
|
|
|
|
|
|
self.branding_style.choices = sorted(
|
2019-02-01 16:31:34 +00:00
|
|
|
|
all_branding_options + [self.DEFAULT],
|
2018-08-31 17:31:26 +01:00
|
|
|
|
key=lambda branding: (
|
2019-02-01 16:31:34 +00:00
|
|
|
|
branding[0] != current_branding,
|
2018-08-31 17:31:26 +01:00
|
|
|
|
branding[0] is not self.DEFAULT[0],
|
|
|
|
|
|
branding[1].lower(),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2016-09-20 12:30:00 +01:00
|
|
|
|
|
2019-02-19 17:26:16 +00:00
|
|
|
|
class SetLetterBranding(SetEmailBranding):
|
2019-02-01 16:34:54 +00:00
|
|
|
|
# form is the same, but instead of GOV.UK we have None as a valid option
|
|
|
|
|
|
DEFAULT = (FieldWithNoneOption.NONE_OPTION_VALUE, 'None')
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-02-19 17:26:16 +00:00
|
|
|
|
class PreviewBranding(StripWhitespaceForm):
|
2018-08-02 14:46:01 +01:00
|
|
|
|
|
2018-11-12 09:04:39 +00:00
|
|
|
|
branding_style = HiddenFieldWithNoneOption('branding_style')
|
2018-08-02 14:46:01 +01:00
|
|
|
|
|
|
|
|
|
|
|
2018-02-07 10:30:49 +00:00
|
|
|
|
class ServiceUpdateEmailBranding(StripWhitespaceForm):
|
2018-09-07 11:43:32 +01:00
|
|
|
|
name = StringField('Name of brand')
|
2018-08-09 16:24:22 +01:00
|
|
|
|
text = StringField('Text')
|
2018-02-07 10:30:49 +00:00
|
|
|
|
colour = StringField(
|
|
|
|
|
|
'Colour',
|
|
|
|
|
|
validators=[
|
2018-11-07 10:49:04 +00:00
|
|
|
|
Regexp(regex="^$|^#(?:[0-9a-fA-F]{3}){1,2}$", message='Must be a valid color hex code (starting with #)')
|
2018-02-07 10:30:49 +00:00
|
|
|
|
]
|
|
|
|
|
|
)
|
2017-07-28 15:17:50 +01:00
|
|
|
|
file = FileField_wtf('Upload a PNG logo', validators=[FileAllowed(['png'], 'PNG Images only!')])
|
2018-08-23 14:21:41 +01:00
|
|
|
|
brand_type = RadioField(
|
2018-08-23 17:44:34 +01:00
|
|
|
|
"Brand type",
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
('both', 'GOV.UK and branding'),
|
|
|
|
|
|
('org', 'Branding only'),
|
|
|
|
|
|
('org_banner', 'Branding banner'),
|
|
|
|
|
|
]
|
2018-08-23 14:21:41 +01:00
|
|
|
|
)
|
2017-07-28 15:17:50 +01:00
|
|
|
|
|
2019-11-01 10:43:01 +00:00
|
|
|
|
def validate_name(self, name):
|
2019-01-25 16:54:24 +00:00
|
|
|
|
op = request.form.get('operation')
|
2019-11-01 10:43:01 +00:00
|
|
|
|
if op == 'email-branding-details' and not self.name.data:
|
2019-01-25 16:54:24 +00:00
|
|
|
|
raise ValidationError('This field is required')
|
|
|
|
|
|
|
2017-07-28 15:17:50 +01:00
|
|
|
|
|
2019-01-31 16:56:35 +00:00
|
|
|
|
class SVGFileUpload(StripWhitespaceForm):
|
|
|
|
|
|
file = FileField_wtf(
|
|
|
|
|
|
'Upload an SVG logo',
|
|
|
|
|
|
validators=[
|
|
|
|
|
|
FileAllowed(['svg'], 'SVG Images only!'),
|
|
|
|
|
|
DataRequired(message="You need to upload a file to submit")
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ServiceLetterBrandingDetails(StripWhitespaceForm):
|
|
|
|
|
|
name = StringField('Name of brand', validators=[DataRequired()])
|
|
|
|
|
|
|
|
|
|
|
|
|
2018-09-26 16:42:40 +01:00
|
|
|
|
class PDFUploadForm(StripWhitespaceForm):
|
|
|
|
|
|
file = FileField_wtf(
|
2019-09-06 10:39:23 +01:00
|
|
|
|
'Upload a letter in PDF format',
|
2018-09-27 17:55:18 +01:00
|
|
|
|
validators=[
|
2019-10-15 15:56:06 +01:00
|
|
|
|
FileAllowed(['pdf'], 'Save your letter as a PDF and try again.'),
|
|
|
|
|
|
DataRequired(message="You need to choose a file to upload")
|
2018-09-27 17:55:18 +01:00
|
|
|
|
]
|
2018-09-26 16:42:40 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:22:37 +00:00
|
|
|
|
class EmailFieldInWhitelist(EmailField, StripWhitespaceStringField):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InternationalPhoneNumberInWhitelist(InternationalPhoneNumber, StripWhitespaceStringField):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class Whitelist(StripWhitespaceForm):
|
2016-09-20 12:30:00 +01:00
|
|
|
|
|
|
|
|
|
|
def populate(self, email_addresses, phone_numbers):
|
|
|
|
|
|
for form_field, existing_whitelist in (
|
|
|
|
|
|
(self.email_addresses, email_addresses),
|
|
|
|
|
|
(self.phone_numbers, phone_numbers)
|
|
|
|
|
|
):
|
|
|
|
|
|
for index, value in enumerate(existing_whitelist):
|
|
|
|
|
|
form_field[index].data = value
|
|
|
|
|
|
|
|
|
|
|
|
email_addresses = FieldList(
|
2017-12-11 16:22:37 +00:00
|
|
|
|
EmailFieldInWhitelist(
|
2016-09-20 12:30:00 +01:00
|
|
|
|
'',
|
|
|
|
|
|
validators=[
|
|
|
|
|
|
Optional(),
|
2018-02-14 14:35:16 +00:00
|
|
|
|
ValidEmail()
|
2016-09-20 12:30:00 +01:00
|
|
|
|
],
|
|
|
|
|
|
default=''
|
|
|
|
|
|
),
|
|
|
|
|
|
min_entries=5,
|
|
|
|
|
|
max_entries=5,
|
|
|
|
|
|
label="Email addresses"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
phone_numbers = FieldList(
|
2017-12-11 16:22:37 +00:00
|
|
|
|
InternationalPhoneNumberInWhitelist(
|
2016-09-20 12:30:00 +01:00
|
|
|
|
'',
|
|
|
|
|
|
validators=[
|
|
|
|
|
|
Optional()
|
|
|
|
|
|
],
|
|
|
|
|
|
default=''
|
|
|
|
|
|
),
|
|
|
|
|
|
min_entries=5,
|
|
|
|
|
|
max_entries=5,
|
|
|
|
|
|
label="Mobile numbers"
|
|
|
|
|
|
)
|
2017-01-03 10:45:06 +00:00
|
|
|
|
|
2017-01-03 16:14:25 +00:00
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class DateFilterForm(StripWhitespaceForm):
|
2017-01-03 10:45:06 +00:00
|
|
|
|
start_date = DateField("Start Date", [validators.optional()])
|
2017-01-03 16:14:25 +00:00
|
|
|
|
end_date = DateField("End Date", [validators.optional()])
|
|
|
|
|
|
include_from_test_key = BooleanField("Include test keys", default="checked", false_values={"N"})
|
Merge email, text message + letter templates pages
Right now we have separate pages for email and text message templates.
In the future we will also have a separate page for letter templates.
This commit changes Notify to only have one page for all templates.
What is the problem?
---
The left-hand navigation is getting quite crowded, at 8 items for a
service that can send letters. Research suggests that the number of
objects an average human can hold in working memory is 7 ± 2 [1]. So
we’re at the limit of how many items the navigation should have.
In the future we will need to search/sort/filter templates by attributes
other than type, for example:
- show me the ‘confirmation’ templates
- show me the most recently used templates
- show me all templates containing the placeholder `((ref_no))`
These are hypothetical for now, but these needs (or others) may become
real in the future. At this point pre-filtering the list of templates
by type would restrict what searches a user could do. So by making this
change now we’re in a better position to iterate the design in the
future.
What’s the change?
---
This commit replaces the ‘Email templates’, ‘Text message templates’ and
‘Letter templates’ pages with one page called ‘Templates’.
This new templates page shows all the templates for the service, sorted
by most recently created first (as before).
To add a new template there is a new page with a form asking you what
kind of template you want to create. This is necessary because in the
past we knew what kind of template you wanted to create based on the
kind you were looking at.
What’s the impact of this change on new users?
---
This change alters the onboarding process slightly. We still want to
take people through the empty templates page from the call-to-action on
the dashboard because it helps them understand that to send a message
using Notify you need a template. But because we don’t have separate
pages for emails/text messages we will have to send users through the
extra step of choosing what kind of template to create. This is a bit
clunkier on first use but:
- it still gets the point across
- it takes them through the actual flow they will be using to create new
templates in the future (ie they’re learning how to use Notify, not
just being taken through a special onboarding route)
I’m not too worried about this change in terms of the experience for new
users. Furthermore, by making it now we get to validate whether it’s
causing any problems in the lab research booked for next week.
What’s the impact of this change on current services?
---
Looking at the top 15 services by number of templates[2], most are using
either text messages or emails. So this change would not have a
significant impact on these services because the page will not get any
longer. In other words we wouldn’t be making it worse for them.
Those services who do use both are not using as many templates. The
worst-case scenario is SSCS, who have 16 templates, evenly split between
email and text messages. So they would go from having 8 templates per
page to 16, which is still less than half the number that HMPO or
Digital Marketplace are managing.
References
---
1. https://en.wikipedia.org/wiki/The_Magical_Number_Seven,_Plus_or_Minus_Two
2. Template usage by service
Service name | Template count | Template types
---------------------------------------|----------------|---------------
Her Majesty's Passport Office | 40 | sms
Digital Marketplace | 40 | email
GovWifi-Staging | 19 | sms
GovWifi | 18 | sms
Digital Apprenticeship Service | 16 | email
SSCS | 16 | both
Crown Commercial Service MI Collection | 15 | email
Help with Prison Visits | 12 | both
Digital Future | 12 | email
Export Licensing Service | 11 | email
Civil Money Claims | 9 | both
DVLA Drivers Medical Service | 9 | sms
GOV.UK Notify | 8 | both
Manage your benefit overpayments | 8 | both
Tax Renewals | 8 | both
2017-02-28 12:16:35 +00:00
|
|
|
|
|
|
|
|
|
|
|
2019-07-22 13:09:03 +01:00
|
|
|
|
class RequiredDateFilterForm(StripWhitespaceForm):
|
|
|
|
|
|
start_date = DateField("Start Date")
|
|
|
|
|
|
end_date = DateField("End Date")
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-02-06 17:32:13 +00:00
|
|
|
|
class SearchByNameForm(StripWhitespaceForm):
|
2017-03-14 10:46:38 +00:00
|
|
|
|
|
2019-08-15 12:41:46 +01:00
|
|
|
|
search = SearchField(
|
|
|
|
|
|
'Search by name',
|
|
|
|
|
|
validators=[DataRequired("You need to enter full or partial name to search by.")],
|
|
|
|
|
|
)
|
2017-05-04 09:30:55 +01:00
|
|
|
|
|
|
|
|
|
|
|
2018-07-10 16:33:13 +01:00
|
|
|
|
class SearchUsersByEmailForm(StripWhitespaceForm):
|
|
|
|
|
|
|
2018-07-10 17:24:20 +01:00
|
|
|
|
search = SearchField(
|
|
|
|
|
|
'Search by name or email address',
|
2018-07-10 16:33:13 +01:00
|
|
|
|
validators=[
|
2018-07-10 17:24:20 +01:00
|
|
|
|
DataRequired("You need to enter full or partial email address to search by.")
|
|
|
|
|
|
],
|
2018-07-10 16:33:13 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2018-01-26 17:21:06 +00:00
|
|
|
|
class SearchUsersForm(StripWhitespaceForm):
|
|
|
|
|
|
|
|
|
|
|
|
search = SearchField('Search by name or email address')
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class SearchNotificationsForm(StripWhitespaceForm):
|
2017-05-30 13:51:25 +01:00
|
|
|
|
|
2018-08-07 14:44:09 +01:00
|
|
|
|
to = SearchField()
|
|
|
|
|
|
|
|
|
|
|
|
labels = {
|
|
|
|
|
|
'email': 'Search by email address',
|
|
|
|
|
|
'sms': 'Search by phone number',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, message_type, *args, **kwargs):
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
self.to.label.text = self.labels.get(
|
|
|
|
|
|
message_type,
|
|
|
|
|
|
'Search by phone number or email address',
|
|
|
|
|
|
)
|
2017-05-30 13:51:25 +01:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class PlaceholderForm(StripWhitespaceForm):
|
2017-05-04 09:30:55 +01:00
|
|
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-06-21 17:34:22 +01:00
|
|
|
|
class PasswordFieldShowHasContent(StringField):
|
|
|
|
|
|
widget = widgets.PasswordInput(hide_value=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class ServiceInboundNumberForm(StripWhitespaceForm):
|
2017-10-24 15:37:44 +01:00
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
self.inbound_number.choices = kwargs['inbound_number_choices']
|
|
|
|
|
|
|
|
|
|
|
|
inbound_number = RadioField(
|
|
|
|
|
|
"Select your inbound number",
|
|
|
|
|
|
validators=[
|
|
|
|
|
|
DataRequired("Option must be selected")
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2018-04-26 17:23:44 +01:00
|
|
|
|
class CallbackForm(StripWhitespaceForm):
|
|
|
|
|
|
|
|
|
|
|
|
def validate(self):
|
2018-07-06 11:47:35 +01:00
|
|
|
|
return super().validate() or self.url.data == ''
|
2018-04-26 17:23:44 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ServiceReceiveMessagesCallbackForm(CallbackForm):
|
2017-12-08 10:52:38 +00:00
|
|
|
|
url = StringField(
|
|
|
|
|
|
"URL",
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators=[DataRequired(message='Cannot be empty'),
|
2017-12-04 15:07:11 +00:00
|
|
|
|
Regexp(regex="^https.*", message='Must be a valid https URL')]
|
|
|
|
|
|
)
|
2017-12-08 10:52:38 +00:00
|
|
|
|
bearer_token = PasswordFieldShowHasContent(
|
2017-12-04 15:07:11 +00:00
|
|
|
|
"Bearer token",
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators=[DataRequired(message='Cannot be empty'),
|
2017-12-04 15:07:11 +00:00
|
|
|
|
Length(min=10, message='Must be at least 10 characters')]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2017-12-08 10:52:38 +00:00
|
|
|
|
|
2018-04-26 17:23:44 +01:00
|
|
|
|
class ServiceDeliveryStatusCallbackForm(CallbackForm):
|
2017-12-08 10:52:38 +00:00
|
|
|
|
url = StringField(
|
|
|
|
|
|
"URL",
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators=[DataRequired(message='Cannot be empty'),
|
2017-12-08 10:52:38 +00:00
|
|
|
|
Regexp(regex="^https.*", message='Must be a valid https URL')]
|
|
|
|
|
|
)
|
|
|
|
|
|
bearer_token = PasswordFieldShowHasContent(
|
|
|
|
|
|
"Bearer token",
|
2019-09-12 16:42:33 +01:00
|
|
|
|
validators=[DataRequired(message='Cannot be empty'),
|
2017-12-08 10:52:38 +00:00
|
|
|
|
Length(min=10, message='Must be at least 10 characters')]
|
|
|
|
|
|
)
|
2017-06-15 16:20:07 +01:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class InternationalSMSForm(StripWhitespaceForm):
|
2017-09-26 14:05:02 +01:00
|
|
|
|
enabled = RadioField(
|
|
|
|
|
|
'Send text messages to international phone numbers',
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
('on', 'On'),
|
|
|
|
|
|
('off', 'Off'),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class SMSPrefixForm(StripWhitespaceForm):
|
2017-11-03 15:01:41 +00:00
|
|
|
|
enabled = RadioField(
|
2017-11-06 14:12:25 +00:00
|
|
|
|
'',
|
2017-11-03 15:01:41 +00:00
|
|
|
|
choices=[
|
|
|
|
|
|
('on', 'On'),
|
|
|
|
|
|
('off', 'Off'),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-05-04 09:30:55 +01:00
|
|
|
|
def get_placeholder_form_instance(
|
|
|
|
|
|
placeholder_name,
|
|
|
|
|
|
dict_to_populate_from,
|
2018-03-16 14:17:43 +00:00
|
|
|
|
template_type,
|
2017-05-25 09:40:37 +01:00
|
|
|
|
optional_placeholder=False,
|
|
|
|
|
|
allow_international_phone_numbers=False,
|
2017-05-04 09:30:55 +01:00
|
|
|
|
):
|
|
|
|
|
|
|
2018-03-16 14:17:43 +00:00
|
|
|
|
if (
|
|
|
|
|
|
Columns.make_key(placeholder_name) == 'emailaddress' and
|
|
|
|
|
|
template_type == 'email'
|
|
|
|
|
|
):
|
2017-05-25 09:40:37 +01:00
|
|
|
|
field = email_address(label=placeholder_name, gov_user=False)
|
2018-03-16 14:17:43 +00:00
|
|
|
|
elif (
|
|
|
|
|
|
Columns.make_key(placeholder_name) == 'phonenumber' and
|
|
|
|
|
|
template_type == 'sms'
|
|
|
|
|
|
):
|
2017-05-25 09:40:37 +01:00
|
|
|
|
if allow_international_phone_numbers:
|
|
|
|
|
|
field = international_phone_number(label=placeholder_name)
|
|
|
|
|
|
else:
|
2017-08-29 14:52:24 +01:00
|
|
|
|
field = uk_mobile_number(label=placeholder_name)
|
2017-05-25 09:40:37 +01:00
|
|
|
|
elif optional_placeholder:
|
|
|
|
|
|
field = StringField(placeholder_name)
|
|
|
|
|
|
else:
|
|
|
|
|
|
field = StringField(placeholder_name, validators=[
|
2019-09-12 16:42:33 +01:00
|
|
|
|
DataRequired(message='Cannot be empty')
|
2017-05-25 09:40:37 +01:00
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
PlaceholderForm.placeholder_value = field
|
2017-05-04 09:30:55 +01:00
|
|
|
|
|
|
|
|
|
|
return PlaceholderForm(
|
|
|
|
|
|
placeholder_value=dict_to_populate_from.get(placeholder_name, '')
|
|
|
|
|
|
)
|
2017-10-16 16:35:35 +01:00
|
|
|
|
|
|
|
|
|
|
|
2017-12-11 16:02:12 +00:00
|
|
|
|
class SetSenderForm(StripWhitespaceForm):
|
2017-10-16 16:35:35 +01:00
|
|
|
|
|
|
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
self.sender.choices = kwargs['sender_choices']
|
|
|
|
|
|
self.sender.label.text = kwargs['sender_label']
|
|
|
|
|
|
|
|
|
|
|
|
sender = RadioField()
|
2018-01-03 10:44:36 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SetTemplateSenderForm(StripWhitespaceForm):
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
self.sender.choices = kwargs['sender_choices']
|
|
|
|
|
|
self.sender.label.text = 'Select your sender'
|
|
|
|
|
|
|
|
|
|
|
|
sender = RadioField()
|
2018-02-12 09:26:13 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LinkOrganisationsForm(StripWhitespaceForm):
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
self.organisations.choices = kwargs['choices']
|
|
|
|
|
|
|
|
|
|
|
|
organisations = RadioField(
|
|
|
|
|
|
'Select an organisation',
|
|
|
|
|
|
validators=[
|
|
|
|
|
|
DataRequired()
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2018-05-18 14:05:48 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BrandingOptionsEmail(StripWhitespaceForm):
|
|
|
|
|
|
|
2019-09-12 14:33:11 +01:00
|
|
|
|
FALLBACK_OPTION_VALUE = 'something_else'
|
|
|
|
|
|
FALLBACK_OPTION = (FALLBACK_OPTION_VALUE, 'Something else')
|
|
|
|
|
|
|
|
|
|
|
|
options = RadioField('Choose your new email branding')
|
|
|
|
|
|
something_else = TextAreaField('Describe the branding you want')
|
2018-07-17 14:39:04 +01:00
|
|
|
|
|
2019-09-12 13:45:35 +01:00
|
|
|
|
def __init__(self, service, *args, **kwargs):
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
self.options.choices = tuple(self.get_available_choices(service))
|
2019-10-09 16:42:30 +01:00
|
|
|
|
if self.something_else_is_only_option:
|
|
|
|
|
|
self.options.data = self.FALLBACK_OPTION_VALUE
|
2019-09-12 13:45:35 +01:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def get_available_choices(service):
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
2019-09-12 15:03:32 +01:00
|
|
|
|
service.organisation_type == Organisation.TYPE_CENTRAL and
|
2019-09-12 13:45:35 +01:00
|
|
|
|
service.organisation.email_branding_id is None and
|
|
|
|
|
|
service.email_branding_id is not None
|
|
|
|
|
|
):
|
|
|
|
|
|
yield ('govuk', 'GOV.UK')
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
2019-09-12 15:03:32 +01:00
|
|
|
|
service.organisation_type == Organisation.TYPE_CENTRAL and
|
2019-09-12 13:45:35 +01:00
|
|
|
|
service.organisation and
|
|
|
|
|
|
service.organisation.email_branding_id is None and
|
|
|
|
|
|
service.email_branding_name.lower() != 'GOV.UK and {}'.format(service.organisation.name).lower()
|
|
|
|
|
|
):
|
|
|
|
|
|
yield ('govuk_and_org', 'GOV.UK and {}'.format(service.organisation.name))
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
2019-09-12 15:03:32 +01:00
|
|
|
|
service.organisation_type in {
|
|
|
|
|
|
Organisation.TYPE_NHS_CENTRAL,
|
|
|
|
|
|
Organisation.TYPE_NHS_LOCAL,
|
|
|
|
|
|
Organisation.TYPE_NHS_GP,
|
|
|
|
|
|
} and service.email_branding_name != 'NHS'
|
2019-09-12 13:45:35 +01:00
|
|
|
|
):
|
|
|
|
|
|
yield ('nhs', 'NHS')
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
service.organisation and
|
2019-09-12 15:03:32 +01:00
|
|
|
|
service.organisation_type not in {
|
|
|
|
|
|
Organisation.TYPE_NHS_LOCAL,
|
|
|
|
|
|
Organisation.TYPE_NHS_CENTRAL,
|
|
|
|
|
|
Organisation.TYPE_NHS_GP,
|
|
|
|
|
|
} and (
|
2019-09-12 13:45:35 +01:00
|
|
|
|
service.email_branding_id is None or
|
|
|
|
|
|
service.email_branding_id != service.organisation.email_branding_id
|
|
|
|
|
|
)
|
|
|
|
|
|
):
|
|
|
|
|
|
yield ('organisation', service.organisation.name)
|
|
|
|
|
|
|
2019-09-12 14:33:11 +01:00
|
|
|
|
yield BrandingOptionsEmail.FALLBACK_OPTION
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def something_else_is_only_option(self):
|
|
|
|
|
|
return self.options.choices == (self.FALLBACK_OPTION,)
|
|
|
|
|
|
|
|
|
|
|
|
def validate_something_else(self, field):
|
|
|
|
|
|
if (
|
|
|
|
|
|
self.something_else_is_only_option or
|
|
|
|
|
|
self.options.data == self.FALLBACK_OPTION_VALUE
|
|
|
|
|
|
) and not field.data:
|
2019-09-18 14:21:25 +01:00
|
|
|
|
raise ValidationError('Cannot be empty')
|
2019-09-12 14:33:11 +01:00
|
|
|
|
|
|
|
|
|
|
if self.options.data != self.FALLBACK_OPTION_VALUE:
|
|
|
|
|
|
field.data = ''
|
2019-09-12 13:45:35 +01:00
|
|
|
|
|
2018-07-17 14:39:04 +01:00
|
|
|
|
|
|
|
|
|
|
class ServiceDataRetentionForm(StripWhitespaceForm):
|
|
|
|
|
|
|
|
|
|
|
|
notification_type = RadioField(
|
|
|
|
|
|
'What notification type?',
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
('email', 'Email'),
|
|
|
|
|
|
('sms', 'SMS'),
|
|
|
|
|
|
('letter', 'Letter'),
|
|
|
|
|
|
],
|
|
|
|
|
|
validators=[DataRequired()],
|
|
|
|
|
|
)
|
|
|
|
|
|
days_of_retention = IntegerField(label="Days of retention",
|
|
|
|
|
|
validators=[validators.NumberRange(min=3, max=90,
|
|
|
|
|
|
message="Must be between 3 and 90")],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ServiceDataRetentionEditForm(StripWhitespaceForm):
|
|
|
|
|
|
days_of_retention = IntegerField(label="Days of retention",
|
|
|
|
|
|
validators=[validators.NumberRange(min=3, max=90,
|
|
|
|
|
|
message="Must be between 3 and 90")],
|
|
|
|
|
|
)
|
2018-09-06 16:34:23 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ReturnedLettersForm(StripWhitespaceForm):
|
|
|
|
|
|
references = TextAreaField(
|
|
|
|
|
|
u'Letter references',
|
|
|
|
|
|
validators=[
|
2019-09-12 16:42:33 +01:00
|
|
|
|
DataRequired(message="Cannot be empty"),
|
2018-09-06 16:34:23 +01:00
|
|
|
|
]
|
|
|
|
|
|
)
|
2018-11-01 16:02:43 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TemplateFolderForm(StripWhitespaceForm):
|
2019-03-18 16:12:12 +00:00
|
|
|
|
def __init__(self, all_service_users=None, *args, **kwargs):
|
2019-03-15 14:13:27 +00:00
|
|
|
|
super().__init__(*args, **kwargs)
|
2019-03-18 16:12:12 +00:00
|
|
|
|
if all_service_users is not None:
|
|
|
|
|
|
self.users_with_permission.all_service_users = all_service_users
|
|
|
|
|
|
self.users_with_permission.choices = [
|
|
|
|
|
|
(item.id, item.name) for item in all_service_users
|
2019-03-15 14:13:27 +00:00
|
|
|
|
]
|
|
|
|
|
|
|
2019-04-01 17:23:16 +01:00
|
|
|
|
users_with_permission = MultiCheckboxField('Team members who can see this folder')
|
2019-09-12 16:42:33 +01:00
|
|
|
|
name = StringField('Folder name', validators=[DataRequired(message='Cannot be empty')])
|
2018-11-08 11:56:29 +00:00
|
|
|
|
|
|
|
|
|
|
|
2018-11-20 18:03:57 +00:00
|
|
|
|
def required_for_ops(*operations):
|
|
|
|
|
|
operations = set(operations)
|
|
|
|
|
|
|
|
|
|
|
|
def validate(form, field):
|
2018-12-07 16:38:48 +00:00
|
|
|
|
if form.op not in operations and any(field.raw_data):
|
2018-11-20 18:03:57 +00:00
|
|
|
|
# super weird
|
2018-12-07 16:38:48 +00:00
|
|
|
|
raise validators.StopValidation('Must be empty')
|
|
|
|
|
|
if form.op in operations and not any(field.raw_data):
|
2019-09-12 16:42:33 +01:00
|
|
|
|
raise validators.StopValidation('Cannot be empty')
|
2018-11-20 18:03:57 +00:00
|
|
|
|
return validate
|
|
|
|
|
|
|
|
|
|
|
|
|
2018-11-08 11:56:29 +00:00
|
|
|
|
class TemplateAndFoldersSelectionForm(Form):
|
2018-11-27 14:33:50 +00:00
|
|
|
|
"""
|
2018-12-07 16:38:48 +00:00
|
|
|
|
This form expects the form data to include an operation, based on which submit button is clicked.
|
2018-11-27 14:33:50 +00:00
|
|
|
|
If enter is pressed, unknown will be sent by a hidden submit button at the top of the form.
|
|
|
|
|
|
The value of this operation affects which fields are required, expected to be empty, or optional.
|
|
|
|
|
|
|
|
|
|
|
|
* unknown
|
|
|
|
|
|
currently not implemented, but in the future will try and work out if there are any obvious commands that can be
|
|
|
|
|
|
assumed based on which fields are empty vs populated.
|
2018-12-04 14:47:05 +00:00
|
|
|
|
* move-to-existing-folder
|
2018-11-27 14:33:50 +00:00
|
|
|
|
must have data for templates_and_folders checkboxes, and move_to radios
|
2018-12-04 14:47:05 +00:00
|
|
|
|
* move-to-new-folder
|
2018-11-27 14:33:50 +00:00
|
|
|
|
must have data for move_to_new_folder_name, cannot have data for move_to_existing_folder_name
|
2018-12-04 14:47:05 +00:00
|
|
|
|
* add-new-folder
|
2018-11-27 14:33:50 +00:00
|
|
|
|
must have data for move_to_existing_folder_name, cannot have data for move_to_new_folder_name
|
|
|
|
|
|
"""
|
2018-11-08 11:56:29 +00:00
|
|
|
|
|
|
|
|
|
|
ALL_TEMPLATES_FOLDER = {
|
2019-01-04 13:34:15 +00:00
|
|
|
|
'name': 'Templates',
|
2018-11-12 09:04:39 +00:00
|
|
|
|
'id': RadioFieldWithNoneOption.NONE_OPTION_VALUE,
|
2018-11-08 11:56:29 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
2018-11-23 16:29:21 +00:00
|
|
|
|
all_template_folders,
|
|
|
|
|
|
template_list,
|
2018-11-28 13:48:13 +00:00
|
|
|
|
allow_adding_letter_template,
|
|
|
|
|
|
allow_adding_copy_of_template,
|
2018-11-08 11:56:29 +00:00
|
|
|
|
*args,
|
|
|
|
|
|
**kwargs
|
|
|
|
|
|
):
|
|
|
|
|
|
|
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
|
2018-11-23 16:29:21 +00:00
|
|
|
|
self.templates_and_folders.choices = template_list.as_id_and_name
|
2018-11-08 11:56:29 +00:00
|
|
|
|
|
2018-11-20 18:03:57 +00:00
|
|
|
|
self.op = None
|
2018-11-28 13:48:13 +00:00
|
|
|
|
self.is_move_op = self.is_add_folder_op = self.is_add_template_op = False
|
2018-11-15 17:31:07 +00:00
|
|
|
|
|
2018-12-18 14:40:53 +00:00
|
|
|
|
self.move_to.all_template_folders = all_template_folders
|
2018-11-23 16:29:21 +00:00
|
|
|
|
self.move_to.choices = [
|
|
|
|
|
|
(item['id'], item['name'])
|
|
|
|
|
|
for item in ([self.ALL_TEMPLATES_FOLDER] + all_template_folders)
|
2018-11-08 11:56:29 +00:00
|
|
|
|
]
|
|
|
|
|
|
|
2018-11-30 15:02:55 +00:00
|
|
|
|
self.add_template_by_template_type.choices = list(filter(None, [
|
2019-02-20 14:39:40 +00:00
|
|
|
|
('email', 'Email'),
|
|
|
|
|
|
('sms', 'Text message'),
|
|
|
|
|
|
('letter', 'Letter') if allow_adding_letter_template else None,
|
|
|
|
|
|
('copy-existing', 'Copy an existing template') if allow_adding_copy_of_template else None,
|
2018-11-30 15:02:55 +00:00
|
|
|
|
]))
|
2018-11-28 13:48:13 +00:00
|
|
|
|
|
2018-11-30 16:31:42 +00:00
|
|
|
|
def is_selected(self, template_folder_id):
|
|
|
|
|
|
return template_folder_id in (self.templates_and_folders.data or [])
|
|
|
|
|
|
|
2018-11-15 17:31:07 +00:00
|
|
|
|
def validate(self):
|
2018-11-20 18:03:57 +00:00
|
|
|
|
self.op = request.form.get('operation')
|
2018-11-15 17:31:07 +00:00
|
|
|
|
|
2018-12-04 14:47:05 +00:00
|
|
|
|
self.is_move_op = self.op in {'move-to-existing-folder', 'move-to-new-folder'}
|
|
|
|
|
|
self.is_add_folder_op = self.op in {'add-new-folder', 'move-to-new-folder'}
|
|
|
|
|
|
self.is_add_template_op = self.op in {'add-new-template'}
|
2018-11-15 17:31:07 +00:00
|
|
|
|
|
2018-11-28 13:48:13 +00:00
|
|
|
|
if not (self.is_add_folder_op or self.is_move_op or self.is_add_template_op):
|
2018-11-15 17:31:07 +00:00
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
return super().validate()
|
|
|
|
|
|
|
2018-11-20 18:03:57 +00:00
|
|
|
|
def get_folder_name(self):
|
2018-12-04 14:47:05 +00:00
|
|
|
|
if self.op == 'add-new-folder':
|
2018-11-20 18:03:57 +00:00
|
|
|
|
return self.add_new_folder_name.data
|
2018-12-04 14:47:05 +00:00
|
|
|
|
elif self.op == 'move-to-new-folder':
|
2018-11-20 18:03:57 +00:00
|
|
|
|
return self.move_to_new_folder_name.data
|
|
|
|
|
|
return None
|
2018-11-15 17:31:07 +00:00
|
|
|
|
|
2018-11-20 18:03:57 +00:00
|
|
|
|
templates_and_folders = MultiCheckboxField('Choose templates or folders', validators=[
|
2018-12-04 14:47:05 +00:00
|
|
|
|
required_for_ops('move-to-new-folder', 'move-to-existing-folder')
|
2018-11-20 18:03:57 +00:00
|
|
|
|
])
|
2019-01-03 14:58:20 +00:00
|
|
|
|
# if no default set, it is set to None, which process_data transforms to '__NONE__'
|
|
|
|
|
|
# this means '__NONE__' (self.ALL_TEMPLATES option) is selected when no form data has been submitted
|
|
|
|
|
|
# set default to empty string so process_data method doesn't perform any transformation
|
2018-12-18 14:40:53 +00:00
|
|
|
|
move_to = NestedRadioField(
|
|
|
|
|
|
'Choose a folder',
|
2019-01-02 17:12:18 +00:00
|
|
|
|
default='',
|
2018-12-18 14:40:53 +00:00
|
|
|
|
validators=[
|
2019-01-03 14:58:20 +00:00
|
|
|
|
required_for_ops('move-to-existing-folder'),
|
|
|
|
|
|
Optional()
|
2018-12-18 14:40:53 +00:00
|
|
|
|
])
|
2018-12-04 14:47:05 +00:00
|
|
|
|
add_new_folder_name = StringField('Folder name', validators=[required_for_ops('add-new-folder')])
|
|
|
|
|
|
move_to_new_folder_name = StringField('Folder name', validators=[required_for_ops('move-to-new-folder')])
|
2018-11-28 13:48:13 +00:00
|
|
|
|
|
2019-02-20 14:39:40 +00:00
|
|
|
|
add_template_by_template_type = RadioFieldWithRequiredMessage('New template', validators=[
|
2018-12-07 16:38:48 +00:00
|
|
|
|
required_for_ops('add-new-template'),
|
2018-12-03 17:29:23 +00:00
|
|
|
|
Optional(),
|
2018-12-07 17:09:15 +00:00
|
|
|
|
], required_message='Select the type of template you want to add')
|
2019-02-15 10:27:38 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ClearCacheForm(StripWhitespaceForm):
|
|
|
|
|
|
model_type = RadioField(
|
|
|
|
|
|
'What do you want to clear today',
|
|
|
|
|
|
validators=[DataRequired()]
|
|
|
|
|
|
)
|
2019-05-13 14:50:40 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GoLiveNotesForm(StripWhitespaceForm):
|
|
|
|
|
|
request_to_go_live_notes = TextAreaField(
|
|
|
|
|
|
'Go live notes',
|
|
|
|
|
|
filters=[lambda x: x or None],
|
|
|
|
|
|
)
|
2019-06-18 14:24:29 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AcceptAgreementForm(StripWhitespaceForm):
|
|
|
|
|
|
|
2019-06-18 15:26:04 +01:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_organisation(cls, org):
|
|
|
|
|
|
|
|
|
|
|
|
if org.agreement_signed_on_behalf_of_name and org.agreement_signed_on_behalf_of_email_address:
|
|
|
|
|
|
who = 'someone-else'
|
|
|
|
|
|
elif org.agreement_signed_version: # only set if user has submitted form previously
|
|
|
|
|
|
who = 'me'
|
|
|
|
|
|
else:
|
|
|
|
|
|
who = None
|
|
|
|
|
|
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
version=org.agreement_signed_version,
|
|
|
|
|
|
who=who,
|
|
|
|
|
|
on_behalf_of_name=org.agreement_signed_on_behalf_of_name,
|
|
|
|
|
|
on_behalf_of_email=org.agreement_signed_on_behalf_of_email_address,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2019-06-18 14:24:29 +01:00
|
|
|
|
version = StringField(
|
2019-07-18 10:46:01 +01:00
|
|
|
|
'Which version of the agreement do you want to accept?'
|
2019-06-18 14:24:29 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
who = RadioField(
|
2019-07-18 15:58:24 +01:00
|
|
|
|
'Who are you accepting the agreement for?',
|
2019-06-18 14:24:29 +01:00
|
|
|
|
choices=(
|
|
|
|
|
|
(
|
|
|
|
|
|
'me',
|
2019-07-18 15:58:24 +01:00
|
|
|
|
'Yourself',
|
2019-06-18 14:24:29 +01:00
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
'someone-else',
|
2019-07-18 15:58:24 +01:00
|
|
|
|
'Someone else',
|
2019-06-18 14:24:29 +01:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
validators=[DataRequired()],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
on_behalf_of_name = StringField(
|
2019-07-18 10:46:01 +01:00
|
|
|
|
'What’s their name?'
|
2019-06-18 14:24:29 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
on_behalf_of_email = email_address(
|
|
|
|
|
|
'What’s their email address?',
|
|
|
|
|
|
required=False,
|
|
|
|
|
|
gov_user=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2019-06-18 15:26:04 +01:00
|
|
|
|
def __validate_if_nominating(self, field):
|
|
|
|
|
|
if self.who.data == 'someone-else':
|
|
|
|
|
|
if not field.data:
|
2019-09-12 16:42:33 +01:00
|
|
|
|
raise ValidationError('Cannot be empty')
|
2019-06-18 15:26:04 +01:00
|
|
|
|
else:
|
|
|
|
|
|
field.data = ''
|
|
|
|
|
|
|
|
|
|
|
|
validate_on_behalf_of_name = __validate_if_nominating
|
|
|
|
|
|
validate_on_behalf_of_email = __validate_if_nominating
|
|
|
|
|
|
|
2019-06-18 14:24:29 +01:00
|
|
|
|
def validate_version(self, field):
|
|
|
|
|
|
try:
|
|
|
|
|
|
float(field.data)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
raise ValidationError("Must be a number")
|