Aug 17 - Update forms will be assigned per contract
This commit is contained in:
@@ -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.
|
**`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
|
### 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` | jwt_required | All active facilities scoped to user |
|
||||||
| `GET /api/v1/facilities/<id>/areas` | jwt_required | Areas for a facility |
|
| `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` | 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 |
|
| `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
|
### Phase B Endpoints
|
||||||
|
|
||||||
@@ -970,7 +986,20 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
|
|||||||
→ phase48_user_ui_theme
|
→ phase48_user_ui_theme
|
||||||
→ phase49_external_inspector
|
→ phase49_external_inspector
|
||||||
→ phase50_default_modern
|
→ 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
|
#### 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. |
|
| 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`. |
|
| 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. |
|
| 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_(...))`. |
|
| 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). |
|
| 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). |
|
| 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
@@ -16,11 +16,13 @@ GET /api/v1/templates/<template_id>
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import Blueprint, g
|
from flask import Blueprint, g, request
|
||||||
from app import db
|
from app import db
|
||||||
from app.models.inspection import InspectionTemplate
|
from app.models.inspection import InspectionTemplate
|
||||||
|
from app.models.facility import Facility
|
||||||
from app.api.errors import api_ok, api_error
|
from app.api.errors import api_ok, api_error
|
||||||
from app.api.decorators import jwt_required
|
from app.api.decorators import jwt_required
|
||||||
|
from app.utils.scope import get_inspector_scope
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,6 +33,71 @@ _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
|||||||
'project_manager', 'auditor'}
|
'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:
|
def _template_summary_payload(template: InspectionTemplate) -> dict:
|
||||||
"""Serialize a template to the lightweight summary dict (no form_schema)."""
|
"""Serialize a template to the lightweight summary dict (no form_schema)."""
|
||||||
return {
|
return {
|
||||||
@@ -88,17 +155,21 @@ def list_templates():
|
|||||||
user.username, user.role)
|
user.username, user.role)
|
||||||
return api_error('Access denied', 403)
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
templates = (
|
# Optional ?project_id= narrows to one contract (matches the web picker);
|
||||||
InspectionTemplate.query
|
# ?facility_id= is accepted as a convenience and resolved to its contract.
|
||||||
.filter_by(active=True)
|
project_id = request.args.get('project_id', type=int)
|
||||||
.order_by(InspectionTemplate.name)
|
if project_id is None:
|
||||||
.all()
|
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]
|
payload = [_template_summary_payload(t) for t in templates]
|
||||||
|
|
||||||
logger.info('API TEMPLATES | list | user=%s | count=%d',
|
logger.info('API TEMPLATES | list | user=%s | project_id=%s | count=%d',
|
||||||
user.username, len(payload))
|
user.username, project_id, len(payload))
|
||||||
|
|
||||||
return api_ok({'templates': payload, 'count': len(payload)})
|
return api_ok({'templates': payload, 'count': len(payload)})
|
||||||
|
|
||||||
@@ -144,6 +215,14 @@ def get_template(template_id):
|
|||||||
if template is None:
|
if template is None:
|
||||||
return api_error('Template not found', 404)
|
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',
|
logger.info('API TEMPLATES | detail | user=%s | template_id=%d | name=%s',
|
||||||
user.username, template_id, template.name)
|
user.username, template_id, template.name)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.facility import Facility, Area
|
from app.models.facility import Facility, Area
|
||||||
from app.models.inspection import (InspectionTemplate, ChecklistItem,
|
from app.models.inspection import (InspectionTemplate, ChecklistItem,
|
||||||
Inspection, InspectionResult)
|
Inspection, InspectionResult,
|
||||||
|
TemplateContract)
|
||||||
from app.models.issue import Issue
|
from app.models.issue import Issue
|
||||||
from app.models.project import Project, CustomerAssignment
|
from app.models.project import Project, CustomerAssignment
|
||||||
from app.models.api_token import RefreshToken, DeviceToken
|
from app.models.api_token import RefreshToken, DeviceToken
|
||||||
|
|||||||
@@ -3,6 +3,43 @@ from app.utils.time_utils import now_eastern
|
|||||||
import json
|
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):
|
class InspectionTemplate(db.Model):
|
||||||
__tablename__ = 'inspection_templates'
|
__tablename__ = 'inspection_templates'
|
||||||
|
|
||||||
@@ -18,6 +55,81 @@ class InspectionTemplate(db.Model):
|
|||||||
checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan')
|
checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan')
|
||||||
inspections = db.relationship('Inspection', backref='template', lazy='dynamic')
|
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):
|
def get_form_schema(self):
|
||||||
if self.form_schema is None:
|
if self.form_schema is None:
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -348,7 +348,6 @@ def index():
|
|||||||
def start():
|
def start():
|
||||||
form = StartInspectionForm()
|
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()
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||||
|
|
||||||
# Scope projects to inspector's assigned contracts
|
# 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]
|
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]
|
form.project_id.choices = [(p.id, p.name) for p in projects]
|
||||||
|
|
||||||
# Seed facility choices: use submitted project_id, session value, or first project
|
# Seed facility choices: use submitted project_id, session value, or first project
|
||||||
@@ -374,6 +372,13 @@ def start():
|
|||||||
else:
|
else:
|
||||||
selected_project_id = projects[0].id if projects else None
|
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:
|
if selected_project_id:
|
||||||
facilities = Facility.query.filter_by(active=True, project_id=selected_project_id).order_by(Facility.name).all()
|
facilities = Facility.query.filter_by(active=True, project_id=selected_project_id).order_by(Facility.name).all()
|
||||||
else:
|
else:
|
||||||
@@ -406,6 +411,19 @@ def start():
|
|||||||
if template is None:
|
if template is None:
|
||||||
abort(404)
|
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
|
# Inspector facility scope check — prevent crafted POST from selecting
|
||||||
# a facility outside their assigned contracts.
|
# a facility outside their assigned contracts.
|
||||||
if current_user.is_inspector:
|
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])
|
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 ───────────────────────────────────────────────────────────────────
|
# ── Execute ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST'])
|
@bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST'])
|
||||||
|
|||||||
@@ -192,6 +192,31 @@ def _reject_if_past_end_date(sched, form):
|
|||||||
return True
|
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):
|
def _open_inspection_ids(schedules):
|
||||||
"""{schedule_id: inspection_id} for schedules with an inspection already
|
"""{schedule_id: inspection_id} for schedules with an inspection already
|
||||||
in progress, so the UI offers Continue instead of a duplicate Start."""
|
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:
|
if not form.next_due_date.data:
|
||||||
form.next_due_date.data = now_eastern().date()
|
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(
|
sched = ScheduledInspection(
|
||||||
facility_id = form.facility_id.data,
|
facility_id = form.facility_id.data,
|
||||||
template_id = form.template_id.data,
|
template_id = form.template_id.data,
|
||||||
@@ -331,7 +356,7 @@ def edit(schedule_id):
|
|||||||
form.weekdays.data = sched.weekday_list
|
form.weekdays.data = sched.weekday_list
|
||||||
form.month_mode.data = sched.month_mode or MONTH_MODE_DAY
|
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
|
old_inspector_id = sched.inspector_id
|
||||||
sched.facility_id = form.facility_id.data
|
sched.facility_id = form.facility_id.data
|
||||||
sched.template_id = form.template_id.data
|
sched.template_id = form.template_id.data
|
||||||
|
|||||||
+31
-7
@@ -2,7 +2,8 @@ import logging
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app import db
|
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.forms import InspectionTemplateForm, ChecklistItemForm
|
||||||
from app.utils.decorators import supervisor_required
|
from app.utils.decorators import supervisor_required
|
||||||
import json
|
import json
|
||||||
@@ -13,6 +14,17 @@ bp = Blueprint('templates', __name__, url_prefix='/templates')
|
|||||||
logger = logging.getLogger(__name__)
|
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
|
# Template CRUD
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -29,6 +41,7 @@ def index():
|
|||||||
@supervisor_required
|
@supervisor_required
|
||||||
def create_template():
|
def create_template():
|
||||||
form = InspectionTemplateForm()
|
form = InspectionTemplateForm()
|
||||||
|
_populate_contract_choices(form)
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
template = InspectionTemplate(
|
template = InspectionTemplate(
|
||||||
@@ -38,11 +51,15 @@ def create_template():
|
|||||||
created_by=current_user.id
|
created_by=current_user.id
|
||||||
)
|
)
|
||||||
db.session.add(template)
|
db.session.add(template)
|
||||||
|
db.session.flush() # need template.id before linking contracts
|
||||||
|
template.set_contracts(form.contract_ids.data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r',
|
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r contracts=%s',
|
||||||
current_user.username, template.id, template.name)
|
current_user.username, template.id, template.name,
|
||||||
|
template.contract_ids or 'shared')
|
||||||
log_action(ACTION_CREATE, 'Template', template.id, template.name,
|
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')
|
flash(f'Template "{template.name}" created successfully.', 'success')
|
||||||
return redirect(url_for('templates.form_editor', template_id=template.id))
|
return redirect(url_for('templates.form_editor', template_id=template.id))
|
||||||
@@ -72,16 +89,23 @@ def edit_template(template_id):
|
|||||||
if template is None:
|
if template is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
form = InspectionTemplateForm(obj=template)
|
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():
|
if form.validate_on_submit():
|
||||||
template.name = form.name.data
|
template.name = form.name.data
|
||||||
template.description = form.description.data
|
template.description = form.description.data
|
||||||
template.frequency = form.frequency.data
|
template.frequency = form.frequency.data
|
||||||
|
template.set_contracts(form.contract_ids.data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r',
|
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r contracts=%s',
|
||||||
current_user.username, template.id, template.name)
|
current_user.username, template.id, template.name,
|
||||||
|
template.contract_ids or 'shared')
|
||||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
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')
|
flash(f'Template "{template.name}" updated successfully.', 'success')
|
||||||
return redirect(url_for('templates.view_template', template_id=template.id))
|
return redirect(url_for('templates.view_template', template_id=template.id))
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,14 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ form.template_id.label(class="form-label fw-semibold") }}
|
{{ 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 %}
|
{% 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>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -54,6 +60,8 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const projectSel = document.getElementById('projectSelect');
|
const projectSel = document.getElementById('projectSelect');
|
||||||
|
const templateSel = document.getElementById('templateSelect');
|
||||||
|
const templateEmpty = document.getElementById('templateEmpty');
|
||||||
const facilitySel = document.getElementById('facilitySelect');
|
const facilitySel = document.getElementById('facilitySelect');
|
||||||
const spinner = document.getElementById('facilitySpinner');
|
const spinner = document.getElementById('facilitySpinner');
|
||||||
const emptyMsg = document.getElementById('facilityEmpty');
|
const emptyMsg = document.getElementById('facilityEmpty');
|
||||||
@@ -61,6 +69,7 @@
|
|||||||
const areaSel = document.getElementById('areaSelect');
|
const areaSel = document.getElementById('areaSelect');
|
||||||
|
|
||||||
const FACILITIES_URL = `{{ url_for('inspections.facilities_for_project', project_id=0) }}`.replace('/0', '/');
|
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', '/');
|
const AREAS_URL = `{{ url_for('inspections.areas_for_facility', facility_id=0) }}`.replace('/0', '/');
|
||||||
|
|
||||||
function loadAreas(facilityId, selectedAreaId) {
|
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) {
|
function loadFacilities(projectId, selectedFacilityId, selectedAreaId) {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
spinner.classList.remove('d-none');
|
spinner.classList.remove('d-none');
|
||||||
@@ -128,6 +159,7 @@
|
|||||||
|
|
||||||
projectSel.addEventListener('change', function () {
|
projectSel.addEventListener('change', function () {
|
||||||
loadFacilities(this.value, null, null);
|
loadFacilities(this.value, null, null);
|
||||||
|
loadTemplates(this.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
facilitySel.addEventListener('change', function () {
|
facilitySel.addEventListener('change', function () {
|
||||||
|
|||||||
@@ -160,6 +160,15 @@
|
|||||||
{{ form.frequency.label(class="form-label fw-semibold small") }}
|
{{ form.frequency.label(class="form-label fw-semibold small") }}
|
||||||
{{ form.frequency(class="form-select form-select-sm") }}
|
{{ form.frequency(class="form-select form-select-sm") }}
|
||||||
</div>
|
</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">
|
<div class="d-grid gap-2">
|
||||||
<button type="submit" class="btn btn-primary btn-sm">
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
|||||||
@@ -27,6 +27,18 @@
|
|||||||
{{ form.frequency.label(class="form-label") }}
|
{{ form.frequency.label(class="form-label") }}
|
||||||
{{ form.frequency(class="form-select") }}
|
{{ form.frequency(class="form-select") }}
|
||||||
</div>
|
</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">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
|
|||||||
@@ -38,6 +38,24 @@
|
|||||||
<i class="bi bi-check2-square"></i> {{ template.checklist_items.count() }} items
|
<i class="bi bi-check2-square"></i> {{ template.checklist_items.count() }} items
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="card-footer bg-transparent d-flex gap-2 align-items-center flex-wrap">
|
<div class="card-footer bg-transparent d-flex gap-2 align-items-center flex-wrap">
|
||||||
|
|||||||
@@ -130,6 +130,11 @@ class InspectionTemplateForm(FlaskForm):
|
|||||||
('daily','Daily'), ('weekly','Weekly'),
|
('daily','Daily'), ('weekly','Weekly'),
|
||||||
('monthly','Monthly'), ('quarterly','Quarterly'),
|
('monthly','Monthly'), ('quarterly','Quarterly'),
|
||||||
], validators=[DataRequired()])
|
], 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):
|
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')
|
||||||
Reference in New Issue
Block a user