diff --git a/app/__init__.py b/app/__init__.py index cac27c73d..04029a83f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -35,6 +35,7 @@ from notifications_utils.recipients import ( ) from notifications_utils.formatters import formatted_list from notifications_utils.sanitise_text import SanitiseASCII +from notifications_utils.timezones import utc_string_to_aware_gmt_datetime from werkzeug.exceptions import abort, HTTPException as WerkzeugHTTPException from werkzeug.local import LocalProxy @@ -69,7 +70,7 @@ from app.notify_client.complaint_api_client import complaint_api_client from app.notify_client.platform_stats_api_client import platform_stats_api_client from app.notify_client.template_folder_api_client import template_folder_api_client from app.commands import setup_commands -from app.utils import get_cdn_domain, gmt_timezones, id_safe +from app.utils import get_cdn_domain, id_safe login_manager = LoginManager() csrf = CSRFProtect() @@ -256,17 +257,17 @@ def format_datetime_numeric(date): def format_date_numeric(date): - return gmt_timezones(date).strftime('%Y-%m-%d') + return utc_string_to_aware_gmt_datetime(date).strftime('%Y-%m-%d') def format_time_24h(date): - return gmt_timezones(date).strftime('%H:%M') + return utc_string_to_aware_gmt_datetime(date).strftime('%H:%M') def get_human_day(time): # Add 1 minute to transform 00:00 into ‘midnight today’ instead of ‘midnight tomorrow’ - date = (gmt_timezones(time) - timedelta(minutes=1)).date() + date = (utc_string_to_aware_gmt_datetime(time) - timedelta(minutes=1)).date() if date == (datetime.utcnow() + timedelta(days=1)).date(): return 'tomorrow' if date == datetime.utcnow().date(): @@ -281,21 +282,21 @@ def format_time(date): '12:00AM': 'Midnight', '12:00PM': 'Midday' }.get( - gmt_timezones(date).strftime('%-I:%M%p'), - gmt_timezones(date).strftime('%-I:%M%p') + utc_string_to_aware_gmt_datetime(date).strftime('%-I:%M%p'), + utc_string_to_aware_gmt_datetime(date).strftime('%-I:%M%p') ).lower() def format_date(date): - return gmt_timezones(date).strftime('%A %d %B %Y') + return utc_string_to_aware_gmt_datetime(date).strftime('%A %d %B %Y') def format_date_normal(date): - return gmt_timezones(date).strftime('%d %B %Y').lstrip('0') + return utc_string_to_aware_gmt_datetime(date).strftime('%d %B %Y').lstrip('0') def format_date_short(date): - return _format_datetime_short(gmt_timezones(date)) + return _format_datetime_short(utc_string_to_aware_gmt_datetime(date)) def _format_datetime_short(datetime): @@ -306,7 +307,7 @@ def format_delta(date): delta = ( datetime.now(timezone.utc) ) - ( - gmt_timezones(date) + utc_string_to_aware_gmt_datetime(date) ) if delta < timedelta(seconds=30): return "just now" diff --git a/app/assets/javascripts/liveSearch.js b/app/assets/javascripts/liveSearch.js index 8f53f3f52..0968156c0 100644 --- a/app/assets/javascripts/liveSearch.js +++ b/app/assets/javascripts/liveSearch.js @@ -9,7 +9,12 @@ $targets.each(function() { - let content = $(this).text(); + let content = $('.live-search-relevant', this).text() || $(this).text(); + + if (query == '') { + $(this).css('display', ''); + return; + } $(this).toggle( normalize(content).indexOf(normalize(query)) > -1 diff --git a/app/assets/stylesheets/components/message.scss b/app/assets/stylesheets/components/message.scss index ec44dd8a7..69baaf689 100644 --- a/app/assets/stylesheets/components/message.scss +++ b/app/assets/stylesheets/components/message.scss @@ -5,9 +5,31 @@ margin: 0; a { - display: block; + display: inline; margin-bottom: -$gutter; padding-bottom: $gutter; + + &:focus { + + // Use box shadow instead of outline to avoid buggy outline + // rendering in Firefox + outline: none; + box-shadow: 0 0 0 3px $yellow; + + @include ie-lte(8) { + // Box shadow is not supported in IE8 + outline: 3px solid $yellow; + } + + } + } + + &-separator { + display: inline-block; + vertical-align: top; + color: $secondary-text-colour; + padding: 0 4px 0 5px; + font-weight: normal; } } @@ -40,11 +62,32 @@ } + &-hidden-by-default { + display: none; + } + + &-without-ancestors { + + .message-name { + + a { + display: block; + + &:first-child { + display: block; + } + } + + } + + } + } &-folder { - a { + a:first-child { + display: inline-block; text-indent: 40px; background-image: file-url('folder-blue-bold.svg'); background-repeat: no-repeat; diff --git a/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss b/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss index ec75473d3..34bb42526 100644 --- a/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss +++ b/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss @@ -48,6 +48,11 @@ .page-footer { margin-bottom: 0; + min-height: 50px; + } + + .notification-status { + margin: 0; } } diff --git a/app/assets/stylesheets/views/users.scss b/app/assets/stylesheets/views/users.scss index 4854de20f..efbf79c31 100644 --- a/app/assets/stylesheets/views/users.scss +++ b/app/assets/stylesheets/views/users.scss @@ -11,6 +11,19 @@ $item-top-padding: $gutter-half; border-top: 1px solid $border-colour; position: relative; + h3 { + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: $secondary-text-colour; // So the ellipsis is grey + + .heading-small { + color: $black; + } + + } + &:last-child { border-bottom: 1px solid $border-colour; } diff --git a/app/config.py b/app/config.py index f446bd7ed..0325b4ce3 100644 --- a/app/config.py +++ b/app/config.py @@ -124,7 +124,7 @@ class Staging(Config): HTTP_PROTOCOL = 'https' HEADER_COLOUR = '#6F72AF' # $mauve STATSD_ENABLED = True - CSV_UPLOAD_BUCKET_NAME = 'staging-notify-csv-upload' + CSV_UPLOAD_BUCKET_NAME = 'staging-notifications-csv-upload' LOGO_UPLOAD_BUCKET_NAME = 'public-logos-staging' MOU_BUCKET_NAME = 'staging-notify.works-mou' NOTIFY_ENVIRONMENT = 'staging' diff --git a/app/domains.yml b/app/domains.yml index 839e14aa4..a3ce9d7d5 100644 --- a/app/domains.yml +++ b/app/domains.yml @@ -2374,7 +2374,7 @@ internalauditscotland.gov.uk: inverclyde.gov.uk: owner: Inverclyde Council crown: false - agreement_signed: false + agreement_signed: true iow.gov.uk: owner: Isle of Wight Council crown: false @@ -2629,7 +2629,7 @@ lincoln.gov.uk: lincolnshire.gov.uk: owner: Lincolnshire County Council crown: false - agreement_signed: false + agreement_signed: true lincsbc.gov.uk: owner: East Lindsey District Council crown: false @@ -3246,7 +3246,7 @@ northtyneside.gov.uk: northumberland.gov.uk: owner: Northumberland County Council crown: false - agreement_signed: false + agreement_signed: true northwalesadoption.gov.uk: owner: Wrexham County Borough Council crown: false @@ -3494,11 +3494,8 @@ pkc.gov.uk: plymouth.gov.uk: owner: Plymouth City Council crown: false - agreement_signed: false -plymouthmuseum.gov.uk: - owner: Plymouth City Council - crown: false - agreement_signed: false + agreement_signed: true +plymouthmuseum.gov.uk: plymouth.gov.uk pocklington.gov.uk: owner: Pocklington Town Council crown: false @@ -4659,7 +4656,7 @@ towcester-tc.gov.uk: towerhamlets.gov.uk: owner: Tower Hamlets London Borough Council crown: false - agreement_signed: false + agreement_signed: true towynkinmelbay-tc.gov.uk: owner: Towyn and Kinmel Bay Town Council crown: false @@ -5070,6 +5067,10 @@ wixford-pc.gov.uk: owner: Stratford-on-Avon District Council crown: false agreement_signed: false +wmfs.net: + owner: West Midlands Fire Service + crown: false + agreement_signed: true woking.gov.uk: owner: Woking Borough Council crown: false diff --git a/app/email_domains.yml b/app/email_domains.yml index 115926e42..dcdcbf782 100644 --- a/app/email_domains.yml +++ b/app/email_domains.yml @@ -40,3 +40,4 @@ - hscni.net - bi.team - networkrail.co.uk +- uksbs.co.uk diff --git a/app/main/forms.py b/app/main/forms.py index b5299c0cb..b4ddb4b6a 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -1143,8 +1143,8 @@ class TemplateAndFoldersSelectionForm(Form): def __init__( self, - service, - template_type, + all_template_folders, + template_list, current_folder_id, *args, **kwargs @@ -1152,20 +1152,12 @@ class TemplateAndFoldersSelectionForm(Form): super().__init__(*args, **kwargs) - self.templates_and_folders.choices = self.ids_and_names( - service.get_template_folders_and_templates(template_type, current_folder_id) - ) + self.templates_and_folders.choices = template_list.as_id_and_name - self.move_to.choices = self.ids_and_names( - [self.ALL_TEMPLATES_FOLDER] + service.all_template_folders, - exclude=current_folder_id, - ) - - @staticmethod - def ids_and_names(items, exclude=None): - return [ - (item['id'], item['name']) for item in items - if item['id'] != str(exclude) + self.move_to.choices = [ + (item['id'], item['name']) + for item in ([self.ALL_TEMPLATES_FOLDER] + all_template_folders) + if item['id'] != str(current_folder_id) ] templates_and_folders = MultiCheckboxField('Choose templates or folders') diff --git a/app/main/views/notifications.py b/app/main/views/notifications.py index 03187cb24..d9ff30463 100644 --- a/app/main/views/notifications.py +++ b/app/main/views/notifications.py @@ -65,6 +65,7 @@ def view_notification(service_id, notification_id): notification_id=notification_id, filetype='png', ), + expand_emails=True, page_count=page_count, show_recipient=True, redact_missing_personalisation=True, diff --git a/app/main/views/service_settings.py b/app/main/views/service_settings.py index 6924b74e0..6166d61cc 100644 --- a/app/main/views/service_settings.py +++ b/app/main/views/service_settings.py @@ -552,8 +552,12 @@ def service_set_letters(service_id): @main.route("/services//service-settings/set-postage", methods=['GET', 'POST']) @login_required -@user_is_platform_admin +@user_has_permissions('manage_service') def service_set_postage(service_id): + + if not current_service.has_permission('letter'): + abort(404) + form = ServicePostageForm(postage=current_service.postage) if form.validate_on_submit(): diff --git a/app/main/views/templates.py b/app/main/views/templates.py index aa9ea60c9..4b5c43f85 100644 --- a/app/main/views/templates.py +++ b/app/main/views/templates.py @@ -29,6 +29,7 @@ from app.main.forms import ( ) from app.main.views.send import get_example_csv_rows, get_sender_details from app.models.service import Service +from app.models.template_list import TemplateList from app.template_previews import TemplatePreview, get_page_count_for_letter from app.utils import ( email_or_sms_not_enabled, @@ -108,8 +109,11 @@ def start_tour(service_id, template_id): @user_has_permissions() def choose_template(service_id, template_type='all', template_folder_id=None): + template_list = TemplateList(current_service, template_type, template_folder_id) + templates_and_folders_form = TemplateAndFoldersSelectionForm( - service=current_service, + all_template_folders=current_service.all_template_folders, + template_list=template_list, template_type=template_type, current_folder_id=template_folder_id, ) @@ -126,9 +130,7 @@ def choose_template(service_id, template_type='all', template_folder_id=None): current_template_folder_id=template_folder_id, can_manage_folders=can_manage_folders(), template_folder_path=current_service.get_template_folder_path(template_folder_id), - template_folder_has_contents=current_service.get_template_folders_and_templates('all', template_folder_id), - template_folders=current_service.get_template_folders(template_type, template_folder_id), - templates=current_service.get_templates(template_type, template_folder_id), + template_list=template_list, show_search_box=current_service.count_of_templates_and_folders > 7, show_template_nav=( current_service.has_multiple_template_types @@ -318,7 +320,7 @@ def copy_template(service_id, template_id): return add_service_template(service_id, template['template_type']) template['template_content'] = template['content'] - template['name'] = 'Copy of ‘{}’'.format(template['name']) + template['name'] = _get_template_copy_name(template, current_service.all_templates) form = form_objects[template['template_type']](**template) return render_template( @@ -330,6 +332,20 @@ def copy_template(service_id, template_id): ) +def _get_template_copy_name(template, existing_templates): + + template_names = [existing['name'] for existing in existing_templates] + + for index in reversed(range(1, 10)): + if '{} (copy {})'.format(template['name'], index) in template_names: + return '{} (copy {})'.format(template['name'], index + 1) + + if '{} (copy)'.format(template['name']) in template_names: + return '{} (copy 2)'.format(template['name']) + + return '{} (copy)'.format(template['name']) + + @main.route("/services//templates/action-blocked///") @login_required @user_has_permissions('manage_templates') @@ -405,16 +421,18 @@ def manage_template_folder(service_id, template_folder_id): @login_required @user_has_permissions('manage_templates') def delete_template_folder(service_id, template_folder_id): + if not current_service.has_permission('edit_folders'): abort(403) - form = TemplateFolderForm() - template_folder_path = current_service.get_template_folder_path(template_folder_id) - template_folder_name = template_folder_path[-1]["name"] + + template_folder = current_service.get_template_folder(template_folder_id) + + form = TemplateFolderForm(name=template_folder['name']) if len(current_service.get_template_folders_and_templates( template_type="all", template_folder_id=template_folder_id )) > 0: - flash("You must empty this folder before you can delete it".format(template_folder_name), 'info') + flash("You must empty this folder before you can delete it".format(template_folder['name']), 'info') return redirect( url_for( '.choose_template', service_id=service_id, template_type="all", template_folder_id=template_folder_id @@ -426,12 +444,12 @@ def delete_template_folder(service_id, template_folder_id): template_folder_api_client.delete_template_folder(current_service.id, template_folder_id) return redirect( - url_for('.choose_template', service_id=service_id) + url_for('.choose_template', service_id=service_id, template_folder_id=template_folder['parent_id']) ) except HTTPError as e: msg = "Folder is not empty" if e.status_code == 400 and msg in e.message: - flash("You must empty this folder before you can delete it".format(template_folder_name), 'info') + flash("You must empty this folder before you can delete it", 'info') return redirect( url_for( '.choose_template', @@ -443,15 +461,14 @@ def delete_template_folder(service_id, template_folder_id): else: abort(500, e) - flash("Are you sure you want to delete the ‘{}’ folder?".format(template_folder_name), 'delete') + flash("Are you sure you want to delete the ‘{}’ folder?".format(template_folder['name']), 'delete') return render_template( 'views/templates/manage-template-folder.html', form=form, - template_folder_path=template_folder_path, + template_folder_path=current_service.get_template_folder_path(template_folder_id), current_service_id=current_service.id, template_folder_id=template_folder_id, template_type="all", - delete_folder=True ) diff --git a/app/models/service.py b/app/models/service.py index 66d279430..8a04d79e5 100644 --- a/app/models/service.py +++ b/app/models/service.py @@ -369,7 +369,7 @@ class Service(): @property def count_of_templates_and_folders(self): - return len(self.get_template_folders_and_templates('all', None)) + return len(self.all_templates + self.all_template_folders) def move_to_folder(self, ids_to_move, move_to): diff --git a/app/models/template_list.py b/app/models/template_list.py new file mode 100644 index 000000000..329ceab1f --- /dev/null +++ b/app/models/template_list.py @@ -0,0 +1,124 @@ +class TemplateList(): + + def __init__( + self, + service, + template_type='all', + template_folder_id=None, + ): + self.service = service + self.template_type = template_type + self.template_folder_id = template_folder_id + + def __iter__(self): + for item in self.get_templates_and_folders( + self.template_type, self.template_folder_id, ancestors=[] + ): + yield item + + def get_templates_and_folders(self, template_type, template_folder_id, ancestors): + + for item in self.service.get_template_folders( + template_type, template_folder_id + ): + yield TemplateListFolder( + item, + folders=self.service.get_template_folders( + self.template_type, item['id'] + ), + templates=self.service.get_templates( + self.template_type, item['id'] + ), + ancestors=ancestors, + ) + for sub_item in self.get_templates_and_folders( + template_type, item['id'], ancestors + [item] + ): + yield sub_item + + for item in self.service.get_templates( + self.template_type, template_folder_id + ): + yield TemplateListTemplate( + item, + ancestors=ancestors, + ) + + @property + def as_id_and_name(self): + return [(item.id, item.name) for item in self] + + @property + def templates_to_show(self): + return any(self) + + @property + def folder_is_empty(self): + return not any(self.get_templates_and_folders( + 'all', self.template_folder_id, [] + )) + + +class TemplateListItem(): + + def __init__( + self, + template_or_folder, + ancestors, + ): + self.id = template_or_folder['id'] + self.name = template_or_folder['name'] + self.ancestors = ancestors + + +class TemplateListTemplate(TemplateListItem): + + is_folder = False + + def __init__( + self, + template, + ancestors, + ): + super().__init__(template, ancestors) + self.hint = { + 'email': 'Email template', + 'sms': 'Text message template', + 'letter': 'Letter template', + }.get(template['template_type']) + + +class TemplateListFolder(TemplateListItem): + + is_folder = True + + def __init__( + self, + folder, + templates, + folders, + ancestors, + ): + super().__init__(folder, ancestors) + self.number_of_templates = len(templates) + self.number_of_folders = len(folders) + + @property + def _hint_parts(self): + + if self.number_of_folders == self.number_of_templates == 0: + yield 'Empty' + + if self.number_of_templates == 1: + yield '1 template' + elif self.number_of_templates > 1: + yield '{} templates'.format(self.number_of_templates) + + if self.number_of_folders == 1: + yield '1 folder' + elif self.number_of_folders > 1: + yield '{} folders'.format(self.number_of_folders) + + @property + def hint(self): + return ', '.join(self._hint_parts) diff --git a/app/templates/components/message-count-label.html b/app/templates/components/message-count-label.html index 0e5cfeedc..14b997665 100644 --- a/app/templates/components/message-count-label.html +++ b/app/templates/components/message-count-label.html @@ -55,26 +55,3 @@ {%- endif -%} {%- endif %} {%- endmacro %} - - -{% macro folder_contents_count(number_of_folders, number_of_templates) %} - - {% if number_of_folders == number_of_templates == 0 %} - Empty - {% endif %} - - {% if number_of_templates == 1 %} - {{ number_of_templates }} template - {%- elif number_of_templates > 1 -%} - {{ number_of_templates }} templates - {%- endif -%} - - {%- if number_of_folders and number_of_templates %}, {% endif -%} - - {%- if number_of_folders == 1 -%} - {{ number_of_folders }} folder - {%- elif number_of_folders > 1 -%} - {{ number_of_folders }} folders - {% endif %} - -{%- endmacro %} diff --git a/app/templates/views/check/ok.html b/app/templates/views/check/ok.html index b35e6a040..52804960e 100644 --- a/app/templates/views/check/ok.html +++ b/app/templates/views/check/ok.html @@ -39,7 +39,7 @@ {% if template.template_type != 'letter' or not request.args.from_test %} {% else %} - Download as a printable PDF + Download as a PDF {% endif %} Back diff --git a/app/templates/views/manage-users.html b/app/templates/views/manage-users.html index 577abcc64..5de9f9e95 100644 --- a/app/templates/views/manage-users.html +++ b/app/templates/views/manage-users.html @@ -33,7 +33,7 @@
{% for user in users %}
-

