""" 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/ admin: one submission POST /enrollment/admin/ admin: office-use fields + status GET /enrollment/admin/.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/', 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/.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'}, )