Jul 17 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT7
This commit is contained in:
+1
-1
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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):
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user