2018-02-06 11:02:54 +00:00
|
|
|
|
import os
|
2021-01-06 12:12:01 +00:00
|
|
|
|
from datetime import datetime, timedelta
|
2018-02-20 11:22:17 +00:00
|
|
|
|
from functools import wraps
|
|
|
|
|
|
from itertools import chain
|
|
|
|
|
|
from urllib.parse import urlparse
|
2016-10-27 17:31:13 +01:00
|
|
|
|
|
2020-05-11 10:52:30 +01:00
|
|
|
|
import pytz
|
2019-06-18 18:00:36 +01:00
|
|
|
|
from dateutil import parser
|
2020-05-27 09:03:14 +01:00
|
|
|
|
from flask import (
|
|
|
|
|
|
abort,
|
|
|
|
|
|
current_app,
|
|
|
|
|
|
g,
|
|
|
|
|
|
make_response,
|
|
|
|
|
|
redirect,
|
|
|
|
|
|
request,
|
|
|
|
|
|
session,
|
|
|
|
|
|
url_for,
|
|
|
|
|
|
)
|
2019-07-01 15:22:08 +01:00
|
|
|
|
from flask_login import current_user, login_required
|
2018-08-23 16:11:08 +01:00
|
|
|
|
from notifications_utils.field import Field
|
2021-01-06 12:12:01 +00:00
|
|
|
|
from notifications_utils.formatters import unescaped_formatted_list
|
2019-06-18 18:00:36 +01:00
|
|
|
|
from notifications_utils.letter_timings import letter_can_be_cancelled
|
2020-04-03 14:13:44 +01:00
|
|
|
|
from notifications_utils.postal_address import PostalAddress
|
2018-02-16 11:35:36 +00:00
|
|
|
|
from notifications_utils.recipients import RecipientCSV
|
2016-12-08 11:50:59 +00:00
|
|
|
|
from notifications_utils.template import (
|
2020-07-01 17:43:30 +01:00
|
|
|
|
BroadcastPreviewTemplate,
|
2016-12-08 11:50:59 +00:00
|
|
|
|
EmailPreviewTemplate,
|
2017-04-28 16:04:52 +01:00
|
|
|
|
LetterImageTemplate,
|
2016-12-20 14:38:34 +00:00
|
|
|
|
LetterPreviewTemplate,
|
2018-02-20 11:22:17 +00:00
|
|
|
|
SMSPreviewTemplate,
|
2016-12-08 11:50:59 +00:00
|
|
|
|
)
|
2019-06-18 18:00:36 +01:00
|
|
|
|
from notifications_utils.timezones import (
|
2020-05-11 10:52:30 +01:00
|
|
|
|
convert_bst_to_utc,
|
2019-06-18 18:00:36 +01:00
|
|
|
|
convert_utc_to_bst,
|
|
|
|
|
|
utc_string_to_aware_gmt_datetime,
|
|
|
|
|
|
)
|
2017-12-30 16:54:39 +00:00
|
|
|
|
from orderedset._orderedset import OrderedSet
|
|
|
|
|
|
from werkzeug.datastructures import MultiDict
|
2019-03-21 16:41:22 +00:00
|
|
|
|
from werkzeug.routing import RequestRedirect
|
2016-02-19 16:38:04 +00:00
|
|
|
|
|
2021-01-06 12:31:39 +00:00
|
|
|
|
from app.models.spreadsheet import Spreadsheet
|
2019-05-28 16:11:54 +01:00
|
|
|
|
from app.notify_client.organisations_api_client import organisations_client
|
|
|
|
|
|
|
2018-03-19 15:25:26 +00:00
|
|
|
|
SENDING_STATUSES = ['created', 'pending', 'sending', 'pending-virus-check']
|
2018-09-06 16:34:23 +01:00
|
|
|
|
DELIVERED_STATUSES = ['delivered', 'sent', 'returned-letter']
|
2018-12-04 15:07:20 +00:00
|
|
|
|
FAILURE_STATUSES = ['failed', 'temporary-failure', 'permanent-failure',
|
2019-01-10 15:54:31 +00:00
|
|
|
|
'technical-failure', 'virus-scan-failed', 'validation-failed']
|
2017-01-30 17:27:09 +00:00
|
|
|
|
REQUESTED_STATUSES = SENDING_STATUSES + DELIVERED_STATUSES + FAILURE_STATUSES
|
|
|
|
|
|
|
2020-08-11 15:44:17 +01:00
|
|
|
|
NOTIFICATION_TYPES = ["sms", "email", "letter", "broadcast"]
|
|
|
|
|
|
|
2019-10-01 17:16:15 +01:00
|
|
|
|
|
2019-06-03 13:10:49 +01:00
|
|
|
|
with open('{}/email_domains.txt'.format(
|
2019-04-08 09:46:05 +01:00
|
|
|
|
os.path.dirname(os.path.realpath(__file__))
|
|
|
|
|
|
)) as email_domains:
|
2019-06-03 13:10:49 +01:00
|
|
|
|
GOVERNMENT_EMAIL_DOMAIN_NAMES = [line.strip() for line in email_domains]
|
2019-04-08 09:46:05 +01:00
|
|
|
|
|
2017-01-30 17:27:09 +00:00
|
|
|
|
|
2019-07-01 15:22:08 +01:00
|
|
|
|
user_is_logged_in = login_required
|
|
|
|
|
|
|
|
|
|
|
|
|
2018-02-28 18:13:29 +00:00
|
|
|
|
def user_has_permissions(*permissions, **permission_kwargs):
|
2016-02-19 16:38:04 +00:00
|
|
|
|
def wrap(func):
|
|
|
|
|
|
@wraps(func)
|
|
|
|
|
|
def wrap_func(*args, **kwargs):
|
2019-07-01 15:22:08 +01:00
|
|
|
|
if not current_user.is_authenticated:
|
|
|
|
|
|
return current_app.login_manager.unauthorized()
|
|
|
|
|
|
if not current_user.has_permissions(*permissions, **permission_kwargs):
|
|
|
|
|
|
abort(403)
|
|
|
|
|
|
return func(*args, **kwargs)
|
2016-02-19 16:38:04 +00:00
|
|
|
|
return wrap_func
|
|
|
|
|
|
return wrap
|
2016-03-07 18:47:05 +00:00
|
|
|
|
|
|
|
|
|
|
|
2020-06-30 15:32:00 +01:00
|
|
|
|
def service_has_permission(permission):
|
2020-07-03 10:00:55 +01:00
|
|
|
|
|
2020-06-30 15:32:00 +01:00
|
|
|
|
from app import current_service
|
2020-07-03 10:00:55 +01:00
|
|
|
|
|
2020-06-30 15:32:00 +01:00
|
|
|
|
def wrap(func):
|
|
|
|
|
|
@wraps(func)
|
|
|
|
|
|
def wrap_func(*args, **kwargs):
|
|
|
|
|
|
if not current_service or not current_service.has_permission(permission):
|
|
|
|
|
|
abort(403)
|
|
|
|
|
|
return func(*args, **kwargs)
|
|
|
|
|
|
return wrap_func
|
|
|
|
|
|
return wrap
|
|
|
|
|
|
|
|
|
|
|
|
|
2018-12-12 13:10:46 +00:00
|
|
|
|
def user_is_gov_user(f):
|
|
|
|
|
|
@wraps(f)
|
|
|
|
|
|
def wrapped(*args, **kwargs):
|
2019-07-01 15:22:08 +01:00
|
|
|
|
if not current_user.is_authenticated:
|
|
|
|
|
|
return current_app.login_manager.unauthorized()
|
2018-12-12 13:10:46 +00:00
|
|
|
|
if not current_user.is_gov_user:
|
|
|
|
|
|
abort(403)
|
|
|
|
|
|
return f(*args, **kwargs)
|
|
|
|
|
|
return wrapped
|
|
|
|
|
|
|
|
|
|
|
|
|
2018-02-27 16:45:20 +00:00
|
|
|
|
def user_is_platform_admin(f):
|
|
|
|
|
|
@wraps(f)
|
|
|
|
|
|
def wrapped(*args, **kwargs):
|
|
|
|
|
|
if not current_user.is_authenticated:
|
2019-07-01 15:22:08 +01:00
|
|
|
|
return current_app.login_manager.unauthorized()
|
2018-02-27 16:45:20 +00:00
|
|
|
|
if not current_user.platform_admin:
|
|
|
|
|
|
abort(403)
|
|
|
|
|
|
return f(*args, **kwargs)
|
|
|
|
|
|
return wrapped
|
|
|
|
|
|
|
|
|
|
|
|
|
2016-06-17 11:36:30 +01:00
|
|
|
|
def redirect_to_sign_in(f):
|
|
|
|
|
|
@wraps(f)
|
|
|
|
|
|
def wrapped(*args, **kwargs):
|
|
|
|
|
|
if 'user_details' not in session:
|
|
|
|
|
|
return redirect(url_for('main.sign_in'))
|
|
|
|
|
|
else:
|
|
|
|
|
|
return f(*args, **kwargs)
|
|
|
|
|
|
return wrapped
|
|
|
|
|
|
|
|
|
|
|
|
|
2016-03-07 18:47:05 +00:00
|
|
|
|
def get_errors_for_csv(recipients, template_type):
|
|
|
|
|
|
|
|
|
|
|
|
errors = []
|
|
|
|
|
|
|
2018-03-05 15:57:10 +00:00
|
|
|
|
if any(recipients.rows_with_bad_recipients):
|
2016-03-07 18:47:05 +00:00
|
|
|
|
number_of_bad_recipients = len(list(recipients.rows_with_bad_recipients))
|
|
|
|
|
|
if 'sms' == template_type:
|
|
|
|
|
|
if 1 == number_of_bad_recipients:
|
|
|
|
|
|
errors.append("fix 1 phone number")
|
|
|
|
|
|
else:
|
|
|
|
|
|
errors.append("fix {} phone numbers".format(number_of_bad_recipients))
|
|
|
|
|
|
elif 'email' == template_type:
|
|
|
|
|
|
if 1 == number_of_bad_recipients:
|
|
|
|
|
|
errors.append("fix 1 email address")
|
|
|
|
|
|
else:
|
|
|
|
|
|
errors.append("fix {} email addresses".format(number_of_bad_recipients))
|
2016-11-10 14:10:39 +00:00
|
|
|
|
elif 'letter' == template_type:
|
|
|
|
|
|
if 1 == number_of_bad_recipients:
|
|
|
|
|
|
errors.append("fix 1 address")
|
|
|
|
|
|
else:
|
|
|
|
|
|
errors.append("fix {} addresses".format(number_of_bad_recipients))
|
2016-03-07 18:47:05 +00:00
|
|
|
|
|
2018-03-05 15:57:10 +00:00
|
|
|
|
if any(recipients.rows_with_missing_data):
|
2016-03-07 18:47:05 +00:00
|
|
|
|
number_of_rows_with_missing_data = len(list(recipients.rows_with_missing_data))
|
|
|
|
|
|
if 1 == number_of_rows_with_missing_data:
|
2016-04-18 11:27:23 +01:00
|
|
|
|
errors.append("enter missing data in 1 row")
|
2016-03-07 18:47:05 +00:00
|
|
|
|
else:
|
2016-04-18 11:27:23 +01:00
|
|
|
|
errors.append("enter missing data in {} rows".format(number_of_rows_with_missing_data))
|
2016-03-07 18:47:05 +00:00
|
|
|
|
|
2019-11-21 12:12:04 +00:00
|
|
|
|
if any(recipients.rows_with_message_too_long):
|
|
|
|
|
|
number_of_rows_with_message_too_long = len(list(recipients.rows_with_message_too_long))
|
|
|
|
|
|
if 1 == number_of_rows_with_message_too_long:
|
2019-11-27 15:56:55 +00:00
|
|
|
|
errors.append("shorten the message in 1 row")
|
2019-11-21 12:12:04 +00:00
|
|
|
|
else:
|
2019-11-27 15:56:55 +00:00
|
|
|
|
errors.append("shorten the messages in {} rows".format(number_of_rows_with_message_too_long))
|
2019-11-21 12:12:04 +00:00
|
|
|
|
|
|
|
|
|
|
if any(recipients.rows_with_empty_message):
|
|
|
|
|
|
number_of_rows_with_empty_message = len(list(recipients.rows_with_empty_message))
|
|
|
|
|
|
if 1 == number_of_rows_with_empty_message:
|
2019-11-27 15:56:55 +00:00
|
|
|
|
errors.append("check you have content for the empty message in 1 row")
|
2019-11-21 12:12:04 +00:00
|
|
|
|
else:
|
2019-11-27 15:56:55 +00:00
|
|
|
|
errors.append("check you have content for the empty messages in {} rows".format(
|
|
|
|
|
|
number_of_rows_with_empty_message
|
|
|
|
|
|
))
|
2019-11-21 12:12:04 +00:00
|
|
|
|
|
2016-03-07 18:47:05 +00:00
|
|
|
|
return errors
|
2016-03-16 16:57:10 +00:00
|
|
|
|
|
|
|
|
|
|
|
2020-04-19 22:46:13 +01:00
|
|
|
|
def get_sample_template(template_type):
|
|
|
|
|
|
if template_type == 'email':
|
|
|
|
|
|
return EmailPreviewTemplate({'content': 'any', 'subject': '', 'template_type': 'email'})
|
|
|
|
|
|
if template_type == 'sms':
|
|
|
|
|
|
return SMSPreviewTemplate({'content': 'any', 'template_type': 'sms'})
|
|
|
|
|
|
if template_type == 'letter':
|
2020-05-06 14:17:12 +01:00
|
|
|
|
return LetterImageTemplate(
|
|
|
|
|
|
{'content': 'any', 'subject': '', 'template_type': 'letter'}, postage='second', image_url='x', page_count=1
|
|
|
|
|
|
)
|
2020-04-19 22:46:13 +01:00
|
|
|
|
|
|
|
|
|
|
|
2017-01-13 11:35:27 +00:00
|
|
|
|
def generate_notifications_csv(**kwargs):
|
|
|
|
|
|
from app import notification_api_client
|
2019-01-30 09:42:15 +00:00
|
|
|
|
from app.s3_client.s3_csv_client import s3download
|
2017-01-13 11:35:27 +00:00
|
|
|
|
if 'page' not in kwargs:
|
|
|
|
|
|
kwargs['page'] = 1
|
2018-01-12 14:03:31 +00:00
|
|
|
|
|
2018-02-16 11:35:36 +00:00
|
|
|
|
if kwargs.get('job_id'):
|
|
|
|
|
|
original_file_contents = s3download(kwargs['service_id'], kwargs['job_id'])
|
|
|
|
|
|
original_upload = RecipientCSV(
|
|
|
|
|
|
original_file_contents,
|
2020-04-19 22:46:13 +01:00
|
|
|
|
template=get_sample_template(kwargs['template_type']),
|
2018-02-16 11:35:36 +00:00
|
|
|
|
)
|
|
|
|
|
|
original_column_headers = original_upload.column_headers
|
|
|
|
|
|
fieldnames = ['Row number'] + original_column_headers + ['Template', 'Type', 'Job', 'Status', 'Time']
|
2018-01-12 14:03:31 +00:00
|
|
|
|
else:
|
2020-01-07 15:40:17 +00:00
|
|
|
|
fieldnames = ['Recipient', 'Reference', 'Template', 'Type', 'Sent by', 'Sent by email', 'Job', 'Status', 'Time']
|
2018-01-12 14:03:31 +00:00
|
|
|
|
|
2017-04-20 14:55:14 +01:00
|
|
|
|
yield ','.join(fieldnames) + '\n'
|
2017-01-13 11:35:27 +00:00
|
|
|
|
|
|
|
|
|
|
while kwargs['page']:
|
|
|
|
|
|
notifications_resp = notification_api_client.get_notifications_for_service(**kwargs)
|
2018-02-16 12:34:59 +00:00
|
|
|
|
for notification in notifications_resp['notifications']:
|
|
|
|
|
|
if kwargs.get('job_id'):
|
2018-02-16 11:35:36 +00:00
|
|
|
|
values = [
|
|
|
|
|
|
notification['row_number'],
|
|
|
|
|
|
] + [
|
2018-03-05 15:57:10 +00:00
|
|
|
|
original_upload[notification['row_number'] - 1].get(header).data
|
2018-02-16 11:35:36 +00:00
|
|
|
|
for header in original_column_headers
|
|
|
|
|
|
] + [
|
|
|
|
|
|
notification['template_name'],
|
|
|
|
|
|
notification['template_type'],
|
|
|
|
|
|
notification['job_name'],
|
|
|
|
|
|
notification['status'],
|
2018-02-16 12:34:59 +00:00
|
|
|
|
notification['created_at'],
|
2018-02-16 11:35:36 +00:00
|
|
|
|
]
|
2018-02-16 12:34:59 +00:00
|
|
|
|
else:
|
2018-01-12 14:03:31 +00:00
|
|
|
|
values = [
|
2020-01-07 15:40:17 +00:00
|
|
|
|
# the recipient for precompiled letters is the full address block
|
|
|
|
|
|
notification['recipient'].splitlines()[0].lstrip().rstrip(' ,'),
|
|
|
|
|
|
notification['client_reference'],
|
2018-06-25 16:29:40 +01:00
|
|
|
|
notification['template_name'],
|
|
|
|
|
|
notification['template_type'],
|
2018-09-06 14:41:55 +01:00
|
|
|
|
notification['created_by_name'] or '',
|
2018-12-06 11:54:34 +00:00
|
|
|
|
notification['created_by_email_address'] or '',
|
2018-09-06 14:41:55 +01:00
|
|
|
|
notification['job_name'] or '',
|
2018-01-12 14:03:31 +00:00
|
|
|
|
notification['status'],
|
2018-06-25 16:29:40 +01:00
|
|
|
|
notification['created_at']
|
2018-01-12 14:03:31 +00:00
|
|
|
|
]
|
2018-03-06 15:11:59 +00:00
|
|
|
|
yield Spreadsheet.from_rows([map(str, values)]).as_csv_data
|
2018-02-16 12:34:59 +00:00
|
|
|
|
|
2017-01-13 11:35:27 +00:00
|
|
|
|
if notifications_resp['links'].get('next'):
|
|
|
|
|
|
kwargs['page'] += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
return
|
2017-06-12 17:21:25 +01:00
|
|
|
|
raise Exception("Should never reach here")
|
|
|
|
|
|
|
|
|
|
|
|
|
2016-03-16 16:57:10 +00:00
|
|
|
|
def get_page_from_request():
|
|
|
|
|
|
if 'page' in request.args:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(request.args['page'])
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
else:
|
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
|
2016-10-10 14:50:49 +01:00
|
|
|
|
def generate_previous_dict(view, service_id, page, url_args=None):
|
2016-10-10 17:15:57 +01:00
|
|
|
|
return generate_previous_next_dict(view, service_id, page - 1, 'Previous page', url_args or {})
|
2016-10-10 14:50:49 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_next_dict(view, service_id, page, url_args=None):
|
2016-10-10 17:15:57 +01:00
|
|
|
|
return generate_previous_next_dict(view, service_id, page + 1, 'Next page', url_args or {})
|
2016-10-10 14:50:49 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_previous_next_dict(view, service_id, page, title, url_args):
|
2016-03-16 16:57:10 +00:00
|
|
|
|
return {
|
2016-10-10 14:50:49 +01:00
|
|
|
|
'url': url_for(view, service_id=service_id, page=page, **url_args),
|
2016-03-16 16:57:10 +00:00
|
|
|
|
'title': title,
|
2016-10-10 14:50:49 +01:00
|
|
|
|
'label': 'page {}'.format(page)
|
2016-03-16 16:57:10 +00:00
|
|
|
|
}
|
2016-03-30 17:12:00 +01:00
|
|
|
|
|
|
|
|
|
|
|
2016-07-05 11:39:07 +01:00
|
|
|
|
def get_help_argument():
|
|
|
|
|
|
return request.args.get('help') if request.args.get('help') in ('1', '2', '3') else None
|
2016-10-25 18:10:15 +01:00
|
|
|
|
|
|
|
|
|
|
|
2019-05-28 16:11:54 +01:00
|
|
|
|
def email_address_ends_with(email_address, known_domains):
|
2019-04-08 09:46:05 +01:00
|
|
|
|
return any(
|
|
|
|
|
|
email_address.lower().endswith((
|
|
|
|
|
|
"@{}".format(known),
|
|
|
|
|
|
".{}".format(known),
|
|
|
|
|
|
))
|
2019-05-28 16:11:54 +01:00
|
|
|
|
for known in known_domains
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_gov_user(email_address):
|
|
|
|
|
|
return email_address_ends_with(
|
|
|
|
|
|
email_address, GOVERNMENT_EMAIL_DOMAIN_NAMES
|
|
|
|
|
|
) or email_address_ends_with(
|
|
|
|
|
|
email_address, organisations_client.get_domains()
|
2019-04-08 09:46:05 +01:00
|
|
|
|
)
|
2016-12-05 11:51:19 +00:00
|
|
|
|
|
|
|
|
|
|
|
2016-12-20 14:38:34 +00:00
|
|
|
|
def get_template(
|
|
|
|
|
|
template,
|
|
|
|
|
|
service,
|
|
|
|
|
|
show_recipient=False,
|
|
|
|
|
|
letter_preview_url=None,
|
2017-04-20 10:40:15 +01:00
|
|
|
|
page_count=1,
|
2017-06-24 17:18:49 +01:00
|
|
|
|
redact_missing_personalisation=False,
|
2017-10-17 16:06:15 +01:00
|
|
|
|
email_reply_to=None,
|
2017-11-16 14:13:32 +00:00
|
|
|
|
sms_sender=None,
|
2016-12-20 14:38:34 +00:00
|
|
|
|
):
|
2016-12-08 11:50:59 +00:00
|
|
|
|
if 'email' == template['template_type']:
|
|
|
|
|
|
return EmailPreviewTemplate(
|
|
|
|
|
|
template,
|
2018-07-20 09:17:20 +01:00
|
|
|
|
from_name=service.name,
|
|
|
|
|
|
from_address='{}@notifications.service.gov.uk'.format(service.email_from),
|
2017-06-24 17:18:49 +01:00
|
|
|
|
show_recipient=show_recipient,
|
|
|
|
|
|
redact_missing_personalisation=redact_missing_personalisation,
|
2017-10-17 16:06:15 +01:00
|
|
|
|
reply_to=email_reply_to,
|
2016-12-08 11:50:59 +00:00
|
|
|
|
)
|
|
|
|
|
|
if 'sms' == template['template_type']:
|
|
|
|
|
|
return SMSPreviewTemplate(
|
|
|
|
|
|
template,
|
2018-07-20 09:17:20 +01:00
|
|
|
|
prefix=service.name,
|
|
|
|
|
|
show_prefix=service.prefix_sms,
|
2017-11-16 13:35:17 +00:00
|
|
|
|
sender=sms_sender,
|
2017-11-16 14:13:32 +00:00
|
|
|
|
show_sender=bool(sms_sender),
|
2017-06-24 17:18:49 +01:00
|
|
|
|
show_recipient=show_recipient,
|
|
|
|
|
|
redact_missing_personalisation=redact_missing_personalisation,
|
2016-12-08 11:50:59 +00:00
|
|
|
|
)
|
|
|
|
|
|
if 'letter' == template['template_type']:
|
2016-12-20 14:38:34 +00:00
|
|
|
|
if letter_preview_url:
|
2017-04-28 16:04:52 +01:00
|
|
|
|
return LetterImageTemplate(
|
2016-12-20 14:38:34 +00:00
|
|
|
|
template,
|
2017-04-28 16:04:52 +01:00
|
|
|
|
image_url=letter_preview_url,
|
2017-04-20 10:40:15 +01:00
|
|
|
|
page_count=int(page_count),
|
2018-09-10 11:21:43 +01:00
|
|
|
|
contact_block=template['reply_to_text'],
|
2019-02-06 14:54:58 +00:00
|
|
|
|
postage=template['postage'],
|
2016-12-20 14:38:34 +00:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
return LetterPreviewTemplate(
|
2017-03-03 16:53:24 +00:00
|
|
|
|
template,
|
2018-01-03 10:44:36 +00:00
|
|
|
|
contact_block=template['reply_to_text'],
|
2017-06-24 17:18:49 +01:00
|
|
|
|
admin_base_url=current_app.config['ADMIN_BASE_URL'],
|
|
|
|
|
|
redact_missing_personalisation=redact_missing_personalisation,
|
2016-12-20 14:38:34 +00:00
|
|
|
|
)
|
2020-07-01 17:43:30 +01:00
|
|
|
|
if 'broadcast' == template['template_type']:
|
|
|
|
|
|
return BroadcastPreviewTemplate(
|
|
|
|
|
|
template,
|
|
|
|
|
|
)
|
2016-12-28 11:06:11 +00:00
|
|
|
|
|
|
|
|
|
|
|
2017-01-25 15:59:06 +00:00
|
|
|
|
def get_current_financial_year():
|
|
|
|
|
|
now = datetime.utcnow()
|
|
|
|
|
|
current_month = int(now.strftime('%-m'))
|
|
|
|
|
|
current_year = int(now.strftime('%Y'))
|
|
|
|
|
|
return current_year if current_month > 3 else current_year - 1
|
2017-06-12 17:21:25 +01:00
|
|
|
|
|
|
|
|
|
|
|
2018-11-29 11:41:13 +00:00
|
|
|
|
def get_logo_cdn_domain():
|
2017-07-24 15:20:40 +01:00
|
|
|
|
parsed_uri = urlparse(current_app.config['ADMIN_BASE_URL'])
|
|
|
|
|
|
|
|
|
|
|
|
if parsed_uri.netloc.startswith('localhost'):
|
|
|
|
|
|
return 'static-logos.notify.tools'
|
|
|
|
|
|
|
|
|
|
|
|
subdomain = parsed_uri.hostname.split('.')[0]
|
|
|
|
|
|
domain = parsed_uri.netloc[len(subdomain + '.'):]
|
|
|
|
|
|
|
|
|
|
|
|
return "static-logos.{}".format(domain)
|
2017-12-30 16:54:39 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_filter_args(filter_dict):
|
|
|
|
|
|
if not isinstance(filter_dict, MultiDict):
|
|
|
|
|
|
filter_dict = MultiDict(filter_dict)
|
|
|
|
|
|
|
|
|
|
|
|
return MultiDict(
|
|
|
|
|
|
(
|
|
|
|
|
|
key,
|
|
|
|
|
|
(','.join(filter_dict.getlist(key))).split(',')
|
|
|
|
|
|
)
|
|
|
|
|
|
for key in filter_dict.keys()
|
|
|
|
|
|
if ''.join(filter_dict.getlist(key))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_status_filters(filter_args):
|
|
|
|
|
|
status_filters = filter_args.get('status', [])
|
|
|
|
|
|
return list(OrderedSet(chain(
|
|
|
|
|
|
(status_filters or REQUESTED_STATUSES),
|
|
|
|
|
|
DELIVERED_STATUSES if 'delivered' in status_filters else [],
|
|
|
|
|
|
SENDING_STATUSES if 'sending' in status_filters else [],
|
|
|
|
|
|
FAILURE_STATUSES if 'failed' in status_filters else []
|
|
|
|
|
|
)))
|
2018-02-06 09:29:11 +00:00
|
|
|
|
|
|
|
|
|
|
|
2018-04-30 10:46:39 +01:00
|
|
|
|
def unicode_truncate(s, length):
|
|
|
|
|
|
encoded = s.encode('utf-8')[:length]
|
|
|
|
|
|
return encoded.decode('utf-8', 'ignore')
|
2018-07-02 09:08:21 +01:00
|
|
|
|
|
|
|
|
|
|
|
2018-08-09 16:29:51 +01:00
|
|
|
|
def should_skip_template_page(template_type):
|
|
|
|
|
|
return (
|
2019-02-21 13:03:06 +00:00
|
|
|
|
current_user.has_permissions('send_messages')
|
|
|
|
|
|
and not current_user.has_permissions('manage_templates', 'manage_api_keys')
|
|
|
|
|
|
and template_type != 'letter'
|
2018-08-09 16:29:51 +01:00
|
|
|
|
)
|
2018-08-23 16:11:08 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_default_sms_sender(sms_senders):
|
|
|
|
|
|
return str(next((
|
|
|
|
|
|
Field(x['sms_sender'], html='escape')
|
|
|
|
|
|
for x in sms_senders if x['is_default']
|
|
|
|
|
|
), "None"))
|
2018-11-27 16:49:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
2020-05-11 10:52:30 +01:00
|
|
|
|
def printing_today_or_tomorrow(created_at):
|
|
|
|
|
|
print_cutoff = convert_bst_to_utc(
|
|
|
|
|
|
convert_utc_to_bst(datetime.utcnow()).replace(hour=17, minute=30)
|
|
|
|
|
|
).replace(tzinfo=pytz.utc)
|
|
|
|
|
|
created_at = utc_string_to_aware_gmt_datetime(created_at)
|
2018-11-27 16:49:01 +00:00
|
|
|
|
|
2020-05-11 10:52:30 +01:00
|
|
|
|
if created_at < print_cutoff:
|
2018-11-27 16:49:01 +00:00
|
|
|
|
return 'today'
|
|
|
|
|
|
else:
|
|
|
|
|
|
return 'tomorrow'
|
2019-02-21 13:03:06 +00:00
|
|
|
|
|
|
|
|
|
|
|
2020-05-11 10:52:30 +01:00
|
|
|
|
def get_letter_printing_statement(status, created_at, long_form=True):
|
2019-06-18 18:00:36 +01:00
|
|
|
|
created_at_dt = parser.parse(created_at).replace(tzinfo=None)
|
|
|
|
|
|
if letter_can_be_cancelled(status, created_at_dt):
|
2020-05-11 10:52:30 +01:00
|
|
|
|
decription = 'Printing starts' if long_form else 'Printing'
|
|
|
|
|
|
return f'{decription} {printing_today_or_tomorrow(created_at)} at 5:30pm'
|
2019-06-18 18:00:36 +01:00
|
|
|
|
else:
|
|
|
|
|
|
printed_datetime = utc_string_to_aware_gmt_datetime(created_at) + timedelta(hours=6, minutes=30)
|
|
|
|
|
|
if printed_datetime.date() == datetime.now().date():
|
|
|
|
|
|
return 'Printed today at 5:30pm'
|
|
|
|
|
|
elif printed_datetime.date() == datetime.now().date() - timedelta(days=1):
|
|
|
|
|
|
return 'Printed yesterday at 5:30pm'
|
|
|
|
|
|
|
|
|
|
|
|
printed_date = printed_datetime.strftime('%d %B').lstrip('0')
|
2020-05-11 10:52:30 +01:00
|
|
|
|
description = 'Printed on' if long_form else 'Printed'
|
2019-06-18 18:00:36 +01:00
|
|
|
|
|
2020-05-11 10:52:30 +01:00
|
|
|
|
return f'{description} {printed_date} at 5:30pm'
|
2019-06-18 18:00:36 +01:00
|
|
|
|
|
|
|
|
|
|
|
2019-10-15 15:53:29 +01:00
|
|
|
|
LETTER_VALIDATION_MESSAGES = {
|
|
|
|
|
|
'letter-not-a4-portrait-oriented': {
|
2020-01-10 14:42:56 +00:00
|
|
|
|
'title': 'Your letter is not A4 portrait size',
|
2020-01-10 17:04:50 +00:00
|
|
|
|
'detail': (
|
|
|
|
|
|
'You need to change the size or orientation of {invalid_pages}. <br>'
|
2020-02-21 11:35:13 +00:00
|
|
|
|
'Files must meet our '
|
2020-02-21 15:30:48 +00:00
|
|
|
|
'<a class="govuk-link govuk-link--destructive" href="{letter_spec_guidance}" target="_blank">'
|
2020-02-21 11:35:13 +00:00
|
|
|
|
'letter specification'
|
|
|
|
|
|
'</a>.'
|
2020-01-10 17:04:50 +00:00
|
|
|
|
),
|
2020-01-10 17:29:58 +00:00
|
|
|
|
'summary': (
|
|
|
|
|
|
'Validation failed because {invalid_pages} {invalid_pages_are_or_is} not A4 portrait size.<br>'
|
2020-02-21 11:35:13 +00:00
|
|
|
|
'Files must meet our '
|
2020-09-22 15:36:08 +01:00
|
|
|
|
'<a class="govuk-link govuk-link--no-visited-state" href="{letter_spec_guidance}">'
|
2020-02-21 11:35:13 +00:00
|
|
|
|
'letter specification'
|
|
|
|
|
|
'</a>.'
|
2020-01-10 17:29:58 +00:00
|
|
|
|
),
|
2019-10-15 15:53:29 +01:00
|
|
|
|
},
|
|
|
|
|
|
'content-outside-printable-area': {
|
2020-01-10 14:42:56 +00:00
|
|
|
|
'title': 'Your content is outside the printable area',
|
2020-01-10 17:04:50 +00:00
|
|
|
|
'detail': (
|
|
|
|
|
|
'You need to edit {invalid_pages}.<br>'
|
2020-02-21 11:35:13 +00:00
|
|
|
|
'Files must meet our '
|
2020-09-22 15:36:08 +01:00
|
|
|
|
'<a class="govuk-link govuk-link--destructive" href="{letter_spec_guidance}">'
|
2020-02-21 11:35:13 +00:00
|
|
|
|
'letter specification'
|
|
|
|
|
|
'</a>.'
|
2020-01-10 17:04:50 +00:00
|
|
|
|
),
|
2020-01-10 17:29:58 +00:00
|
|
|
|
'summary': (
|
|
|
|
|
|
'Validation failed because content is outside the printable area on {invalid_pages}.<br>'
|
2020-02-21 11:35:13 +00:00
|
|
|
|
'Files must meet our '
|
2020-09-22 15:36:08 +01:00
|
|
|
|
'<a class="govuk-link govuk-link--no-visited-state" href="{letter_spec_guidance}" target="_blank">'
|
2020-02-21 11:35:13 +00:00
|
|
|
|
'letter specification'
|
|
|
|
|
|
'</a>.'
|
2020-01-10 17:29:58 +00:00
|
|
|
|
),
|
2019-10-15 15:53:29 +01:00
|
|
|
|
},
|
|
|
|
|
|
'letter-too-long': {
|
|
|
|
|
|
'title': 'Your letter is too long',
|
2020-01-10 17:04:50 +00:00
|
|
|
|
'detail': (
|
2020-04-09 11:03:07 +01:00
|
|
|
|
'Letters must be 10 pages or less (5 double-sided sheets of paper). <br>'
|
2020-01-10 17:04:50 +00:00
|
|
|
|
'Your letter is {page_count} pages long.'
|
|
|
|
|
|
),
|
2020-01-10 17:29:58 +00:00
|
|
|
|
'summary': (
|
|
|
|
|
|
'Validation failed because this letter is {page_count} pages long.<br>'
|
2020-04-09 11:03:07 +01:00
|
|
|
|
'Letters must be 10 pages or less (5 double-sided sheets of paper).'
|
2020-01-10 17:29:58 +00:00
|
|
|
|
),
|
2019-10-15 15:53:29 +01:00
|
|
|
|
},
|
|
|
|
|
|
'no-encoded-string': {
|
|
|
|
|
|
'title': 'Sanitise failed - No encoded string'
|
|
|
|
|
|
},
|
|
|
|
|
|
'unable-to-read-the-file': {
|
|
|
|
|
|
'title': 'There’s a problem with your file',
|
2020-01-10 17:04:50 +00:00
|
|
|
|
'detail': (
|
|
|
|
|
|
'Notify cannot read this PDF.'
|
|
|
|
|
|
'<br>Save a new copy of your file and try again.'
|
|
|
|
|
|
),
|
2020-01-10 17:29:58 +00:00
|
|
|
|
'summary': (
|
2020-01-20 15:54:07 +00:00
|
|
|
|
'Validation failed because Notify cannot read this PDF.<br>'
|
|
|
|
|
|
'Save a new copy of your file and try again.'
|
2020-01-10 17:29:58 +00:00
|
|
|
|
),
|
2020-01-10 14:42:56 +00:00
|
|
|
|
},
|
|
|
|
|
|
'address-is-empty': {
|
|
|
|
|
|
'title': 'The address block is empty',
|
2020-01-10 17:04:50 +00:00
|
|
|
|
'detail': (
|
|
|
|
|
|
'You need to add a recipient address.<br>'
|
2020-02-21 11:35:13 +00:00
|
|
|
|
'Files must meet our '
|
2020-02-21 15:30:48 +00:00
|
|
|
|
'<a class="govuk-link govuk-link--destructive" href="{letter_spec_guidance}" target="_blank">'
|
2020-02-21 11:35:13 +00:00
|
|
|
|
'letter specification'
|
|
|
|
|
|
'</a>.'
|
2020-01-10 17:04:50 +00:00
|
|
|
|
),
|
2020-01-10 17:29:58 +00:00
|
|
|
|
'summary': (
|
|
|
|
|
|
'Validation failed because the address block is empty.<br>'
|
2020-02-21 11:35:13 +00:00
|
|
|
|
'Files must meet our '
|
2020-09-22 15:36:08 +01:00
|
|
|
|
'<a class="govuk-link govuk-link--no-visited-state" href="{letter_spec_guidance}" target="_blank">'
|
2020-02-21 11:35:13 +00:00
|
|
|
|
'letter specification'
|
|
|
|
|
|
'</a>.'
|
2020-01-10 17:29:58 +00:00
|
|
|
|
),
|
2020-04-02 11:23:17 +01:00
|
|
|
|
},
|
|
|
|
|
|
'not-a-real-uk-postcode': {
|
2020-04-02 13:39:40 +01:00
|
|
|
|
'title': 'There’s a problem with the address for this letter',
|
2020-04-02 11:23:17 +01:00
|
|
|
|
'detail': (
|
2020-04-02 11:45:57 +01:00
|
|
|
|
'The last line of the address must be a real UK postcode.'
|
2020-04-02 11:23:17 +01:00
|
|
|
|
),
|
|
|
|
|
|
'summary': (
|
2020-04-02 11:45:57 +01:00
|
|
|
|
'Validation failed because the last line of the address is not a real UK postcode.'
|
2020-04-02 11:23:17 +01:00
|
|
|
|
),
|
2020-04-03 14:13:44 +01:00
|
|
|
|
},
|
2020-09-10 11:18:20 +01:00
|
|
|
|
'cant-send-international-letters': {
|
|
|
|
|
|
'title': 'There’s a problem with the address for this letter',
|
|
|
|
|
|
'detail': (
|
|
|
|
|
|
'You do not have permission to send letters to other countries.'
|
|
|
|
|
|
),
|
|
|
|
|
|
'summary': (
|
|
|
|
|
|
'Validation failed because your service cannot send letters to other countries.'
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
2020-05-04 10:14:16 +01:00
|
|
|
|
'not-a-real-uk-postcode-or-country': {
|
|
|
|
|
|
'title': 'There’s a problem with the address for this letter',
|
|
|
|
|
|
'detail': (
|
|
|
|
|
|
'The last line of the address must be a UK postcode or '
|
|
|
|
|
|
'another country.'
|
|
|
|
|
|
),
|
|
|
|
|
|
'summary': (
|
|
|
|
|
|
'Validation failed because the last line of the address is '
|
|
|
|
|
|
'not a UK postcode or another country.'
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
2020-04-03 14:13:44 +01:00
|
|
|
|
'not-enough-address-lines': {
|
|
|
|
|
|
'title': 'There’s a problem with the address for this letter',
|
|
|
|
|
|
'detail': (
|
|
|
|
|
|
f'The address must be at least {PostalAddress.MIN_LINES} '
|
|
|
|
|
|
f'lines long.'
|
|
|
|
|
|
),
|
|
|
|
|
|
'summary': (
|
|
|
|
|
|
f'Validation failed because the address must be at least '
|
|
|
|
|
|
f'{PostalAddress.MIN_LINES} lines long.'
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
'too-many-address-lines': {
|
|
|
|
|
|
'title': 'There’s a problem with the address for this letter',
|
|
|
|
|
|
'detail': (
|
|
|
|
|
|
f'The address must be no more than {PostalAddress.MAX_LINES} '
|
|
|
|
|
|
f'lines long.'
|
|
|
|
|
|
),
|
|
|
|
|
|
'summary': (
|
|
|
|
|
|
f'Validation failed because the address must be no more '
|
|
|
|
|
|
f'than {PostalAddress.MAX_LINES} lines long.'
|
|
|
|
|
|
),
|
2020-07-28 13:44:14 +01:00
|
|
|
|
},
|
|
|
|
|
|
'invalid-char-in-address': {
|
|
|
|
|
|
'title': 'There’s a problem with the address for this letter',
|
|
|
|
|
|
'detail': (
|
|
|
|
|
|
"Address lines must not start with any of the following characters: @ ( ) = [ ] ” \\ / ,"
|
|
|
|
|
|
),
|
|
|
|
|
|
'summary': (
|
|
|
|
|
|
"Validation failed because address lines must not start with any of the "
|
|
|
|
|
|
"following characters: @ ( ) = [ ] ” \\ / ,"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
2020-07-31 18:31:26 +01:00
|
|
|
|
'notify-tag-found-in-content': {
|
|
|
|
|
|
'title': 'There’s a problem with your letter',
|
|
|
|
|
|
'detail': (
|
|
|
|
|
|
'Your file includes a letter you’ve downloaded from Notify.<br>'
|
|
|
|
|
|
'You need to edit {invalid_pages}.'
|
|
|
|
|
|
),
|
|
|
|
|
|
'summary': (
|
|
|
|
|
|
'Validation failed because your file includes a letter '
|
|
|
|
|
|
'you’ve downloaded from Notify on {invalid_pages}.'
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
2019-10-15 15:53:29 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_letter_validation_error(validation_message, invalid_pages=None, page_count=None):
|
2020-01-27 15:07:40 +00:00
|
|
|
|
if not invalid_pages:
|
|
|
|
|
|
invalid_pages = []
|
2019-10-15 15:53:29 +01:00
|
|
|
|
if validation_message not in LETTER_VALIDATION_MESSAGES:
|
|
|
|
|
|
return {'title': 'Validation failed'}
|
|
|
|
|
|
|
2020-01-10 17:29:58 +00:00
|
|
|
|
invalid_pages_are_or_is = 'is' if len(invalid_pages) == 1 else 'are'
|
|
|
|
|
|
|
2019-10-15 15:53:29 +01:00
|
|
|
|
invalid_pages = unescaped_formatted_list(
|
2020-01-27 15:07:40 +00:00
|
|
|
|
invalid_pages,
|
2019-10-15 15:53:29 +01:00
|
|
|
|
before_each='',
|
|
|
|
|
|
after_each='',
|
|
|
|
|
|
prefix='page',
|
|
|
|
|
|
prefix_plural='pages'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'title': LETTER_VALIDATION_MESSAGES[validation_message]['title'],
|
|
|
|
|
|
'detail': LETTER_VALIDATION_MESSAGES[validation_message]['detail'].format(
|
|
|
|
|
|
invalid_pages=invalid_pages,
|
2020-01-10 17:29:58 +00:00
|
|
|
|
invalid_pages_are_or_is=invalid_pages_are_or_is,
|
2019-10-15 15:53:29 +01:00
|
|
|
|
page_count=page_count,
|
2020-09-22 15:36:08 +01:00
|
|
|
|
letter_spec_guidance=url_for('.letter_specification')
|
2020-01-10 17:29:58 +00:00
|
|
|
|
),
|
|
|
|
|
|
'summary': LETTER_VALIDATION_MESSAGES[validation_message]['summary'].format(
|
|
|
|
|
|
invalid_pages=invalid_pages,
|
|
|
|
|
|
invalid_pages_are_or_is=invalid_pages_are_or_is,
|
|
|
|
|
|
page_count=page_count,
|
2020-09-22 15:36:08 +01:00
|
|
|
|
letter_spec_guidance=url_for('.letter_specification'),
|
2020-01-10 17:29:58 +00:00
|
|
|
|
),
|
2019-10-15 15:53:29 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-03-21 16:41:22 +00:00
|
|
|
|
class PermanentRedirect(RequestRedirect):
|
|
|
|
|
|
"""
|
|
|
|
|
|
In Werkzeug 0.15.0 the status code for RequestRedirect changed from 301 to 308.
|
|
|
|
|
|
308 status codes are not supported when Internet Explorer is used with Windows 7
|
|
|
|
|
|
and Windows 8.1, so this class keeps the original status code of 301.
|
|
|
|
|
|
"""
|
|
|
|
|
|
code = 301
|
2019-10-18 16:09:39 +01:00
|
|
|
|
|
|
|
|
|
|
|
2020-09-29 13:17:12 +01:00
|
|
|
|
def is_less_than_days_ago(date_from_db, number_of_days):
|
|
|
|
|
|
return (
|
2020-10-06 11:34:08 +01:00
|
|
|
|
datetime.utcnow().astimezone(pytz.utc) - parser.parse(date_from_db)
|
2020-09-29 13:17:12 +01:00
|
|
|
|
).days < number_of_days
|
2020-05-26 17:39:25 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def hide_from_search_engines(f):
|
|
|
|
|
|
@wraps(f)
|
|
|
|
|
|
def decorated_function(*args, **kwargs):
|
|
|
|
|
|
g.hide_from_search_engines = True
|
2020-05-27 09:03:14 +01:00
|
|
|
|
response = make_response(f(*args, **kwargs))
|
|
|
|
|
|
response.headers['X-Robots-Tag'] = 'noindex'
|
|
|
|
|
|
return response
|
2020-05-26 17:39:25 +01:00
|
|
|
|
return decorated_function
|
2020-08-13 12:38:12 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Function to merge two dict or lists with a JSON-like structure into one.
|
|
|
|
|
|
# JSON-like means they can contain all types JSON can: all the main primitives
|
|
|
|
|
|
# plus nested lists or dictionaries.
|
|
|
|
|
|
# Merge is additive. New values overwrite old and collections are added to.
|
|
|
|
|
|
def merge_jsonlike(source, destination):
|
|
|
|
|
|
def merge_items(source_item, destination_item):
|
|
|
|
|
|
if isinstance(source_item, dict) and isinstance(destination_item, dict):
|
|
|
|
|
|
merge_dicts(source_item, destination_item)
|
|
|
|
|
|
elif isinstance(source_item, list) and isinstance(destination_item, list):
|
|
|
|
|
|
merge_lists(source_item, destination_item)
|
|
|
|
|
|
else: # primitive value
|
|
|
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
def merge_lists(source, destination):
|
2021-01-26 12:16:08 +00:00
|
|
|
|
last_src_idx = len(source) - 1
|
2021-01-21 16:49:12 +00:00
|
|
|
|
for idx, item in enumerate(destination):
|
2021-01-26 12:16:08 +00:00
|
|
|
|
if idx <= last_src_idx:
|
2021-01-21 16:49:12 +00:00
|
|
|
|
# assign destination value if can't be merged into source
|
|
|
|
|
|
if merge_items(source[idx], destination[idx]) is False:
|
|
|
|
|
|
source[idx] = destination[idx]
|
|
|
|
|
|
else:
|
2020-08-13 12:38:12 +01:00
|
|
|
|
source.append(item)
|
|
|
|
|
|
|
|
|
|
|
|
def merge_dicts(source, destination):
|
|
|
|
|
|
for key, value in destination.items():
|
|
|
|
|
|
if key in source:
|
|
|
|
|
|
# assign destination value if can't be merged into source
|
|
|
|
|
|
if merge_items(source[key], value) is False:
|
|
|
|
|
|
source[key] = value
|
|
|
|
|
|
else:
|
|
|
|
|
|
source[key] = value
|
|
|
|
|
|
|
|
|
|
|
|
merge_items(source, destination)
|