116 lines
4.1 KiB
Python
116 lines
4.1 KiB
Python
"""
|
|
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)
|
|
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 'classic'
|
|
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)
|