Aug 6 - Add enrollment page

This commit is contained in:
2026-08-06 11:10:26 -04:00
parent c988f8cabb
commit 633a5b865f
12 changed files with 1239 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
"""
app/enrollment/routes.py
------------------------
The JQC Enrollment Form.
GET /enrollment public form (NO login)
POST /enrollment submit → thank-you page
GET /enrollment/admin admin: all submissions
GET /enrollment/admin/<id> admin: one submission
POST /enrollment/admin/<id> admin: office-use fields + status
GET /enrollment/admin/<id>.json admin: raw JSON download
GET /enrollment/admin/export.csv admin: all submissions as CSV
Separation
----------
This module imports NOTHING from app.models and writes NOTHING to the database
(see app/enrollment/__init__.py). Its only couplings to the rest of the app are
the ones it cannot sensibly reinvent: the app factory, CSRF, the rate limiter,
and @admin_required for the admin views.
The public page is login-free, so it follows the same hardening as the `public`
blueprint (rule 74): CSRF-protected form, rate limited, honeypot-guarded, and
its own standalone template with no authenticated nav.
"""
import csv
import io
import logging
from datetime import datetime
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, abort, Response, current_app)
from flask_login import login_required
from app import limiter
from app.utils.decorators import admin_required
from . import schema, storage
logger = logging.getLogger(__name__)
bp = Blueprint(
'enrollment', __name__,
url_prefix='/enrollment',
# Own template folder — enrollment templates never mix into app/templates.
template_folder='templates',
)
#: Bots find public forms fast. A human filling in a 6-person enrollment form
#: does not need more than a few attempts an hour from one address.
_SUBMIT_RATE_LIMIT = '5 per hour'
_MAX_TEXT = 200 # per free-text field; anything longer is truncated
_MAX_NOTES = 2000
def _clean(value, limit=_MAX_TEXT):
"""Trim and length-cap one submitted text field."""
return (value or '').strip()[:limit]
# ── 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)
@bp.route('', methods=['POST'])
@bp.route('/', methods=['POST'])
@limiter.limit(_SUBMIT_RATE_LIMIT)
def submit():
"""Parse, validate and store one enrollment submission."""
# Honeypot: a field hidden from humans via CSS. Anything that fills it in
# is a bot. Answer 200 as though accepted so it learns nothing.
if (request.form.get('website') or '').strip():
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'))
# ── Step 1 matrix ────────────────────────────────────────────────────
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}'))
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
}
# ── 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']]
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.')
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
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,
# Filled in later by staff on the admin page.
'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)
except Exception:
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
logger.info('ENROLLMENT | submitted | id=%s project=%r users=%d ip=%s',
record['id'], project_name, len(named), request.remote_addr)
return render_template('enrollment/submitted.html', reference=record['id'])
# ── Admin ────────────────────────────────────────────────────────────────────
@bp.route('/admin')
@login_required
@admin_required
def admin_list():
records = storage.load_all()
logger.info('ENROLLMENT | admin_list | count=%d', len(records))
return render_template('enrollment/admin_list.html',
records=records, schema=schema)
@bp.route('/admin/<submission_id>', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_detail(submission_id):
record = storage.load(submission_id)
if record is None:
abort(404)
if request.method == 'POST':
office = {k: _clean(request.form.get(k)) for k, _ in schema.OFFICE_FIELDS}
status = request.form.get('status', 'new')
if status not in schema.STATUSES:
status = record.get('status', 'new')
record = storage.update_office(submission_id, office, status)
if record is None:
abort(404)
flash('Enrollment record updated.', 'success')
return redirect(url_for('enrollment.admin_detail',
submission_id=submission_id))
return render_template('enrollment/admin_detail.html',
record=record, schema=schema)
@bp.route('/admin/<submission_id>.json')
@login_required
@admin_required
def admin_download(submission_id):
import json
record = storage.load(submission_id)
if record is None:
abort(404)
return Response(
json.dumps(record, indent=2, ensure_ascii=False),
mimetype='application/json',
headers={'Content-Disposition':
f'attachment; filename=enrollment-{submission_id}.json'},
)
@bp.route('/admin/export.csv')
@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."""
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',
'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')
row = [
rec.get('id', ''),
rec.get('submitted_at', ''),
schema.STATUS_LABELS.get(rec.get('status'), rec.get('status', '')),
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 '',
]
for ref, _label, _scope in schema.TASKS:
cell = rec.get('matrix', {}).get(str(ref), {}).get(key)
row.append('Yes' if cell else '')
w.writerow(row)
logger.info('ENROLLMENT | csv export | submissions=%d', len(records))
stamp = datetime.now().strftime('%Y%m%d')
return Response(
buf.getvalue(),
mimetype='text/csv',
headers={'Content-Disposition':
f'attachment; filename=jqc-enrollments-{stamp}.csv'},
)