Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Send file by email prototype #5384

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/assets/stylesheets/views/template.scss
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,8 @@
}

}

.template-action-button {
display: block;
text-align: center;
}
3 changes: 1 addition & 2 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ class Config:
INVITATION_EXPIRY_SECONDS = 3600 * 24 * 2 # 2 days - also set on api
EMAIL_2FA_EXPIRY_SECONDS = 1800 # 30 Minutes

# mix(govuk-colour("dark-grey"), govuk-colour("mid-grey"))
HEADER_COLOUR = os.environ.get("HEADER_COLOUR", "#81878b")
HEADER_COLOUR = "#1d70b8"
HTTP_PROTOCOL = os.environ.get("HTTP_PROTOCOL", "http")
NOTIFY_APP_NAME = "admin"
NOTIFY_LOG_LEVEL = "DEBUG"
Expand Down
22 changes: 22 additions & 0 deletions app/main/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2514,6 +2514,18 @@ class SetServiceDataRetentionForm(StripWhitespaceForm):
)


class SetServiceAttachmentDataRetentionForm(StripWhitespaceForm):
weeks_of_retention = GovukIntegerField(
label="Number of weeks",
things="the number of weeks",
validators=[
NotifyDataRequired(thing="a number of weeks"),
validators.NumberRange(min=1, max=78, message="The number of weeks must be between 1 and 78"),
],
param_extensions={"hint": {"text": "Must be between 1 week and 78 weeks (18 months)"}},
)