+

{%- if user.name -%} {{ user.name }}  {%- endif -%} diff --git a/app/templates/views/notifications/check.html b/app/templates/views/notifications/check.html index 8ab9434cc..21854b4b7 100644 --- a/app/templates/views/notifications/check.html +++ b/app/templates/views/notifications/check.html @@ -65,7 +65,7 @@ {% endif %} Back {% if template.template_type == 'letter' %} - Download as a printable PDF + Download as a PDF {% endif %}

diff --git a/app/templates/views/notifications/notification.html b/app/templates/views/notifications/notification.html index 15fbc194a..d0e8c9613 100644 --- a/app/templates/views/notifications/notification.html +++ b/app/templates/views/notifications/notification.html @@ -51,15 +51,23 @@

Estimated delivery date: {{ estimated_letter_delivery_date|string|format_date_short }}

-

- Download as a PDF -

{% endif %} {% endif %} {{ template|string }} - {% if template.template_type != 'letter' %} + {% if template.template_type == 'letter' %} +
+ +
+ {% elif template.template_type == 'email' %} +
+ {{ ajax_block(partials, updates_url, 'status', finished=finished) }} +
+ {% elif template.template_type == 'sms' %} {{ ajax_block(partials, updates_url, 'status', finished=finished) }} {% endif %} diff --git a/app/templates/views/service-settings.html b/app/templates/views/service-settings.html index e57fbf2fa..977956be1 100644 --- a/app/templates/views/service-settings.html +++ b/app/templates/views/service-settings.html @@ -234,20 +234,18 @@ )}} {% endcall %} - {% if current_user.platform_admin %} - {% call settings_row(if_has_permission='letter') %} - {{ text_field('Postage') }} - {% set postage = {'first': 'First class only', 'second': 'Second class only'} %} - {{ text_field(postage[current_service.postage]) }} - {{ edit_field( - 'Change', - url_for('.service_set_postage', - service_id=current_service.id), - permissions=['manage_service'] - ) - }} - {% endcall %} - {% endif %} + {% call settings_row(if_has_permission='letter') %} + {{ text_field('Postage') }} + {% set postage = {'first': 'First class only', 'second': 'Second class only'} %} + {{ text_field(postage[current_service.postage]) }} + {{ edit_field( + 'Change', + url_for('.service_set_postage', + service_id=current_service.id), + permissions=['manage_service'] + ) + }} + {% endcall %} {% endcall %}
diff --git a/app/templates/views/templates/_move_to.html b/app/templates/views/templates/_move_to.html index 4b9564c3d..10ab965a2 100644 --- a/app/templates/views/templates/_move_to.html +++ b/app/templates/views/templates/_move_to.html @@ -1,7 +1,7 @@ {% from "components/radios.html" import radios %} {% from "components/page-footer.html" import page_footer %} -{% if templates_and_folders_form.move_to.choices and (templates or template_folders) %} +{% if templates_and_folders_form.move_to.choices and template_list.templates_to_show %} {{ radios(templates_and_folders_form.move_to) }} {{ page_footer('Move selected', button_name='operation', button_value='move') }} {% endif %} diff --git a/app/templates/views/templates/_template_list.html b/app/templates/views/templates/_template_list.html index 3c1cb7555..f26e32b0d 100644 --- a/app/templates/views/templates/_template_list.html +++ b/app/templates/views/templates/_template_list.html @@ -1,52 +1,43 @@ {% from "components/checkbox.html" import unlabelled_checkbox %} {% from "components/message-count-label.html" import folder_contents_count, message_count_label %} -{% if service_has_templates_or_folders and not templates and not template_folders %} +{% if not template_list.templates_to_show %}

- {% if template_folder_has_contents %} - There are no {{ message_count_label(1, template_type, suffix='') }} templates in this folder - {% else %} + {% if template_list.folder_is_empty %} This folder is empty + {% else %} + There are no {{ message_count_label(1, template_type, suffix='') }} templates in this folder {% endif %}

{% else %}