Files
2026-08-07 16:32:40 -04:00

141 lines
5.2 KiB
Python

"""
app/routes/ui.py
----------------
Web portal design switch and the pages the modern sidebar links to (MT-16).
Routes
POST /ui/theme switch_theme() — flip users.ui_theme classic ↔ modern
GET /ui/about about() — About Us page
GET /ui/support-center support_center() — support hub of how-to cards
GET /ui/theme-votes theme_votes() — admin tally of design choices
Nothing here changes existing behaviour: the theme flag only selects which
layout shell base.html extends. Every page template is untouched.
Multi-tenant note
-----------------
`users` is a per-tenant table, so every query in this module is automatically
scoped to the caller's tenant by the routing session — the vote tally shows one
tenant's users, never the estate. There is deliberately no cross-tenant rollup
here; that belongs in the control plane if it is ever wanted.
"""
import logging
from flask import (Blueprint, render_template, redirect, request,
url_for, flash, current_app)
from flask_login import login_required, current_user
from sqlalchemy import func
from app import db
from app.models.user import User, ROLE_LABELS
from app.utils.audit import log_action, ACTION_UPDATE
bp = Blueprint('ui', __name__, url_prefix='/ui')
logger = logging.getLogger(__name__)
VALID_THEMES = ('classic', 'modern')
def _default_theme():
"""Fallback design for accounts that have never chosen one."""
default = (current_app.config.get('DEFAULT_UI_THEME') or 'classic').lower()
return default if default in VALID_THEMES else 'classic'
def _safe_next(target):
"""Only allow same-site relative redirects (open-redirect guard).
A protocol-relative URL ('//evil.com') is a valid redirect target to the
browser but points off-site, so the leading-slash test alone is not enough.
"""
if not target:
return url_for('dashboard.index')
if target.startswith('/') and not target.startswith('//'):
return target
return url_for('dashboard.index')
# ── Design switch ────────────────────────────────────────────────────────────
@bp.route('/theme', methods=['POST'])
@login_required
def switch_theme():
"""Persist the user's design choice, then return them to the same page."""
theme = (request.form.get('theme') or '').strip().lower()
if theme not in VALID_THEMES:
flash('Unknown design option.', 'warning')
return redirect(_safe_next(request.form.get('next')))
previous = current_user.ui_theme or _default_theme()
if previous != theme:
current_user.ui_theme = theme
db.session.commit()
# log_action() commits internally — always AFTER the business commit.
log_action(
action = ACTION_UPDATE,
entity_type = 'User',
entity_id = current_user.id,
entity_label = current_user.username,
details = f'ui_theme={previous}{theme}',
)
logger.info('UI | theme switch | user=%s | %s -> %s',
current_user.username, previous, theme)
flash('Now showing the {} design. You can switch back any time from '
'the account menu.'.format('new' if theme == 'modern' else 'classic'),
'info')
return redirect(_safe_next(request.form.get('next')))
# ── New pages (linked from the modern sidebar) ───────────────────────────────
@bp.route('/about')
@login_required
def about():
return render_template('ui/about.html')
@bp.route('/support-center')
@login_required
def support_center():
return render_template('ui/support_center.html')
# ── Admin: which design are people actually keeping? ─────────────────────────
@bp.route('/theme-votes')
@login_required
def theme_votes():
if current_user.role != 'admin':
flash('You do not have permission to view the design vote tally.', 'danger')
return redirect(url_for('dashboard.index'))
default = _default_theme()
rows = (db.session.query(User.ui_theme, func.count(User.id))
.filter(User.active == True) # noqa: E712 — SQL boolean
.group_by(User.ui_theme)
.all())
tally = {t: 0 for t in VALID_THEMES}
for theme, count in rows:
key = theme if theme in VALID_THEMES else default
tally[key] = tally.get(key, 0) + count
total = sum(tally.values())
by_role = (db.session.query(User.role, User.ui_theme, func.count(User.id))
.filter(User.active == True) # noqa: E712
.group_by(User.role, User.ui_theme)
.order_by(User.role)
.all())
return render_template('ui/theme_votes.html',
tally=tally, total=total, by_role=by_role,
default_theme=default,
# by_role yields raw role strings from a group_by,
# so hand the template the same label source the
# User.role_label property uses (MT-15).
role_labels=ROLE_LABELS)