Aug 6 - Update enrollment page
This commit is contained in:
+132
-66
@@ -26,6 +26,7 @@ its own standalone template with no authenticated nav.
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
@@ -59,13 +60,78 @@ def _clean(value, limit=_MAX_TEXT):
|
||||
return (value or '').strip()[:limit]
|
||||
|
||||
|
||||
#: Person rows are named person_<n>_<field>. The client controls <n> (rows can
|
||||
#: be added and removed in any order), so the server discovers the indexes that
|
||||
#: were actually posted rather than trusting a count field.
|
||||
_PERSON_FIELD_RE = re.compile(r'^person_(\d+)_role$')
|
||||
|
||||
|
||||
def _parse_people(form):
|
||||
"""Return the submitted people as an ordered list of dicts.
|
||||
|
||||
Each entry gets a stable `key` (p1, p2, …) assigned by POSITION, not by the
|
||||
client's index — so the matrix keys in a stored submission are always dense
|
||||
and predictable no matter which rows the customer deleted before sending.
|
||||
"""
|
||||
indexes = sorted(
|
||||
int(m.group(1))
|
||||
for m in (_PERSON_FIELD_RE.match(k) for k in form.keys()) if m
|
||||
)
|
||||
|
||||
people = []
|
||||
for idx in indexes:
|
||||
role = form.get(f'person_{idx}_role', '')
|
||||
if role not in schema.ROLE_KEYS:
|
||||
role = schema.DEFAULT_FIRST_ROLE
|
||||
name = _clean(form.get(f'person_{idx}_name'))
|
||||
job_title = _clean(form.get(f'person_{idx}_job_title'))
|
||||
email = _clean(form.get(f'person_{idx}_email'))
|
||||
# Drop rows the customer added but left completely blank.
|
||||
if not (name or job_title or email):
|
||||
continue
|
||||
people.append({
|
||||
'key': f'p{len(people) + 1}',
|
||||
'form_index': idx, # so the matrix cells can be read back
|
||||
'role': role,
|
||||
'name': name,
|
||||
'job_title': job_title,
|
||||
'email': email,
|
||||
})
|
||||
if len(people) >= schema.MAX_PEOPLE:
|
||||
logger.warning('ENROLLMENT | people capped at %d', schema.MAX_PEOPLE)
|
||||
break
|
||||
return people
|
||||
|
||||
|
||||
def _seed_people(people, matrix, mobile_app):
|
||||
"""Shape the submitted people for the page to re-render after an error.
|
||||
|
||||
Folds each person's ticked tasks into their own row, so the browser can
|
||||
rebuild the table from scratch with fresh row indexes and still restore
|
||||
every answer.
|
||||
"""
|
||||
seed = []
|
||||
for person in people:
|
||||
seed.append({
|
||||
'role': person['role'],
|
||||
'name': person['name'],
|
||||
'job_title': person['job_title'],
|
||||
'email': person['email'],
|
||||
'tasks': [ref for ref, _l, _s in schema.TASKS
|
||||
if matrix.get(str(ref), {}).get(person['key'])],
|
||||
'mobile': bool(mobile_app.get(person['key'])),
|
||||
})
|
||||
return seed
|
||||
|
||||
|
||||
# ── Public form ──────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@bp.route('/', methods=['GET'])
|
||||
def form():
|
||||
"""Render the blank enrollment form. No login — the link is emailed out."""
|
||||
return render_template('enrollment/form.html', schema=schema)
|
||||
return render_template('enrollment/form.html', schema=schema,
|
||||
seed_people=[])
|
||||
|
||||
|
||||
@bp.route('', methods=['POST'])
|
||||
@@ -82,82 +148,80 @@ def submit():
|
||||
project_name = _clean(request.form.get('project_name'))
|
||||
request_by = _clean(request.form.get('request_by'))
|
||||
|
||||
# ── Step 1 matrix ────────────────────────────────────────────────────
|
||||
people = _parse_people(request.form)
|
||||
|
||||
# ── Step 2 matrix + Step 3 mobile, keyed by person ───────────────────
|
||||
# Only cells the person's role actually offers are read, so a crafted POST
|
||||
# cannot record an admin-only task against an inspector.
|
||||
matrix = {}
|
||||
for ref, _label, scope in schema.TASKS:
|
||||
row = {}
|
||||
for col_key, _col_label in schema.columns_for(scope):
|
||||
row[col_key] = bool(request.form.get(f'task_{ref}_{col_key}'))
|
||||
for person in people:
|
||||
if schema.task_applies(scope, person['role']):
|
||||
row[person['key']] = bool(
|
||||
request.form.get(f'task_{ref}_person_{person["form_index"]}'))
|
||||
matrix[str(ref)] = row
|
||||
|
||||
# ── Step 2 registrants ───────────────────────────────────────────────
|
||||
registrants = []
|
||||
for key, label in schema.REGISTRANTS:
|
||||
entry = {
|
||||
'key': key,
|
||||
'label': label,
|
||||
'include': bool(request.form.get(f'reg_{key}_include')),
|
||||
'name': _clean(request.form.get(f'reg_{key}_name')),
|
||||
'job_title': _clean(request.form.get(f'reg_{key}_job_title')),
|
||||
'email': _clean(request.form.get(f'reg_{key}_email')),
|
||||
}
|
||||
registrants.append(entry)
|
||||
|
||||
# ── Step 3 mobile app ────────────────────────────────────────────────
|
||||
mobile_app = {
|
||||
col_key: bool(request.form.get(f'mobile_{col_key}'))
|
||||
for col_key, _ in schema.COLUMNS
|
||||
p['key']: bool(request.form.get(f'mobile_person_{p["form_index"]}'))
|
||||
for p in people
|
||||
}
|
||||
|
||||
# ── Validation ───────────────────────────────────────────────────────
|
||||
# A row counts as a real person only when it has BOTH a name and an email —
|
||||
# a half-filled row cannot be set up, so it must not pass as one.
|
||||
named = [r for r in registrants if r['name'] and r['email']]
|
||||
# A person counts only with BOTH a name and an email — a half-filled row
|
||||
# cannot be set up, so it must not pass as one.
|
||||
named = [p for p in people if p['name'] and p['email']]
|
||||
errors = []
|
||||
if not project_name:
|
||||
errors.append('Project Name is required.')
|
||||
if not request_by:
|
||||
errors.append('Request by is required.')
|
||||
if not named:
|
||||
errors.append('Please provide at least one user with both a name and '
|
||||
'an email address in Step 2.')
|
||||
for r in registrants:
|
||||
if r['email'] and '@' not in r['email']:
|
||||
errors.append(f'"{r["label"]}" has an email address that does not '
|
||||
f'look valid.')
|
||||
errors.append('Please add at least one person with both a name and an '
|
||||
'email address.')
|
||||
for p in people:
|
||||
if p['email'] and '@' not in p['email']:
|
||||
errors.append(f'"{p["name"] or p["key"]}" has an email address that '
|
||||
f'does not look valid.')
|
||||
seen = set()
|
||||
for p in named:
|
||||
low = p['email'].lower()
|
||||
if low in seen:
|
||||
errors.append(f'{p["email"]} is listed more than once — each person '
|
||||
f'needs their own email address.')
|
||||
seen.add(low)
|
||||
|
||||
prior = {
|
||||
'project_name': project_name,
|
||||
'request_by': request_by,
|
||||
'date_requested': _clean(request.form.get('date_requested')),
|
||||
'notes': _clean(request.form.get('notes'), _MAX_NOTES),
|
||||
'people': people,
|
||||
'matrix': matrix,
|
||||
'mobile_app': mobile_app,
|
||||
}
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
flash(e, 'danger')
|
||||
# Re-render with what they typed so nothing is retyped.
|
||||
return render_template(
|
||||
'enrollment/form.html', schema=schema,
|
||||
submitted={'project_name': project_name, 'request_by': request_by,
|
||||
'date_requested': _clean(request.form.get('date_requested')),
|
||||
'notes': _clean(request.form.get('notes'), _MAX_NOTES),
|
||||
'matrix': matrix, 'registrants': registrants,
|
||||
'mobile_app': mobile_app},
|
||||
), 400
|
||||
'enrollment/form.html', schema=schema, submitted=prior,
|
||||
seed_people=_seed_people(people, matrix, mobile_app)), 400
|
||||
|
||||
now = datetime.now()
|
||||
record = {
|
||||
'id': storage.new_id(now),
|
||||
'submitted_at': now.isoformat(timespec='seconds'),
|
||||
'project_name': project_name,
|
||||
'request_by': request_by,
|
||||
'date_requested': _clean(request.form.get('date_requested')),
|
||||
'notes': _clean(request.form.get('notes'), _MAX_NOTES),
|
||||
'matrix': matrix,
|
||||
'registrants': registrants,
|
||||
'mobile_app': mobile_app,
|
||||
record = dict(prior)
|
||||
record.update({
|
||||
'id': storage.new_id(now),
|
||||
'submitted_at': now.isoformat(timespec='seconds'),
|
||||
# Filled in later by staff on the admin page.
|
||||
'office': {k: '' for k, _ in schema.OFFICE_FIELDS},
|
||||
'status': 'new',
|
||||
'office': {k: '' for k, _ in schema.OFFICE_FIELDS},
|
||||
'status': 'new',
|
||||
'meta': {
|
||||
'ip': request.remote_addr,
|
||||
'user_agent': (request.headers.get('User-Agent') or '')[:300],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try:
|
||||
storage.save(record)
|
||||
@@ -165,9 +229,11 @@ def submit():
|
||||
logger.exception('ENROLLMENT | save failed | project=%r', project_name)
|
||||
flash('Sorry — we could not save your form. Please try again, or '
|
||||
'email us directly.', 'danger')
|
||||
return render_template('enrollment/form.html', schema=schema), 500
|
||||
return render_template(
|
||||
'enrollment/form.html', schema=schema, submitted=prior,
|
||||
seed_people=_seed_people(people, matrix, mobile_app)), 500
|
||||
|
||||
logger.info('ENROLLMENT | submitted | id=%s project=%r users=%d ip=%s',
|
||||
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'])
|
||||
@@ -229,22 +295,20 @@ def admin_download(submission_id):
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_export_csv():
|
||||
"""One row per REGISTRANT (not per submission) — that is the unit of work
|
||||
when actually setting the accounts up."""
|
||||
"""One row per PERSON (not per submission) — that is the unit of work when
|
||||
actually setting the accounts up. Reads through schema.people_of(), so
|
||||
submissions stored in the older fixed-seat format export identically."""
|
||||
records = storage.load_all()
|
||||
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf)
|
||||
task_headers = [f'{ref}. {label}' for ref, label, _ in schema.TASKS]
|
||||
w.writerow(['Submission ID', 'Submitted At', 'Status', 'Project Name',
|
||||
'Requested By', 'Date Requested', 'Seat', 'Name', 'Job Title',
|
||||
'Requested By', 'Date Requested', 'Role', 'Name', 'Job Title',
|
||||
'Email', 'Mobile App'] + task_headers)
|
||||
|
||||
for rec in records:
|
||||
for reg in rec.get('registrants', []):
|
||||
if not (reg.get('name') or reg.get('email')):
|
||||
continue
|
||||
key = reg.get('key')
|
||||
for person in schema.people_of(rec):
|
||||
row = [
|
||||
rec.get('id', ''),
|
||||
rec.get('submitted_at', ''),
|
||||
@@ -252,15 +316,17 @@ def admin_export_csv():
|
||||
rec.get('project_name', ''),
|
||||
rec.get('request_by', ''),
|
||||
rec.get('date_requested', ''),
|
||||
reg.get('label', ''),
|
||||
reg.get('name', ''),
|
||||
reg.get('job_title', ''),
|
||||
reg.get('email', ''),
|
||||
'Yes' if rec.get('mobile_app', {}).get(key) else '',
|
||||
person['role_label'],
|
||||
person['name'],
|
||||
person['job_title'],
|
||||
person['email'],
|
||||
'Yes' if schema.wants_mobile(rec, person['key']) else '',
|
||||
]
|
||||
for ref, _label, _scope in schema.TASKS:
|
||||
cell = rec.get('matrix', {}).get(str(ref), {}).get(key)
|
||||
row.append('Yes' if cell else '')
|
||||
for ref, _label, scope in schema.TASKS:
|
||||
if not schema.task_applies(scope, person['role']):
|
||||
row.append('n/a')
|
||||
else:
|
||||
row.append('Yes' if schema.cell(rec, ref, person['key']) else '')
|
||||
w.writerow(row)
|
||||
|
||||
logger.info('ENROLLMENT | csv export | submissions=%d', len(records))
|
||||
|
||||
Reference in New Issue
Block a user