Aug 17 - Update forms will be assigned per contract

This commit is contained in:
2026-08-17 15:45:48 -04:00
parent fb85f7dc28
commit 9d7721213b
13 changed files with 471 additions and 25 deletions
+34 -3
View File
@@ -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/<id>/areas` | jwt_required | Areas for a facility |
| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) |
| `GET /api/v1/templates/<id>` | 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/<id>` | 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. `<form id="issuesBulkForm">` sits above the table and each checkbox carries `form="issuesBulkForm"`. Same for `inspectionsBulkForm`. Applies to all four list templates (classic + modern). |
+88 -9
View File
@@ -16,11 +16,13 @@ GET /api/v1/templates/<template_id>
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)
+2 -1
View File
@@ -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
+112
View File
@@ -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'<TemplateContract template={self.template_id} project={self.project_id}>'
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 []
+39 -2
View File
@@ -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/<int:project_id>')
@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('/<int:inspection_id>/execute', methods=['GET', 'POST'])
+27 -2
View File
@@ -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
+31 -7
View File
@@ -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))
+33 -1
View File
@@ -13,8 +13,14 @@
<div class="mb-3">
{{ 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 %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
{# phase52 — the list shows shared forms plus the ones attached to
the selected contract, refreshed by JS when the contract changes. #}
<div class="form-text">Shows forms available on the selected contract.</div>
<div id="templateEmpty" class="form-text text-danger d-none">
No forms are available on this contract yet.
</div>
</div>
<div class="mb-3">
@@ -54,6 +60,8 @@
<script>
(function () {
const projectSel = document.getElementById('projectSelect');
const templateSel = document.getElementById('templateSelect');
const templateEmpty = document.getElementById('templateEmpty');
const facilitySel = document.getElementById('facilitySelect');
const spinner = document.getElementById('facilitySpinner');
const emptyMsg = document.getElementById('facilityEmpty');
@@ -61,6 +69,7 @@
const areaSel = document.getElementById('areaSelect');
const FACILITIES_URL = `{{ url_for('inspections.facilities_for_project', project_id=0) }}`.replace('/0', '/');
const TEMPLATES_URL = `{{ url_for('inspections.templates_for_project', project_id=0) }}`.replace('/0', '/');
const AREAS_URL = `{{ url_for('inspections.areas_for_facility', facility_id=0) }}`.replace('/0', '/');
function loadAreas(facilityId, selectedAreaId) {
@@ -92,6 +101,28 @@
});
}
// Forms are per-contract (phase52): a customer's bespoke form must not be
// offered on another customer's facilities. Keeps the currently selected
// form if it is still valid on the new contract.
function loadTemplates(projectId) {
if (!projectId || !templateSel) return;
const keep = templateSel.value;
fetch(TEMPLATES_URL + projectId)
.then(r => r.json())
.then(data => {
templateSel.innerHTML = '';
data.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name;
if (String(t.id) === keep) opt.selected = true;
templateSel.appendChild(opt);
});
templateEmpty.classList.toggle('d-none', data.length > 0);
})
.catch(() => {}); // leave the server-rendered list in place
}
function loadFacilities(projectId, selectedFacilityId, selectedAreaId) {
if (!projectId) return;
spinner.classList.remove('d-none');
@@ -128,6 +159,7 @@
projectSel.addEventListener('change', function () {
loadFacilities(this.value, null, null);
loadTemplates(this.value);
});
facilitySel.addEventListener('change', function () {
+9
View File
@@ -160,6 +160,15 @@
{{ form.frequency.label(class="form-label fw-semibold small") }}
{{ form.frequency(class="form-select form-select-sm") }}
</div>
<div class="mb-3">
{{ form.contract_ids.label(class="form-label fw-semibold small") }}
{{ form.contract_ids(class="form-select form-select-sm", size=6) }}
<div class="form-text small">
Nothing selected = shared with every contract. Select contracts to
restrict this form to them (hidden from all other customers).
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-sm">
+12
View File
@@ -27,6 +27,18 @@
{{ form.frequency.label(class="form-label") }}
{{ form.frequency(class="form-select") }}
</div>
<div class="mb-3">
{{ form.contract_ids.label(class="form-label") }}
{{ form.contract_ids(class="form-select", size=6) }}
<div class="form-text">
Leave <strong>nothing selected</strong> to share this form with
every contract. Select one or more contracts to make it
specific to them — it will then be hidden from every other
customer's facilities, on the web and in the iPad app.
Ctrl/Cmd-click to select several.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
+18
View File
@@ -38,6 +38,24 @@
<i class="bi bi-check2-square"></i> {{ template.checklist_items.count() }} items
</small>
</div>
{# phase52 — who may use this form. No links = shared with all. #}
<div class="mt-2">
{% if template.is_shared %}
<span class="badge bg-light text-dark border"
title="Available on every contract">
<i class="bi bi-globe2"></i> Shared
</span>
{% else %}
{% for link in template.contract_links %}
<span class="badge bg-primary"
title="Only available on this contract">
<i class="bi bi-briefcase"></i>
{{ link.project.name if link.project else 'contract #' ~ link.project_id }}
</span>
{% endfor %}
{% endif %}
</div>
</div>
<div class="card-footer bg-transparent d-flex gap-2 align-items-center flex-wrap">
+5
View File
@@ -130,6 +130,11 @@ class InspectionTemplateForm(FlaskForm):
('daily','Daily'), ('weekly','Weekly'),
('monthly','Monthly'), ('quarterly','Quarterly'),
], validators=[DataRequired()])
# phase52 — which contracts may use this form. Choices are populated in the
# route. Selecting NONE leaves the form shared with every contract, which
# is the default and what every pre-phase52 template does.
contract_ids = SelectMultipleField('Available on contracts', coerce=int,
validators=[Optional()])
class ChecklistItemForm(FlaskForm):
@@ -0,0 +1,61 @@
"""phase52 — restrict forms to specific contracts
Creates `template_contracts`: one row = "this inspection template is available
on this contract". Backs per-customer forms — a customer's bespoke form must
not be visible to, or startable against, another customer's facilities.
**No rows for a template means SHARED (available on every contract)**, not
"available nowhere". That convention is why this migration needs no backfill
and cannot change behaviour on deploy: every template that exists today has no
rows and therefore stays available everywhere, exactly as before. A form only
becomes customer-specific once an admin attaches it to at least one contract.
Inverting that default later would silently hide every shared form from every
contract, so it is enforced in one place InspectionTemplate.available_query()
which the pickers, their POST validation, and the mobile API all use.
Table-existence check safe to re-run.
"""
revision = 'phase52_template_contracts'
down_revision = 'phase51_user_notif_matrix'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def _has_table(conn, name):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {'t': name}).scalar() > 0
def upgrade():
conn = op.get_bind()
if _has_table(conn, 'template_contracts'):
return
op.create_table(
'template_contracts',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('template_id', sa.Integer,
sa.ForeignKey('inspection_templates.id', ondelete='CASCADE'),
nullable=False, index=True),
sa.Column('project_id', sa.Integer,
sa.ForeignKey('projects.id', ondelete='CASCADE'),
nullable=False, index=True),
sa.Column('created_at', sa.DateTime, nullable=False,
server_default=sa.func.now()),
sa.UniqueConstraint('template_id', 'project_id', name='uq_template_contract'),
)
def downgrade():
conn = op.get_bind()
if _has_table(conn, 'template_contracts'):
# Dropping the table returns every form to shared — no form becomes
# unusable, they just stop being restricted.
op.drop_table('template_contracts')