Aug 7 - Update: Enrollment intake form
This commit is contained in:
@@ -338,6 +338,12 @@ def create_app(config_name='default'):
|
||||
app.register_blueprint(signup.bp)
|
||||
app.register_blueprint(landing.bp)
|
||||
app.register_blueprint(ui.bp)
|
||||
|
||||
# ── Enrollment form (self-contained — see app/enrollment/__init__.py) ────
|
||||
# Registered last and via its own helper so the package stays deletable:
|
||||
# removing app/enrollment/ and these two lines removes the feature entirely.
|
||||
from app.enrollment import register_enrollment
|
||||
register_enrollment(app)
|
||||
# Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from
|
||||
# Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects
|
||||
# which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods).
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
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).
|
||||
MT NOTE: "no database" does NOT mean "no tenant". Submissions are filed per
|
||||
tenant on disk, and the admin views only ever list the calling tenant's own
|
||||
directory — see storage.enrollment_dir().
|
||||
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'),
|
||||
)
|
||||
# Only the ROOT is created at boot. Per-tenant subdirectories are created
|
||||
# lazily on first use by storage.enrollment_dir(), because the tenant is
|
||||
# not known until a request is bound.
|
||||
os.makedirs(app.config['ENROLLMENT_DIR'], exist_ok=True)
|
||||
app.register_blueprint(bp)
|
||||
app.logger.info('Enrollment | storage root: %s | per-tenant=%s',
|
||||
app.config['ENROLLMENT_DIR'],
|
||||
bool(app.config.get('MULTI_TENANT_ENABLED')))
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
app/enrollment/mailer.py
|
||||
------------------------
|
||||
The enrollment confirmation email.
|
||||
|
||||
Sent to the requester after a submission is stored. One job, and it must never
|
||||
be able to break that: the record is already safely on disk before this runs,
|
||||
so every failure path here is logged and swallowed. A bounced confirmation must
|
||||
not cost the customer their enrollment.
|
||||
|
||||
Sending happens on a background thread (rule 14 — never block the HTTP
|
||||
response), and the From identity comes from branded_sender() so it stays an
|
||||
SMTP-authorized address that actually delivers (rules 64 / 76).
|
||||
|
||||
This is the only part of app/enrollment that touches shared mail
|
||||
infrastructure. It performs exactly ONE database read — resolving the active
|
||||
admin accounts to notify — and no write. That read is a deliberate, narrowed
|
||||
exception to the package's no-models rule (rule 88): the alternative, a
|
||||
hand-maintained recipient list in config, drifts out of step with reality the
|
||||
first time someone joins or leaves. Everything else here stays model-free.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from flask import current_app, render_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _tenant_branding():
|
||||
"""(display_name, support_email) for the calling tenant.
|
||||
|
||||
MT: ST hardcodes its own company name and a personal corrections address in
|
||||
this module and in schema.py. Sending either to another tenant's customers
|
||||
would be wrong and confusing, so both are resolved from TenantSettings at
|
||||
send time, falling back to neutral defaults.
|
||||
|
||||
Best-effort: any failure returns the defaults rather than blocking the
|
||||
email, matching the rest of this module's never-raise contract.
|
||||
"""
|
||||
name, support = 'Janitorial QC', None
|
||||
try:
|
||||
from app.models.tenant_settings import TenantSettings
|
||||
row = TenantSettings.query.first()
|
||||
if row is not None:
|
||||
name = row.display_name or name
|
||||
support = row.support_email or None
|
||||
except Exception:
|
||||
logger.debug('ENROLLMENT | tenant branding unavailable, using defaults')
|
||||
if not support:
|
||||
support = (current_app.config.get('ENROLLMENT_CORRECTIONS_EMAIL')
|
||||
or current_app.config.get('MAIL_DEFAULT_SENDER') or '')
|
||||
return name, support
|
||||
|
||||
|
||||
def _text_body(record, people, corrections_email):
|
||||
"""Plain-text alternative — some recipients see only this."""
|
||||
lines = [
|
||||
f'Hi {record.get("request_by") or "there"},',
|
||||
'',
|
||||
'Thank you — we have received your JQC enrollment form.',
|
||||
'',
|
||||
f'Reference: {record.get("id")}',
|
||||
f'Project: {record.get("project_name")}',
|
||||
'',
|
||||
f'People to be set up ({len(people)}):',
|
||||
]
|
||||
for i, person in enumerate(people, start=1):
|
||||
lines.append(
|
||||
f' {i}. {person["name"]} — {person["role_label"]} — {person["email"]}'
|
||||
)
|
||||
lines += [
|
||||
'',
|
||||
'Our team will create these accounts. Each person will receive their own '
|
||||
'email invitation with sign-in instructions.',
|
||||
'',
|
||||
f'If anything above is wrong, simply send an email to '
|
||||
f'{corrections_email}, and we will correct it.',
|
||||
'',
|
||||
_tenant_branding()[0],
|
||||
]
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _dispatch(msg, label, record):
|
||||
"""Send one message on a background thread. Never raises.
|
||||
|
||||
Rule 14 — the HTTP response must not wait on SMTP. The submission is
|
||||
already on disk by the time anything here runs, so a mail failure is
|
||||
logged and dropped rather than surfaced to the customer.
|
||||
"""
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def _send():
|
||||
with app.app_context():
|
||||
try:
|
||||
from app import mail
|
||||
mail.send(msg)
|
||||
logger.info('ENROLLMENT %s SENT | to=%s | id=%s',
|
||||
label, msg.recipients, record.get('id'))
|
||||
except Exception as exc:
|
||||
logger.error('ENROLLMENT %s FAILED | to=%s | id=%s | error=%s',
|
||||
label, msg.recipients, record.get('id'), exc)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
|
||||
def _admin_recipients():
|
||||
"""Addresses to alert when a new enrollment arrives.
|
||||
|
||||
Active `admin` accounts, plus any extra addresses in the optional
|
||||
ENROLLMENT_NOTIFY_EMAILS config (comma-separated) for people who should be
|
||||
told but do not hold a JQC login. Deduplicated case-insensitively.
|
||||
|
||||
The User import is function-local and read-only — see the module docstring.
|
||||
"""
|
||||
emails = []
|
||||
try:
|
||||
from app.models.user import User
|
||||
rows = User.query.filter(User.role == 'admin',
|
||||
User.active == True).all() # noqa: E712
|
||||
emails += [u.email for u in rows if u.email]
|
||||
except Exception:
|
||||
# A DB problem must not stop the confirmation going out, nor the
|
||||
# submission from succeeding.
|
||||
logger.exception('ENROLLMENT | could not resolve admin recipients')
|
||||
|
||||
extra = current_app.config.get('ENROLLMENT_NOTIFY_EMAILS') or ''
|
||||
emails += [e.strip() for e in extra.split(',') if e.strip()]
|
||||
|
||||
seen, out = set(), []
|
||||
for e in emails:
|
||||
low = e.lower()
|
||||
if low not in seen:
|
||||
seen.add(low)
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
def send_admin_notification(record, base_url=None):
|
||||
"""Alert JQC admins that a new enrollment form has arrived. Never raises."""
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
logger.warning('ENROLLMENT ADMIN EMAIL SKIPPED | no MAIL_SERVER | id=%s',
|
||||
record.get('id'))
|
||||
return
|
||||
|
||||
try:
|
||||
from flask_mail import Message
|
||||
from app.utils.mail_utils import branded_sender
|
||||
from . import schema
|
||||
|
||||
recipients = _admin_recipients()
|
||||
if not recipients:
|
||||
logger.warning('ENROLLMENT | no admin recipients for id=%s',
|
||||
record.get('id'))
|
||||
return
|
||||
|
||||
effective_base = (base_url
|
||||
or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||
people = schema.people_of(record)
|
||||
link = f'{effective_base}/enrollment/admin/{record.get("id")}'
|
||||
|
||||
lines = [
|
||||
'A new JQC enrollment form has been submitted.',
|
||||
'',
|
||||
f'Project: {record.get("project_name")}',
|
||||
f'Requester: {record.get("request_by")} <{record.get("requester_email")}>',
|
||||
f'Reference: {record.get("id")}',
|
||||
f'People: {len(people)}',
|
||||
'',
|
||||
f'Open it here: {link}',
|
||||
]
|
||||
if record.get('notes'):
|
||||
lines += ['', f'Customer notes: {record["notes"]}']
|
||||
|
||||
msg = Message(
|
||||
subject = f'[JQC] New enrollment — {record.get("project_name")}',
|
||||
sender = branded_sender(effective_base),
|
||||
recipients = recipients,
|
||||
body = '\n'.join(lines),
|
||||
html = render_template('enrollment/email_admin_notice.html',
|
||||
record=record, people=people,
|
||||
schema=schema, link=link),
|
||||
)
|
||||
_dispatch(msg, 'ADMIN EMAIL', record)
|
||||
|
||||
except Exception:
|
||||
logger.exception('ENROLLMENT ADMIN EMAIL BUILD FAILED | id=%s',
|
||||
record.get('id'))
|
||||
|
||||
|
||||
def send_confirmation(record, base_url=None):
|
||||
"""Email the requester a copy of what they submitted. Never raises."""
|
||||
email = (record.get('requester_email') or '').strip()
|
||||
if not email:
|
||||
return
|
||||
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
logger.warning('ENROLLMENT EMAIL SKIPPED | no MAIL_SERVER | id=%s',
|
||||
record.get('id'))
|
||||
return
|
||||
|
||||
try:
|
||||
from flask_mail import Message
|
||||
from app.utils.mail_utils import branded_sender
|
||||
from . import schema
|
||||
|
||||
effective_base = (base_url
|
||||
or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||
people = schema.people_of(record)
|
||||
|
||||
msg = Message(
|
||||
subject = f'[JQC] Enrollment received — {record.get("project_name")}',
|
||||
sender = branded_sender(effective_base),
|
||||
recipients = [email],
|
||||
body = _text_body(record, people, _tenant_branding()[1]),
|
||||
html = render_template('enrollment/email_confirmation.html',
|
||||
record=record, people=people,
|
||||
schema=schema,
|
||||
corrections_email=_tenant_branding()[1]),
|
||||
)
|
||||
|
||||
_dispatch(msg, 'EMAIL', record)
|
||||
|
||||
except Exception:
|
||||
# Building the message failed (bad template, mail misconfigured, …).
|
||||
# The submission is already saved — log it and move on.
|
||||
logger.exception('ENROLLMENT EMAIL BUILD FAILED | id=%s', record.get('id'))
|
||||
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
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
|
||||
import re
|
||||
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 mailer, 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]
|
||||
|
||||
|
||||
#: Person rows are named person_<n>_<field>. The client controls <n> (rows can
|
||||
#: be added and removed in any order), so the server discovers the indexes that
|
||||
#: were actually posted rather than trusting a count field.
|
||||
_PERSON_FIELD_RE = re.compile(r'^person_(\d+)_role$')
|
||||
|
||||
|
||||
def _parse_people(form):
|
||||
"""Return the submitted people as an ordered list of dicts.
|
||||
|
||||
Each entry gets a stable `key` (p1, p2, …) assigned by POSITION, not by the
|
||||
client's index — so the matrix keys in a stored submission are always dense
|
||||
and predictable no matter which rows the customer deleted before sending.
|
||||
"""
|
||||
indexes = sorted(
|
||||
int(m.group(1))
|
||||
for m in (_PERSON_FIELD_RE.match(k) for k in form.keys()) if m
|
||||
)
|
||||
|
||||
people = []
|
||||
for idx in indexes:
|
||||
role = form.get(f'person_{idx}_role', '')
|
||||
if role not in schema.ROLE_KEYS:
|
||||
role = schema.DEFAULT_FIRST_ROLE
|
||||
name = _clean(form.get(f'person_{idx}_name'))
|
||||
job_title = _clean(form.get(f'person_{idx}_job_title'))
|
||||
email = _clean(form.get(f'person_{idx}_email'))
|
||||
# Drop rows the customer added but left completely blank.
|
||||
if not (name or job_title or email):
|
||||
continue
|
||||
people.append({
|
||||
'key': f'p{len(people) + 1}',
|
||||
'form_index': idx, # so the matrix cells can be read back
|
||||
'role': role,
|
||||
'name': name,
|
||||
'job_title': job_title,
|
||||
'email': email,
|
||||
})
|
||||
if len(people) >= schema.MAX_PEOPLE:
|
||||
logger.warning('ENROLLMENT | people capped at %d', schema.MAX_PEOPLE)
|
||||
break
|
||||
return people
|
||||
|
||||
|
||||
def _seed_people(people, matrix, mobile_app):
|
||||
"""Shape the submitted people for the page to re-render after an error.
|
||||
|
||||
Folds each person's ticked tasks into their own row, so the browser can
|
||||
rebuild the table from scratch with fresh row indexes and still restore
|
||||
every answer.
|
||||
"""
|
||||
seed = []
|
||||
for person in people:
|
||||
seed.append({
|
||||
'role': person['role'],
|
||||
'name': person['name'],
|
||||
'job_title': person['job_title'],
|
||||
'email': person['email'],
|
||||
'tasks': [ref for ref, _l, _s in schema.TASKS
|
||||
if matrix.get(str(ref), {}).get(person['key'])],
|
||||
'mobile': bool(mobile_app.get(person['key'])),
|
||||
})
|
||||
return seed
|
||||
|
||||
|
||||
# ── 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,
|
||||
seed_people=[])
|
||||
|
||||
|
||||
@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'))
|
||||
requester_email = _clean(request.form.get('requester_email'))
|
||||
|
||||
people = _parse_people(request.form)
|
||||
|
||||
# ── Step 2 matrix + Step 3 mobile, keyed by person ───────────────────
|
||||
# Only cells the person's role actually offers are read, so a crafted POST
|
||||
# cannot record an admin-only task against an inspector.
|
||||
matrix = {}
|
||||
for ref, _label, scope in schema.TASKS:
|
||||
row = {}
|
||||
for person in people:
|
||||
if schema.task_applies(scope, person['role']):
|
||||
row[person['key']] = bool(
|
||||
request.form.get(f'task_{ref}_person_{person["form_index"]}'))
|
||||
matrix[str(ref)] = row
|
||||
|
||||
mobile_app = {
|
||||
p['key']: bool(request.form.get(f'mobile_person_{p["form_index"]}'))
|
||||
for p in people
|
||||
}
|
||||
|
||||
# ── Validation ───────────────────────────────────────────────────────
|
||||
# A person counts only with BOTH a name and an email — a half-filled row
|
||||
# cannot be set up, so it must not pass as one.
|
||||
named = [p for p in people if p['name'] and p['email']]
|
||||
errors = []
|
||||
if not project_name:
|
||||
errors.append('Project Name is required.')
|
||||
if not request_by:
|
||||
errors.append('Request by is required.')
|
||||
if not requester_email:
|
||||
errors.append('Requester email is required — we send your confirmation '
|
||||
'there.')
|
||||
elif '@' not in requester_email:
|
||||
errors.append('The requester email address does not look valid.')
|
||||
if not named:
|
||||
errors.append('Please add at least one person with both a name and an '
|
||||
'email address.')
|
||||
for p in people:
|
||||
if p['email'] and '@' not in p['email']:
|
||||
errors.append(f'"{p["name"] or p["key"]}" has an email address that '
|
||||
f'does not look valid.')
|
||||
seen = set()
|
||||
for p in named:
|
||||
low = p['email'].lower()
|
||||
if low in seen:
|
||||
errors.append(f'{p["email"]} is listed more than once — each person '
|
||||
f'needs their own email address.')
|
||||
seen.add(low)
|
||||
|
||||
prior = {
|
||||
'project_name': project_name,
|
||||
'request_by': request_by,
|
||||
'requester_email': requester_email,
|
||||
'date_requested': _clean(request.form.get('date_requested')),
|
||||
'notes': _clean(request.form.get('notes'), _MAX_NOTES),
|
||||
'people': people,
|
||||
'matrix': matrix,
|
||||
'mobile_app': mobile_app,
|
||||
}
|
||||
|
||||
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=prior,
|
||||
seed_people=_seed_people(people, matrix, mobile_app)), 400
|
||||
|
||||
now = datetime.now()
|
||||
record = dict(prior)
|
||||
record.update({
|
||||
'id': storage.new_id(now),
|
||||
'submitted_at': now.isoformat(timespec='seconds'),
|
||||
# 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, submitted=prior,
|
||||
seed_people=_seed_people(people, matrix, mobile_app)), 500
|
||||
|
||||
logger.info('ENROLLMENT | submitted | id=%s project=%r people=%d ip=%s',
|
||||
record['id'], project_name, len(named), request.remote_addr)
|
||||
|
||||
# Both emails fire AFTER the save and are fully guarded — a mail problem
|
||||
# must never cost the customer their submission.
|
||||
mailer.send_confirmation(record, base_url=request.host_url) # requester
|
||||
mailer.send_admin_notification(record, base_url=request.host_url) # JQC admins
|
||||
|
||||
return render_template('enrollment/submitted.html', reference=record['id'],
|
||||
email=requester_email)
|
||||
|
||||
|
||||
# ── 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 PERSON (not per submission) — that is the unit of work when
|
||||
actually setting the accounts up. Reads through schema.people_of(), so
|
||||
submissions stored in the older fixed-seat format export identically."""
|
||||
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', 'Role', 'Name', 'Job Title',
|
||||
'Email', 'Mobile App'] + task_headers)
|
||||
|
||||
for rec in records:
|
||||
for person in schema.people_of(rec):
|
||||
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', ''),
|
||||
person['role_label'],
|
||||
person['name'],
|
||||
person['job_title'],
|
||||
person['email'],
|
||||
'Yes' if schema.wants_mobile(rec, person['key']) else '',
|
||||
]
|
||||
for ref, _label, scope in schema.TASKS:
|
||||
if not schema.task_applies(scope, person['role']):
|
||||
row.append('n/a')
|
||||
else:
|
||||
row.append('Yes' if schema.cell(rec, ref, person['key']) 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,218 @@
|
||||
"""
|
||||
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 a
|
||||
role 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). The ROLES below
|
||||
happen to mirror the app's user roles, but they are a COPY on purpose — the
|
||||
public form must not import the User model.
|
||||
"""
|
||||
|
||||
# ── Roles a person can be enrolled as ────────────────────────────────────────
|
||||
# key -> label, shown in the Step 1 role dropdown.
|
||||
ROLES = [
|
||||
('admin', 'Admin'),
|
||||
('director', 'Director'),
|
||||
('auditor', 'Auditor'),
|
||||
('inspector', 'Inspector'),
|
||||
('external_inspector', 'External Inspector'),
|
||||
]
|
||||
|
||||
ROLE_LABELS = dict(ROLES)
|
||||
ROLE_KEYS = [k for k, _ in ROLES]
|
||||
|
||||
#: Roles that act on the administrative side of the printed form (the
|
||||
#: "Admin / Director" column). Everything else is an inspector seat. Drives
|
||||
#: both the recommendation preset and eligibility for admin-only tasks.
|
||||
ADMIN_ROLES = {'admin', 'director', 'auditor'}
|
||||
|
||||
#: Role pre-selected for the first row — the form starts with one
|
||||
#: administrative contact, as on the printed sheet.
|
||||
DEFAULT_FIRST_ROLE = 'admin'
|
||||
|
||||
#: Upper bound on people per submission. Generous for a real enrollment, but
|
||||
#: bounded so a scripted POST cannot make us build an unbounded matrix.
|
||||
MAX_PEOPLE = 25
|
||||
|
||||
|
||||
def is_admin_role(role):
|
||||
return role in ADMIN_ROLES
|
||||
|
||||
|
||||
# ── Task rows ────────────────────────────────────────────────────────────────
|
||||
# ref, label, scope. scope 'admin_only' means the cell is offered only to
|
||||
# people in an ADMIN_ROLES role (ref 10 on the printed form).
|
||||
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'),
|
||||
]
|
||||
|
||||
TASK_LABELS = {ref: label for ref, label, _ in TASKS}
|
||||
|
||||
|
||||
def task_applies(scope, role):
|
||||
"""True when a task row offers a checkbox to someone in `role`."""
|
||||
return scope == 'all' or is_admin_role(role)
|
||||
|
||||
|
||||
# ── Step 3 ───────────────────────────────────────────────────────────────────
|
||||
MOBILE_APP_LABEL = 'JQC Mobile App For Smart Device'
|
||||
|
||||
|
||||
# ── Recommended defaults ─────────────────────────────────────────────────────
|
||||
# ref -> (recommended for admin-side roles, recommended for inspector roles).
|
||||
# None = the row offers that side no cell.
|
||||
#
|
||||
# The printed form showed this as a separate RECOMMENDATION table for the
|
||||
# customer to copy by hand. It is now applied by the "Recommendation selection"
|
||||
# button instead, so the table is no longer rendered — but this mapping is
|
||||
# still the authority, and is handed to the page as JSON.
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
def recommendation_for(role):
|
||||
"""Return {task_ref: bool} — the recommended preset for one role.
|
||||
|
||||
Rows that offer this role no cell are omitted rather than set False, so
|
||||
the caller never ticks a checkbox that does not exist.
|
||||
"""
|
||||
admin_side = is_admin_role(role)
|
||||
preset = {}
|
||||
for ref, _label, scope in TASKS:
|
||||
if not task_applies(scope, role):
|
||||
continue
|
||||
rec = RECOMMENDATION.get(ref, (False, False))
|
||||
value = rec[0] if admin_side else rec[1]
|
||||
if value is None:
|
||||
continue
|
||||
preset[ref] = bool(value)
|
||||
return preset
|
||||
|
||||
|
||||
def recommendation_map():
|
||||
"""{role_key: {task_ref: bool}} for every role — serialised to the page."""
|
||||
return {role: recommendation_for(role) for role in ROLE_KEYS}
|
||||
|
||||
|
||||
#: Where a customer should write if their submission needs correcting. The
|
||||
#: confirmation email is sent FROM the unmonitored no-reply identity
|
||||
#: (branded_sender), so "reply to this email" would go nowhere — point them
|
||||
#: here instead. Used by both the text and HTML bodies of the confirmation.
|
||||
# MT: retained only as a last-resort default. The address actually shown to a
|
||||
# customer is resolved per tenant at send time from TenantSettings.support_email
|
||||
# — see mailer._tenant_branding(). Never send one tenant's customers another
|
||||
# tenant's (or a developer's personal) address.
|
||||
CORRECTIONS_EMAIL = ''
|
||||
|
||||
|
||||
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 the tenant AFTER receipt, on the admin detail page only. The
|
||||
# printed sheet showed these to the customer as a blank "for office use" block;
|
||||
# the web form does not render them at all — a customer cannot fill them in, so
|
||||
# showing them was only noise.
|
||||
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',
|
||||
}
|
||||
|
||||
|
||||
# ── Legacy record support ────────────────────────────────────────────────────
|
||||
# Submissions taken before the form moved to free-form people used six fixed
|
||||
# seats. Stored files are never rewritten, so the admin views normalise on
|
||||
# read instead — one shape to render, whichever format is on disk.
|
||||
_LEGACY_SEAT_ROLES = {
|
||||
'admin': 'admin',
|
||||
'inspector_1': 'inspector',
|
||||
'inspector_2': 'inspector',
|
||||
'inspector_3': 'inspector',
|
||||
'inspector_4': 'inspector',
|
||||
'inspector_5': 'inspector',
|
||||
}
|
||||
|
||||
|
||||
def people_of(record):
|
||||
"""Return a submission's people as a uniform list, old format or new.
|
||||
|
||||
Each entry: {key, role, role_label, name, job_title, email}.
|
||||
"""
|
||||
if record.get('people'):
|
||||
out = []
|
||||
for p in record['people']:
|
||||
role = p.get('role', 'inspector')
|
||||
out.append({
|
||||
'key': p.get('key', ''),
|
||||
'role': role,
|
||||
'role_label': ROLE_LABELS.get(role, role.replace('_', ' ').title()),
|
||||
'name': p.get('name', ''),
|
||||
'job_title': p.get('job_title', ''),
|
||||
'email': p.get('email', ''),
|
||||
})
|
||||
return out
|
||||
|
||||
# Legacy: fixed seats under 'registrants'.
|
||||
out = []
|
||||
for reg in record.get('registrants', []):
|
||||
if not (reg.get('name') or reg.get('email')):
|
||||
continue
|
||||
role = _LEGACY_SEAT_ROLES.get(reg.get('key'), 'inspector')
|
||||
out.append({
|
||||
'key': reg.get('key', ''),
|
||||
'role': role,
|
||||
'role_label': ROLE_LABELS.get(role, role.title()),
|
||||
'name': reg.get('name', ''),
|
||||
'job_title': reg.get('job_title', ''),
|
||||
'email': reg.get('email', ''),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def cell(record, ref, person_key):
|
||||
"""True when `person_key` was ticked for task `ref` in this submission."""
|
||||
return bool(record.get('matrix', {}).get(str(ref), {}).get(person_key))
|
||||
|
||||
|
||||
def wants_mobile(record, person_key):
|
||||
return bool(record.get('mobile_app', {}).get(person_key))
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
app/enrollment/storage.py
|
||||
-------------------------
|
||||
Flat-file persistence for enrollment submissions — one JSON document per
|
||||
submission, under the directory named by config ENROLLMENT_DIR, in a
|
||||
per-tenant subdirectory (see 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}$')
|
||||
|
||||
|
||||
class TenantUnresolved(RuntimeError):
|
||||
"""Raised when multi-tenancy is on but no tenant is bound to the request.
|
||||
|
||||
Deliberately fatal rather than falling back to the shared root directory:
|
||||
a fallback would put one tenant's submissions where every other tenant's
|
||||
admin can read them.
|
||||
"""
|
||||
|
||||
|
||||
def enrollment_dir():
|
||||
"""Absolute path of THIS TENANT's submission directory, created on first use.
|
||||
|
||||
Multi-tenant isolation (MT-17)
|
||||
------------------------------
|
||||
ST keeps every submission in one flat directory. In MT that directory is
|
||||
shared by every tenant on the host, so /enrollment/admin would list other
|
||||
organisations' submissions — names, emails and phone numbers of people at
|
||||
another company. Submissions are therefore filed under a per-tenant
|
||||
subdirectory:
|
||||
|
||||
<ENROLLMENT_DIR>/t<tenant_id>/<submission>.json
|
||||
|
||||
``t<id>`` mirrors ``storage.tenant_key_prefix()`` so the on-disk layout is
|
||||
the same shape as the media object keys.
|
||||
|
||||
When MULTI_TENANT_ENABLED is false the root directory is used unchanged,
|
||||
so a single-tenant deploy behaves exactly like ST.
|
||||
|
||||
When multi-tenancy IS enabled but no tenant is bound, this raises rather
|
||||
than falling back to the root — see TenantUnresolved.
|
||||
"""
|
||||
root = current_app.config['ENROLLMENT_DIR']
|
||||
|
||||
if not current_app.config.get('MULTI_TENANT_ENABLED'):
|
||||
os.makedirs(root, exist_ok=True)
|
||||
return root
|
||||
|
||||
from flask import g
|
||||
tenant = getattr(g, 'tenant', None)
|
||||
if tenant is None:
|
||||
logger.error('ENROLLMENT | no tenant bound — refusing to touch storage')
|
||||
raise TenantUnresolved(
|
||||
'enrollment storage requires a resolved tenant when '
|
||||
'MULTI_TENANT_ENABLED is set'
|
||||
)
|
||||
|
||||
path = os.path.join(root, f't{tenant.id}')
|
||||
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,181 @@
|
||||
{% 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">Requester email</dt>
|
||||
<dd class="col-7">
|
||||
{% if record.requester_email %}
|
||||
<a href="mailto:{{ record.requester_email }}">{{ record.requester_email }}</a>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</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>
|
||||
|
||||
{# ── The people to set up ───────────────────────────────────────────── #}
|
||||
{% set people = schema.people_of(record) %}
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header fw-semibold">
|
||||
Users to Register
|
||||
<span class="badge bg-secondary rounded-pill ms-1">{{ people | length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:50px;">No.</th>
|
||||
<th>Role</th><th>Name</th><th>Job Title</th><th>Email</th>
|
||||
<th class="text-center">Mobile App</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for person in people %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td><span class="badge bg-light text-dark border">{{ person.role_label }}</span></td>
|
||||
<td class="fw-semibold">{{ person.name or '—' }}</td>
|
||||
<td>{{ person.job_title or '—' }}</td>
|
||||
<td>
|
||||
{% if person.email %}
|
||||
<a href="mailto:{{ person.email }}">{{ person.email }}</a>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if schema.wants_mobile(record, person.key) %}
|
||||
<i class="bi bi-phone-fill text-primary" title="Wants the mobile app"></i>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── The requested task matrix — one column per person ──────────────── #}
|
||||
<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 style="min-width:280px;">Task / Function</th>
|
||||
{% for person in people %}
|
||||
<th class="text-center" style="min-width:120px;">
|
||||
{{ person.name or 'Person ' ~ loop.index }}
|
||||
<div class="fw-normal text-muted" style="font-size:.75rem;">
|
||||
{{ person.role_label }}
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ref, label, scope in schema.TASKS %}
|
||||
<tr>
|
||||
<td class="text-center">{{ ref }}</td>
|
||||
<td>{{ label }}</td>
|
||||
{% for person in people %}
|
||||
<td class="text-center">
|
||||
{% if not schema.task_applies(scope, person.role) %}
|
||||
<span class="text-muted" title="Not available for this role">·</span>
|
||||
{% elif schema.cell(record, ref, person.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,98 @@
|
||||
{% 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 %}
|
||||
{# people_of() normalises both the current and the legacy stored shape #}
|
||||
{% set named = schema.people_of(r) %}
|
||||
{% 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,89 @@
|
||||
{# Internal alert to JQC admins when a new enrollment form arrives. Inline
|
||||
styles only and no external assets — mail clients strip <style> blocks and
|
||||
block remote resources. #}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family:Arial,Helvetica,sans-serif;color:#333;max-width:640px;margin:auto;padding:12px;">
|
||||
|
||||
<h2 style="color:#1a6fb5;margin:0 0 4px;">New enrollment form</h2>
|
||||
<p style="color:#6b7280;margin:0 0 20px;">JQC · internal notification</p>
|
||||
|
||||
<table style="border-collapse:collapse;margin:0 0 18px;">
|
||||
<tr>
|
||||
<td style="padding:4px 14px 4px 0;color:#6b7280;">Project</td>
|
||||
<td style="padding:4px 0;font-weight:bold;">{{ record.project_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 14px 4px 0;color:#6b7280;">Requester</td>
|
||||
<td style="padding:4px 0;">
|
||||
{{ record.request_by }}
|
||||
{% if record.requester_email %}
|
||||
<<a href="mailto:{{ record.requester_email }}">{{ record.requester_email }}</a>>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if record.date_requested %}
|
||||
<tr>
|
||||
<td style="padding:4px 14px 4px 0;color:#6b7280;">Date requested</td>
|
||||
<td style="padding:4px 0;">{{ record.date_requested }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td style="padding:4px 14px 4px 0;color:#6b7280;">Reference</td>
|
||||
<td style="padding:4px 0;">{{ record.id }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 22px;">
|
||||
<a href="{{ link }}"
|
||||
style="background:#1a6fb5;color:#fff;text-decoration:none;padding:10px 18px;
|
||||
border-radius:6px;display:inline-block;font-weight:bold;">
|
||||
Open in JQC
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h3 style="font-size:1rem;margin:0 0 8px;">
|
||||
Accounts requested ({{ people | length }})
|
||||
</h3>
|
||||
|
||||
<table style="border-collapse:collapse;width:100%;font-size:.92rem;">
|
||||
<thead>
|
||||
<tr style="background:#dbeafe;">
|
||||
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Name</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Role</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Email</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:center;">Mobile App</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for person in people %}
|
||||
<tr>
|
||||
<td style="border:1px solid #cbd5e1;padding:6px 9px;">
|
||||
{{ person.name }}
|
||||
{% if person.job_title %}
|
||||
<div style="color:#6b7280;font-size:.82rem;">{{ person.job_title }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="border:1px solid #cbd5e1;padding:6px 9px;">{{ person.role_label }}</td>
|
||||
<td style="border:1px solid #cbd5e1;padding:6px 9px;">{{ person.email }}</td>
|
||||
<td style="border:1px solid #cbd5e1;padding:6px 9px;text-align:center;">
|
||||
{{ 'Yes' if schema.wants_mobile(record, person.key) else '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if record.notes %}
|
||||
<h3 style="font-size:1rem;margin:22px 0 6px;">Customer notes</h3>
|
||||
<div style="white-space:pre-wrap;background:#f8fafc;border:1px solid #e5e7eb;
|
||||
border-radius:6px;padding:10px;">{{ record.notes }}</div>
|
||||
{% endif %}
|
||||
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:26px 0 12px;">
|
||||
<p style="color:#9ca3af;font-size:.8rem;margin:0;">
|
||||
You are receiving this because you hold a JQC admin account. The full
|
||||
selection of tasks per person is on the enrollment page.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
{# Confirmation email sent to the requester. Inline styles only and no external
|
||||
assets — mail clients strip <style> blocks and block remote resources. #}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family:Arial,Helvetica,sans-serif;color:#333;max-width:640px;margin:auto;padding:12px;">
|
||||
|
||||
<h2 style="color:#1a6fb5;margin:0 0 4px;">Enrollment received</h2>
|
||||
<p style="color:#6b7280;margin:0 0 20px;">{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}</p>
|
||||
|
||||
<p>Hi {{ record.request_by or 'there' }},</p>
|
||||
<p>Thank you — we have received your JQC enrollment form. Our team will set up
|
||||
the accounts listed below.</p>
|
||||
|
||||
<table style="border-collapse:collapse;margin:18px 0;">
|
||||
<tr>
|
||||
<td style="padding:4px 14px 4px 0;color:#6b7280;">Reference</td>
|
||||
<td style="padding:4px 0;font-weight:bold;">{{ record.id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 14px 4px 0;color:#6b7280;">Project</td>
|
||||
<td style="padding:4px 0;font-weight:bold;">{{ record.project_name }}</td>
|
||||
</tr>
|
||||
{% if record.date_requested %}
|
||||
<tr>
|
||||
<td style="padding:4px 14px 4px 0;color:#6b7280;">Date requested</td>
|
||||
<td style="padding:4px 0;">{{ record.date_requested }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
<h3 style="font-size:1rem;margin:22px 0 8px;">
|
||||
People to be set up ({{ people | length }})
|
||||
</h3>
|
||||
|
||||
<table style="border-collapse:collapse;width:100%;font-size:.92rem;">
|
||||
<thead>
|
||||
<tr style="background:#dbeafe;">
|
||||
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Name</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Role</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Email</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:center;">Mobile App</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for person in people %}
|
||||
<tr>
|
||||
<td style="border:1px solid #cbd5e1;padding:6px 9px;">
|
||||
{{ person.name }}
|
||||
{% if person.job_title %}
|
||||
<div style="color:#6b7280;font-size:.82rem;">{{ person.job_title }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="border:1px solid #cbd5e1;padding:6px 9px;">{{ person.role_label }}</td>
|
||||
<td style="border:1px solid #cbd5e1;padding:6px 9px;">{{ person.email }}</td>
|
||||
<td style="border:1px solid #cbd5e1;padding:6px 9px;text-align:center;">
|
||||
{{ 'Yes' if schema.wants_mobile(record, person.key) else '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:22px;">
|
||||
Each person will receive their own email invitation with sign-in
|
||||
instructions, along with a quick guide to the web portal and the mobile app.
|
||||
</p>
|
||||
<p>If anything above is wrong, simply send an email to
|
||||
<a href="mailto:{{ corrections_email }}">{{ corrections_email }}</a>,
|
||||
and we will correct it.</p>
|
||||
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:26px 0 12px;">
|
||||
<p style="color:#9ca3af;font-size:.8rem;margin:0;">
|
||||
You are receiving this because this address was given as the requester on a
|
||||
JQC enrollment form. Reference {{ record.id }}.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,434 @@
|
||||
<!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 Form — {{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}</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:1180px; 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 { min-width:104px; text-align:center; }
|
||||
.chk-col input { width:18px; height:18px; }
|
||||
.people-table thead th { background:#dcfce7; }
|
||||
.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; }
|
||||
.col-person { font-weight:700; font-size:.84rem; line-height:1.2; }
|
||||
.col-role { font-weight:400; font-size:.76rem; color:#4b5563; display:block; margin-top:2px; }
|
||||
.cell-na { color:#cbd5e1; }
|
||||
.office-note { color:#6b7280; font-size:.82rem; }
|
||||
.note-list { font-size:.92rem; }
|
||||
.scroll-x { overflow-x:auto; }
|
||||
.empty-hint { border:1px dashed #cbd5e1; border-radius:8px; padding:20px;
|
||||
text-align:center; color:#6b7280; }
|
||||
/* 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 { min-width:70px; }
|
||||
}
|
||||
@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">{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}</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 %}
|
||||
|
||||
<noscript>
|
||||
<div class="alert alert-warning no-print">
|
||||
This form needs JavaScript enabled — the task table is built from the
|
||||
people you add. Please enable JavaScript, or contact us and we will send
|
||||
you a printable copy.
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<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 ─────────────────────────────────────────────────────── #}
|
||||
{# The printed sheet carried a blank "for office use" block here. It is not
|
||||
rendered on the web form — a customer cannot fill it in. Those fields
|
||||
still exist and are filled by staff on the admin detail page. #}
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-12 col-lg-8">
|
||||
<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"
|
||||
placeholder="Your name"
|
||||
value="{{ submitted.request_by if submitted else '' }}"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">Requester email:</td>
|
||||
<td><input type="email" name="requester_email" required maxlength="200"
|
||||
placeholder="you@company.com"
|
||||
value="{{ submitted.requester_email 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 class="form-text mt-1">
|
||||
We send your confirmation, with a copy of everything below, to the
|
||||
requester email.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Step 1 — the people ────────────────────────────────────────── #}
|
||||
<div class="step-head">
|
||||
Step 1: <span>Please list everyone who needs access. Each person will
|
||||
receive an email invitation at the address you provide.</span>
|
||||
</div>
|
||||
|
||||
<div class="scroll-x">
|
||||
<table class="grid people-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ref-col">No.</th>
|
||||
<th class="left" style="min-width:190px;">Role</th>
|
||||
<th class="left" style="min-width:190px;">First and last name</th>
|
||||
<th class="left" style="min-width:160px;">Job Title</th>
|
||||
<th class="left" style="min-width:210px;">Email Address</th>
|
||||
<th style="width:52px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="peopleBody"><!-- rows injected by JS --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 no-print">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="addPersonBtn">
|
||||
<i class="bi bi-plus-lg"></i> Add another person
|
||||
</button>
|
||||
<span class="text-muted small ms-2" id="peopleCount"></span>
|
||||
</div>
|
||||
|
||||
{# ── Step 2 — the task matrix, built from Step 1 ────────────────── #}
|
||||
<div class="step-head">
|
||||
Step 2: <span>Please check the task/function for each user, or apply our
|
||||
recommended selection and adjust it.</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 no-print">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="recommendBtn">
|
||||
<i class="bi bi-magic"></i> Recommendation selection
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" id="clearBtn">
|
||||
Clear all
|
||||
</button>
|
||||
<div class="form-text">
|
||||
Our recommendation keeps administrators and directors from receiving an
|
||||
overwhelming number of email notifications. You can change any box afterwards.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scroll-x" id="matrixWrap"><!-- table injected by JS --></div>
|
||||
|
||||
{# ── Step 3 — mobile app ────────────────────────────────────────── #}
|
||||
<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" id="mobileWrap"><!-- table injected by JS --></div>
|
||||
|
||||
{# ── Notes ──────────────────────────────────────────────────────── #}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Data handed over from schema.py — the single source of truth ────────
|
||||
var ROLES = {{ schema.ROLES | tojson }};
|
||||
var TASKS = {{ schema.TASKS | tojson }};
|
||||
var ADMIN_ROLES = {{ schema.ADMIN_ROLES | list | tojson }};
|
||||
var RECOMMENDATION = {{ schema.recommendation_map() | tojson }};
|
||||
var DEFAULT_ROLE = {{ schema.DEFAULT_FIRST_ROLE | tojson }};
|
||||
var MOBILE_LABEL = {{ schema.MOBILE_APP_LABEL | tojson }};
|
||||
var MAX_PEOPLE = {{ schema.MAX_PEOPLE | tojson }};
|
||||
var SEED = {{ seed_people | tojson }};
|
||||
|
||||
var peopleBody = document.getElementById('peopleBody');
|
||||
var matrixWrap = document.getElementById('matrixWrap');
|
||||
var mobileWrap = document.getElementById('mobileWrap');
|
||||
var countLabel = document.getElementById('peopleCount');
|
||||
|
||||
// Row indexes only ever increase, so removing a middle row can never make a
|
||||
// new row reuse a departed row's field names. The server re-keys people by
|
||||
// position on receipt, so gaps here are harmless.
|
||||
var nextIndex = 0;
|
||||
|
||||
function isAdminRole(role) { return ADMIN_ROLES.indexOf(role) !== -1; }
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── Step 1 rows ─────────────────────────────────────────────────────────
|
||||
function addPerson(seed) {
|
||||
if (peopleBody.rows.length >= MAX_PEOPLE) return;
|
||||
seed = seed || {};
|
||||
var i = nextIndex++;
|
||||
var tr = document.createElement('tr');
|
||||
tr.dataset.index = i;
|
||||
|
||||
var options = ROLES.map(function (r) {
|
||||
var sel = (seed.role || DEFAULT_ROLE) === r[0] ? ' selected' : '';
|
||||
return '<option value="' + esc(r[0]) + '"' + sel + '>' + esc(r[1]) + '</option>';
|
||||
}).join('');
|
||||
|
||||
tr.innerHTML =
|
||||
'<td class="ref-col row-num"></td>' +
|
||||
'<td><select class="form-select form-select-sm person-role" ' +
|
||||
'name="person_' + i + '_role" aria-label="Role">' + options + '</select></td>' +
|
||||
'<td><input type="text" class="cell-input person-name" name="person_' + i + '_name" ' +
|
||||
'maxlength="200" placeholder="First and last name" value="' + esc(seed.name) + '"></td>' +
|
||||
'<td><input type="text" class="cell-input" name="person_' + i + '_job_title" ' +
|
||||
'maxlength="200" placeholder="Job title" value="' + esc(seed.job_title) + '"></td>' +
|
||||
'<td><input type="email" class="cell-input" name="person_' + i + '_email" ' +
|
||||
'maxlength="200" placeholder="name@company.com" value="' + esc(seed.email) + '"></td>' +
|
||||
'<td class="text-center no-print">' +
|
||||
'<button type="button" class="btn btn-sm btn-link text-danger p-0 remove-person" ' +
|
||||
'title="Remove this person" aria-label="Remove this person">' +
|
||||
'<i class="bi bi-x-circle"></i></button></td>';
|
||||
|
||||
peopleBody.appendChild(tr);
|
||||
|
||||
if (seed.tasks) { tr.dataset.seedTasks = seed.tasks.join(','); }
|
||||
if (seed.mobile) { tr.dataset.seedMobile = '1'; }
|
||||
return tr;
|
||||
}
|
||||
|
||||
function renumber() {
|
||||
Array.prototype.forEach.call(peopleBody.rows, function (tr, n) {
|
||||
tr.querySelector('.row-num').textContent = n + 1;
|
||||
});
|
||||
var n = peopleBody.rows.length;
|
||||
countLabel.textContent = n + (n === 1 ? ' person' : ' people')
|
||||
+ (n >= MAX_PEOPLE ? ' (maximum reached)' : '');
|
||||
// Never let the last row be removed — the form needs at least one person.
|
||||
Array.prototype.forEach.call(peopleBody.rows, function (tr) {
|
||||
tr.querySelector('.remove-person').style.visibility = n > 1 ? '' : 'hidden';
|
||||
});
|
||||
document.getElementById('addPersonBtn').disabled = n >= MAX_PEOPLE;
|
||||
}
|
||||
|
||||
// ── Read the current people out of Step 1 ───────────────────────────────
|
||||
function currentPeople() {
|
||||
return Array.prototype.map.call(peopleBody.rows, function (tr, n) {
|
||||
var name = tr.querySelector('.person-name').value.trim();
|
||||
var role = tr.querySelector('.person-role').value;
|
||||
return {
|
||||
index: tr.dataset.index,
|
||||
role: role,
|
||||
label: name || ('Person ' + (n + 1)),
|
||||
roleLabel: (ROLES.filter(function (r) { return r[0] === role; })[0] || ['', role])[1]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step 2 + Step 3 tables ──────────────────────────────────────────────
|
||||
// Rebuilt whenever Step 1 changes. Existing ticks are preserved by field
|
||||
// name, so renaming someone or adding a colleague never clears the grid.
|
||||
function renderMatrix() {
|
||||
var people = currentPeople();
|
||||
var checked = {};
|
||||
document.querySelectorAll('.matrix-box:checked, .mobile-box:checked')
|
||||
.forEach(function (cb) { checked[cb.name] = true; });
|
||||
|
||||
// Seeded state from a validation-error re-render, applied once.
|
||||
Array.prototype.forEach.call(peopleBody.rows, function (tr) {
|
||||
if (tr.dataset.seedTasks) {
|
||||
tr.dataset.seedTasks.split(',').filter(Boolean).forEach(function (ref) {
|
||||
checked['task_' + ref + '_person_' + tr.dataset.index] = true;
|
||||
});
|
||||
delete tr.dataset.seedTasks;
|
||||
}
|
||||
if (tr.dataset.seedMobile) {
|
||||
checked['mobile_person_' + tr.dataset.index] = true;
|
||||
delete tr.dataset.seedMobile;
|
||||
}
|
||||
});
|
||||
|
||||
if (!people.length) {
|
||||
matrixWrap.innerHTML = '<div class="empty-hint">Add someone in Step 1 and ' +
|
||||
'their column will appear here.</div>';
|
||||
mobileWrap.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
var head = '<tr><th class="ref-col">Ref</th>' +
|
||||
'<th class="left">Role Descriptions: Tasks and Functions</th>' +
|
||||
people.map(function (p) {
|
||||
return '<th class="chk-col"><span class="col-person">' + esc(p.label) +
|
||||
'</span><span class="col-role">' + esc(p.roleLabel) + '</span></th>';
|
||||
}).join('') + '</tr>';
|
||||
|
||||
var body = TASKS.map(function (t) {
|
||||
var ref = t[0], label = t[1], scope = t[2];
|
||||
var cells = people.map(function (p) {
|
||||
// An admin-only row offers no cell to an inspector — matching the
|
||||
// server, which refuses to record one.
|
||||
if (scope !== 'all' && !isAdminRole(p.role)) {
|
||||
return '<td class="chk-col cell-na" title="Not available for this role">·</td>';
|
||||
}
|
||||
var nm = 'task_' + ref + '_person_' + p.index;
|
||||
return '<td class="chk-col"><input type="checkbox" class="form-check-input matrix-box" ' +
|
||||
'name="' + nm + '" data-ref="' + ref + '" data-index="' + p.index + '" ' +
|
||||
'aria-label="' + esc(label) + ' — ' + esc(p.label) + '"' +
|
||||
(checked[nm] ? ' checked' : '') + '></td>';
|
||||
}).join('');
|
||||
return '<tr><td class="ref-col">' + ref + '</td><td>' + esc(label) + '</td>' + cells + '</tr>';
|
||||
}).join('');
|
||||
|
||||
matrixWrap.innerHTML = '<table class="grid"><thead>' + head + '</thead><tbody>' +
|
||||
body + '</tbody></table>';
|
||||
|
||||
var mobileCells = people.map(function (p) {
|
||||
var nm = 'mobile_person_' + p.index;
|
||||
return '<td class="chk-col"><input type="checkbox" class="form-check-input mobile-box" ' +
|
||||
'name="' + nm + '" aria-label="Mobile app — ' + esc(p.label) + '"' +
|
||||
(checked[nm] ? ' checked' : '') + '></td>';
|
||||
}).join('');
|
||||
|
||||
mobileWrap.innerHTML =
|
||||
'<table class="grid"><thead><tr><th class="ref-col">No.</th>' +
|
||||
'<th class="left">Mobile App</th>' +
|
||||
people.map(function (p) {
|
||||
return '<th class="chk-col"><span class="col-person">' + esc(p.label) + '</span></th>';
|
||||
}).join('') +
|
||||
'</tr></thead><tbody><tr><td class="ref-col">7</td><td>' + esc(MOBILE_LABEL) + '</td>' +
|
||||
mobileCells + '</tr></tbody></table>';
|
||||
}
|
||||
|
||||
// ── Recommendation preset ───────────────────────────────────────────────
|
||||
// Applies the mapping from schema.RECOMMENDATION for each person's role.
|
||||
// Overwrites the grid (that is what "apply the recommendation" means), and
|
||||
// leaves Step 3 alone — who carries a tablet is not something we can guess.
|
||||
function applyRecommendation() {
|
||||
var roleByIndex = {};
|
||||
Array.prototype.forEach.call(peopleBody.rows, function (tr) {
|
||||
roleByIndex[tr.dataset.index] = tr.querySelector('.person-role').value;
|
||||
});
|
||||
document.querySelectorAll('.matrix-box').forEach(function (cb) {
|
||||
var preset = RECOMMENDATION[roleByIndex[cb.dataset.index]] || {};
|
||||
cb.checked = !!preset[cb.dataset.ref];
|
||||
});
|
||||
}
|
||||
|
||||
// ── Wiring ──────────────────────────────────────────────────────────────
|
||||
document.getElementById('addPersonBtn').addEventListener('click', function () {
|
||||
addPerson(); renumber(); renderMatrix();
|
||||
var rows = peopleBody.rows;
|
||||
rows[rows.length - 1].querySelector('.person-name').focus();
|
||||
});
|
||||
|
||||
peopleBody.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.remove-person');
|
||||
if (!btn || peopleBody.rows.length <= 1) return;
|
||||
btn.closest('tr').remove();
|
||||
renumber(); renderMatrix();
|
||||
});
|
||||
|
||||
// Role changes the available cells; the name changes the column heading.
|
||||
peopleBody.addEventListener('change', function (e) {
|
||||
if (e.target.classList.contains('person-role')) renderMatrix();
|
||||
});
|
||||
peopleBody.addEventListener('input', function (e) {
|
||||
if (e.target.classList.contains('person-name')) renderMatrix();
|
||||
});
|
||||
|
||||
document.getElementById('recommendBtn').addEventListener('click', applyRecommendation);
|
||||
document.getElementById('clearBtn').addEventListener('click', function () {
|
||||
document.querySelectorAll('.matrix-box, .mobile-box').forEach(function (cb) {
|
||||
cb.checked = false;
|
||||
});
|
||||
});
|
||||
|
||||
// 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…';
|
||||
});
|
||||
|
||||
// ── Initial state ───────────────────────────────────────────────────────
|
||||
if (SEED && SEED.length) {
|
||||
SEED.forEach(function (p) { addPerson(p); });
|
||||
} else {
|
||||
addPerson(); // one administrative contact to start
|
||||
}
|
||||
renumber();
|
||||
renderMatrix();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!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 email %}
|
||||
<p class="mb-4">
|
||||
<i class="bi bi-envelope-check text-success"></i>
|
||||
A confirmation has been sent to <strong>{{ email }}</strong>.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% 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">{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user