Aug 17 - Update forms will be assigned per contract
This commit is contained in:
+88
-9
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user