class AdminServiceAddDataRetentionForm(StripWhitespaceForm):
notification_type = GovukRadiosField(
"What notification type?",
Expand Down Expand Up @@ -2977,3 +2989,13 @@ def validate_report_has_been_processed(self, field):

if field.data and self.report_completed:
raise ValidationError("There is a problem. You have already marked the report as Completed")


class EmailAttachmentForm(StripWhitespaceForm):
file = FileField(
"Add recipients",
validators=[
DataRequired(message="You need to chose a file to upload"),
FileSize(max_size=10 * 1024 * 1024, message="The file must be smaller than 10MB"),
],
)
136 changes: 136 additions & 0 deletions app/main/views/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from flask_login import current_user
from notifications_python_client.errors import HTTPError
from notifications_utils import SMS_CHAR_COUNT_LIMIT
from notifications_utils.field import Field
from notifications_utils.pdf import pdf_page_count
from notifications_utils.s3 import s3download
from notifications_utils.template import Template
Expand All @@ -41,14 +42,17 @@
from app.main import main, no_cookie
from app.main.forms import (
CopyTemplateForm,
EmailAttachmentForm,
EmailTemplateForm,
FieldWithNoneOption,
LetterTemplateForm,
LetterTemplateLanguagesForm,
LetterTemplatePostageForm,
OnOffSettingForm,
PDFUploadForm,
RenameTemplateForm,
SearchTemplatesForm,
SetServiceAttachmentDataRetentionForm,
SetTemplateSenderForm,
SMSTemplateForm,
TemplateAndFoldersSelectionForm,
Expand Down Expand Up @@ -767,6 +771,8 @@ def edit_service_template(service_id, template_id, language=None):
else:
raise e
else:
if new_template.template_type == "email":
new_template.attachments.prune_orphans()
editing_english_content_in_bilingual_letter = (
template.template_type == "letter" and template.welsh_page_count and language != "welsh"
)
Expand Down Expand Up @@ -1392,6 +1398,136 @@ def letter_template_change_language(template_id, service_id):
)


@main.route("/services/<uuid:service_id>/templates/<uuid:template_id>/attachments", methods=["GET", "POST"])
@user_has_permissions("manage_templates")
def email_template_manage_attachments(template_id, service_id):
template = current_service.get_template(template_id)
rows = [
{
"key": {
"classes": "notify-summary-list__key notify-summary-list__key--35-100",
"html": Field(f"(({placeholder}))"),
},
"value": {
"text": template.attachments[placeholder].file_name or "No file attached",
"classes": "" if template.attachments[placeholder] else "govuk-hint",
},
"actions": {
"items": [
{
"href": url_for(
"main.email_template_manage_attachment",
service_id=service_id,
template_id=template_id,
placeholder=placeholder.strip(),
),
"text": "Change",
"visuallyHiddenText": "service name",
"classes": "govuk-link--no-visited-state",
}
]
},
}
for placeholder in template.all_placeholders
]

return render_template(
"views/templates/manage-email-attachments.html",
template=template,
rows=rows,
)


@main.route("/services/<uuid:service_id>/templates/<uuid:template_id>/attachment", methods=["GET", "POST"])
@user_has_permissions("manage_templates")
def email_template_manage_attachment(template_id, service_id):
template = current_service.get_template(template_id)
placeholder = request.args.get("placeholder", "")
attachment = template.attachments[placeholder]
delete = bool(request.args.get("delete"))
form = EmailAttachmentForm()
if delete and request.method == "POST":
del template.attachments[placeholder]
return redirect(
url_for("main.email_template_manage_attachments", service_id=current_service.id, template_id=template.id)
)
if form.validate_on_submit():
attachment.file_name = form.file.data.filename
return redirect(
url_for(
"main.email_template_manage_attachment",
service_id=current_service.id,
template_id=template.id,
placeholder=placeholder,
)
)
return render_template(
"views/templates/manage-email-attachment.html",
template=template,
placeholder=placeholder,
form=form,
attachment=attachment,
delete=delete,
)


@main.route("/services/<uuid:service_id>/templates/<uuid:template_id>/attachment/retention", methods=["GET", "POST"])
@user_has_permissions("manage_templates")
def email_template_manage_attachment_retention(template_id, service_id):
template = current_service.get_template(template_id)
placeholder = request.args.get("placeholder")
attachment = template.attachments[placeholder]
form = SetServiceAttachmentDataRetentionForm(weeks_of_retention=attachment.weeks_of_retention)
if form.validate_on_submit():
attachment.weeks_of_retention = form.weeks_of_retention.data
return redirect(
url_for(
"main.email_template_manage_attachment",
service_id=current_service.id,
template_id=template.id,
placeholder=placeholder,
)
)
return render_template(
"views/templates/manage-email-attachment-retention.html",
template=template,
placeholder=placeholder,
form=form,
)


@main.route(
"/services/<uuid:service_id>/templates/<uuid:template_id>/attachment/email-confirmation", methods=["GET", "POST"]
)
@user_has_permissions("manage_templates")
def email_template_manage_attachment_email_confirmation(template_id, service_id):
template = current_service.get_template(template_id)
placeholder = request.args.get("placeholder")
attachment = template.attachments[placeholder]
form = OnOffSettingForm(
"Require recipient to confirm email address",
truthy="Yes",
falsey="No",
enabled=attachment.email_confirmation,
)
if form.validate_on_submit():
attachment.email_confirmation = form.enabled.data
return redirect(
url_for(
"main.email_template_manage_attachment",
service_id=current_service.id,
template_id=template.id,
placeholder=placeholder,
)
)
return render_template(
"views/templates/manage-email-attachment-email-confirmation.html",
template=template,
placeholder=placeholder,
form=form,
)


@main.route("/services/<uuid:service_id>/templates/<uuid:template_id>/change-language/confirm", methods=["GET", "POST"])
@user_has_permissions("manage_templates")
def letter_template_confirm_remove_welsh(template_id, service_id):
Expand Down
91 changes: 91 additions & 0 deletions app/models/template_attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import base64
import json

from notifications_utils.insensitive_dict import InsensitiveDict
from notifications_utils.insensitive_dict import InsensitiveSet as UtilsInsensitiveSet

from app.extensions import redis_client
from app.models import JSONModel


# Implements https://github.com/alphagov/notifications-utils/pull/1197/files
class InsensitiveSet(UtilsInsensitiveSet):
def __contains__(self, key):
return key in InsensitiveDict.from_keys(self)


class TemplateAttachment(JSONModel):
BASE_URL = "https://www.download.example.gov.uk/f/"

file_name: str
weeks_of_retention: int
email_confirmation: bool

__sort_attribute__ = "file_name"

def __init__(self, *args, parent, placeholder_name, **kwargs):
super().__init__(*args, **kwargs)
self._parent = parent
self._placeholder_name = placeholder_name

def __repr__(self):
return f"{self.__class__.__name__}(<{self._dict}>)"

def __setattr__(self, name, value):
if name not in self.__annotations__.keys() or not hasattr(self, name):
return super().__setattr__(name, value)
self._dict[name] = value
self._parent[self._placeholder_name] = self._dict

def __bool__(self):
return bool(self.file_name)

@property
def url(self):
if not self.file_name:
return
return f"{self.BASE_URL}{base64.b64encode(self.file_name.encode()).decode()}"


class TemplateAttachments(InsensitiveDict):
def __init__(self, template):
self._template = template
super().__init__(json.loads(redis_client.get(self.cache_key) or "{}"))

@property
def cache_key(self):
return f"template-{self._template.id}-attachments"

def __getitem__(self, placeholder_name):
if placeholder_name not in self:
self[placeholder_name] = {
"file_name": None,
"weeks_of_retention": 26,
"email_confirmation": True,
}
return TemplateAttachment(
super().__getitem__(placeholder_name),
parent=self,
placeholder_name=placeholder_name,
)

def __setitem__(self, placeholder_name, value):
super().__setitem__(placeholder_name, value)
redis_client.set(self.cache_key, json.dumps(self))

def __delitem__(self, placeholder_name):
super().__delitem__(InsensitiveDict.make_key(placeholder_name))
redis_client.set(self.cache_key, json.dumps(self))

@property
def count(self):
return sum(bool(self[key]) for key in self if key in InsensitiveSet(self._template.all_placeholders))

@property
def as_personalisation(self):
return {placeholder: self[placeholder].url for placeholder in self}

def prune_orphans(self):
for placeholder in self.keys():
if placeholder not in InsensitiveSet(self._template.all_placeholders):
del self[placeholder]
7 changes: 2 additions & 5 deletions app/notify_session.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime

from flask import Flask, Request, Response, request
from flask.sessions import SecureCookieSession, SecureCookieSessionInterface
Expand All @@ -17,10 +17,7 @@ def _get_inactive_session_expiry(self, app, session_start: datetime):
"""
absolute_expiration = session_start + app.permanent_session_lifetime

if current_user and current_user.platform_admin:
refresh_duration = timedelta(seconds=app.config["PLATFORM_ADMIN_INACTIVE_SESSION_TIMEOUT"])
else:
refresh_duration = app.permanent_session_lifetime
refresh_duration = app.permanent_session_lifetime

return min(datetime.now(UTC) + refresh_duration, absolute_expiration)

Expand Down
48 changes: 42 additions & 6 deletions app/templates/views/templates/_email_or_sms_template.html
Original file line number Diff line number Diff line change
@@ -1,18 +1,54 @@
<div class="govuk-grid-row">
{% if current_user.has_permissions('send_messages', restrict_admin_usage=True) %}
<div class="govuk-grid-column-one-half govuk-!-margin-bottom-4">
<a href="{{ url_for(".set_sender", service_id=current_service.id, template_id=template.id) }}" class="govuk-link govuk-link--inverse pill-separate-item">
Get ready to send<span class="govuk-visually-hidden"> a message using this template</span>
</a>
{{ govukButton({
"element": "a",
"text": "Get ready to send",
"href": url_for(".set_sender", service_id=current_service.id, template_id=template.id),
"classes": "govuk-button--secondary template-action-button"
}) }}
</div>
{% endif %}
{% if current_user.has_permissions('manage_templates') %}
<div class="govuk-grid-column-one-half govuk-!-margin-bottom-4">
<a href="{{ url_for(".edit_service_template", service_id=current_service.id, template_id=template.id) }}" class="govuk-link govuk-link--inverse pill-separate-item">
Edit<span class="govuk-visually-hidden"> this template</span>
</a>
{{ govukButton({
"element": "a",
"text": "Edit",
"href": url_for(".edit_service_template", service_id=current_service.id, template_id=template.id),
"classes": "govuk-button--secondary template-action-button"
}) }}
</div>
{% endif %}
</div>

{{ template|string }}

{% if template.template_type == "email" %}

<div class="govuk-!-margin-bottom-2">
<div class="js-stick-at-bottom-when-scrolling">
<span class="govuk-hint" style="position: relative; top: 7px;">
{% if not template.attachments.count %}
No files
{% elif template.attachments.count == 1 %}
1 file
{% else %}
{{ template.attachments.count }} files
{% endif %}
attached
</span>

{{ govukButton({
"element": "a",
"text": "Manage attachments",
"href": url_for(
'.email_template_manage_attachments',
service_id=current_service.id,
template_id=template.id
),
"classes": "govuk-button--secondary change-language"
}) }}
</div>
</div>

{% endif %}
Loading