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
+60
View File
@@ -24,6 +24,10 @@ def index():
now = now_eastern()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
# MT-16 — Monday 00:00 of the current week, for the modern dashboard's
# "submitted this week" tile. Derived from today_start so it inherits
# now_eastern() rather than mixing in a second clock.
week_start = today_start - timedelta(days=today_start.weekday())
is_inspector = current_user.is_inspector
is_privileged = current_user.role in ['admin', 'director']
@@ -62,6 +66,14 @@ def index():
Inspection.inspection_date < today_end,
).count()
# MT-16 — fully completed & submitted so far this week (Monday → now).
# Reuses base_q, so it inherits the same role scoping as every other tile.
submitted_this_week = base_q.filter(
Inspection.status == 'completed',
Inspection.inspection_date >= week_start,
Inspection.inspection_date < today_end,
).count()
# ── Open issues (inspector: all issues in contracted facilities) ───────
open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
if is_inspector:
@@ -282,6 +294,26 @@ def index():
stale_q = stale_q.filter(False)
stale_in_progress = stale_q.count()
# ── In-progress inspections, all ages (MT-16) ─────────────────────────────
# stale_in_progress above counts only those older than 24h. The modern
# dashboard shows the full in-progress count as its own tile, so this is a
# separate query with the SAME role scoping rather than a reuse of stale_q.
inprog_q = Inspection.query.filter(Inspection.status == 'in_progress')
if is_inspector:
if not inspector_facility_ids:
inprog_q = inprog_q.filter(False)
else:
inprog_q = inprog_q.filter(
Inspection.facility_id.in_(inspector_facility_ids),
Inspection.inspector_id == current_user.id,
)
elif is_customer:
if customer_facility_ids:
inprog_q = inprog_q.filter(Inspection.facility_id.in_(customer_facility_ids))
else:
inprog_q = inprog_q.filter(False)
in_progress_total = inprog_q.count()
# ── Unassigned open issues ────────────────────────────────────────────────
from app.models.facility import Area as _AreaU
unassigned_q = Issue.query.outerjoin(_AreaU, Issue.area_id == _AreaU.id).filter(
@@ -349,6 +381,11 @@ def index():
# inspections list, so surfacing them here would double-report the work.
sched_upcoming = []
sched_overdue_count = 0
# MT-16 — the modern dashboard additionally shows the total number of active
# plans ("On Schedules") and offers Continue instead of a duplicate Start
# where an inspection is already underway for that plan.
sched_total = 0
sched_open_inspections = {}
if not is_customer:
from app.models.inspection_schedule import InspectionSchedule
_today = now_eastern().date()
@@ -365,11 +402,34 @@ def index():
s for s in _all_sched
if s.next_run_at and _today <= s.next_run_at.date() <= _today + timedelta(days=7)
][:8]
sched_total = len(_all_sched) # active plan-mode schedules
# {schedule_id: inspection_id} for plans with an inspection already in
# progress. MT's FK is Inspection.inspection_schedule_id (ST calls it
# scheduled_inspection_id). Ordered ascending so that when a plan somehow
# has more than one open inspection, the dict keeps the LOWEST id — the
# original, not a later duplicate.
_sched_ids = [s.id for s in sched_upcoming if s.id]
if _sched_ids:
_open_rows = (
Inspection.query
.filter(Inspection.inspection_schedule_id.in_(_sched_ids),
Inspection.status == 'in_progress')
.order_by(Inspection.id.desc())
.all()
)
sched_open_inspections = {
r.inspection_schedule_id: r.id for r in _open_rows
}
return render_template(
'dashboard.html',
sched_upcoming = sched_upcoming,
sched_overdue_count = sched_overdue_count,
sched_total = sched_total,
sched_open_inspections = sched_open_inspections,
in_progress_total = in_progress_total,
submitted_this_week = submitted_this_week,
week_start_str = week_start.strftime('%Y-%m-%d'),
today_inspections = today_inspections,
completed_today = completed_today,
open_issues = open_issues,
+140
View File
@@ -0,0 +1,140 @@
"""
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)