262 lines
12 KiB
Python
262 lines
12 KiB
Python
from app import db
|
|
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'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(255), nullable=False)
|
|
description = db.Column(db.Text)
|
|
frequency = db.Column(db.Enum('daily', 'weekly', 'monthly', 'quarterly'))
|
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'))
|
|
created_at = db.Column(db.DateTime, default=now_eastern)
|
|
form_schema = db.Column(db.JSON, nullable=True)
|
|
active = db.Column(db.Boolean, default=True, nullable=False)
|
|
|
|
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 []
|
|
if isinstance(self.form_schema, str):
|
|
try:
|
|
return json.loads(self.form_schema)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return []
|
|
return self.form_schema
|
|
|
|
def __repr__(self):
|
|
return f'<InspectionTemplate {self.name}>'
|
|
|
|
|
|
class ChecklistItem(db.Model):
|
|
__tablename__ = 'checklist_items'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False)
|
|
category = db.Column(db.String(100))
|
|
item_description = db.Column(db.Text, nullable=False)
|
|
scoring_type = db.Column(db.Enum('pass_fail', 'rating_5', 'rating_10'))
|
|
weight = db.Column(db.Numeric(3, 2), default=1.00)
|
|
requires_photo = db.Column(db.Boolean, default=False)
|
|
display_order = db.Column(db.Integer)
|
|
|
|
results = db.relationship('InspectionResult', backref='checklist_item', lazy='dynamic')
|
|
|
|
def __repr__(self):
|
|
return f'<ChecklistItem {self.item_description[:30]}>'
|
|
|
|
|
|
class Inspection(db.Model):
|
|
__tablename__ = 'inspections'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False)
|
|
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False)
|
|
area_id = db.Column(db.Integer, db.ForeignKey('areas.id'))
|
|
inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
|
inspection_date = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
|
overall_score = db.Column(db.Numeric(5, 2))
|
|
status = db.Column(db.Enum('in_progress', 'completed', 'flagged'), default='in_progress')
|
|
notes = db.Column(db.Text) # inspector free-text notes
|
|
form_data = db.Column(db.JSON) # filled form field responses {field_id: value}
|
|
completed_at = db.Column(db.DateTime)
|
|
mobile_local_id = db.Column(db.String(64), nullable=True, index=True)
|
|
submit_latitude = db.Column(db.Numeric(10, 7), nullable=True)
|
|
submit_longitude = db.Column(db.Numeric(10, 7), nullable=True)
|
|
|
|
# Links a completed inspection back to the ScheduledInspection that
|
|
# prompted it (phase36). NULL for ad-hoc/manual inspections.
|
|
scheduled_inspection_id = db.Column(
|
|
db.Integer, db.ForeignKey('scheduled_inspections.id', ondelete='SET NULL'),
|
|
nullable=True, index=True
|
|
)
|
|
|
|
# ── Re-inspection / follow-up workflow ────────────────────────────────
|
|
parent_inspection_id = db.Column(
|
|
db.Integer, db.ForeignKey('inspections.id', ondelete='SET NULL'), nullable=True
|
|
)
|
|
follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
|
|
follow_up_note = db.Column(db.Text, nullable=True)
|
|
# Who asked for the follow-up (phase46). NULL for legacy rows flagged before
|
|
# the column existed. Matters because customers can now raise the request
|
|
# themselves — staff need to see at a glance that the client is waiting on
|
|
# this one, not another internal reviewer.
|
|
follow_up_requested_by = db.Column(
|
|
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True
|
|
)
|
|
follow_up_requested_at = db.Column(db.DateTime, nullable=True)
|
|
|
|
# phase53 — who is to PERFORM the follow-up re-inspection.
|
|
#
|
|
# NULL keeps the original behaviour: the follow-up belongs to the
|
|
# inspection's own inspector. When set, that person owns it instead — they
|
|
# are the one notified, and the one it appears for on the iPad. Lets a
|
|
# director (or a Customer Director) hand a re-inspection to someone other
|
|
# than whoever did the original.
|
|
#
|
|
# This is the THIRD FK from inspections to users (rule 86): every
|
|
# relationship spanning the two must pin foreign_keys explicitly, or the
|
|
# mapper is ambiguous and blows up on first ORM USE rather than at import.
|
|
follow_up_assigned_to = db.Column(
|
|
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True
|
|
)
|
|
|
|
results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
|
issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
|
# The ScheduledInspection this inspection was started from (phase36), if any.
|
|
# None for ad-hoc/manual inspections or if the schedule was later deleted.
|
|
scheduled_inspection = db.relationship('ScheduledInspection',
|
|
foreign_keys=[scheduled_inspection_id])
|
|
# The user who requested the follow-up (phase46) — a customer or a manager.
|
|
# Explicit foreign_keys: `inspector_id` also points at users.
|
|
follow_up_requester = db.relationship('User',
|
|
foreign_keys=[follow_up_requested_by])
|
|
follow_up_assignee = db.relationship('User',
|
|
foreign_keys=[follow_up_assigned_to])
|
|
|
|
@property
|
|
def follow_up_owner(self):
|
|
"""Who is expected to carry out the follow-up.
|
|
|
|
The explicit assignee when one is set, otherwise the inspection's own
|
|
inspector — the single definition of ownership, so the web display, the
|
|
notification and the mobile API filter cannot disagree about who owns a
|
|
follow-up.
|
|
"""
|
|
return self.follow_up_assignee or self.inspector
|
|
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
|
|
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
|
|
|
|
def __repr__(self):
|
|
return f'<Inspection {self.id} - {self.inspection_date}>'
|
|
|
|
|
|
class InspectionResult(db.Model):
|
|
__tablename__ = 'inspection_results'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id'), nullable=False)
|
|
checklist_item_id = db.Column(db.Integer, db.ForeignKey('checklist_items.id'), nullable=False)
|
|
score = db.Column(db.Numeric(5, 2))
|
|
passed = db.Column(db.Boolean)
|
|
comments = db.Column(db.Text)
|
|
photo_path = db.Column(db.String(255))
|
|
|
|
def __repr__(self):
|
|
return f'<InspectionResult {self.id}>' |