Aug 7 - Update: UI change - MT16

This commit is contained in:
2026-08-07 16:32:40 -04:00
parent 6ca30c0dea
commit 513f708ee9
18 changed files with 3992 additions and 527 deletions
+94
View File
@@ -28,8 +28,41 @@ limiter = Limiter(
)
# ── Web portal design: per-request template overrides (MT-16) ────────────────
# A user on the 'modern' design gets templates/modern/<name>.html in place of
# templates/<name>.html whenever that override exists; otherwise the normal
# template is used and only the layout shell + CSS differ.
#
# The rewrite happens in get_template() (not in the loader) so Jinja's template
# cache is keyed on the REWRITTEN name — a cached modern template can never be
# served to a classic user, or vice versa. A loader-level swap would have that
# bug, and in MT it would leak across tenants sharing a worker process.
from flask.templating import Environment as _FlaskJinjaEnvironment
class ThemedEnvironment(_FlaskJinjaEnvironment):
"""Jinja environment that redirects template names to modern/<name>."""
# Populated once in create_app() by scanning templates/modern/.
jqc_modern_templates: set = set()
def get_template(self, name, parent=None, globals=None):
if (isinstance(name, str)
and self.jqc_modern_templates
and not name.startswith('modern/')):
candidate = 'modern/' + name
if candidate in self.jqc_modern_templates:
from flask import g, has_request_context
if has_request_context() and getattr(g, 'jqc_theme', 'classic') == 'modern':
name = candidate
return super().get_template(name, parent, globals)
def create_app(config_name='default'):
app = Flask(__name__)
# Must be assigned BEFORE app.jinja_env is first touched (it is a cached
# property), so the themed subclass is the one actually instantiated.
app.jinja_environment = ThemedEnvironment
app.config.from_object(config[config_name])
# Unwrap X-Forwarded-For / X-Forwarded-Proto set by Nginx so Flask sees
@@ -127,6 +160,65 @@ def create_app(config_name='default'):
from app.utils import storage as _storage
app.jinja_env.globals['media_url'] = _storage.media_url
# ── Web portal design wiring (MT-16) ──────────────────────────────────
# Index the modern/ override templates once at boot, so get_template()
# never has to touch the filesystem per request.
_modern_root = os.path.join(app.template_folder or 'templates', 'modern')
if not os.path.isabs(_modern_root):
_modern_root = os.path.join(app.root_path, _modern_root)
_modern_set = set()
if os.path.isdir(_modern_root):
for _dirpath, _dirnames, _filenames in os.walk(_modern_root):
for _fn in _filenames:
if _fn.endswith('.html'):
_rel = os.path.relpath(os.path.join(_dirpath, _fn), _modern_root)
_modern_set.add('modern/' + _rel.replace(os.sep, '/'))
ThemedEnvironment.jqc_modern_templates = _modern_set
app.logger.info('UI themes | modern overrides indexed: %s', len(_modern_set))
from flask import g, request as _request
@app.before_request
def resolve_ui_theme():
"""Stash the active design on `g` for ThemedEnvironment.get_template()."""
# The mobile API renders no templates and authenticates by JWT — skip it
# so this never touches the Flask-Login session loader on API traffic.
if _request.path.startswith('/api/'):
# The API renders no templates; 'classic' here only means "never
# rewrite a template name" (see ThemedEnvironment.get_template).
g.jqc_theme = 'classic'
return
from flask_login import current_user as _cu
# MT-16 — the fallback is configurable per deployment. It defaults to
# 'classic' so an existing tenant's users see no change until they opt
# in; a stored users.ui_theme always wins over the default.
default = app.config.get('DEFAULT_UI_THEME', 'classic')
theme = default
try:
if _cu.is_authenticated:
theme = _cu.ui_theme or default
except Exception: # DB column missing (migration not yet run)
theme = default
g.jqc_theme = theme if theme in ('classic', 'modern') else default
@app.context_processor
def inject_ui_theme():
"""Give base.html the shell to extend."""
from app.utils.time_utils import now_eastern
theme = getattr(g, 'jqc_theme',
app.config.get('DEFAULT_UI_THEME', 'classic'))
_now = now_eastern()
return {
'jqc_theme': theme,
'jqc_layout': 'layouts/modern.html' if theme == 'modern'
else 'layouts/classic.html',
# Long-form date shown in the modern dashboard header. The day is
# interpolated rather than formatted with '%-d' — that flag is a
# glibc extension and raises ValueError on Windows, which would
# 500 every page (this context processor runs on both themes).
'now_display': f'{_now.strftime("%A, %B")} {_now.day}, {_now.year}',
}
# ── Inject unread notification count into every template context ──────
# This powers the red badge on the navbar bell icon without requiring
# individual routes to pass the count manually.
@@ -221,6 +313,7 @@ def create_app(config_name='default'):
from app.routes import tenant_settings # MT-7 — tenant self-service
from app.routes import signup # MT-8+ — public self-service signup
from app.routes import landing # Public apex marketing/landing page
from app.routes import ui # MT-16 — design switch + new pages
from app.billing import bp as billing_bp # MT-8 — Stripe billing
app.register_blueprint(auth.bp)
@@ -244,6 +337,7 @@ def create_app(config_name='default'):
app.register_blueprint(tenant_settings.bp)
app.register_blueprint(signup.bp)
app.register_blueprint(landing.bp)
app.register_blueprint(ui.bp)
# 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).