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'' 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'' 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'' 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')) # phase43: set when this inspection was started from / materialised by a # schedule. ON DELETE SET NULL — deleting a schedule never deletes history. inspection_schedule_id = db.Column( db.Integer, db.ForeignKey('inspection_schedules.id', ondelete='SET NULL'), nullable=True, index=True ) 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) # ── 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) # phase49 — WHO asked for the follow-up and when. `follow_up_required` alone # cannot distinguish a client request from an internal one, and staff need to # know who is waiting. Set by flag_followup(), nulled by clear_followup(). # NULL on every pre-phase49 row, which the UI renders as an unattributed # follow-up exactly as before. follow_up_requested_by = db.Column( db.Integer, db.ForeignKey('users.id', ondelete='SET NULL', name='fk_inspections_follow_up_requested_by'), nullable=True, ) follow_up_requested_at = db.Column(db.DateTime, 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') follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'), lazy='dynamic', foreign_keys='Inspection.parent_inspection_id') # phase49. Explicit foreign_keys is required: inspector_id also points at # users.id, so SQLAlchemy cannot infer which column this relationship uses. follow_up_requester = db.relationship('User', foreign_keys=[follow_up_requested_by]) # The schedule this inspection was started from / materialised by, so the # detail view can show the cadence and who set it up. Explicit foreign_keys # again: inspection_schedules.parent_inspection_id points back here (phase48), # so neither side's join is inferable. inspection_schedule = db.relationship( 'InspectionSchedule', foreign_keys=[inspection_schedule_id]) def __repr__(self): return f'' 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''