Aug 7 - Update: Enrollment intake form

This commit is contained in:
2026-08-07 16:52:49 -04:00
parent 513f708ee9
commit f3eb4badef
13 changed files with 2216 additions and 0 deletions
+6
View File
@@ -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).
+48
View File
@@ -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')))
+229
View File
@@ -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'))
+352
View File
@@ -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'},
)
+218
View File
@@ -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))
+194
View File
@@ -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 &amp; Functions</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th style="width:50px;">Ref</th>
<th 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 %}
&lt;<a href="mailto:{{ record.requester_email }}">{{ record.requester_email }}</a>&gt;
{% 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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── 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>
+244
View File
@@ -0,0 +1,244 @@
"""
tests/test_enrollment.py
------------------------
Behaviour tests for MT-17 the enrollment intake form.
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers:
* the public form renders without a login and carries no authenticated nav
* a submission is stored, is readable back, and the customer's own answers
are immutable afterwards (only the office block and status are writable)
* malformed submission ids are rejected before touching the filesystem
(path traversal)
* the admin views require a login
* TENANT ISOLATION: with multi-tenancy enabled, submissions are filed under
a per-tenant directory, one tenant's admin list never contains another's,
and an unresolved tenant raises rather than falling back to the shared root
The isolation tests are the reason this module exists. ST keeps every
submission in one flat directory, which in MT would put the names, emails and
phone numbers of one organisation's staff in front of another organisation's
admin. `test_unresolved_tenant_refuses_rather_than_sharing` pins the fail-closed
behaviour specifically, because a fallback to the root directory would be a
silent cross-tenant leak rather than a visible error.
"""
import os
import pytest
@pytest.fixture
def client(app, tmp_path):
"""Fresh schema + test client, with enrollment storage in a temp dir."""
with app.app_context():
from app import db
from app.models import inspector_assignment # noqa: F401
db.drop_all()
db.create_all()
app.config['ENROLLMENT_DIR'] = str(tmp_path / 'enrollments')
os.makedirs(app.config['ENROLLMENT_DIR'], exist_ok=True)
yield app.test_client()
db.session.remove()
def _user(username, role):
from app import db
from app.models.user import User
u = User(username=username, full_name=username.title(), role=role,
email=f'{username}@example.com', active=True)
u.set_password('pw-correct1')
db.session.add(u)
db.session.commit()
return u
def _login(client, user):
resp = client.post('/auth/login',
data={'username': user.username, 'password': 'pw-correct1'},
follow_redirects=True)
assert 'Login - ' not in resp.get_data(as_text=True), 'login failed'
return resp
def _record(app, project_name='Acme Tower'):
"""Build and save one submission through the real storage layer.
Mirrors the payload routes.submit() actually writes the admin templates
read every one of these keys, so a minimal stub renders as UndefinedError
rather than exercising the page.
"""
from datetime import datetime
from app.enrollment import storage, schema
now = datetime.now()
rec = {
'project_name': project_name,
'request_by': 'Reception',
'requester_email': 'reception@example.com',
'date_requested': now.strftime('%Y-%m-%d'),
'notes': '',
'people': [],
'matrix': {},
'mobile_app': {},
'id': storage.new_id(now),
'submitted_at': now.isoformat(timespec='seconds'),
'office': {k: '' for k, _ in schema.OFFICE_FIELDS},
'status': 'new',
'meta': {'ip': '127.0.0.1', 'user_agent': 'pytest'},
}
storage.save(rec)
return rec
# ── Public form ──────────────────────────────────────────────────────────────
def test_public_form_renders_without_login(client):
resp = client.get('/enrollment')
assert resp.status_code == 200
body = resp.get_data(as_text=True)
assert 'Login - ' not in body
# Login-free page: it must not carry the authenticated portal nav.
assert 'jqc-sidebar' not in body
def test_admin_list_requires_login(client):
resp = client.get('/enrollment/admin')
assert resp.status_code == 302
assert '/auth/login' in resp.headers.get('Location', '')
def test_admin_list_renders_for_admin(client, app):
admin = _user('ada', 'admin')
_login(client, admin)
with app.test_request_context():
_record(app, 'Listed Site')
resp = client.get('/enrollment/admin')
assert resp.status_code == 200
assert 'Listed Site' in resp.get_data(as_text=True)
# ── Storage round-trip ───────────────────────────────────────────────────────
def test_submission_round_trips(client, app):
from app.enrollment import storage
with app.test_request_context():
rec = _record(app)
back = storage.load(rec['id'])
assert back is not None
assert back['project_name'] == 'Acme Tower'
def test_customer_answers_are_immutable_after_submission(client, app):
"""Only the office block and status are writable — the file must stay an
accurate record of what the customer actually asked for."""
from app.enrollment import storage
with app.test_request_context():
rec = _record(app, project_name='Original Name')
storage.update_office(rec['id'], {'assigned_to': 'ops'}, 'in_review')
back = storage.load(rec['id'])
assert back['project_name'] == 'Original Name' # untouched
assert back['office']['assigned_to'] == 'ops'
assert back['status'] == 'in_review'
@pytest.mark.parametrize('bad_id', [
'../../etc/passwd',
'..%2f..%2fetc',
'not-an-id',
'',
'20260101-120000-ZZZZZZZZ', # non-hex suffix
])
def test_malformed_ids_are_rejected(client, app, bad_id):
"""A crafted id must never be interpolated into a filesystem path."""
from app.enrollment import storage
with app.test_request_context():
assert storage.load(bad_id) is None
# ── Tenant isolation ─────────────────────────────────────────────────────────
class _FakeTenant:
def __init__(self, tid):
self.id = tid
def test_single_tenant_mode_uses_the_root_directory(client, app):
"""With MT off, behaviour matches ST exactly — no per-tenant subdirectory."""
from app.enrollment import storage
with app.test_request_context():
assert app.config.get('MULTI_TENANT_ENABLED') is False
assert storage.enrollment_dir() == app.config['ENROLLMENT_DIR']
def test_submissions_are_filed_per_tenant(client, app):
from flask import g
from app.enrollment import storage
root = app.config['ENROLLMENT_DIR']
app.config['MULTI_TENANT_ENABLED'] = True
try:
with app.test_request_context():
g.tenant = _FakeTenant(7)
assert storage.enrollment_dir() == os.path.join(root, 't7')
_record(app, 'Tenant Seven Site')
with app.test_request_context():
g.tenant = _FakeTenant(9)
assert storage.enrollment_dir() == os.path.join(root, 't9')
finally:
app.config['MULTI_TENANT_ENABLED'] = False
assert os.path.isdir(os.path.join(root, 't7'))
def test_one_tenant_never_sees_anothers_submissions(client, app):
"""The leak this phase exists to prevent."""
from flask import g
from app.enrollment import storage
app.config['MULTI_TENANT_ENABLED'] = True
try:
with app.test_request_context():
g.tenant = _FakeTenant(7)
_record(app, 'Seven Confidential')
with app.test_request_context():
g.tenant = _FakeTenant(9)
others = storage.load_all()
names = [r.get('project_name') for r in others]
assert 'Seven Confidential' not in names
assert others == []
finally:
app.config['MULTI_TENANT_ENABLED'] = False
def test_unresolved_tenant_refuses_rather_than_sharing(client, app):
"""Fail closed. Falling back to the shared root would be a silent
cross-tenant leak; an exception is loud and safe."""
from flask import g
from app.enrollment import storage
app.config['MULTI_TENANT_ENABLED'] = True
try:
with app.test_request_context():
g.tenant = None
with pytest.raises(storage.TenantUnresolved):
storage.enrollment_dir()
finally:
app.config['MULTI_TENANT_ENABLED'] = False
# ── Branding ─────────────────────────────────────────────────────────────────
def test_no_hardcoded_upstream_branding_reaches_a_tenant(client):
"""ST hardcodes its own company name and a personal address in this module.
Neither may ever be shown to another tenant's customers."""
import pathlib
root = pathlib.Path('app/enrollment')
for path in root.rglob('*'):
if path.is_file() and path.suffix in ('.py', '.html'):
text = path.read_text(encoding='utf-8')
assert 'L.T' not in text, f'{path} still carries upstream branding'
assert 'da.nguyen8744' not in text, f'{path} still carries a personal address'