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
+40
View File
@@ -0,0 +1,40 @@
"""
app/enrollment
--------------
The JQC Enrollment Form — a self-contained onboarding intake, deliberately
held apart from the rest of the application.
/enrollment public form emailed to a prospective customer
/enrollment/admin admin-only inbox of submissions
Separation contract (please keep this true)
-------------------------------------------
1. NO app.models imports, and nothing here writes to the database. Enrollment
happens before any contract, facility or user exists, so there is nothing to
key a row against. Submissions are flat JSON files (see storage.py).
2. NO migration, NO model, NO notification-matrix event, NO iPad/API surface.
Deleting this package would remove the two routes and nothing else.
3. Its own template folder (app/enrollment/templates/enrollment/) — enrollment
markup never mixes into app/templates.
4. The only shared code it uses is what it should not reinvent: the app factory,
Flask-WTF CSRF, the rate limiter, and @admin_required.
If this ever needs to CREATE the accounts it describes, do that as a separate,
explicit admin action that reads a stored submission — do not let the public
form reach into the app's models.
"""
from .routes import bp # noqa: F401 (re-exported for register_enrollment)
def register_enrollment(app):
"""Register the blueprint and make sure the storage directory exists."""
import os
app.config.setdefault(
'ENROLLMENT_DIR',
os.path.join(app.instance_path, 'enrollments'),
)
os.makedirs(app.config['ENROLLMENT_DIR'], exist_ok=True)
app.register_blueprint(bp)
app.logger.info('Enrollment | storage dir: %s', app.config['ENROLLMENT_DIR'])
+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'},
)
+110
View File
@@ -0,0 +1,110 @@
"""
app/enrollment/schema.py
------------------------
The JQC Enrollment Form, expressed as data.
This is the SINGLE source of truth for the form's shape. The public template
renders from it, the POST handler parses against it, and the admin detail view
re-renders a stored submission through it. Changing a task label or adding an
inspector column is a one-line edit here — no template or parser change.
Deliberately free of any app model / DB import: the enrollment form describes
what a prospective customer *wants set up*, not anything that exists in the
system yet. Keep it that way (see app/enrollment/__init__.py).
"""
# ── Step 1 columns ───────────────────────────────────────────────────────────
# key -> display label. 'admin' is the Admin/Director column; the rest are the
# five inspector seats on the printed form.
COLUMNS = [
('admin', 'Admin /\nDirector'),
('inspector_1', 'User /\nInspector 1'),
('inspector_2', 'User /\nInspector 2'),
('inspector_3', 'User /\nInspector 3'),
('inspector_4', 'User /\nInspector 4'),
('inspector_5', 'User /\nInspector 5'),
]
INSPECTOR_COLUMNS = [c for c in COLUMNS if c[0] != 'admin']
# ── Step 1 rows ──────────────────────────────────────────────────────────────
# ref, label, columns_offered. Ref 10 (Search/Export Reports) is an
# Admin/Director-only capability on the printed form, so it offers one cell.
TASKS = [
(1, 'Receive new inspection submitted notification', 'all'),
(2, 'Receive issue-related notification', 'all'),
(3, 'New issue created', 'all'),
(4, 'Issue status updated', 'all'),
(5, 'Issue comment added', 'all'),
(6, 'Request follow up / re-inspection', 'all'),
(7, 'Add Comments (issue detail page)', 'all'),
(8, 'Receive issue SLA (at-risk, breached)', 'all'),
(9, 'Log new issue', 'all'),
(10, 'Search/Export Reports (inspection/issue)', 'admin_only'),
]
def columns_for(scope):
"""Return the column list a task row offers."""
return COLUMNS if scope == 'all' else [('admin', 'Admin /\nDirector')]
# ── Step 2 registrants ───────────────────────────────────────────────────────
# key -> row label on the printed form.
REGISTRANTS = [
('admin', 'Administrative Roles (Admin/Director/Auditor)'),
('inspector_1', 'User / Inspector 1'),
('inspector_2', 'User / Inspector 2'),
('inspector_3', 'User / Inspector 3'),
('inspector_4', 'User / Inspector 4'),
('inspector_5', 'User / Inspector 5'),
]
# ── Step 3 ───────────────────────────────────────────────────────────────────
MOBILE_APP_LABEL = 'JQC Mobile App For Smart Device'
# ── Recommendation block (static reference, not an input) ────────────────────
# ref -> (admin_recommended, inspector_recommended). None = no cell on the form.
RECOMMENDATION = {
1: (False, True),
2: (False, True),
3: (True, True),
4: (False, True),
5: (True, True),
6: (True, True),
7: (True, True),
8: (False, True),
9: (True, True),
10: (True, None),
}
RECOMMENDATION_INTRO = (
'To prevent the administrator or director from receiving an overwhelming '
'number of email notifications, we recommend the following:'
)
NOTES = [
'Each user will receive instructions on how to sign up and install the app '
'on their smart device.',
'Along with the installation instructions, users will receive a quick guide '
'to navigate the web portal and app based on their credentials.',
]
# ── Office-use fields (filled in by L.T. Services after receipt) ─────────────
OFFICE_FIELDS = [
('receive_date', 'Receive Date'),
('program_by', 'Program By'),
('date_email_invitation', 'Date email invitation'),
]
STATUSES = ['new', 'in_progress', 'completed']
STATUS_LABELS = {
'new': 'New',
'in_progress': 'In Progress',
'completed': 'Completed',
}
+149
View File
@@ -0,0 +1,149 @@
"""
app/enrollment/storage.py
-------------------------
Flat-file persistence for enrollment submissions — one JSON document per
submission, in the directory named by config ENROLLMENT_DIR.
Why files and not a table
-------------------------
Enrollment happens BEFORE anything exists in the system: there is no contract,
no facility and no user account to key a row against, and the volume is a
handful of documents a year. A directory of readable JSON keeps this feature
completely outside the schema — no model, no migration, nothing to keep in sync
with the rest of the app. It can be backed up with `cp` and read with `cat`.
File naming
-----------
<YYYYmmdd-HHMMSS>-<8 hex>.json
Time-ordered so a plain directory listing sorts chronologically, with random
suffix so two submissions in the same second cannot collide. The stem is the
submission's id and is the ONLY thing the admin URLs accept — see _safe_id().
"""
import json
import logging
import os
import re
import secrets
import tempfile
from datetime import datetime
from flask import current_app
logger = logging.getLogger(__name__)
#: Submission ids are generated by us and must round-trip through a URL and a
#: file path. Anything not matching is rejected before touching the filesystem,
#: so a crafted id can never escape the enrollment directory (path traversal).
_ID_RE = re.compile(r'^\d{8}-\d{6}-[0-9a-f]{8}$')
def enrollment_dir():
"""Absolute path of the submission directory, created on first use."""
path = current_app.config['ENROLLMENT_DIR']
os.makedirs(path, exist_ok=True)
return path
def new_id(when=None):
"""Mint a time-ordered, collision-safe submission id."""
when = when or datetime.now()
return f'{when:%Y%m%d-%H%M%S}-{secrets.token_hex(4)}'
def _safe_id(submission_id):
"""Return the id if it is one of ours, else None.
Never interpolate an unvalidated id into a path — `../../etc/passwd` and
friends. Callers should 404 on None.
"""
if not submission_id or not _ID_RE.match(submission_id):
logger.warning('ENROLLMENT | rejected malformed id=%r', submission_id)
return None
return submission_id
def _path_for(submission_id):
sid = _safe_id(submission_id)
if sid is None:
return None
return os.path.join(enrollment_dir(), f'{sid}.json')
def save(record):
"""Write a submission atomically. Returns the id.
Written to a temp file in the same directory then os.replace()d, so a
crash mid-write can never leave a truncated JSON document that would break
the admin list for every other submission.
"""
sid = record['id']
path = _path_for(sid)
if path is None:
raise ValueError(f'refusing to save malformed id {sid!r}')
directory = os.path.dirname(path)
fd, tmp = tempfile.mkstemp(dir=directory, suffix='.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
json.dump(record, fh, indent=2, ensure_ascii=False)
os.replace(tmp, path)
except Exception:
# Never leave the temp file behind on a failed write.
try:
os.unlink(tmp)
except OSError:
pass
raise
logger.info('ENROLLMENT | saved | id=%s project=%r',
sid, record.get('project_name'))
return sid
def load(submission_id):
"""Return one submission dict, or None if unknown/unreadable."""
path = _path_for(submission_id)
if path is None or not os.path.isfile(path):
return None
try:
with open(path, encoding='utf-8') as fh:
return json.load(fh)
except (OSError, ValueError):
logger.exception('ENROLLMENT | unreadable submission id=%s', submission_id)
return None
def load_all():
"""Return every submission, newest first.
A single corrupt file is skipped with a log line rather than breaking the
whole admin list.
"""
directory = enrollment_dir()
records = []
for name in sorted(os.listdir(directory), reverse=True):
if not name.endswith('.json'):
continue
rec = load(name[:-len('.json')])
if rec is not None:
records.append(rec)
return records
def update_office(submission_id, office, status):
"""Merge the office-use block + status into a stored submission.
Returns the updated record, or None if the id is unknown. Only these
fields are writable after submission — the customer's own answers are
immutable, so the file stays an accurate record of what they asked for.
"""
rec = load(submission_id)
if rec is None:
return None
rec.setdefault('office', {}).update(office)
rec['status'] = status
rec['updated_at'] = datetime.now().isoformat(timespec='seconds')
save(rec)
return rec
@@ -0,0 +1,175 @@
{% extends "base.html" %}
{% block title %}Enrollment — {{ record.project_name }}{% endblock %}
{# One submitted enrollment form, rendered through the same schema the public
page uses. The customer's answers are READ-ONLY here — only the office-use
block and the status are editable, so the file stays a faithful record of
what was actually requested. #}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
<div>
<h2 class="mb-0"><i class="bi bi-clipboard-check"></i> {{ record.project_name }}</h2>
<div class="text-muted small">
Reference {{ record.id }} · submitted {{ record.submitted_at | replace('T', ' ') }}
{% if record.updated_at %}
· updated {{ record.updated_at | replace('T', ' ') }}
{% endif %}
</div>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('enrollment.admin_list') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back
</a>
<a href="{{ url_for('enrollment.admin_download', submission_id=record.id) }}"
class="btn btn-outline-primary">
<i class="bi bi-filetype-json"></i> Download JSON
</a>
</div>
</div>
<div class="row g-3">
{# ── Request details ─────────────────────────────────────────────── #}
<div class="col-12 col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold">Request</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5">Project Name</dt><dd class="col-7">{{ record.project_name or '—' }}</dd>
<dt class="col-5">Request by</dt><dd class="col-7">{{ record.request_by or '—' }}</dd>
<dt class="col-5">Date Requested</dt><dd class="col-7">{{ record.date_requested or '—' }}</dd>
</dl>
{% if record.notes %}
<hr>
<div class="fw-semibold small text-muted mb-1">Customer notes</div>
<div style="white-space:pre-wrap;">{{ record.notes }}</div>
{% endif %}
</div>
</div>
</div>
{# ── Office use — the only editable part ─────────────────────────── #}
<div class="col-12 col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold">For Office Use</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% for key, label in schema.OFFICE_FIELDS %}
<div class="mb-2">
<label class="form-label small mb-1">{{ label }}</label>
<input type="text" name="{{ key }}" class="form-control form-control-sm"
value="{{ record.office.get(key, '') }}">
</div>
{% endfor %}
<div class="mb-3">
<label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm">
{% for s in schema.STATUSES %}
<option value="{{ s }}" {{ 'selected' if record.status == s }}>
{{ schema.STATUS_LABELS[s] }}
</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-sm btn-primary">
<i class="bi bi-save"></i> Save
</button>
</form>
</div>
</div>
</div>
</div>
{# ── Step 2: who to set up ──────────────────────────────────────────── #}
<div class="card shadow-sm mt-3">
<div class="card-header fw-semibold">Users to Register</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th>Seat</th><th>Name</th><th>Job Title</th><th>Email</th>
<th class="text-center">Mobile App</th>
</tr>
</thead>
<tbody>
{% for reg in record.registrants %}
{% if reg.name or reg.email %}
<tr>
<td>
{{ reg.label }}
{% if reg.include %}
<i class="bi bi-check-circle-fill text-success ms-1"
title="Ticked on the form"></i>
{% endif %}
</td>
<td class="fw-semibold">{{ reg.name or '—' }}</td>
<td>{{ reg.job_title or '—' }}</td>
<td>
{% if reg.email %}
<a href="mailto:{{ reg.email }}">{{ reg.email }}</a>
{% else %}—{% endif %}
</td>
<td class="text-center">
{% if record.mobile_app.get(reg.key) %}
<i class="bi bi-phone-fill text-primary" title="Wants the mobile app"></i>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ── Step 1: the requested matrix ───────────────────────────────────── #}
<div class="card shadow-sm mt-3">
<div class="card-header fw-semibold">Requested Tasks &amp; Functions</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th style="width:50px;">Ref</th>
<th>Task / Function</th>
{% for col_key, col_label in schema.COLUMNS %}
<th class="text-center" style="width:110px;">
{{ col_label.replace('\n', ' ') }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for ref, label, scope in schema.TASKS %}
{% set row = record.matrix.get(ref|string, {}) %}
<tr>
<td class="text-center">{{ ref }}</td>
<td>{{ label }}</td>
{% for col_key, _col_label in schema.COLUMNS %}
<td class="text-center">
{% if scope != 'all' and col_key != 'admin' %}
<span class="text-muted">·</span>
{% elif row.get(col_key) %}
<i class="bi bi-check-square-fill text-success"></i>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% if record.meta %}
<div class="text-muted small mt-3">
Submitted from {{ record.meta.ip or 'unknown address' }}
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}Enrollment Forms{% endblock %}
{# Admin inbox of enrollment submissions. Extends base.html so it picks up
whichever design (classic / modern) the admin has selected. #}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-2">
<h2 class="mb-0"><i class="bi bi-clipboard-plus"></i> Enrollment Forms</h2>
<div class="d-flex gap-2">
<a href="{{ url_for('enrollment.form') }}" target="_blank"
class="btn btn-outline-secondary" title="Open the public form in a new tab">
<i class="bi bi-box-arrow-up-right"></i> View public form
</a>
{% if records %}
<a href="{{ url_for('enrollment.admin_export_csv') }}" class="btn btn-outline-success">
<i class="bi bi-file-earmark-spreadsheet"></i> Export CSV
</a>
{% endif %}
</div>
</div>
<div class="alert alert-info d-flex align-items-start gap-2">
<i class="bi bi-info-circle mt-1"></i>
<div>
Send customers this link to enroll:
<code>{{ url_for('enrollment.form', _external=True) }}</code><br>
<span class="small text-muted">
Submissions are stored as JSON files on the server, outside the database —
one file per form.
</span>
</div>
</div>
{% if records %}
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Submitted</th>
<th>Project</th>
<th>Requested By</th>
<th class="text-center">Users</th>
<th class="text-center">Mobile App</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for r in records %}
{% set named = r.registrants | selectattr('email') | selectattr('name') | list %}
{% set mobile_count = r.mobile_app.values() | select | list | length %}
<tr>
<td class="text-nowrap">
<small>{{ r.submitted_at | replace('T', ' ') }}</small>
</td>
<td class="fw-semibold">{{ r.project_name or '—' }}</td>
<td>{{ r.request_by or '—' }}</td>
<td class="text-center">
<span class="badge bg-secondary">{{ named | length }}</span>
</td>
<td class="text-center">
{% if mobile_count %}
<span class="badge bg-info text-dark">{{ mobile_count }}</span>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td>
<span class="badge bg-{{ 'success' if r.status == 'completed'
else 'warning text-dark' if r.status == 'in_progress'
else 'danger' }}">
{{ schema.STATUS_LABELS.get(r.status, r.status) }}
</span>
</td>
<td class="text-end text-nowrap">
<a href="{{ url_for('enrollment.admin_detail', submission_id=r.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Open
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
No enrollment forms have been submitted yet.
</div>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>JQC Enrollment Form — L.T. Services, Inc</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background:#f1f5f9; color:#1f2937; }
.sheet { max-width:1140px; margin:24px auto 60px; background:#fff;
border:1px solid #d7dee6; border-radius:10px; padding:32px 34px 40px; }
.form-title { color:#1a6fb5; font-weight:800; font-size:2rem; text-align:center; margin:0; }
.form-sub { text-align:center; color:#6b7280; margin-bottom:26px; }
.step-head { font-weight:700; margin:30px 0 10px; }
.step-head span { font-weight:400; }
table.grid { width:100%; border-collapse:collapse; }
table.grid th, table.grid td { border:1px solid #cbd5e1; padding:6px 9px; vertical-align:middle; }
table.grid thead th { background:#dbeafe; font-weight:700; text-align:center; font-size:.86rem; line-height:1.25; }
table.grid thead th.left { text-align:left; }
.ref-col { width:52px; text-align:center; }
.chk-col { width:118px; text-align:center; }
.chk-col input { width:18px; height:18px; }
.reg-table thead th { background:#dcfce7; }
.rec-table thead th { background:#fde4d3; }
.hdr-table td { border:1px solid #cbd5e1; padding:6px 9px; }
.hdr-table .lbl { background:#f8fafc; font-weight:600; width:170px; white-space:nowrap; }
.hdr-table input { border:none; outline:none; width:100%; }
.hdr-table input:focus { background:#eff6ff; }
.cell-input { border:1px solid transparent; background:transparent; width:100%;
padding:2px 4px; border-radius:4px; }
.cell-input:focus { border-color:#1a6fb5; background:#fff; outline:none; }
.office-note { color:#6b7280; font-size:.82rem; }
.note-list { font-size:.92rem; }
/* Honeypot — hidden from humans, visible to naive bots. Not type=hidden:
some bots skip those. */
.hp { position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden; }
@media (max-width: 820px) {
.sheet { padding:18px 14px 30px; margin:10px; }
table.grid { font-size:.8rem; }
.chk-col { width:64px; }
.scroll-x { overflow-x:auto; }
}
@media print {
body { background:#fff; }
.sheet { border:none; margin:0; max-width:none; }
.no-print { display:none !important; }
}
</style>
</head>
<body>
<div class="sheet">
<h1 class="form-title">JQC Enrollment Form</h1>
<div class="form-sub">by L.T Services, Inc</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} no-print">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('enrollment.submit') }}" id="enrollForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Honeypot — must stay empty. #}
<div class="hp" aria-hidden="true">
<label>Website<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
</div>
{# ── Header ─────────────────────────────────────────────────────── #}
<div class="row g-3 mb-2">
<div class="col-12 col-lg-6">
<table class="hdr-table" style="width:100%;">
<tr>
<td class="lbl">Project Name</td>
<td><input type="text" name="project_name" required maxlength="200"
value="{{ submitted.project_name if submitted else '' }}"></td>
</tr>
<tr>
<td class="lbl">Request by:</td>
<td><input type="text" name="request_by" required maxlength="200"
value="{{ submitted.request_by if submitted else '' }}"></td>
</tr>
<tr>
<td class="lbl">Date Requested:</td>
<td><input type="date" name="date_requested"
value="{{ submitted.date_requested if submitted else '' }}"></td>
</tr>
</table>
</div>
<div class="col-12 col-lg-6">
<table class="hdr-table" style="width:100%;">
{% for key, label in schema.OFFICE_FIELDS %}
<tr>
<td class="lbl">{{ label }}</td>
<td class="office-note">For office use</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{# ── Step 1 ─────────────────────────────────────────────────────── #}
<div class="step-head">
Step 1: <span>Please check the task/function for each user. Refer to our
recommendations at the bottom of this page.</span>
</div>
<div class="scroll-x">
<table class="grid">
<thead>
<tr>
<th class="ref-col">Ref</th>
<th class="left">Role Descriptions: Tasks and Functions</th>
{% for col_key, col_label in schema.COLUMNS %}
<th class="chk-col">{{ col_label.replace('\n', ' ') }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for ref, label, scope in schema.TASKS %}
<tr>
<td class="ref-col">{{ ref }}</td>
<td>{{ label }}</td>
{% for col_key, _col_label in schema.COLUMNS %}
{% if scope == 'all' or col_key == 'admin' %}
<td class="chk-col">
<input type="checkbox" class="form-check-input"
name="task_{{ ref }}_{{ col_key }}"
aria-label="{{ label }} — {{ _col_label.replace('\n',' ') }}"
{% if submitted and submitted.matrix[ref|string][col_key] %}checked{% endif %}>
</td>
{% else %}
{# Ref 10 is Admin/Director only on the printed form. #}
<td class="chk-col"></td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# ── Step 2 ─────────────────────────────────────────────────────── #}
<div class="step-head">
Step 2: <span>Please provide the following information for setup. Each user
will receive a notification at the email provided below to sign in.</span>
</div>
<div class="scroll-x">
<table class="grid reg-table">
<thead>
<tr>
<th class="ref-col">No.</th>
<th class="left">Role Descriptions Register</th>
<th style="width:52px;"></th>
<th class="left">First and last name</th>
<th class="left">Job Title</th>
<th class="left">Email Address</th>
</tr>
</thead>
<tbody>
{% for key, label in schema.REGISTRANTS %}
{% set reg = (submitted.registrants[loop.index0] if submitted else none) %}
<tr>
<td class="ref-col">{{ loop.index }}</td>
<td>{{ label }}</td>
<td class="text-center">
<input type="checkbox" class="form-check-input" name="reg_{{ key }}_include"
aria-label="Register {{ label }}"
{% if reg and reg.include %}checked{% endif %}>
</td>
<td><input type="text" class="cell-input" name="reg_{{ key }}_name" maxlength="200"
value="{{ reg.name if reg else '' }}"></td>
<td><input type="text" class="cell-input" name="reg_{{ key }}_job_title" maxlength="200"
value="{{ reg.job_title if reg else '' }}"></td>
<td><input type="email" class="cell-input" name="reg_{{ key }}_email" maxlength="200"
value="{{ reg.email if reg else '' }}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# ── Step 3 ─────────────────────────────────────────────────────── #}
<div class="step-head">
Step 3: <span>Please check the box next to the user who will receive the
app for smart devices.</span>
</div>
<div class="scroll-x">
<table class="grid">
<thead>
<tr>
<th class="ref-col">No.</th>
<th class="left">Mobile App</th>
{% for col_key, col_label in schema.COLUMNS %}
<th class="chk-col">{{ '' if col_key == 'admin' else col_label.replace('\n',' ') }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<td class="ref-col">7</td>
<td>{{ schema.MOBILE_APP_LABEL }}</td>
{% for col_key, col_label in schema.COLUMNS %}
<td class="chk-col">
<input type="checkbox" class="form-check-input" name="mobile_{{ col_key }}"
aria-label="Mobile app — {{ col_label.replace('\n',' ') }}"
{% if submitted and submitted.mobile_app[col_key] %}checked{% endif %}>
</td>
{% endfor %}
</tr>
</tbody>
</table>
</div>
{# ── Notes from the customer ────────────────────────────────────── #}
<div class="step-head">Anything else we should know? <span>(optional)</span></div>
<textarea name="notes" class="form-control" rows="3" maxlength="2000"
placeholder="Special requirements, timing, additional users…">{{ submitted.notes if submitted else '' }}</textarea>
{# ── Recommendation (reference only) ────────────────────────────── #}
<div class="step-head">RECOMMENDATION</div>
<p class="mb-2">{{ schema.RECOMMENDATION_INTRO }}</p>
<div class="scroll-x">
<table class="grid rec-table">
<thead>
<tr>
<th class="ref-col">Ref</th>
<th class="left">Role Descriptions: Tasks and Functions</th>
<th class="chk-col">Admin / Director</th>
<th class="chk-col">User / Inspectors</th>
</tr>
</thead>
<tbody>
{% for ref, label, scope in schema.TASKS %}
{% set rec = schema.RECOMMENDATION[ref] %}
<tr>
<td class="ref-col">{{ ref }}</td>
<td>{{ label }}</td>
<td class="chk-col">
{% if rec[0] %}<i class="bi bi-check-square-fill text-success"></i>
{% else %}<i class="bi bi-square text-muted"></i>{% endif %}
</td>
<td class="chk-col">
{% if rec[1] is none %}—
{% elif rec[1] %}<i class="bi bi-check-square-fill text-success"></i>
{% else %}<i class="bi bi-square text-muted"></i>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="step-head">Note:</div>
<ol class="note-list">
{% for note in schema.NOTES %}<li>{{ note }}</li>{% endfor %}
</ol>
<div class="d-flex gap-2 mt-4 no-print">
<button type="submit" class="btn btn-primary px-4" id="submitBtn">
<i class="bi bi-send"></i> Submit Enrollment
</button>
<button type="button" class="btn btn-outline-secondary" onclick="window.print()">
<i class="bi bi-printer"></i> Print
</button>
</div>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Disable on first submit — a double tap must not file two enrollments.
document.getElementById('enrollForm').addEventListener('submit', function () {
var btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.innerHTML = 'Submitting…';
});
</script>
</body>
</html>
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Enrollment received — JQC</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background:#f1f5f9; }
.card-wrap { max-width:600px; margin:80px auto; }
</style>
</head>
<body>
<div class="card-wrap">
<div class="card shadow-sm border-0">
<div class="card-body text-center p-5">
<i class="bi bi-check-circle-fill text-success" style="font-size:3.2rem;"></i>
<h1 class="h4 mt-3 mb-2">Thank you — your enrollment form has been received.</h1>
<p class="text-muted mb-4">
Our team will set up the accounts you listed. Each user will receive an
email invitation with sign-in instructions, and a quick guide for the
web portal and the mobile app.
</p>
{% if reference %}
<div class="border rounded-3 p-3 bg-light d-inline-block">
<div class="text-muted small">Your reference number</div>
<div class="fw-bold" style="letter-spacing:.02em;">{{ reference }}</div>
</div>
<p class="text-muted small mt-3 mb-0">
Please quote this reference if you contact us about your enrollment.
</p>
{% endif %}
</div>
</div>
<div class="text-center text-muted small mt-3">JQC by L.T Services, Inc</div>
</div>
</body>
</html>