Aug 6 - Add enrollment page
This commit is contained in:
@@ -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'},
|
||||
)
|
||||
Reference in New Issue
Block a user