""" ui.py ----- Web-portal design A/B test (phase48). Routes POST /ui/theme switch_theme() — flip users.ui_theme classic ↔ modern GET /ui/about about() — About Us page (new, modern deck) GET /ui/support-center support_center() — Support hub of how-to cards (new) GET /ui/theme-votes theme_votes() — admin tally of which design users kept Nothing here changes existing behaviour: the theme flag only selects which layout shell base.html extends. """ 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 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 _safe_next(target: str | None) -> str: """Only allow same-site relative redirects (open-redirect guard).""" 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 current_app.config.get( 'DEFAULT_UI_THEME', 'modern') 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')) 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: tally[theme or 'classic'] = tally.get(theme or 'classic', 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)