Aug 6 - Update enrollment page, add email notification

This commit is contained in:
2026-08-06 12:00:22 -04:00
parent 5bc7592c1a
commit 548e262b2a
9 changed files with 239 additions and 19 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
"Bash(SECRET_KEY=x DATABASE_URL=sqlite:///:memory: DIGEST_SECRET=x MAIL_SERVER=localhost MAIL_USERNAME=x MAIL_PASSWORD=x MAIL_PORT=587 APP_BASE_URL=http://localhost MAIL_DEFAULT_SENDER=x@x.com python -c ' *)", "Bash(SECRET_KEY=x DATABASE_URL=sqlite:///:memory: DIGEST_SECRET=x MAIL_SERVER=localhost MAIL_USERNAME=x MAIL_PASSWORD=x MAIL_PORT=587 APP_BASE_URL=http://localhost MAIL_DEFAULT_SENDER=x@x.com python -c ' *)",
"Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/routes/dashboard.py',encoding='utf-8'\\).read\\(\\)\\); print\\('dashboard route OK'\\)\")", "Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/routes/dashboard.py',encoding='utf-8'\\).read\\(\\)\\); print\\('dashboard route OK'\\)\")",
"Bash(python -c ' *)", "Bash(python -c ' *)",
"Bash(git diff *)" "Bash(git diff *)",
"Bash(python -c \"import ast;ast.parse\\(open\\('app/enrollment/routes.py',encoding='utf-8'\\).read\\(\\)\\);print\\('parses OK'\\)\")"
] ]
} }
} }
+8
View File
@@ -1659,6 +1659,8 @@ If it ever needs to *create* the accounts it describes, do that as a **separate
The printed form had six fixed seats (Admin/Director + Inspector 15) and a static RECOMMENDATION table for the customer to copy by hand. The web form reworks that: The printed form had six fixed seats (Admin/Director + Inspector 15) and a static RECOMMENDATION table for the customer to copy by hand. The web form reworks that:
The header collects Project Name, **Request by**, **Requester email** (required — the confirmation goes there) and Date Requested. The printed sheet's blank *"for office use"* block 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.
1. **Step 1 — the people.** Free-form rows, each with a **role dropdown** (`schema.ROLES`: Admin / Director / Auditor / Inspector / External Inspector), name, job title, email. Starts with one row defaulted to `DEFAULT_FIRST_ROLE`; **"Add another person"** appends more, capped at `MAX_PEOPLE` (25). The last row cannot be removed. 1. **Step 1 — the people.** Free-form rows, each with a **role dropdown** (`schema.ROLES`: Admin / Director / Auditor / Inspector / External Inspector), name, job title, email. Starts with one row defaulted to `DEFAULT_FIRST_ROLE`; **"Add another person"** appends more, capped at `MAX_PEOPLE` (25). The last row cannot be removed.
2. **Step 2 — the task matrix**, with **one column per person from Step 1**, rebuilt in the browser whenever a name, role or row changes. Existing ticks survive a rebuild (preserved by field name). 2. **Step 2 — the task matrix**, with **one column per person from Step 1**, rebuilt in the browser whenever a name, role or row changes. Existing ticks survive a rebuild (preserved by field name).
3. **Step 3 — mobile app**, likewise one column per person. 3. **Step 3 — mobile app**, likewise one column per person.
@@ -1675,6 +1677,12 @@ A **"Recommendation selection"** button applies `schema.recommendation_map()` pe
**Legacy submissions.** Files stored in the original fixed-seat format are never rewritten; `schema.people_of()` / `cell()` / `wants_mobile()` normalise on read, so the admin list, detail view and CSV render both shapes identically — verified against a hand-written legacy file. **Legacy submissions.** Files stored in the original fixed-seat format are never rewritten; `schema.people_of()` / `cell()` / `wants_mobile()` normalise on read, so the admin list, detail view and CSV render both shapes identically — verified against a hand-written legacy file.
### Confirmation email
`mailer.send_confirmation(record)` emails the requester a copy of what they submitted (reference number, project, and the full people table with roles and mobile-app flags), HTML + plain-text, rendered from `templates/enrollment/email_confirmation.html`. Sent on a background thread (rule 14) with `branded_sender()` as the From (rules 64/76).
**It cannot cost a customer their enrollment.** It fires *after* `storage.save()` and every failure path is caught and logged: no `MAIL_SERVER`, `mail.send` raising, or the template itself blowing up all still return the normal thank-you page with the record safely on disk — verified for all three. `mailer.py` is the only part of the package touching shared mail infrastructure; it still imports no models and writes no DB row.
### Storage ### Storage
One JSON document per submission in `ENROLLMENT_DIR`, named `<YYYYmmdd-HHMMSS>-<8 hex>.json` — time-ordered so a directory listing sorts chronologically, random suffix so two submissions in the same second cannot collide. The stem is the submission id and the **only** thing the admin URLs accept. One JSON document per submission in `ENROLLMENT_DIR`, named `<YYYYmmdd-HHMMSS>-<8 hex>.json` — time-ordered so a directory listing sorts chronologically, random suffix so two submissions in the same second cannot collide. The stem is the submission id and the **only** thing the admin URLs accept.
+104
View File
@@ -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'))
+14 -2
View File
@@ -36,7 +36,7 @@ from flask_login import login_required
from app import limiter from app import limiter
from app.utils.decorators import admin_required from app.utils.decorators import admin_required
from . import schema, storage from . import mailer, schema, storage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -147,6 +147,7 @@ def submit():
project_name = _clean(request.form.get('project_name')) project_name = _clean(request.form.get('project_name'))
request_by = _clean(request.form.get('request_by')) request_by = _clean(request.form.get('request_by'))
requester_email = _clean(request.form.get('requester_email'))
people = _parse_people(request.form) people = _parse_people(request.form)
@@ -176,6 +177,11 @@ def submit():
errors.append('Project Name is required.') errors.append('Project Name is required.')
if not request_by: if not request_by:
errors.append('Request by is required.') 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: if not named:
errors.append('Please add at least one person with both a name and an ' errors.append('Please add at least one person with both a name and an '
'email address.') 'email address.')
@@ -194,6 +200,7 @@ def submit():
prior = { prior = {
'project_name': project_name, 'project_name': project_name,
'request_by': request_by, 'request_by': request_by,
'requester_email': requester_email,
'date_requested': _clean(request.form.get('date_requested')), 'date_requested': _clean(request.form.get('date_requested')),
'notes': _clean(request.form.get('notes'), _MAX_NOTES), 'notes': _clean(request.form.get('notes'), _MAX_NOTES),
'people': people, 'people': people,
@@ -236,7 +243,12 @@ def submit():
logger.info('ENROLLMENT | submitted | id=%s project=%r people=%d ip=%s', logger.info('ENROLLMENT | submitted | id=%s project=%r people=%d ip=%s',
record['id'], project_name, len(named), request.remote_addr) 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 ──────────────────────────────────────────────────────────────────── # ── Admin ────────────────────────────────────────────────────────────────────
+5 -1
View File
@@ -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 = [ OFFICE_FIELDS = [
('receive_date', 'Receive Date'), ('receive_date', 'Receive Date'),
('program_by', 'Program By'), ('program_by', 'Program By'),
@@ -37,6 +37,12 @@
<dl class="row mb-0"> <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">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">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> <dt class="col-5">Date Requested</dt><dd class="col-7">{{ record.date_requested or '—' }}</dd>
</dl> </dl>
{% if record.notes %} {% 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>
+14 -10
View File
@@ -84,8 +84,11 @@
</div> </div>
{# ── Header ─────────────────────────────────────────────────────── #} {# ── 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="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%;"> <table class="hdr-table" style="width:100%;">
<tr> <tr>
<td class="lbl">Project Name</td> <td class="lbl">Project Name</td>
@@ -95,24 +98,25 @@
<tr> <tr>
<td class="lbl">Request by:</td> <td class="lbl">Request by:</td>
<td><input type="text" name="request_by" required maxlength="200" <td><input type="text" name="request_by" required maxlength="200"
placeholder="Your name"
value="{{ submitted.request_by if submitted else '' }}"></td> value="{{ submitted.request_by if submitted else '' }}"></td>
</tr> </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> <tr>
<td class="lbl">Date Requested:</td> <td class="lbl">Date Requested:</td>
<td><input type="date" name="date_requested" <td><input type="date" name="date_requested"
value="{{ submitted.date_requested if submitted else '' }}"></td> value="{{ submitted.date_requested if submitted else '' }}"></td>
</tr> </tr>
</table> </table>
<div class="form-text mt-1">
We send your confirmation, with a copy of everything below, to the
requester email.
</div> </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> </div>
</div> </div>
@@ -23,6 +23,12 @@
email invitation with sign-in instructions, and a quick guide for the email invitation with sign-in instructions, and a quick guide for the
web portal and the mobile app. web portal and the mobile app.
</p> </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 %} {% if reference %}
<div class="border rounded-3 p-3 bg-light d-inline-block"> <div class="border rounded-3 p-3 bg-light d-inline-block">
<div class="text-muted small">Your reference number</div> <div class="text-muted small">Your reference number</div>