""" 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)