274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""
|
|
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'},
|
|
)
|