From 88af63691292d12b78dce7f16ed86b1b6bf75c57 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 19 Aug 2026 14:43:16 -0400 Subject: [PATCH] Aug 19 - Update New Inspection Schedule page layout --- app/routes/inspection_schedules.py | 47 +- app/routes/inspections.py | 21 +- app/templates/inspection_schedules/form.html | 578 +++++++++++------- app/templates/inspection_schedules/index.html | 18 +- app/templates/layouts/classic.html | 9 + app/templates/layouts/modern.html | 7 +- app/templates/ui/about.html | 38 ++ 7 files changed, 482 insertions(+), 236 deletions(-) diff --git a/app/routes/inspection_schedules.py b/app/routes/inspection_schedules.py index 4e34cb0..5455ff3 100644 --- a/app/routes/inspection_schedules.py +++ b/app/routes/inspection_schedules.py @@ -530,6 +530,31 @@ def index(): completed_count=completed_count) +def _active_contracts(): + """Contracts offered in the UI-only contract selector on the form. + + Narrowed to a Customer Director's own contracts so the selector cannot even + name another customer's contract. The selector is not submitted — the + facility is what the route validates (rule 61) — so this is presentation, + with _scope_errors() doing the enforcing. + """ + q = Project.query.filter_by(active=True) + if _is_customer_director(current_user): + pids = _customer_project_ids() + q = q.filter(Project.id.in_(pids)) if pids else q.filter(False) + return q.order_by(Project.name).all() + + +def _project_for_facility(facility_id): + """The contract a facility belongs to — seeds the contract selector so an + edit, or a re-render after a validation error, comes back with both + dropdowns as the user left them.""" + if not facility_id: + return None + fac = db.session.get(Facility, facility_id) + return fac.project_id if fac else None + + def _form_choices(): """Lists offered on the schedule form, narrowed to the actor's scope. @@ -569,6 +594,8 @@ def _form_choices(): @schedule_manager_required def create(): templates, facilities, inspectors = _form_choices() + projects = _active_contracts() + selected_project_id = None if request.method == 'POST': name = request.form.get('name', '').strip() @@ -576,6 +603,8 @@ def create(): facility_id = request.form.get('facility_id', type=int) area_id = request.form.get('area_id', type=int) or None inspector_id = request.form.get('inspector_id', type=int) + # Re-seeds the contract selector when this POST comes back invalid. + selected_project_id = _project_for_facility(facility_id) frequency = request.form.get('frequency', 'weekly') mode = request.form.get('mode', 'auto') notes = request.form.get('notes', '').strip() or None @@ -600,6 +629,8 @@ def create(): for e in errors: flash(e, 'warning') return render_template('inspection_schedules/form.html', + projects=projects, + selected_project_id=selected_project_id, templates=templates, facilities=facilities, inspectors=inspectors, frequencies=_FREQUENCIES, frequency_labels=InspectionSchedule.FREQUENCY_LABELS, @@ -633,6 +664,8 @@ def create(): for e in end_errors: flash(e, 'warning') return render_template('inspection_schedules/form.html', + projects=projects, + selected_project_id=selected_project_id, templates=templates, facilities=facilities, inspectors=inspectors, frequencies=_FREQUENCIES, frequency_labels=InspectionSchedule.FREQUENCY_LABELS, @@ -652,6 +685,8 @@ def create(): return redirect(url_for('inspection_schedules.index')) return render_template('inspection_schedules/form.html', + projects=projects, + selected_project_id=selected_project_id, templates=templates, facilities=facilities, inspectors=inspectors, frequencies=_FREQUENCIES, frequency_labels=InspectionSchedule.FREQUENCY_LABELS, @@ -668,6 +703,8 @@ def edit(schedule_id): if not _schedule_in_scope(schedule): abort(403) templates, facilities, inspectors = _form_choices() + projects = _active_contracts() + selected_project_id = _project_for_facility(schedule.facility_id) if request.method == 'POST': old_inspector_id = schedule.inspector_id @@ -675,6 +712,8 @@ def edit(schedule_id): template_id = request.form.get('template_id', type=int) facility_id = request.form.get('facility_id', type=int) inspector_id = request.form.get('inspector_id', type=int) + selected_project_id = (_project_for_facility(facility_id) + or selected_project_id) frequency = request.form.get('frequency', schedule.frequency) if frequency not in _FREQUENCIES: @@ -688,6 +727,8 @@ def edit(schedule_id): for e in errors: flash(e, 'warning') return render_template('inspection_schedules/form.html', + projects=projects, + selected_project_id=selected_project_id, schedule=schedule, templates=templates, facilities=facilities, inspectors=inspectors, frequencies=_FREQUENCIES, @@ -736,6 +777,8 @@ def edit(schedule_id): for e in end_errors: flash(e, 'warning') return render_template('inspection_schedules/form.html', + projects=projects, + selected_project_id=selected_project_id, schedule=schedule, templates=templates, facilities=facilities, inspectors=inspectors, frequencies=_FREQUENCIES, @@ -763,7 +806,9 @@ def edit(schedule_id): flash(f'Inspection schedule "{schedule.name}" updated.', 'success') return redirect(url_for('inspection_schedules.index')) - return render_template('inspection_schedules/form.html', schedule=schedule, + return render_template('inspection_schedules/form.html', + projects=projects, + selected_project_id=selected_project_id, schedule=schedule, templates=templates, facilities=facilities, inspectors=inspectors, frequencies=_FREQUENCIES, frequency_labels=InspectionSchedule.FREQUENCY_LABELS, diff --git a/app/routes/inspections.py b/app/routes/inspections.py index 706c8c1..ca1fe24 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -476,10 +476,23 @@ def areas_for_facility(facility_id): @bp.route('/facilities_for_project/') @login_required def facilities_for_project(project_id): - facilities = (Facility.query - .filter_by(active=True, project_id=project_id) - .order_by(Facility.name) - .all()) + """Active facilities on one contract, for the Contract -> Facility cascade. + + **Scoped to the caller.** Every page that renders a facility dropdown + already limits it to what the viewer may see; this endpoint refills that + same dropdown, so without the same scope it would happily list another + customer's building names to anyone who guessed a contract id — the leak + rule 96 describes on the mobile API. Empty list rather than 403, so it does + not confirm whether the contract exists either. + """ + q = Facility.query.filter_by(active=True, project_id=project_id) + if current_user.is_inspector: + fids = get_inspector_scope(current_user) or [] + q = q.filter(Facility.id.in_(fids)) if fids else q.filter(False) + elif current_user.role == 'customer': + fids = get_customer_scope(current_user) or [] + q = q.filter(Facility.id.in_(fids)) if fids else q.filter(False) + facilities = q.order_by(Facility.name).all() return jsonify([{'id': f.id, 'name': f.name} for f in facilities]) diff --git a/app/templates/inspection_schedules/form.html b/app/templates/inspection_schedules/form.html index f4407c6..bbe6ac2 100644 --- a/app/templates/inspection_schedules/form.html +++ b/app/templates/inspection_schedules/form.html @@ -1,144 +1,234 @@ {% extends "base.html" %} {% block title %}{{ title }}{% endblock %} + +{# Laid out to match the single-tenant scheduled-inspection form: one narrow + card, Contract → Facility cascade at the top, then what/who, then when. + + MT keeps three fields ST does not have — the schedule NAME (required by + inspection_schedules.name), the AREA (a schedule may target one area) and + the auto/plan MODE. They are placed next to the field they qualify rather + than in a block of their own. #} + {% block content %} +{# ── Sticky values ──────────────────────────────────────────────────────── + This form is hand-built (no WTForms), so a re-render after a validation + error would otherwise come back blank and the user would retype everything. + On POST every field reads back from request.form; otherwise from the saved + schedule (edit) or its default (create). ST gets this free from WTForms — + this is the equivalent. #} +{% set posted = request.form if request.method == 'POST' else None %} +{% set v_name = posted.get('name') if posted else (schedule.name if schedule else '') %} +{% set v_facility = (posted.get('facility_id')|int(0)) if posted else (schedule.facility_id if schedule else 0) %} +{% set v_area = (posted.get('area_id')|int(0)) if posted else (schedule.area_id if schedule and schedule.area_id else 0) %} +{% set v_template = (posted.get('template_id')|int(0)) if posted else (schedule.template_id if schedule else 0) %} +{% set v_inspector = (posted.get('inspector_id')|int(0)) if posted else (schedule.inspector_id if schedule else 0) %} +{% set v_frequency = posted.get('frequency') if posted else (schedule.frequency if schedule else 'weekly') %} +{% set v_due = posted.get('next_due_date') if posted else (schedule.due_date.isoformat() if schedule and schedule.due_date else '') %} +{% set v_end = posted.get('end_date') if posted else (schedule.end_date.isoformat() if schedule and schedule.end_date else '') %} +{% set v_mode = posted.get('mode') if posted else (schedule.mode if schedule else 'auto') %} +{% set v_notes = posted.get('notes') if posted else (schedule.notes if schedule and schedule.notes else '') %} +{% set v_month_mode = posted.get('month_mode') if posted else (schedule.month_mode if schedule else 'day_of_month') %} +{% set v_dom = posted.get('day_of_month') if posted else (schedule.day_of_month if schedule and schedule.day_of_month else '') %} +{% set v_nth_week = (posted.get('nth_week')|int(0)) if posted else (schedule.nth_week if schedule and schedule.nth_week else 0) %} +{% set v_nth_weekday = (posted.get('nth_weekday')|int(-1)) if posted else (schedule.nth_weekday if schedule and schedule.nth_weekday is not none else -1) %} +{% set v_weekdays = (posted.getlist('weekdays')|map('int')|list) if posted else (schedule.weekday_list if schedule else []) %} +{% set v_active = (posted.get('active') is not none) if posted else (schedule.active if schedule else True) %}
-
-

{{ title }}

- -
+
+
+
{{ title }}
- + + -
- - -
+
+ + +
Shown in the schedule list and in the inspector's reminder.
+
-
-
- - + + {% for p in projects %} + + {% endfor %} + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ Shared forms plus any built for this contract. A form belonging to + another contract is rejected on save, not merely hidden here. +
-
- - -
-
- {# ── Recurrence detail (phase46) ──────────────────────────────────── - Only the block matching the chosen frequency is shown; the server - validates the same block and clears the others on save. #} - diff --git a/app/templates/ui/about.html b/app/templates/ui/about.html index 25c2001..7001186 100644 --- a/app/templates/ui/about.html +++ b/app/templates/ui/about.html @@ -69,6 +69,44 @@
+ {# The enrollment intake form is served by THIS app and is public (no login), + so it is linked with url_for() rather than an absolute URL: the link then + stays on whatever host the user is already on — which in MT is the tenant's + own subdomain or custom domain — and cannot rot if that domain changes. + Submissions are read at Admin -> Enrollment Forms. #} + + + +