Aug 7 - Update: UI change - MT16
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user