Aug 7 - Update: Enrollment intake form
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user