195 lines
6.4 KiB
Python
195 lines
6.4 KiB
Python
"""
|
|
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
|