Aug 17 - Update forms will be assigned per contract
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user