diff --git a/CLAUDE.md b/CLAUDE.md index e2c5ceb..ef1bba3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -321,6 +321,22 @@ Triage of `handler_type` + facility/vendor detail fields on the **update** panel **`reported_by`:** Added in phase18. Set at creation time to the user who filed the issue. Nullable for backward compatibility. Used by `GET /api/v1/issues` to return issues the inspector created but hasn't been assigned yet. +### TemplateContract (Phase 52) + +``` +template_contracts: id, template_id (FK→inspection_templates CASCADE, indexed), + project_id (FK→projects CASCADE, indexed), created_at + UniqueConstraint(template_id, project_id) +``` + +**Restricts a form to specific contracts** — a customer's bespoke form must not be visible to, or startable against, another customer's facilities. + +**No rows means the form is SHARED** (available on every contract), not "available nowhere". That convention is the whole migration story: every template that existed before phase52 has no rows, so nothing changed on deploy, and a form becomes customer-specific only when an admin attaches it to at least one contract. Inverting the default would silently hide every shared form from every contract. + +`InspectionTemplate` helpers: `contract_ids`, `is_shared`, `available_for_project(project_id)`, `set_contracts([ids])` (does **not** commit), and the static **`available_query(project_id)`** — the single definition of "which forms may this contract use", used by every picker, by the POST validation behind it, and by the mobile API, so they cannot disagree. A facility with **no** contract can only use shared forms (fail-closed). + +Managed on the template create/edit pages via an "Available on contracts" multi-select (admin/director); the template list shows a **Shared** badge or one badge per contract. + ### Notification / NotificationPreference ``` @@ -663,8 +679,8 @@ The last eight styles (`SummaryTitle` through `TableCell`) were added for the fa |---|---|---| | `GET /api/v1/facilities` | jwt_required | All active facilities scoped to user | | `GET /api/v1/facilities//areas` | jwt_required | Areas for a facility | -| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) | -| `GET /api/v1/templates/` | jwt_required | Full template with form_schema | +| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema). **Contract-scoped (phase52):** an inspector gets shared forms plus those attached to their assigned contracts. Optional `?project_id=` / `?facility_id=` narrows to one contract — **and is intersected with the caller's own scope**, so passing another customer's facility id returns `[]` rather than listing their form names. | +| `GET /api/v1/templates/` | jwt_required | Full template with form_schema. **404** (not 403) when the form is restricted to a contract the caller cannot reach — whether another customer's form exists is not their business. | ### Phase B Endpoints @@ -970,7 +986,20 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif → phase48_user_ui_theme → phase49_external_inspector → phase50_default_modern - → phase51_user_notif_matrix ← HEAD + → phase51_user_notif_matrix + → phase52_template_contracts ← HEAD + +#### phase52 — restrict forms to specific contracts + +Revision id `phase52_template_contracts`. Creates `template_contracts` — see §5 `TemplateContract`. + +**No backfill, and it cannot change behaviour on deploy.** Every existing template has no rows, and no rows means *shared with every contract*, which is exactly what they do today. Table-existence check — safe to re-run. `downgrade()` drops the table, returning every form to shared: no form becomes unusable, they just stop being restricted. + +**Deploy order:** +```bash +flask db upgrade +sudo systemctl restart gunicorn +``` #### phase51 — per-account notification overrides @@ -1633,6 +1662,8 @@ timeout = 30 | 85 | **`next_due_date` is mutable state, `end_date` is a fixed boundary — never conflate them** | `fulfill()` rewrites `next_due_date` after every completed inspection; `end_date` is set by the manager and never touched by the app. The old single label "Start / Due Date" said both at once, which is what users reported as confusing. The label now follows context — `form.next_due_date.label.text` is set to "Start Date" in `create()` and "Next Due Date" in `edit()`. Do not rename the `next_due_date` column to match a label: it is indexed, it is the API payload key the iPad decodes, and the reminder cron filters on it. | | 87 | **Never write `role == 'inspector'` — use `user.is_inspector` (`User.INSPECTOR_ROLES`)** | phase49 added `external_inspector`, which must behave as an inspector everywhere. An equality check silently drops it into the *privileged* branch of every `if inspector: scope … else: org-wide` block — i.e. a third-party inspector would see **every contract in the system**. This is a fail-OPEN mistake: nothing errors, the data just leaks. The sweep converted ~44 Python sites and 7 template sites; the only surviving `== 'inspector'` literals are the matrix docstring, the `MATRIX_DEFAULTS` mirror comprehension, and the default-checked box in `admin/broadcast.html`. Query-level checks use `User.role.in_(User.INSPECTOR_ROLES)` (never `filter_by(role='inspector')`). A **new** `app/api/*` blueprint's `_ALLOWED_ROLES` must include `external_inspector`, same as rule 79 requires for `auditor`. | | 88 | **`app/enrollment/` writes no DB row and has exactly ONE read — keep the vertical slice sealed** | The enrollment form describes accounts that do NOT exist yet (no contract, facility or user to key a row against), so it stores flat JSON in `ENROLLMENT_DIR` and owns its own templates. The single permitted model access is `mailer._admin_recipients()` reading active `admin` users to address the new-enrollment alert — function-local, read-only, and guarded so a DB failure cannot break a submission. Adding a model/migration for enrollment, or letting the public POST **create** Users, would couple an unauthenticated endpoint to the account system — the exact thing the separation buys. If enrollment must ever provision accounts, do it as a separate admin-triggered action that reads a stored submission. Submission ids are filesystem paths: validate against `_ID_RE` before every open (path traversal). See §24. | +| 95 | **A template with NO `template_contracts` rows is SHARED, not hidden** | The empty set means "available on every contract" — that is what makes phase52 additive and why it needed no backfill. Reading it the other way would hide every pre-phase52 form from every contract at once. The convention lives in exactly one place, `InspectionTemplate.available_query()`; every picker, the POST validation behind it, and the mobile API call it rather than writing their own filter. A facility with no contract gets shared forms only (fail-closed). | +| 96 | **An explicit `?project_id=` / `?facility_id=` filter must still be intersected with the caller's own scope** | Accepting a caller-supplied contract filter *instead of* their scope is a leak, not a filter: a Customer Inspector could pass another customer's facility id and get that customer's form names back. `_visible_templates()` returns `[]` for an out-of-scope contract — empty rather than an error, so the endpoint does not confirm the contract exists either. Applies to any future endpoint that takes a scope-shaped query parameter. | | 93 | **The flag-issue assignee list is contract-scoped, and BOTH call sites must use `_assignable_staff_for()`** | `execute()` renders the dropdown, `flag_issue()` builds the choices that validate the POST — the choices are the security boundary. Two separate queries had already drifted (offcanvas offered project_manager/auditor, choices rejected them), which silently discarded issues. Contract scoping applies to the two inspector roles for EVERY actor, not just customer ones: an org-wide list let anyone assign another client's Customer Inspector, who was then emailed that facility's name and issue description. Never widen this back to an unscoped `User.query.filter(role.in_(...))`. | | 94 | **A failed flag-issue POST must return a non-2xx** | The offcanvas JS branches on `res.ok`, so a 200 re-render of the invalid form reads as success: the panel closes, the page reloads, and no issue exists — with nothing in the logs and no message to the user. `flag_issue()` returns 400 on a failed POST for exactly this reason. Any future AJAX-submitted form needs the same treatment (rule 60 is the same failure seen from the other end). | | 91 | **A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 `form=` attribute** | Wrapping the table in the bulk form nests the per-row delete/unfollow forms inside it, and browsers **silently discard** nested forms (rule 9) — the row buttons would post nothing, with no console error and no server log. `
` sits above the table and each checkbox carries `form="issuesBulkForm"`. Same for `inspectionsBulkForm`. Applies to all four list templates (classic + modern). | diff --git a/app/api/templates.py b/app/api/templates.py index 195de63..5945f90 100644 --- a/app/api/templates.py +++ b/app/api/templates.py @@ -16,11 +16,13 @@ GET /api/v1/templates/ import logging -from flask import Blueprint, g +from flask import Blueprint, g, request from app import db from app.models.inspection import InspectionTemplate +from app.models.facility import Facility from app.api.errors import api_ok, api_error from app.api.decorators import jwt_required +from app.utils.scope import get_inspector_scope logger = logging.getLogger(__name__) @@ -31,6 +33,71 @@ _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector', 'project_manager', 'auditor'} +def _visible_project_ids(user): + """Contract ids whose forms this user may see, or None for "no limit". + + An inspector (ours or a customer's) is limited to the contracts they are + assigned; every other allowed role sees all. Derived from the facilities + get_inspector_scope() returns, so the API can never disagree with the web. + """ + if not user.is_inspector: + return None + fids = get_inspector_scope(user) or [] + if not fids: + return [] + return sorted({ + f.project_id + for f in Facility.query.filter(Facility.id.in_(fids)).all() + if f.project_id + }) + + +def _visible_templates(user, project_id=None): + """Forms this user may use, optionally narrowed to one contract. + + phase52 — a form attached to specific contracts must not reach an + inspector working for a different customer. Shared forms (no contract + links) stay visible to everyone, which is what keeps existing installs + behaving exactly as before. + + With `project_id`: exactly the web picker's list for that contract. + Without: the union across every contract the user can reach — the iPad + caches templates up-front and picks the facility later, so it needs the + whole set it might legitimately use. + """ + pids = _visible_project_ids(user) + + if project_id is not None: + # The caller names a contract. Their OWN scope still applies — without + # this, passing another customer's facility_id would list that + # customer's form names back to an inspector who has no business + # seeing them. Empty list, not an error: the endpoint must not confirm + # whether that contract exists either. + if pids is not None and project_id not in pids: + logger.warning('API TEMPLATES | out-of-scope project filter | ' + 'user=%s | project_id=%s', user.username, project_id) + return [] + return InspectionTemplate.available_query(project_id).all() + + if pids is None: + return (InspectionTemplate.query + .filter_by(active=True) + .order_by(InspectionTemplate.name) + .all()) + + seen, out = set(), [] + # Always include the shared forms, even when the user has no contracts — + # otherwise an unassigned inspector would see nothing at all rather than + # the standard forms. + for pid in list(pids) + [None]: + for t in InspectionTemplate.available_query(pid).all(): + if t.id not in seen: + seen.add(t.id) + out.append(t) + out.sort(key=lambda t: (t.name or '').lower()) + return out + + def _template_summary_payload(template: InspectionTemplate) -> dict: """Serialize a template to the lightweight summary dict (no form_schema).""" return { @@ -88,17 +155,21 @@ def list_templates(): user.username, user.role) return api_error('Access denied', 403) - templates = ( - InspectionTemplate.query - .filter_by(active=True) - .order_by(InspectionTemplate.name) - .all() - ) + # Optional ?project_id= narrows to one contract (matches the web picker); + # ?facility_id= is accepted as a convenience and resolved to its contract. + project_id = request.args.get('project_id', type=int) + if project_id is None: + facility_id = request.args.get('facility_id', type=int) + if facility_id is not None: + facility = db.session.get(Facility, facility_id) + project_id = facility.project_id if facility else None + + templates = _visible_templates(user, project_id) payload = [_template_summary_payload(t) for t in templates] - logger.info('API TEMPLATES | list | user=%s | count=%d', - user.username, len(payload)) + logger.info('API TEMPLATES | list | user=%s | project_id=%s | count=%d', + user.username, project_id, len(payload)) return api_ok({'templates': payload, 'count': len(payload)}) @@ -144,6 +215,14 @@ def get_template(template_id): if template is None: return api_error('Template not found', 404) + # phase52 — a restricted form must not be fetchable by an inspector on a + # different customer's contracts. 404 rather than 403: whether another + # customer's form exists is itself not this user's business. + if template.id not in {t.id for t in _visible_templates(user)}: + logger.warning('API TEMPLATES | out-of-contract fetch blocked | ' + 'user=%s | template_id=%s', user.username, template_id) + return api_error('Template not found', 404) + logger.info('API TEMPLATES | detail | user=%s | template_id=%d | name=%s', user.username, template_id, template.name) diff --git a/app/models/__init__.py b/app/models/__init__.py index 2a47b15..1ebe2f8 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,7 +1,8 @@ from app.models.user import User from app.models.facility import Facility, Area from app.models.inspection import (InspectionTemplate, ChecklistItem, - Inspection, InspectionResult) + Inspection, InspectionResult, + TemplateContract) from app.models.issue import Issue from app.models.project import Project, CustomerAssignment from app.models.api_token import RefreshToken, DeviceToken diff --git a/app/models/inspection.py b/app/models/inspection.py index 67643ed..cb75691 100644 --- a/app/models/inspection.py +++ b/app/models/inspection.py @@ -3,6 +3,43 @@ from app.utils.time_utils import now_eastern import json +class TemplateContract(db.Model): + """Restricts a form to specific contracts (phase52). + + A customer's bespoke form must not be visible to — or startable against — + another customer's facilities. One row = "this template is available on + this contract". + + **No rows means the template is SHARED** (available on every contract), not + "available nowhere". That is what makes the feature additive: every + template that existed before phase52 has no rows, so nothing changed on + deploy, and a form becomes customer-specific only when an admin attaches it + to at least one contract. The empty-set-means-all convention is the whole + migration story — do not "fix" it to mean the opposite. + """ + + __tablename__ = 'template_contracts' + + id = db.Column(db.Integer, primary_key=True) + template_id = db.Column(db.Integer, + db.ForeignKey('inspection_templates.id', ondelete='CASCADE'), + nullable=False, index=True) + project_id = db.Column(db.Integer, + db.ForeignKey('projects.id', ondelete='CASCADE'), + nullable=False, index=True) + created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + + project = db.relationship('Project', backref='template_contracts') + + __table_args__ = ( + db.UniqueConstraint('template_id', 'project_id', + name='uq_template_contract'), + ) + + def __repr__(self): + return f'' + + class InspectionTemplate(db.Model): __tablename__ = 'inspection_templates' @@ -18,6 +55,81 @@ class InspectionTemplate(db.Model): checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan') inspections = db.relationship('Inspection', backref='template', lazy='dynamic') + # phase52 — contract restrictions. Deleting a template removes its links. + contract_links = db.relationship('TemplateContract', backref='template', + lazy='dynamic', + cascade='all, delete-orphan') + + # ── Contract availability (phase52) ────────────────────────────────── + + @property + def contract_ids(self): + """Project ids this form is restricted to; empty = shared with all.""" + return sorted(l.project_id for l in self.contract_links.all()) + + @property + def is_shared(self): + """True when the form carries no restriction and is available anywhere.""" + return self.contract_links.count() == 0 + + def available_for_project(self, project_id): + """Can this form be used on `project_id`? + + Shared forms are usable anywhere, including on a facility that has no + contract at all. A restricted form needs an explicit link, so a + facility with no contract (project_id None) can only ever use shared + forms — fail-closed, which is the right side to err on. + """ + if self.is_shared: + return True + if project_id is None: + return False + return project_id in set(self.contract_ids) + + @staticmethod + def available_query(project_id, active_only=True): + """Query of templates usable on `project_id` (shared + linked). + + The single definition of "which forms may this contract use". Every + picker, the POST validation behind it, and the mobile API all go + through here so they cannot disagree — a picker that offers more than + the validator accepts silently drops work (see rule 93 for the same + failure in the assignee dropdown). + """ + q = InspectionTemplate.query + if active_only: + q = q.filter(InspectionTemplate.active == True) + + shared = ~InspectionTemplate.contract_links.any() + if project_id is None: + # No contract to match against — only unrestricted forms apply. + return q.filter(shared).order_by(InspectionTemplate.name) + + linked = InspectionTemplate.contract_links.any( + TemplateContract.project_id == project_id + ) + return q.filter(db.or_(shared, linked)).order_by(InspectionTemplate.name) + + def set_contracts(self, project_ids): + """Replace this form's contract restrictions. + + Pass an empty list to make the form shared again. Does NOT commit — + the caller owns the transaction. Returns True if anything changed. + """ + wanted = {int(p) for p in project_ids or []} + existing = {l.project_id: l for l in self.contract_links.all()} + + changed = False + for pid, link in existing.items(): + if pid not in wanted: + db.session.delete(link) + changed = True + for pid in wanted: + if pid not in existing: + db.session.add(TemplateContract(template_id=self.id, project_id=pid)) + changed = True + return changed + def get_form_schema(self): if self.form_schema is None: return [] diff --git a/app/routes/inspections.py b/app/routes/inspections.py index bf15130..5750378 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -348,7 +348,6 @@ def index(): def start(): form = StartInspectionForm() - templates = InspectionTemplate.query.filter_by(active=True).order_by(InspectionTemplate.name).all() projects = Project.query.filter_by(active=True).order_by(Project.name).all() # Scope projects to inspector's assigned contracts @@ -360,7 +359,6 @@ def start(): } projects = [p for p in projects if p.id in assigned_pids] - form.template_id.choices = [(t.id, t.name) for t in templates] form.project_id.choices = [(p.id, p.name) for p in projects] # Seed facility choices: use submitted project_id, session value, or first project @@ -374,6 +372,13 @@ def start(): else: selected_project_id = projects[0].id if projects else None + # phase52 — forms are offered per CONTRACT: shared forms plus any attached + # to the selected contract. This is also the POST validation (SelectField + # validates against its choices), so a crafted template_id for another + # customer's form is rejected here, not merely hidden in the UI. + templates = InspectionTemplate.available_query(selected_project_id).all() + form.template_id.choices = [(t.id, t.name) for t in templates] + if selected_project_id: facilities = Facility.query.filter_by(active=True, project_id=selected_project_id).order_by(Facility.name).all() else: @@ -406,6 +411,19 @@ def start(): if template is None: abort(404) + # Belt-and-braces: the choices above already reject a form that is not + # available on this contract, but that guard lives in how the list was + # built. Re-assert it against the FACILITY actually chosen, so a future + # change to the choice-building cannot quietly open a cross-customer + # hole here. + _fac = db.session.get(Facility, form.facility_id.data) + if not template.available_for_project(_fac.project_id if _fac else None): + logger_msg = ('INSPECTION START BLOCKED | template=%s not available for ' + 'facility=%s | user=%s') + current_app.logger.warning(logger_msg, template.id, + form.facility_id.data, current_user.username) + abort(403) + # Inspector facility scope check — prevent crafted POST from selecting # a facility outside their assigned contracts. if current_user.is_inspector: @@ -463,6 +481,25 @@ def facilities_for_project(project_id): return jsonify([{'id': f.id, 'name': f.name} for f in facilities]) +# ── AJAX: forms available on a given contract (phase52) ────────────────────── + +@bp.route('/templates_for_project/') +@login_required +def templates_for_project(project_id): + """Forms usable on this contract — shared ones plus any attached to it. + + Powers the Contract -> Form cascade on the start-inspection page, the same + way facilities_for_project powers Contract -> Facility. Read-only, and the + real gate is still the POST validation in start(); this only keeps the + picker honest as the contract changes. + """ + templates = InspectionTemplate.available_query(project_id).all() + return jsonify([ + {'id': t.id, 'name': t.name, 'shared': t.is_shared} + for t in templates + ]) + + # ── Execute ─────────────────────────────────────────────────────────────────── @bp.route('//execute', methods=['GET', 'POST']) diff --git a/app/routes/scheduled_inspections.py b/app/routes/scheduled_inspections.py index 7582825..afa8a30 100644 --- a/app/routes/scheduled_inspections.py +++ b/app/routes/scheduled_inspections.py @@ -192,6 +192,31 @@ def _reject_if_past_end_date(sched, form): return True +def _reject_template_not_on_contract(form): + """True (and a form error set) if the chosen form isn't usable at the + chosen facility (phase52). + + The template choices stay unrestricted for the same reason the facility + choices do (rule 61 — the contract selector is UI-only, so POST validation + must not depend on it). That means the contract restriction has to be + enforced HERE, after validation, against the facility actually picked. + Without this a manager could schedule one customer's bespoke form against + another customer's facility, and the mismatch would only surface when the + inspector opened it. + """ + facility = db.session.get(Facility, form.facility_id.data) + template = db.session.get(InspectionTemplate, form.template_id.data) + if template is None or facility is None: + return False + if template.available_for_project(facility.project_id): + return False + form.template_id.errors.append( + f'"{template.name}" is not available on ' + f'{facility.project.name if facility.project else "this facility\'s contract"}. ' + f'Choose a form attached to that contract, or a shared form.') + return True + + def _open_inspection_ids(schedules): """{schedule_id: inspection_id} for schedules with an inspection already in progress, so the UI offers Continue instead of a duplicate Start.""" @@ -274,7 +299,7 @@ def create(): if not form.next_due_date.data: form.next_due_date.data = now_eastern().date() - if form.validate_on_submit(): + if form.validate_on_submit() and not _reject_template_not_on_contract(form): sched = ScheduledInspection( facility_id = form.facility_id.data, template_id = form.template_id.data, @@ -331,7 +356,7 @@ def edit(schedule_id): form.weekdays.data = sched.weekday_list form.month_mode.data = sched.month_mode or MONTH_MODE_DAY - if form.validate_on_submit(): + if form.validate_on_submit() and not _reject_template_not_on_contract(form): old_inspector_id = sched.inspector_id sched.facility_id = form.facility_id.data sched.template_id = form.template_id.data diff --git a/app/routes/templates.py b/app/routes/templates.py index 3bef2d5..238b7f1 100644 --- a/app/routes/templates.py +++ b/app/routes/templates.py @@ -2,7 +2,8 @@ import logging from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort from flask_login import login_required, current_user from app import db -from app.models.inspection import InspectionTemplate, ChecklistItem +from app.models.inspection import InspectionTemplate, ChecklistItem, TemplateContract +from app.models.project import Project from app.utils.forms import InspectionTemplateForm, ChecklistItemForm from app.utils.decorators import supervisor_required import json @@ -13,6 +14,17 @@ bp = Blueprint('templates', __name__, url_prefix='/templates') logger = logging.getLogger(__name__) +def _populate_contract_choices(form): + """Contract options for the "Available on contracts" multi-select. + + Selecting none leaves the form SHARED (usable on every contract) — that is + the default and what every template did before phase52. See + TemplateContract. + """ + contracts = Project.query.filter_by(active=True).order_by(Project.name).all() + form.contract_ids.choices = [(p.id, p.name) for p in contracts] + + # --------------------------------------------------------------------------- # Template CRUD # --------------------------------------------------------------------------- @@ -29,6 +41,7 @@ def index(): @supervisor_required def create_template(): form = InspectionTemplateForm() + _populate_contract_choices(form) if form.validate_on_submit(): template = InspectionTemplate( @@ -38,11 +51,15 @@ def create_template(): created_by=current_user.id ) db.session.add(template) + db.session.flush() # need template.id before linking contracts + template.set_contracts(form.contract_ids.data) db.session.commit() - logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r', - current_user.username, template.id, template.name) + logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r contracts=%s', + current_user.username, template.id, template.name, + template.contract_ids or 'shared') log_action(ACTION_CREATE, 'Template', template.id, template.name, - f'frequency={template.frequency}') + f'frequency={template.frequency}; ' + f'contracts={template.contract_ids or "shared"}') flash(f'Template "{template.name}" created successfully.', 'success') return redirect(url_for('templates.form_editor', template_id=template.id)) @@ -72,16 +89,23 @@ def edit_template(template_id): if template is None: abort(404) form = InspectionTemplateForm(obj=template) + _populate_contract_choices(form) + if request.method == 'GET': + # obj= cannot read the association rows; seed the multi-select from them. + form.contract_ids.data = template.contract_ids if form.validate_on_submit(): template.name = form.name.data template.description = form.description.data template.frequency = form.frequency.data + template.set_contracts(form.contract_ids.data) db.session.commit() - logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r', - current_user.username, template.id, template.name) + logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r contracts=%s', + current_user.username, template.id, template.name, + template.contract_ids or 'shared') log_action(ACTION_UPDATE, 'Template', template.id, template.name, - f'frequency={template.frequency}') + f'frequency={template.frequency}; ' + f'contracts={template.contract_ids or "shared"}') flash(f'Template "{template.name}" updated successfully.', 'success') return redirect(url_for('templates.view_template', template_id=template.id)) diff --git a/app/templates/inspections/start.html b/app/templates/inspections/start.html index 2dab55c..5adf0c1 100644 --- a/app/templates/inspections/start.html +++ b/app/templates/inspections/start.html @@ -13,8 +13,14 @@
{{ form.template_id.label(class="form-label fw-semibold") }} - {{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else "")) }} + {{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else ""), id="templateSelect") }} {% for e in form.template_id.errors %}
{{ e }}
{% endfor %} + {# phase52 — the list shows shared forms plus the ones attached to + the selected contract, refreshed by JS when the contract changes. #} +
Shows forms available on the selected contract.
+
+ No forms are available on this contract yet. +
@@ -54,6 +60,8 @@