mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-02-05 02:42:26 -05:00
Merge pull request #77 from alphagov/manage-templates-page
Manage templates page
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from flask import Flask, session, Markup, render_template
|
||||
from flask import Flask, session, Markup, escape, render_template
|
||||
from flask._compat import string_types
|
||||
from flask.ext.sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
@@ -47,6 +47,7 @@ def create_app(config_name, config_overrides=None):
|
||||
|
||||
application.add_template_filter(placeholders)
|
||||
application.add_template_filter(replace_placeholders)
|
||||
application.add_template_filter(nl2br)
|
||||
|
||||
application.after_request(useful_headers_after_request)
|
||||
register_errorhandlers(application)
|
||||
@@ -108,6 +109,14 @@ def placeholders(value):
|
||||
))
|
||||
|
||||
|
||||
def nl2br(value):
|
||||
_paragraph_re = re.compile(r'(?:\r\n|\r|\n){2,}')
|
||||
|
||||
result = u'\n\n'.join(u'<p>%s</p>' % p.replace('\n', Markup('<br>\n'))
|
||||
for p in _paragraph_re.split(escape(value)))
|
||||
return Markup(result)
|
||||
|
||||
|
||||
def replace_placeholders(template, values):
|
||||
if not template:
|
||||
return template
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
$(() => GOVUK.modules.start());
|
||||
|
||||
$(() => new GOVUK.SelectionButtons('.block-label input'));
|
||||
|
||||
@@ -27,3 +27,20 @@
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.banner-dangerous {
|
||||
|
||||
@extend .banner;
|
||||
background: $white;
|
||||
color: $error-colour;
|
||||
border: 5px solid $error-colour;
|
||||
margin: 15px 0;
|
||||
@include bold-19;
|
||||
text-align: left;
|
||||
|
||||
.button {
|
||||
@include button($error-colour);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
24
app/assets/stylesheets/components/email-message.scss
Normal file
24
app/assets/stylesheets/components/email-message.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
.email-message {
|
||||
|
||||
margin-bottom: $gutter;
|
||||
border: 1px solid $border-colour;
|
||||
|
||||
&-subject {
|
||||
border-bottom: 1px solid $border-colour;;
|
||||
padding: 10px;
|
||||
@include bold-19;
|
||||
}
|
||||
|
||||
&-body {
|
||||
border-bottom: 1px solid white;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
max-height: 103px;
|
||||
}
|
||||
|
||||
&-name {
|
||||
@include bold-19;
|
||||
margin: 50px 0 10px 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,26 @@
|
||||
margin-top: $gutter;
|
||||
}
|
||||
|
||||
&-delete-link {
|
||||
|
||||
line-height: 40px;
|
||||
padding: 0 0 0 5px;
|
||||
|
||||
a:visited,
|
||||
a:link {
|
||||
color: $error-colour;
|
||||
display: inline-block;
|
||||
vertical-align: center;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:active {
|
||||
color: $mellow-red;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
.button {}
|
||||
|
||||
.button-destructive {
|
||||
|
||||
@@ -45,4 +45,9 @@
|
||||
margin: -$gutter-half 0 $gutter 0;
|
||||
}
|
||||
|
||||
&-name {
|
||||
@include bold-19;
|
||||
margin: 50px 0 10px 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
@import 'components/browse-list';
|
||||
@import 'components/management-navigation';
|
||||
@import 'components/dropdown';
|
||||
@import 'components/email-message';
|
||||
|
||||
@import 'views/job';
|
||||
|
||||
|
||||
39
app/main/views/_templates.py
Normal file
39
app/main/views/_templates.py
Normal file
@@ -0,0 +1,39 @@
|
||||
templates = [
|
||||
{
|
||||
'type': 'sms',
|
||||
'name': 'Confirmation',
|
||||
'body': 'Lasting power of attorney: We’ve received your application. Applications take between 8 and 10 weeks to process.' # noqa
|
||||
},
|
||||
{
|
||||
'type': 'sms',
|
||||
'name': 'Reminder',
|
||||
'body': 'Vehicle tax: Your vehicle tax for ((registration number)) expires on ((date)). Tax your vehicle at www.gov.uk/vehicle-tax' # noqa
|
||||
},
|
||||
{
|
||||
'type': 'sms',
|
||||
'name': 'Warning',
|
||||
'body': 'Vehicle tax: Your vehicle tax for ((registration number)) has expired. Tax your vehicle at www.gov.uk/vehicle-tax' # noqa
|
||||
},
|
||||
{
|
||||
'type': 'email',
|
||||
'name': 'Application alert 06/2016',
|
||||
'subject': 'Your lasting power of attorney application',
|
||||
'body': """Dear ((name)),
|
||||
|
||||
When you’ve made your lasting power of attorney (LPA), you need to register it \
|
||||
with the Office of the Public Guardian (OPG).
|
||||
|
||||
You can apply to register your LPA yourself if you’re able to make your own decisions.
|
||||
|
||||
Your attorney can also register it for you. You’ll be told if they do and you can \
|
||||
object to the registration.
|
||||
|
||||
It takes between 8 and 10 weeks to register an LPA if there are no mistakes in the application.
|
||||
"""
|
||||
},
|
||||
{
|
||||
'type': 'sms',
|
||||
'name': 'Air quality alert',
|
||||
'body': 'Air pollution levels will be ((level)) in ((region)) tomorrow.'
|
||||
},
|
||||
]
|
||||
@@ -22,22 +22,10 @@ from app.main import main
|
||||
from app.main.forms import CsvUploadForm
|
||||
from app.main.uploader import s3upload
|
||||
|
||||
# TODO move this to the templates directory
|
||||
message_templates = [
|
||||
{
|
||||
'name': 'Reminder',
|
||||
'body': """
|
||||
Vehicle tax: Your vehicle tax for ((registration number)) expires on ((date)).
|
||||
Tax your vehicle at www.gov.uk/vehicle-tax
|
||||
"""
|
||||
},
|
||||
{
|
||||
'name': 'Warning',
|
||||
'body': """
|
||||
Vehicle tax: Your vehicle tax for ((registration number)) has expired.
|
||||
Tax your vehicle at www.gov.uk/vehicle-tax
|
||||
"""
|
||||
},
|
||||
from ._templates import templates
|
||||
|
||||
sms_templates = [
|
||||
template for template in templates if template['type'] == 'sms'
|
||||
]
|
||||
|
||||
|
||||
@@ -66,7 +54,7 @@ def sendsms(service_id):
|
||||
return redirect(url_for('.sendsms', service_id=service_id))
|
||||
|
||||
return render_template('views/send-sms.html',
|
||||
message_templates=message_templates,
|
||||
message_templates=sms_templates,
|
||||
form=form,
|
||||
service_id=service_id)
|
||||
|
||||
@@ -88,7 +76,7 @@ def checksms(service_id):
|
||||
'views/check-sms.html',
|
||||
upload_result=upload_result,
|
||||
filename=filename,
|
||||
message_template=message_templates[0]['body'],
|
||||
message_template=sms_templates[0]['body'],
|
||||
service_id=service_id
|
||||
)
|
||||
elif request.method == 'POST':
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
from flask import request, render_template, redirect, url_for
|
||||
from flask import request, render_template, redirect, url_for, flash
|
||||
from flask_login import login_required
|
||||
|
||||
from app.main import main
|
||||
from app.main.forms import TemplateForm
|
||||
|
||||
from ._templates import templates
|
||||
|
||||
|
||||
@main.route("/services/<int:service_id>/templates")
|
||||
@login_required
|
||||
def manage_templates(service_id):
|
||||
return render_template(
|
||||
'views/manage-templates.html',
|
||||
service_id=service_id
|
||||
service_id=service_id,
|
||||
templates=templates,
|
||||
)
|
||||
|
||||
|
||||
@@ -31,21 +34,49 @@ def add_template(service_id):
|
||||
return redirect(url_for('.manage_templates', service_id=service_id))
|
||||
|
||||
|
||||
@main.route("/services/<int:service_id>/templates/<template_id>", methods=['GET', 'POST'])
|
||||
@main.route("/services/<int:service_id>/templates/<int:template_id>", methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_template(service_id, template_id):
|
||||
|
||||
form = TemplateForm()
|
||||
|
||||
form.template_name.data = 'Reminder'
|
||||
form.template_body.data = 'Vehicle tax: Your vehicle tax for ((registration number)) expires on ((date)). Tax your vehicle at www.gov.uk/vehicle-tax' # noqa
|
||||
form.template_name.data = templates[template_id - 1]['name']
|
||||
form.template_body.data = templates[template_id - 1]['body']
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template(
|
||||
'views/edit-template.html',
|
||||
h1='Edit template',
|
||||
form=form,
|
||||
service_id=service_id
|
||||
service_id=service_id,
|
||||
template_id=template_id
|
||||
)
|
||||
elif request.method == 'POST':
|
||||
return redirect(url_for('.manage_templates', service_id=service_id))
|
||||
|
||||
|
||||
@main.route("/services/<int:service_id>/templates/<int:template_id>/delete", methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def delete_template(service_id, template_id):
|
||||
|
||||
form = TemplateForm()
|
||||
|
||||
form.template_name.data = templates[template_id - 1]['name']
|
||||
form.template_body.data = templates[template_id - 1]['body']
|
||||
|
||||
if request.method == 'GET':
|
||||
|
||||
flash('Are you sure you want to delete ‘{}’?'.format(form.template_name.data), 'delete')
|
||||
|
||||
return render_template(
|
||||
'views/edit-template.html',
|
||||
h1='Edit template',
|
||||
form=form,
|
||||
service_id=service_id,
|
||||
template_id=template_id
|
||||
)
|
||||
elif request.method == 'POST':
|
||||
if request.form.get('delete'):
|
||||
return redirect(url_for('.manage_templates', service_id=service_id))
|
||||
else:
|
||||
return redirect(url_for('.manage_templates', service_id=service_id))
|
||||
|
||||
@@ -68,16 +68,22 @@
|
||||
{% endif %}
|
||||
|
||||
<main id="content" role="main" class="page-container">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="error-summary">
|
||||
<ul>
|
||||
{% for message in messages %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<ul class="banner-dangerous">
|
||||
{% for category, message in messages %}
|
||||
<li class="flash-message">
|
||||
{{ message }}
|
||||
{% if 'delete' == category %}
|
||||
<form method='post'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="submit" class="button" name="delete" value="Yes, delete this template" />
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block fullwidth_content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
19
app/templates/components/email-message.html
Normal file
19
app/templates/components/email-message.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% macro email_message(subject, body, name=None, edit_link=None) %}
|
||||
{% if name %}
|
||||
<h3 class="email-message-name">
|
||||
{% if edit_link %}
|
||||
<a href="{{ edit_link }}">{{ name }}</a>
|
||||
{% else %}
|
||||
{{ name }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% endif %}
|
||||
<div class="email-message">
|
||||
<div class="email-message-subject">
|
||||
{{ subject|placeholders }}
|
||||
</div>
|
||||
<div class="email-message-body">
|
||||
{{ body|nl2br|placeholders }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -1,11 +1,23 @@
|
||||
{% macro page_footer(button_text=None, back_link=False, back_link_text="Back", destructive=False) %}
|
||||
{% macro page_footer(
|
||||
button_text=None,
|
||||
destructive=False,
|
||||
back_link=False,
|
||||
back_link_text="Back",
|
||||
delete_link=False,
|
||||
delete_link_text="delete"
|
||||
) %}
|
||||
<div class="page-footer">
|
||||
{% if button_text %}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="submit" class="button{% if destructive %}-destructive{% endif %}" value="{{ button_text }}" />
|
||||
{% endif %}
|
||||
{% if delete_link %}
|
||||
<span class="page-footer-delete-link">
|
||||
or <a href="{{ delete_link }}">{{ delete_link_text }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if back_link %}
|
||||
<a class="page-footer-back-link" role="button" href="{{ back_link }}">{{ back_link_text }}</a>
|
||||
<a class="page-footer-back-link" href="{{ back_link }}">{{ back_link_text }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{% macro sms_message(body, recipient) %}
|
||||
{% macro sms_message(body, recipient=None, name=None, edit_link=None) %}
|
||||
{% if name %}
|
||||
<h3 class="sms-message-name">
|
||||
{% if edit_link %}
|
||||
<a href="{{ edit_link }}">{{ name }}</a>
|
||||
{% else %}
|
||||
{{ name }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% endif %}
|
||||
<div class="sms-message">
|
||||
<div class="sms-message-wrapper">
|
||||
{{ body|placeholders }}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<li><a href="{{ url_for('.sendsms', service_id=123) }}">Send text messages</a></li>
|
||||
<li><a href="{{ url_for('.sendemail', service_id=123) }}">Send emails</a></li>
|
||||
<li><a href="{{ url_for('.showjobs', service_id=123) }}">Activity</a></li>
|
||||
<li><a href="{{ url_for('.manage_templates', service_id=123) }}">Templates</a></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('.apikeys', service_id=123) }}">API keys and documentation</a></li>
|
||||
|
||||
@@ -14,9 +14,10 @@ GOV.UK Notify | Edit template
|
||||
{{ textbox(form.template_name) }}
|
||||
{{ textbox(form.template_body, highlight_tags=True) }}
|
||||
{{ page_footer(
|
||||
'Save and continue',
|
||||
back_link=url_for('.dashboard', service_id=service_id),
|
||||
back_link_text='Back to manage templates'
|
||||
'Save',
|
||||
delete_link=url_for('.delete_template', service_id=service_id, template_id=template_id) if template_id or None,
|
||||
back_link=url_for('.manage_templates', service_id=service_id),
|
||||
back_link_text='Back to templates'
|
||||
) }}
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{% extends "withnav_template.html" %}
|
||||
{% from "components/sms-message.html" import sms_message %}
|
||||
{% from "components/email-message.html" import email_message %}
|
||||
{% from "components/browse-list.html" import browse_list %}
|
||||
|
||||
{% block page_title %}
|
||||
GOV.UK Notify | Manage templates
|
||||
@@ -6,18 +9,32 @@ GOV.UK Notify | Manage templates
|
||||
|
||||
{% block maincolumn_content %}
|
||||
|
||||
<div class="grid-row">
|
||||
<div class="column-two-thirds">
|
||||
|
||||
<h1 class="heading-xlarge">Manage templates</h1>
|
||||
<h1 class="heading-xlarge">Templates</h1>
|
||||
|
||||
<p>Here's where you can view templates, choose to add one, or edit/delete one.</p>
|
||||
<p>
|
||||
<a href="{{ url_for('.add_template', service_id=service_id) }}">Create new template</a>
|
||||
|
||||
<p>
|
||||
<a href="{{ url_for('.edit_template', service_id=service_id, template_id=1) }}">Here is my first template</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a class="button" href="{{ url_for('.add_template', service_id=service_id) }}" role="button">Add a new message template</a>
|
||||
</p>
|
||||
{% for template in templates %}
|
||||
{% if template.type == 'sms' %}
|
||||
{{ sms_message(
|
||||
template.body,
|
||||
name=template.name,
|
||||
edit_link=url_for('.edit_template', service_id=service_id, template_id=loop.index)
|
||||
) }}
|
||||
{% elif template.type == 'email' %}
|
||||
{{ email_message(
|
||||
template.subject,
|
||||
template.body,
|
||||
name=template.name,
|
||||
edit_link=url_for('.edit_template', service_id=service_id, template_id=loop.index)
|
||||
) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,45 +4,42 @@
|
||||
{% from "components/textbox.html" import textbox %}
|
||||
|
||||
{% block page_title %}
|
||||
GOV.UK Notify | Send text messages
|
||||
GOV.UK Notify | Send text messages
|
||||
{% endblock %}
|
||||
|
||||
{% block maincolumn_content %}
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
|
||||
<h1 class="heading-xlarge">Send text messages</h1>
|
||||
<h1 class="heading-xlarge">Send text messages</h1>
|
||||
|
||||
<h2 class="heading-medium">1. Choose text message template</h2>
|
||||
{% for template in message_templates %}
|
||||
<div class="template-picker-option">
|
||||
<div class="template-picker-option-radio">
|
||||
<label class="block-label" for="template-{{loop.index}}">
|
||||
{{ template.name }}
|
||||
<input type="radio" name="template" id="template-{{loop.index}}" value="{{ template.name }}" />
|
||||
</label>
|
||||
</div>
|
||||
{{ sms_message(template.body) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<fieldset class='form-group'>
|
||||
<legend class="heading-medium">1. Choose text message template</legend>
|
||||
{% for template in message_templates %}
|
||||
<label class="block-label" for="template-{{loop.index}}">
|
||||
{{ template.name }}
|
||||
<input type="radio" name="template" id="template-{{loop.index}}" value="{{ template.name }}" />
|
||||
</label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
|
||||
<p>
|
||||
or <a href="{{ url_for(".add_template", service_id=service_id) }}">create a new template</a>
|
||||
</p>
|
||||
<p>
|
||||
or <a href="{{ url_for(".add_template", service_id=service_id) }}">create a new template</a>
|
||||
</p>
|
||||
|
||||
<h2 class="heading-medium">2. Add recipients</h2>
|
||||
<h2 class="heading-medium">2. Add recipients</h2>
|
||||
|
||||
<p>
|
||||
Upload a CSV file to add your recipients’ details.
|
||||
</p>
|
||||
<p>
|
||||
You can also <a href="#">download an example CSV</a>.
|
||||
</p>
|
||||
<p>
|
||||
{{textbox(form.file)}}
|
||||
</p>
|
||||
<p>
|
||||
Upload a CSV file to add your recipients’ details.
|
||||
</p>
|
||||
<p>
|
||||
You can also <a href="#">download an example CSV</a>.
|
||||
</p>
|
||||
<p>
|
||||
{{textbox(form.file)}}
|
||||
</p>
|
||||
|
||||
{{ page_footer("Continue") }}
|
||||
{{ page_footer("Continue") }}
|
||||
|
||||
</form>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -32,6 +32,7 @@ gulp.task('copy:govuk_template:assets', () => gulp.src('bower_components/govuk_t
|
||||
gulp.task('javascripts', () => gulp
|
||||
.src([
|
||||
paths.src + 'govuk_frontend_toolkit/javascripts/govuk/modules.js',
|
||||
paths.src + 'govuk_frontend_toolkit/javascripts/govuk/selection-buttons.js',
|
||||
paths.src + 'javascripts/highlightTags.js',
|
||||
paths.src + 'javascripts/dropdown.js',
|
||||
paths.src + 'javascripts/main.js'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from flask import url_for
|
||||
from tests.app.main import create_test_user
|
||||
|
||||
|
||||
@@ -6,17 +7,17 @@ def test_should_return_list_of_all_templates(notifications_admin, notifications_
|
||||
with notifications_admin.test_client() as client:
|
||||
user = create_test_user('active')
|
||||
client.login(user)
|
||||
response = client.get('/services/123/templates')
|
||||
response = client.get(url_for('.manage_templates', service_id=123))
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_should_show_page_for_one_templates(notifications_admin, notifications_admin_db, notify_db_session):
|
||||
def test_should_show_page_for_one_template(notifications_admin, notifications_admin_db, notify_db_session):
|
||||
with notifications_admin.test_request_context():
|
||||
with notifications_admin.test_client() as client:
|
||||
user = create_test_user('active')
|
||||
client.login(user)
|
||||
response = client.get('/services/123/templates/template')
|
||||
response = client.get(url_for('.edit_template', service_id=123, template_id=1))
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -26,7 +27,29 @@ def test_should_redirect_when_saving_a_template(notifications_admin, notificatio
|
||||
with notifications_admin.test_client() as client:
|
||||
user = create_test_user('active')
|
||||
client.login(user)
|
||||
response = client.post('/services/123/templates/template')
|
||||
response = client.post(url_for('.edit_template', service_id=123, template_id=1))
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.location == 'http://localhost/services/123/templates'
|
||||
assert response.status_code == 302
|
||||
assert response.location == url_for('.manage_templates', service_id=123, _external=True)
|
||||
|
||||
|
||||
def test_should_show_delete_template_page(notifications_admin, notifications_admin_db, notify_db_session):
|
||||
with notifications_admin.test_request_context():
|
||||
with notifications_admin.test_client() as client:
|
||||
user = create_test_user('active')
|
||||
client.login(user)
|
||||
response = client.get(url_for('.delete_template', service_id=123, template_id=1))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'Are you sure' in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_should_redirect_when_deleting_a_template(notifications_admin, notifications_admin_db, notify_db_session):
|
||||
with notifications_admin.test_request_context():
|
||||
with notifications_admin.test_client() as client:
|
||||
user = create_test_user('active')
|
||||
client.login(user)
|
||||
response = client.post(url_for('.delete_template', service_id=123, template_id=1))
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.location == url_for('.manage_templates', service_id=123, _external=True)
|
||||
|
||||
Reference in New Issue
Block a user