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