Jul 17 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT7

This commit is contained in:
2026-07-17 12:54:07 -04:00
parent 8d9730e3df
commit c706489480
8 changed files with 178 additions and 16 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ def register_api(app):
from app.api.comments import bp as comments_bp from app.api.comments import bp as comments_bp
api_bp.register_blueprint(comments_bp) api_bp.register_blueprint(comments_bp)
# MT-5: Planned inspection assignments (plan-mode schedules) # phase43: Planned inspection assignments (plan-mode schedules)
from app.api.scheduled import bp as scheduled_bp from app.api.scheduled import bp as scheduled_bp
api_bp.register_blueprint(scheduled_bp) api_bp.register_blueprint(scheduled_bp)
+2 -2
View File
@@ -1,7 +1,7 @@
""" """
app/api/scheduled.py app/api/scheduled.py
-------------------- --------------------
Mobile API endpoint for planned inspection assignments (MT-5, phase43). Mobile API endpoint for planned inspection assignments (phase43).
GET /api/v1/scheduled-inspections GET /api/v1/scheduled-inspections
Returns ACTIVE, PLAN-MODE schedules the caller is responsible for. Returns ACTIVE, PLAN-MODE schedules the caller is responsible for.
@@ -17,7 +17,7 @@ Why plan-mode only
`mode='auto'` schedules materialise themselves into a real Inspection at `mode='auto'` schedules materialise themselves into a real Inspection at
next_run_at, which the iPad already fetches via /api/v1/inspections. Returning next_run_at, which the iPad already fetches via /api/v1/inspections. Returning
them here too would show the same work twice, and "Start" is meaningless for a them here too would show the same work twice, and "Start" is meaningless for a
schedule that starts itself. This mirrors the web dashboard panel (MT-4). schedule that starts itself. This mirrors the web dashboard panel (phase43).
A plan-mode schedule is a PLAN, not an inspection — see A plan-mode schedule is a PLAN, not an inspection — see
app/models/inspection_schedule.py for the full lifecycle. app/models/inspection_schedule.py for the full lifecycle.
+7 -4
View File
@@ -8,7 +8,6 @@ app/templates/billing/email/.
import logging import logging
import threading import threading
from urllib.parse import urlparse
from flask import current_app, render_template from flask import current_app, render_template
from flask_mail import Message from flask_mail import Message
from app import mail from app import mail
@@ -92,10 +91,14 @@ def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
app = current_app._get_current_object() app = current_app._get_current_object()
# Derive noreply sender from APP_BASE_URL so billing emails match the tenant domain. # Branded From: the display NAME tracks the tenant, the ADDRESS stays the
# authenticated identity unless the domain is DNS-authorized. Resolved HERE,
# in the request context, because _send() runs on a background thread where
# g.tenant no longer exists. The previous `noreply@{netloc}` sent from the
# tenant's host regardless of SPF authorization. See app/utils/mail_utils.py.
try: try:
_netloc = urlparse(app.config.get('APP_BASE_URL', '')).netloc from app.utils.mail_utils import branded_sender
_sender = f'noreply@{_netloc}' if _netloc else app.config.get('MAIL_DEFAULT_SENDER', '') _sender = branded_sender(app.config.get('APP_BASE_URL', ''))
except Exception: except Exception:
_sender = app.config.get('MAIL_DEFAULT_SENDER', '') _sender = app.config.get('MAIL_DEFAULT_SENDER', '')
+1 -1
View File
@@ -118,7 +118,7 @@ class Issue(db.Model):
# Display labels for handler_type. The web templates hardcode these inline; # Display labels for handler_type. The web templates hardcode these inline;
# this mapping exists so the mobile API can return a human-readable label # this mapping exists so the mobile API can return a human-readable label
# without the client duplicating the strings. (phase43 / MT-5) # without the client duplicating the strings. (phase43)
HANDLER_LABELS = { HANDLER_LABELS = {
'internal': 'Janitorial Staff', 'internal': 'Janitorial Staff',
'facility': 'Facility Staff', 'facility': 'Facility Staff',
+8 -3
View File
@@ -179,7 +179,6 @@ def _send_invite_email(user, token, base_url=None):
from flask import current_app, render_template_string from flask import current_app, render_template_string
from flask_mail import Message from flask_mail import Message
from app import mail from app import mail
from urllib.parse import urlparse
import threading import threading
if not current_app.config.get('MAIL_SERVER'): if not current_app.config.get('MAIL_SERVER'):
@@ -189,8 +188,14 @@ def _send_invite_email(user, token, base_url=None):
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/') effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}' setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}'
host = urlparse(effective_base).netloc or 'janitorialqc.local' # Branded From as a (display_name, address) tuple. The display NAME tracks
sender = f'noreply@{host}' # the tenant (e.g. "Gov Services QC"); the ADDRESS is branded only for
# DNS-authorized domains and otherwise stays the authenticated identity so
# the mail always delivers. The previous `noreply@{host}` sent from whatever
# host the browser was on, which fails SPF/DMARC for any domain this mail
# server isn't authorized for. See app/utils/mail_utils.py and rule 64.
from app.utils.mail_utils import branded_sender
sender = branded_sender(effective_base)
html_body = render_template_string("""<!DOCTYPE html> html_body = render_template_string("""<!DOCTYPE html>
<html> <html>
+1 -1
View File
@@ -66,7 +66,7 @@ def _save_report_photos(file_list):
under "Photo Evidence" on the web (rule 44 — never `result_photos`). under "Photo Evidence" on the web (rule 44 — never `result_photos`).
Writes go through `_save_photo`, which validates magic bytes and routes to Writes go through `_save_photo`, which validates magic bytes and routes to
the active storage backend (MT-2). the active storage backend (see app/utils/storage.py).
""" """
from app.routes.inspections import _save_photo from app.routes.inspections import _save_photo
saved = [] saved = []
+154
View File
@@ -0,0 +1,154 @@
"""
app/utils/mail_utils.py
-----------------------
Helpers for constructing outbound email sender identities.
`branded_sender()` returns a Flask-Mail sender as a ``(display_name, address)``
tuple so outbound branded email (customer invitations, billing notices) shows a
per-tenant identity — WITHOUT risking deliverability.
Two independent things vary:
1. DISPLAY NAME (what the recipient sees, e.g. "Gov Services QC")
Always applied. Costs nothing, needs no DNS. This is the safe default.
2. FROM ADDRESS (the actual @domain, e.g. jqc.noreply@govservicesinc.com)
Only used for domains that are KNOWN to authorize this mail server
(the authenticated sender's own domain, plus anything you add to
SENDER_AUTHORIZED_DOMAINS). Every other domain keeps the authenticated
address so the message still passes SPF/DMARC and delivers.
Result, with no DNS work:
Invited from gov.jqc.app ->
From: "Gov Services QC" <jqc.noreply@ltservicesinc.com>
(branded NAME, deliverable authenticated ADDRESS)
Once a tenant's own domain authorizes this mail server (SPF include + DKIM),
add it to SENDER_AUTHORIZED_DOMAINS and it upgrades to:
From: "Gov Services QC" <jqc.noreply@govservicesinc.com>
Ported from the single-tenant tree with one multi-tenant change: the display
name comes from the TENANT, not a hardcoded host→name dict. Resolution order:
1. TenantSettings.company_name — what the tenant typed into Branding
2. Tenant.name (control plane) — always known for a resolved tenant
3. BRAND_NAMES[domain] — ST's host map, kept for non-tenant hosts
4. DEFAULT_BRAND_NAME
See CLAUDE.md rule 64.
"""
from urllib.parse import urlparse
from flask import current_app, g
# ── Per-domain display names (single-tenant / non-tenant hosts only) ─────────
# In multi-tenant mode the tenant record supplies the name and this map is never
# consulted. It remains for the single-tenant deployment and for hosts that
# resolve to no tenant.
BRAND_NAMES = {
'ltservicesinc.com': 'LT Services QC',
'govservicesinc.com': 'Gov Services QC',
'efs.com': 'EFS QC',
}
DEFAULT_BRAND_NAME = 'Janitorial QC'
# ── Domains cleared to use a BRANDED FROM ADDRESS ────────────────────────────
# Add a registrable domain here ONLY after its DNS authorizes this mail server
# (SPF `include:` + DKIM). Until then the domain still gets its branded display
# NAME but sends from the authenticated address, so it always delivers.
# The authenticated sender's own domain is always treated as authorized and does
# NOT need to be listed here.
# NOTE: written as set([...]) deliberately. A brace literal containing only
# comments is an empty *dict*, which silently becomes a *set* the moment a line
# is uncommented. Membership works either way, so this is cosmetic — but the
# declared type shouldn't change based on whether a comment is uncommented.
SENDER_AUTHORIZED_DOMAINS = set([
# 'govservicesinc.com', # ← uncomment once SPF+DKIM are live for it
# 'efs.com',
])
def _authenticated_sender() -> str:
"""The SMTP identity we authenticate as — always deliverable."""
return (current_app.config.get('MAIL_DEFAULT_SENDER')
or current_app.config.get('MAIL_USERNAME')
or 'jqc.noreply@janitorialqc.local')
def _host_domain(base_url):
"""Registrable domain (last two labels) of base_url, or None if unparseable."""
base = (base_url or current_app.config.get('APP_BASE_URL', '') or '').strip().rstrip('/')
if not base:
return None
netloc = urlparse(base).netloc or urlparse('//' + base).netloc
host = netloc.split('@')[-1].split(':')[0].strip().lower() # drop userinfo/port
if not host or '.' not in host:
return None
labels = [l for l in host.split('.') if l]
return '.'.join(labels[-2:]) if len(labels) >= 2 else host
def _tenant_brand_name():
"""Display name from the current tenant, or None.
Prefers what the tenant typed into Branding (TenantSettings.company_name),
then the control-plane tenant name. Returns None in single-tenant mode or
when no tenant is bound.
Deliberately swallows errors: TenantSettings.query raises ProgrammingError
when a freshly provisioned tenant DB has not been migrated yet, and an
un-migrated tenant must not break sending mail.
"""
if not current_app.config.get('MULTI_TENANT_ENABLED'):
return None
tenant = getattr(g, 'tenant', None) if g else None
if tenant is None:
return None
try:
from app.models.tenant_settings import TenantSettings
settings = TenantSettings.get_or_default()
if settings is not None:
name = (settings.company_name or '').strip()
if name:
return name
except Exception:
pass
return (getattr(tenant, 'name', '') or '').strip() or None
def branded_sender(base_url=None):
"""Return a ``(display_name, address)`` sender tuple for branded email.
The display name tracks the tenant (falling back to the host's domain); the
address is branded only for the authenticated domain and any
SENDER_AUTHORIZED_DOMAINS, and otherwise stays the authenticated address so
the mail still delivers.
Falls back to the bare authenticated sender string whenever the host cannot
be parsed (localhost, empty, no dot), so this can never yield an invalid From.
"""
auth_sender = _authenticated_sender()
if '@' not in auth_sender:
return auth_sender
local, auth_domain = auth_sender.rsplit('@', 1)
auth_domain = auth_domain.lower()
tenant_name = _tenant_brand_name()
domain = _host_domain(base_url)
if not domain:
# Unknown host → safe authenticated address; still brand the name if a
# tenant is bound.
return (tenant_name or DEFAULT_BRAND_NAME, auth_sender)
display_name = tenant_name or BRAND_NAMES.get(domain, DEFAULT_BRAND_NAME)
if domain == auth_domain or domain in SENDER_AUTHORIZED_DOMAINS:
address = f'{local}@{domain}' # branded address — DNS-authorized
else:
address = auth_sender # keep deliverable authenticated address
return (display_name, address)
+4 -4
View File
@@ -9,11 +9,11 @@ addition: per-tenant object-key prefixing. See "Tenant isolation" below.
One interface, backend selected by config ``STORAGE_BACKEND``: One interface, backend selected by config ``STORAGE_BACKEND``:
- ``local`` (default): files under ``app/static/uploads``, served via - ``local`` (default): files under ``app/static/uploads``, served via
``url_for('static', ...)``. **Byte-for-byte identical** to the behavior ``url_for('static', ...)``. **Byte-for-byte identical** to the behavior
before this abstraction existed — MT-2 is a no-op. before this abstraction existed — introducing this seam is a no-op.
- ``s3``: Cloudflare R2 / any S3-compatible store. Private bucket; browser/API - ``s3``: Cloudflare R2 / any S3-compatible store. Private bucket; browser/API
URLs are short-lived presigned GETs. Requires ``boto3`` and the ``R2_*`` URLs are short-lived presigned GETs. Requires ``boto3`` and the ``R2_*``
config keys. boto3 is imported lazily, so a ``local`` deploy needs neither. config keys. boto3 is imported lazily, so a ``local`` deploy needs neither.
Inert until MT-8 flips STORAGE_BACKEND per tenant. Inert until STORAGE_BACKEND is flipped to s3 per tenant at R2 cutover.
The stored **key** is always the relative path ``uploads/<subfolder>/<file>`` — The stored **key** is always the relative path ``uploads/<subfolder>/<file>`` —
the exact string persisted in the DB (`Issue.photo_path`, `result_photos[]`, the exact string persisted in the DB (`Issue.photo_path`, `result_photos[]`,
@@ -34,9 +34,9 @@ payload. Callers stay tenant-agnostic; the DB stays portable.
The **local** backend deliberately does NOT prefix: its layout is the existing The **local** backend deliberately does NOT prefix: its layout is the existing
on-disk tree, and prefixing would relocate every existing file (that is not a on-disk tree, and prefixing would relocate every existing file (that is not a
no-op, and MT-2 must be one). Local mode therefore keeps today's shared no-op, and this seam must be one). Local mode therefore keeps today's shared
``app/static/uploads`` directory across tenants — an isolation weakness that ``app/static/uploads`` directory across tenants — an isolation weakness that
predates this module and is retired when a tenant moves to ``s3`` in MT-8. predates this module and is retired when a tenant moves to ``s3`` at cutover.
Public module-level API (delegates to the active backend): Public module-level API (delegates to the active backend):