Aug 6 - Update enrollment page, add email notification
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
app/enrollment/mailer.py
|
||||
------------------------
|
||||
The enrollment confirmation email.
|
||||
|
||||
Sent to the requester after a submission is stored. One job, and it must never
|
||||
be able to break that: the record is already safely on disk before this runs,
|
||||
so every failure path here is logged and swallowed. A bounced confirmation must
|
||||
not cost the customer their enrollment.
|
||||
|
||||
Sending happens on a background thread (rule 14 — never block the HTTP
|
||||
response), and the From identity comes from branded_sender() so it stays an
|
||||
SMTP-authorized address that actually delivers (rules 64 / 76).
|
||||
|
||||
This is the only part of app/enrollment that touches shared mail
|
||||
infrastructure; it still imports no models and writes no DB row.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from flask import current_app, render_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _text_body(record, people):
|
||||
"""Plain-text alternative — some recipients see only this."""
|
||||
lines = [
|
||||
f'Hi {record.get("request_by") or "there"},',
|
||||
'',
|
||||
'Thank you — we have received your JQC enrollment form.',
|
||||
'',
|
||||
f'Reference: {record.get("id")}',
|
||||
f'Project: {record.get("project_name")}',
|
||||
'',
|
||||
f'People to be set up ({len(people)}):',
|
||||
]
|
||||
for i, person in enumerate(people, start=1):
|
||||
lines.append(
|
||||
f' {i}. {person["name"]} — {person["role_label"]} — {person["email"]}'
|
||||
)
|
||||
lines += [
|
||||
'',
|
||||
'Our team will create these accounts. Each person will receive their own '
|
||||
'email invitation with sign-in instructions.',
|
||||
'',
|
||||
'If anything above is wrong, simply reply to this email and we will '
|
||||
'correct it.',
|
||||
'',
|
||||
'JQC by L.T Services, Inc',
|
||||
]
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def send_confirmation(record, base_url=None):
|
||||
"""Email the requester a copy of what they submitted. Never raises."""
|
||||
email = (record.get('requester_email') or '').strip()
|
||||
if not email:
|
||||
return
|
||||
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
logger.warning('ENROLLMENT EMAIL SKIPPED | no MAIL_SERVER | id=%s',
|
||||
record.get('id'))
|
||||
return
|
||||
|
||||
try:
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from app.utils.mail_utils import branded_sender
|
||||
from . import schema
|
||||
|
||||
effective_base = (base_url
|
||||
or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||
people = schema.people_of(record)
|
||||
|
||||
msg = Message(
|
||||
subject = f'[JQC] Enrollment received — {record.get("project_name")}',
|
||||
sender = branded_sender(effective_base),
|
||||
recipients = [email],
|
||||
body = _text_body(record, people),
|
||||
html = render_template('enrollment/email_confirmation.html',
|
||||
record=record, people=people,
|
||||
schema=schema),
|
||||
)
|
||||
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def _send():
|
||||
with app.app_context():
|
||||
try:
|
||||
mail.send(msg)
|
||||
logger.info('ENROLLMENT EMAIL SENT | to=%s | id=%s',
|
||||
email, record.get('id'))
|
||||
except Exception as exc:
|
||||
logger.error('ENROLLMENT EMAIL FAILED | to=%s | id=%s | error=%s',
|
||||
email, record.get('id'), exc)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
except Exception:
|
||||
# Building the message failed (bad template, mail misconfigured, …).
|
||||
# The submission is already saved — log it and move on.
|
||||
logger.exception('ENROLLMENT EMAIL BUILD FAILED | id=%s', record.get('id'))
|
||||
@@ -36,7 +36,7 @@ from flask_login import login_required
|
||||
from app import limiter
|
||||
from app.utils.decorators import admin_required
|
||||
|
||||
from . import schema, storage
|
||||
from . import mailer, schema, storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -145,8 +145,9 @@ def submit():
|
||||
logger.info('ENROLLMENT | honeypot tripped | ip=%s', request.remote_addr)
|
||||
return render_template('enrollment/submitted.html', reference=None)
|
||||
|
||||
project_name = _clean(request.form.get('project_name'))
|
||||
request_by = _clean(request.form.get('request_by'))
|
||||
project_name = _clean(request.form.get('project_name'))
|
||||
request_by = _clean(request.form.get('request_by'))
|
||||
requester_email = _clean(request.form.get('requester_email'))
|
||||
|
||||
people = _parse_people(request.form)
|
||||
|
||||
@@ -176,6 +177,11 @@ def submit():
|
||||
errors.append('Project Name is required.')
|
||||
if not request_by:
|
||||
errors.append('Request by is required.')
|
||||
if not requester_email:
|
||||
errors.append('Requester email is required — we send your confirmation '
|
||||
'there.')
|
||||
elif '@' not in requester_email:
|
||||
errors.append('The requester email address does not look valid.')
|
||||
if not named:
|
||||
errors.append('Please add at least one person with both a name and an '
|
||||
'email address.')
|
||||
@@ -192,8 +198,9 @@ def submit():
|
||||
seen.add(low)
|
||||
|
||||
prior = {
|
||||
'project_name': project_name,
|
||||
'request_by': request_by,
|
||||
'project_name': project_name,
|
||||
'request_by': request_by,
|
||||
'requester_email': requester_email,
|
||||
'date_requested': _clean(request.form.get('date_requested')),
|
||||
'notes': _clean(request.form.get('notes'), _MAX_NOTES),
|
||||
'people': people,
|
||||
@@ -236,7 +243,12 @@ def submit():
|
||||
logger.info('ENROLLMENT | submitted | id=%s project=%r people=%d ip=%s',
|
||||
record['id'], project_name, len(named), request.remote_addr)
|
||||
|
||||
return render_template('enrollment/submitted.html', reference=record['id'])
|
||||
# Confirmation to the requester. Fired AFTER the save and fully guarded —
|
||||
# a mail problem must never cost the customer their submission.
|
||||
mailer.send_confirmation(record, base_url=request.host_url)
|
||||
|
||||
return render_template('enrollment/submitted.html', reference=record['id'],
|
||||
email=requester_email)
|
||||
|
||||
|
||||
# ── Admin ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -128,7 +128,11 @@ NOTES = [
|
||||
]
|
||||
|
||||
|
||||
# ── Office-use fields (filled in by L.T. Services after receipt) ─────────────
|
||||
# ── Office-use fields ────────────────────────────────────────────────────────
|
||||
# Filled in by L.T. Services AFTER receipt, on the admin detail page only. The
|
||||
# printed sheet showed these to the customer as a blank "for office use" block;
|
||||
# the web form does not render them at all — a customer cannot fill them in, so
|
||||
# showing them was only noise.
|
||||
OFFICE_FIELDS = [
|
||||
('receive_date', 'Receive Date'),
|
||||
('program_by', 'Program By'),
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-5">Project Name</dt><dd class="col-7">{{ record.project_name or '—' }}</dd>
|
||||
<dt class="col-5">Request by</dt><dd class="col-7">{{ record.request_by or '—' }}</dd>
|
||||
<dt class="col-5">Requester email</dt>
|
||||
<dd class="col-7">
|
||||
{% if record.requester_email %}
|
||||
<a href="mailto:{{ record.requester_email }}">{{ record.requester_email }}</a>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</dd>
|
||||
<dt class="col-5">Date Requested</dt><dd class="col-7">{{ record.date_requested or '—' }}</dd>
|
||||
</dl>
|
||||
{% if record.notes %}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{# Confirmation email sent to the requester. Inline styles only and no external
|
||||
assets — mail clients strip <style> blocks and block remote resources. #}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family:Arial,Helvetica,sans-serif;color:#333;max-width:640px;margin:auto;padding:12px;">
|
||||
|
||||
<h2 style="color:#1a6fb5;margin:0 0 4px;">Enrollment received</h2>
|
||||
<p style="color:#6b7280;margin:0 0 20px;">JQC by L.T Services, Inc</p>
|
||||
|
||||
<p>Hi {{ record.request_by or 'there' }},</p>
|
||||
<p>Thank you — we have received your JQC enrollment form. Our team will set up
|
||||
the accounts listed below.</p>
|
||||
|
||||
<table style="border-collapse:collapse;margin:18px 0;">
|
||||
<tr>
|
||||
<td style="padding:4px 14px 4px 0;color:#6b7280;">Reference</td>
|
||||
<td style="padding:4px 0;font-weight:bold;">{{ record.id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 14px 4px 0;color:#6b7280;">Project</td>
|
||||
<td style="padding:4px 0;font-weight:bold;">{{ record.project_name }}</td>
|
||||
</tr>
|
||||
{% if record.date_requested %}
|
||||
<tr>
|
||||
<td style="padding:4px 14px 4px 0;color:#6b7280;">Date requested</td>
|
||||
<td style="padding:4px 0;">{{ record.date_requested }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
<h3 style="font-size:1rem;margin:22px 0 8px;">
|
||||
People to be set up ({{ people | length }})
|
||||
</h3>
|
||||
|
||||
<table style="border-collapse:collapse;width:100%;font-size:.92rem;">
|
||||
<thead>
|
||||
<tr style="background:#dbeafe;">
|
||||
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Name</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Role</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Email</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:center;">Mobile App</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for person in people %}
|
||||
<tr>
|
||||
<td style="border:1px solid #cbd5e1;padding:6px 9px;">
|
||||
{{ person.name }}
|
||||
{% if person.job_title %}
|
||||
<div style="color:#6b7280;font-size:.82rem;">{{ person.job_title }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="border:1px solid #cbd5e1;padding:6px 9px;">{{ person.role_label }}</td>
|
||||
<td style="border:1px solid #cbd5e1;padding:6px 9px;">{{ person.email }}</td>
|
||||
<td style="border:1px solid #cbd5e1;padding:6px 9px;text-align:center;">
|
||||
{{ 'Yes' if schema.wants_mobile(record, person.key) else '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:22px;">
|
||||
Each person will receive their own email invitation with sign-in
|
||||
instructions, along with a quick guide to the web portal and the mobile app.
|
||||
</p>
|
||||
<p>If anything above is wrong, simply reply to this email and we will correct it.</p>
|
||||
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:26px 0 12px;">
|
||||
<p style="color:#9ca3af;font-size:.8rem;margin:0;">
|
||||
You are receiving this because this address was given as the requester on a
|
||||
JQC enrollment form. Reference {{ record.id }}.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -84,8 +84,11 @@
|
||||
</div>
|
||||
|
||||
{# ── Header ─────────────────────────────────────────────────────── #}
|
||||
{# The printed sheet carried a blank "for office use" block here. It is not
|
||||
rendered on the web form — a customer cannot fill it in. Those fields
|
||||
still exist and are filled by staff on the admin detail page. #}
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="col-12 col-lg-8">
|
||||
<table class="hdr-table" style="width:100%;">
|
||||
<tr>
|
||||
<td class="lbl">Project Name</td>
|
||||
@@ -95,24 +98,25 @@
|
||||
<tr>
|
||||
<td class="lbl">Request by:</td>
|
||||
<td><input type="text" name="request_by" required maxlength="200"
|
||||
placeholder="Your name"
|
||||
value="{{ submitted.request_by if submitted else '' }}"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">Requester email:</td>
|
||||
<td><input type="email" name="requester_email" required maxlength="200"
|
||||
placeholder="you@company.com"
|
||||
value="{{ submitted.requester_email if submitted else '' }}"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">Date Requested:</td>
|
||||
<td><input type="date" name="date_requested"
|
||||
value="{{ submitted.date_requested if submitted else '' }}"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<table class="hdr-table" style="width:100%;">
|
||||
{% for key, label in schema.OFFICE_FIELDS %}
|
||||
<tr>
|
||||
<td class="lbl">{{ label }}</td>
|
||||
<td class="office-note">For office use</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<div class="form-text mt-1">
|
||||
We send your confirmation, with a copy of everything below, to the
|
||||
requester email.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
email invitation with sign-in instructions, and a quick guide for the
|
||||
web portal and the mobile app.
|
||||
</p>
|
||||
{% if email %}
|
||||
<p class="mb-4">
|
||||
<i class="bi bi-envelope-check text-success"></i>
|
||||
A confirmation has been sent to <strong>{{ email }}</strong>.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if reference %}
|
||||
<div class="border rounded-3 p-3 bg-light d-inline-block">
|
||||
<div class="text-muted small">Your reference number</div>
|
||||
|
||||
Reference in New Issue
Block a user