First commit

This commit is contained in:
2026-06-26 09:04:34 -04:00
commit 77678ed724
166 changed files with 34842 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
from app.models.user import User
from app.models.facility import Facility, Area
from app.models.inspection import (InspectionTemplate, ChecklistItem,
Inspection, InspectionResult)
from app.models.issue import Issue
from app.models.project import Project, CustomerAssignment
from app.models.api_token import RefreshToken, DeviceToken
from app.models.notification_matrix import NotificationMatrix
+132
View File
@@ -0,0 +1,132 @@
"""
app/models/api_token.py
-----------------------
Persistent storage for JWT refresh tokens and APNs device tokens.
RefreshToken
One row per active mobile session. When the access token expires the
app presents its refresh token here; a new access token is issued and
the refresh token is rotated (old one deleted, new one inserted).
Revocation is instant: delete the row.
DeviceToken
One row per (user, device) pair. Stores the APNs token so the server
can push notifications to the device. Updated on every app launch
because APNs tokens can rotate.
"""
import secrets
from app import db
from app.utils.time_utils import now_eastern
from datetime import timedelta
class RefreshToken(db.Model):
"""
Opaque refresh token stored server-side.
The token value itself is a 64-character hex string generated with
secrets.token_hex(32). Only the SHA-256 hash is stored so that a DB
breach does not expose live tokens.
"""
__tablename__ = 'api_refresh_tokens'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(
db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False,
index=True,
)
# SHA-256 hex digest of the raw token — never store the raw value
token_hash = db.Column(db.String(64), nullable=False, unique=True, index=True)
# Device identifier supplied by the app (UIDevice.identifierForVendor)
device_id = db.Column(db.String(64), nullable=True)
device_name = db.Column(db.String(100), nullable=True) # e.g. "John's iPhone"
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
expires_at = db.Column(db.DateTime, nullable=False)
revoked = db.Column(db.Boolean, nullable=False, default=False)
user = db.relationship('User', foreign_keys=[user_id],
backref=db.backref('refresh_tokens', lazy='dynamic',
cascade='all, delete-orphan'))
@classmethod
def create_for(cls, user, device_id=None, device_name=None,
lifetime_days=30):
"""
Generate a new refresh token, persist it, and return the raw token
string (only time it is ever available in plaintext).
"""
import hashlib
raw = secrets.token_hex(32) # 64-char hex, 256 bits entropy
hashed = hashlib.sha256(raw.encode()).hexdigest()
token = cls(
user_id = user.id,
token_hash = hashed,
device_id = device_id,
device_name = device_name,
expires_at = now_eastern() + timedelta(days=lifetime_days),
)
db.session.add(token)
return raw, token # caller must db.session.commit()
@classmethod
def verify(cls, raw_token):
"""
Look up a refresh token by its raw value.
Returns the RefreshToken row if valid and unexpired, else None.
Expired rows are not deleted here — passive cleanup runs in the
login route (api/auth.py) each time a user authenticates, removing
all expired/revoked tokens for that user. This keeps the table tidy
without requiring a dedicated cron job.
"""
import hashlib
hashed = hashlib.sha256(raw_token.encode()).hexdigest()
row = cls.query.filter_by(token_hash=hashed, revoked=False).first()
if row is None:
return None
if row.expires_at < now_eastern():
return None
return row
def revoke(self):
self.revoked = True
def __repr__(self):
return f'<RefreshToken user={self.user_id} device={self.device_id}>'
class DeviceToken(db.Model):
"""
APNs device token for push notification delivery.
One row per (user, device_id) pair — upserted on every app launch.
The apns_token is the hex string returned by the iOS SDK.
"""
__tablename__ = 'api_device_tokens'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(
db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False,
index=True,
)
device_id = db.Column(db.String(64), nullable=False) # UIDevice.identifierForVendor
apns_token = db.Column(db.String(200), nullable=False)
device_name = db.Column(db.String(100), nullable=True)
app_version = db.Column(db.String(20), nullable=True)
registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
__table_args__ = (
db.UniqueConstraint('user_id', 'device_id', name='uq_device_token_user_device'),
)
user = db.relationship('User', foreign_keys=[user_id],
backref=db.backref('device_tokens', lazy='dynamic',
cascade='all, delete-orphan'))
def __repr__(self):
return f'<DeviceToken user={self.user_id} device={self.device_id}>'
+35
View File
@@ -0,0 +1,35 @@
from app import db
from app.utils.time_utils import now_eastern
class AuditLog(db.Model):
"""
Persistent record of every create / edit / delete action performed by
a user. Entries are immutable once written — never updated or deleted
through the application.
"""
__tablename__ = 'audit_logs'
id = db.Column(db.Integer, primary_key=True)
# Who performed the action (NULL-safe: user may be deleted later)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
username = db.Column(db.String(100), nullable=False) # snapshot at time of action
user_role = db.Column(db.String(20), nullable=False) # snapshot at time of action
# What happened
action = db.Column(db.String(50), nullable=False, index=True) # CREATE / UPDATE / DELETE / LOGIN / LOGOUT / EXPORT
entity_type = db.Column(db.String(50), nullable=False, index=True) # User / Facility / Area / Template / Inspection / Issue / …
entity_id = db.Column(db.Integer, nullable=True) # PK of the affected record (NULL for bulk ops)
entity_label = db.Column(db.String(255), nullable=True) # Human-readable identifier snapshot
# Extra context stored as free-text (key=value pairs, comma-separated)
details = db.Column(db.Text, nullable=True)
# When
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False, index=True)
# Request context
ip_address = db.Column(db.String(45), nullable=True) # supports IPv6
# Relationship — may be None if user was deleted
user = db.relationship('User', foreign_keys=[user_id])
def __repr__(self):
return (f'<AuditLog {self.id} {self.action} {self.entity_type}:{self.entity_id}'
f' by {self.username}>')
+28
View File
@@ -0,0 +1,28 @@
# app/models/broadcast.py
# -----------------------
# Stores admin-sent broadcast notification records.
# Each broadcast creates one Notification row per targeted user —
# the iOS app receives them via its existing poll cycle
# (GET /api/v1/notifications?since=...) with no new API endpoint required.
from app import db
from app.utils.time_utils import now_eastern
class Broadcast(db.Model):
__tablename__ = 'broadcasts'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(255), nullable=False)
body = db.Column(db.Text, nullable=False)
# JSON-encoded list of role strings targeted, e.g. '["inspector","project_manager"]'
target_roles = db.Column(db.JSON, nullable=False, default=list)
sent_by_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
sent_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
# Number of Notification rows created (resolved at send time)
recipient_count = db.Column(db.Integer, default=0, nullable=False)
sent_by = db.relationship('User', foreign_keys=[sent_by_id])
def __repr__(self):
return f'<Broadcast {self.id} "{self.title[:30]}" roles={self.target_roles}>'
+39
View File
@@ -0,0 +1,39 @@
from app import db
from app.utils.time_utils import now_eastern
class Facility(db.Model):
__tablename__ = 'facilities'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
address = db.Column(db.Text)
contact_person = db.Column(db.String(100))
contact_phone = db.Column(db.String(20))
active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=True)
# Phase 1: link facility to a project (nullable for backward compatibility)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='SET NULL'),
nullable=True, index=True)
# Relationships
areas = db.relationship('Area', backref='facility', lazy='dynamic')
inspections = db.relationship('Inspection', backref='facility', lazy='dynamic')
def __repr__(self):
return f'<Facility {self.name}>'
class Area(db.Model):
__tablename__ = 'areas'
id = db.Column(db.Integer, primary_key=True)
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False)
name = db.Column(db.String(255), nullable=False)
area_type = db.Column(db.String(50))
# Relationships
inspections = db.relationship('Inspection', backref='area', lazy='dynamic')
issues = db.relationship('Issue', backref='area', lazy='dynamic')
def __repr__(self):
return f'<Area {self.name}>'
+99
View File
@@ -0,0 +1,99 @@
from app import db
from app.utils.time_utils import now_eastern
import json
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')
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)
# ── 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)
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')
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}>'
+25
View File
@@ -0,0 +1,25 @@
from app import db
from app.utils.time_utils import now_eastern
class InspectorAssignment(db.Model):
__tablename__ = 'inspector_assignments'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False, index=True)
project_id = db.Column(db.Integer,
db.ForeignKey('projects.id', ondelete='CASCADE'),
nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
inspector = db.relationship('User', backref='inspector_assignments')
project = db.relationship('Project', backref='inspector_assignments')
__table_args__ = (
db.UniqueConstraint('user_id', 'project_id', name='uq_inspector_project'),
)
def __repr__(self):
return f'<InspectorAssignment user={self.user_id} project={self.project_id}>'
+118
View File
@@ -0,0 +1,118 @@
from app import db
from app.utils.time_utils import now_eastern
class IssueComment(db.Model):
__tablename__ = 'issue_comments'
id = db.Column(db.Integer, primary_key=True)
issue_id = db.Column(db.Integer, db.ForeignKey('issues.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
status_at_time = db.Column(db.String(20)) # snapshot of issue status when comment was made
body = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
is_customer_visible = db.Column(db.Boolean, nullable=False, default=False)
# Relationships
author = db.relationship('User', foreign_keys=[user_id])
def __repr__(self):
return f'<IssueComment {self.id} issue={self.issue_id}>'
# ── Issue Follower ─────────────────────────────────────────────────────────────
# Association table linking users who opt in to receive notifications
# for any updates on a specific issue.
class IssueFollower(db.Model):
__tablename__ = 'issue_followers'
id = db.Column(db.Integer, primary_key=True)
issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
__table_args__ = (
db.UniqueConstraint('issue_id', 'user_id', name='uq_issue_follower'),
)
user = db.relationship('User', foreign_keys=[user_id])
issue = db.relationship('Issue', foreign_keys=[issue_id], back_populates='followers')
def __repr__(self):
return f'<IssueFollower issue={self.issue_id} user={self.user_id}>'
class Issue(db.Model):
__tablename__ = 'issues'
id = db.Column(db.Integer, primary_key=True)
inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id'))
area_id = db.Column(db.Integer, db.ForeignKey('areas.id'), nullable=True)
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=True)
severity = db.Column(db.Enum('low', 'medium', 'high', 'critical'), nullable=False)
description = db.Column(db.Text, nullable=False)
photo_path = db.Column(db.String(255))
status = db.Column(db.Enum('open', 'in_progress', 'resolved', 'pending_verification'), default='open')
assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'))
# Set at creation time to the user who filed the issue (inspector or admin).
# Nullable for backward compatibility — pre-phase18 rows will be NULL.
# Used by the mobile API to return issues the inspector created but hasn't
# been assigned yet (assigned_to is NULL until a director assigns them).
reported_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
reported_at = db.Column(db.DateTime, default=now_eastern)
resolved_at = db.Column(db.DateTime)
result_notes = db.Column(db.Text)
result_photos = db.Column(db.JSON) # list of relative paths e.g. ["uploads/issue_photos/abc.jpg"]
# Extra evidence photos submitted from the iPad at issue-creation time.
# Stored separately from result_photos (resolution photos added via web)
# so they display under "Photo Evidence" rather than "Resolution Details".
mobile_photo_paths = db.Column(db.JSON, nullable=True)
# ── Resolution verification ──────────────────────────────────────────
verified_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
verified_at = db.Column(db.DateTime, nullable=True)
verification_note = db.Column(db.Text, nullable=True)
# Tracks which SLA alert level has already been notified so cron runs
# don't fire duplicate notifications. Values: None / 'at_risk' / 'breached'
sla_notified = db.Column(db.String(10), nullable=True, default=None)
mobile_local_id = db.Column(db.String(64), nullable=True, index=True) # idempotency key for mobile submissions
# External vendor / contractor assignment (phase26)
vendor_name = db.Column(db.String(100), nullable=True)
vendor_contact = db.Column(db.String(200), nullable=True) # phone or email
vendor_notes = db.Column(db.Text, nullable=True)
# Relationships
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
# Do NOT add a second explicit db.relationship('Area') here — it conflicts
# with that backref at mapper configuration time (CLAUDE.md rule 31 revised).
facility = db.relationship('Facility', foreign_keys=[facility_id], backref='direct_issues')
assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues')
reporter = db.relationship('User', foreign_keys=[reported_by], backref='reported_issues')
verifier = db.relationship('User', foreign_keys=[verified_by], backref='verified_issues')
comments = db.relationship('IssueComment', backref='issue', lazy='dynamic',
order_by='IssueComment.created_at',
cascade='all, delete-orphan')
followers = db.relationship('IssueFollower', back_populates='issue',
cascade='all, delete-orphan', lazy='dynamic')
def is_followed_by(self, user):
"""Return True if the given user is currently following this issue."""
return self.followers.filter_by(user_id=user.id).first() is not None
@property
def resolved_facility(self):
"""Returns the Facility for this issue regardless of which path was used to create it.
Issues created via the standalone form have facility_id set directly.
Issues created via flag_issue (from an inspection) have area_id set.
"""
if self.facility:
return self.facility
if self.area:
return self.area.facility
return None
def __repr__(self):
return f'<Issue {self.id} - {self.severity}>'
+108
View File
@@ -0,0 +1,108 @@
from app import db
from app.utils.time_utils import now_eastern
# ── Event type constants ───────────────────────────────────────────────────────
# These are the canonical keys used across the preference system.
# Every call to notify() should pass one of these as event_type.
EVENT_ISSUE_ASSIGNED = 'issue_assigned'
EVENT_ISSUE_STATUS = 'issue_status'
EVENT_ISSUE_COMMENT = 'issue_comment'
EVENT_ISSUE_FOLLOW = 'issue_follow_update'
EVENT_INSPECTION_DONE = 'inspection_completed'
EVENT_SLA_ALERT = 'sla_alert'
# Fired when an issue is flagged during an inspection (web or mobile).
# Listed here so users can configure email preferences for this event.
EVENT_ISSUE_FLAGGED = 'issue_flagged'
# ── Customer portal events ─────────────────────────────────────────────────
# Fired when an inspection completes or an issue is created/updated at a
# facility the customer is assigned to. Separate constants allow customers
# to manage these preferences independently from internal staff events.
EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
# Fired by the score-trend cron when a facility's rolling avg drops by
# more than the configured threshold vs. the prior period.
EVENT_SCORE_ALERT = 'score_alert'
EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all apps
ALL_EVENT_TYPES = {
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
EVENT_ISSUE_STATUS: 'Issue status changed',
EVENT_ISSUE_COMMENT: 'New comment on issue',
EVENT_ISSUE_FOLLOW: 'Updates on followed issues',
EVENT_ISSUE_FLAGGED: 'Issue flagged (from inspection)',
EVENT_INSPECTION_DONE: 'Inspection completed',
EVENT_SLA_ALERT: 'SLA at-risk / breached alerts',
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
# Customer-facing — only relevant for customer role accounts
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
# Score trend alert — admin/director management use
EVENT_SCORE_ALERT: 'Facility score trend alert (significant drop detected)',
}
class Notification(db.Model):
"""Stores in-app notifications for users.
Each notification is tied to a single recipient and optionally linked to
either an Issue or an Inspection so the UI can build a direct link.
"""
__tablename__ = 'notifications'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
title = db.Column(db.String(255), nullable=False)
body = db.Column(db.Text, nullable=False)
link = db.Column(db.String(512))
is_read = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
# Optional FK references — only one will be populated at a time
issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=True)
inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id', ondelete='CASCADE'), nullable=True)
# Event type — stored for mobile API polling so the iPad can categorise alerts.
# Added phase17; NULL for notifications created before the migration.
event_type = db.Column(db.String(50), nullable=True)
# Digest tracking: set to True when created, cleared after digest email sent
digest_pending = db.Column(db.Boolean, default=False, nullable=False, index=True)
recipient = db.relationship('User', foreign_keys=[user_id], backref='notifications')
def __repr__(self):
return f'<Notification {self.id} user={self.user_id} read={self.is_read}>'
class NotificationPreference(db.Model):
"""Per-user, per-event notification preferences.
One row per (user_id, event_type) combination.
If no row exists for a user+event, defaults apply (email on, no digest).
"""
__tablename__ = 'notification_preferences'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False, index=True)
event_type = db.Column(db.String(50), nullable=False)
email_enabled = db.Column(db.Boolean, default=True, nullable=False)
digest_mode = db.Column(db.Boolean, default=False, nullable=False)
# digest_frequency: 'hourly' or 'daily' — only relevant when digest_mode is True
digest_frequency = db.Column(db.String(10), default='daily', nullable=False)
__table_args__ = (
db.UniqueConstraint('user_id', 'event_type', name='uq_notif_pref_user_event'),
)
user = db.relationship('User', foreign_keys=[user_id],
backref=db.backref('notification_preferences', lazy='dynamic'))
def __repr__(self):
return (f'<NotificationPreference user={self.user_id} '
f'event={self.event_type} email={self.email_enabled} digest={self.digest_mode}>')
+228
View File
@@ -0,0 +1,228 @@
"""
app/models/notification_matrix.py
----------------------------------
Admin-controlled notification matrix.
One row per (event_type, role_key) pair.
role_key values
---------------
admin all users with role='admin'
director all users with role='director'
inspector all users with role='inspector'
project_manager all users with role='project_manager'
customer all customer-portal users assigned to the relevant facility
assignee the specific user the issue/inspection is assigned to
(implicit; always notified regardless of matrix)
custom free-form extra email addresses stored in custom_emails JSON
Default matrix (mirrors current hardcoded behaviour)
-----------------------------------------------------
inspection_completed : admin director inspector pm customer
issue_assigned : admin director inspector pm customer (assignee implicit)
issue_status : admin director inspector pm customer (assignee implicit)
issue_comment : admin director inspector pm customer (assignee implicit)
issue_follow_update : admin director inspector pm customer (followers implicit)
issue_flagged : admin director inspector pm customer (assignee implicit)
issue_created : admin director inspector pm customer (assignee implicit)
issue_updated_customer : admin director inspector pm customer
verification_requested : admin director inspector pm customer
sla_alert : admin director inspector pm customer (assignee + followers implicit)
score_alert : admin director inspector pm customer (facility score drop cron)
"""
import json
from app import db
# Role keys available in the matrix UI
MATRIX_ROLES = [
('admin', 'Admin'),
('director', 'Director'),
('inspector', 'Inspector'),
('project_manager', 'Project Manager'),
('customer', 'Customer'),
('custom', 'Custom Recipients'),
]
# Events shown in the matrix — maps event_key → display label
# event_key is used as the DB event_type value
MATRIX_EVENTS = {
'inspection_completed': 'Inspection completed',
'issue_assigned': 'Issue assigned (new)',
'issue_reassigned': 'Issue reassigned',
'issue_unassigned': 'Issue unassigned',
'issue_status': 'Issue status changed',
'issue_comment': 'Issue comment added',
'issue_follow_update': 'Issue follow update',
'issue_flagged': 'Issue flagged (from inspection)',
'issue_created': 'Issue created (standalone)',
'issue_updated_customer': 'Issue updated (customer)',
'verification_requested': 'Verification requested',
'sla_alert': 'SLA at-risk / breached',
'score_alert': 'Facility score trend alert (significant drop)',
}
# Default enabled state: (event_key, role_key) → True/False
# Mirrors the current hardcoded behaviour exactly.
MATRIX_DEFAULTS = {
# inspection_completed
('inspection_completed', 'admin'): True,
('inspection_completed', 'director'): True,
('inspection_completed', 'inspector'): False,
('inspection_completed', 'project_manager'): False,
('inspection_completed', 'customer'): True,
('inspection_completed', 'custom'): False,
# issue_assigned (assignee is always notified implicitly)
('issue_assigned', 'admin'): False,
('issue_assigned', 'director'): False,
('issue_assigned', 'inspector'): False,
('issue_assigned', 'project_manager'): False,
('issue_assigned', 'customer'): False,
('issue_assigned', 'custom'): False,
# issue_reassigned
('issue_reassigned', 'admin'): False,
('issue_reassigned', 'director'): False,
('issue_reassigned', 'inspector'): False,
('issue_reassigned', 'project_manager'): False,
('issue_reassigned', 'customer'): False,
('issue_reassigned', 'custom'): False,
# issue_unassigned
('issue_unassigned', 'admin'): False,
('issue_unassigned', 'director'): False,
('issue_unassigned', 'inspector'): False,
('issue_unassigned', 'project_manager'): False,
('issue_unassigned', 'customer'): False,
('issue_unassigned', 'custom'): False,
# issue_status
('issue_status', 'admin'): False,
('issue_status', 'director'): False,
('issue_status', 'inspector'): False,
('issue_status', 'project_manager'): False,
('issue_status', 'customer'): False,
('issue_status', 'custom'): False,
# issue_comment
('issue_comment', 'admin'): False,
('issue_comment', 'director'): False,
('issue_comment', 'inspector'): False,
('issue_comment', 'project_manager'): False,
('issue_comment', 'customer'): False,
('issue_comment', 'custom'): False,
# issue_follow_update (followers always notified implicitly)
('issue_follow_update', 'admin'): False,
('issue_follow_update', 'director'): False,
('issue_follow_update', 'inspector'): False,
('issue_follow_update', 'project_manager'): False,
('issue_follow_update', 'customer'): False,
('issue_follow_update', 'custom'): False,
# issue_flagged (from inspection)
('issue_flagged', 'admin'): True,
('issue_flagged', 'director'): True,
('issue_flagged', 'inspector'): False,
('issue_flagged', 'project_manager'): False,
('issue_flagged', 'customer'): True,
('issue_flagged', 'custom'): False,
# issue_created (standalone)
('issue_created', 'admin'): True,
('issue_created', 'director'): True,
('issue_created', 'inspector'): False,
('issue_created', 'project_manager'): False,
('issue_created', 'customer'): True,
('issue_created', 'custom'): False,
# issue_updated_customer
('issue_updated_customer', 'admin'): False,
('issue_updated_customer', 'director'): False,
('issue_updated_customer', 'inspector'): False,
('issue_updated_customer', 'project_manager'): False,
('issue_updated_customer', 'customer'): True,
('issue_updated_customer', 'custom'): False,
# verification_requested
('verification_requested', 'admin'): True,
('verification_requested', 'director'): True,
('verification_requested', 'inspector'): False,
('verification_requested', 'project_manager'): False,
('verification_requested', 'customer'): False,
('verification_requested', 'custom'): False,
# sla_alert (assignee + followers always notified implicitly)
('sla_alert', 'admin'): True,
('sla_alert', 'director'): False,
('sla_alert', 'inspector'): False,
('sla_alert', 'project_manager'): False,
('sla_alert', 'customer'): False,
('sla_alert', 'custom'): False,
# score_alert — facility rolling-avg score drop detected by cron
('score_alert', 'admin'): True,
('score_alert', 'director'): True,
('score_alert', 'inspector'): False,
('score_alert', 'project_manager'): False,
('score_alert', 'customer'): False,
('score_alert', 'custom'): False,
}
class NotificationMatrix(db.Model):
"""Admin-controlled per-event notification routing."""
__tablename__ = 'notification_matrix'
id = db.Column(db.Integer, primary_key=True)
event_type = db.Column(db.String(50), nullable=False)
role_key = db.Column(db.String(30), nullable=False)
enabled = db.Column(db.Boolean, nullable=False, default=True)
custom_emails = db.Column(db.Text, nullable=True) # JSON list, only used when role_key='custom'
__table_args__ = (
db.UniqueConstraint('event_type', 'role_key', name='uq_notif_matrix_event_role'),
)
def get_custom_emails(self):
"""Return custom_emails as a Python list."""
if not self.custom_emails:
return []
try:
result = json.loads(self.custom_emails)
return [e.strip() for e in result if isinstance(e, str) and e.strip()]
except (json.JSONDecodeError, TypeError):
return []
def __repr__(self):
return f'<NotificationMatrix {self.event_type} / {self.role_key} enabled={self.enabled}>'
def get_matrix_row(event_type: str, role_key: str) -> NotificationMatrix:
"""
Return the matrix row for (event_type, role_key), creating it from
defaults if it doesn't exist yet. Safe to call without seeding.
"""
row = NotificationMatrix.query.filter_by(
event_type=event_type, role_key=role_key
).first()
if row is None:
default = MATRIX_DEFAULTS.get((event_type, role_key), False)
row = NotificationMatrix(
event_type=event_type,
role_key=role_key,
enabled=default,
)
db.session.add(row)
db.session.flush()
return row
def is_enabled(event_type: str, role_key: str) -> bool:
"""Return True if the matrix enables notifications for this event/role pair."""
row = NotificationMatrix.query.filter_by(
event_type=event_type, role_key=role_key
).first()
if row is None:
return MATRIX_DEFAULTS.get((event_type, role_key), False)
return row.enabled
def get_custom_emails_for(event_type: str) -> list:
"""Return the custom email list for this event type."""
row = NotificationMatrix.query.filter_by(
event_type=event_type, role_key='custom'
).first()
if row is None:
return []
return row.get_custom_emails()
+63
View File
@@ -0,0 +1,63 @@
from app import db
from app.utils.time_utils import now_eastern
class Project(db.Model):
"""Top-level grouping that contains one or more Facilities.
Each project may have an assigned project_manager (User) and one or more
customer users linked via CustomerAssignment.
"""
__tablename__ = 'projects'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text)
project_manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
# Relationships
project_manager = db.relationship('User', foreign_keys=[project_manager_id],
backref='managed_projects')
facilities = db.relationship('Facility', backref='project', lazy='dynamic')
customer_assignments = db.relationship('CustomerAssignment', back_populates='project',
cascade='all, delete-orphan', lazy='dynamic')
def __repr__(self):
return f'<Project {self.name}>'
class CustomerAssignment(db.Model):
"""Links a customer-role User to a Project and/or a specific Facility.
- project_id only customer can view all facilities within that project
- project_id + facility_id customer is scoped to that specific facility
"""
__tablename__ = 'customer_assignments'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False, index=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='CASCADE'),
nullable=False, index=True)
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id', ondelete='CASCADE'),
nullable=True, index=True)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
__table_args__ = (
db.UniqueConstraint('user_id', 'project_id', 'facility_id',
name='uq_customer_assignment'),
)
# Relationships
user = db.relationship('User', foreign_keys=[user_id],
backref=db.backref('customer_assignments', lazy='dynamic'))
project = db.relationship('Project', foreign_keys=[project_id],
back_populates='customer_assignments')
facility = db.relationship('Facility', foreign_keys=[facility_id],
backref=db.backref('customer_assignments', lazy='dynamic'))
def __repr__(self):
return (f'<CustomerAssignment user={self.user_id} '
f'project={self.project_id} facility={self.facility_id}>')
+69
View File
@@ -0,0 +1,69 @@
"""
app/models/scheduled_report.py
-------------------------------
Stores the configuration for automated scheduled report emails.
"""
from app import db
from app.utils.time_utils import now_eastern
class ScheduledReport(db.Model):
"""Configuration record for a recurring emailed report.
report_type options:
summary overall KPI digest (inspections + issues)
facility single-facility scorecard
issues open/in-progress issues list
frequency options: daily | weekly | monthly
recipients: JSON list of email address strings, e.g.
["manager@acme.com", "client@acme.com"]
include_pdf / include_csv: attach respective exports to the email.
"""
__tablename__ = 'scheduled_reports'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
report_type = db.Column(
db.Enum('summary', 'facility', 'issues'),
nullable=False, default='summary'
)
frequency = db.Column(
db.Enum('daily', 'weekly', 'monthly'),
nullable=False
)
facility_id = db.Column(
db.Integer, db.ForeignKey('facilities.id', ondelete='SET NULL'),
nullable=True
)
recipients = db.Column(db.JSON, nullable=False, default=list)
include_pdf = db.Column(db.Boolean, nullable=False, default=False)
include_csv = db.Column(db.Boolean, nullable=False, default=False)
active = db.Column(db.Boolean, nullable=False, default=True)
created_by = db.Column(
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True
)
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
last_sent_at = db.Column(db.DateTime, nullable=True)
next_send_at = db.Column(db.DateTime, nullable=True)
# Relationships
facility = db.relationship('Facility', foreign_keys=[facility_id])
creator = db.relationship('User', foreign_keys=[created_by])
def recipient_list(self):
"""Return recipients as a Python list (safe even if stored as string)."""
if isinstance(self.recipients, list):
return self.recipients
import json
try:
return json.loads(self.recipients)
except Exception:
return []
def __repr__(self):
return f'<ScheduledReport {self.id} {self.name!r} {self.frequency}>'
+24
View File
@@ -0,0 +1,24 @@
from app import db
from app.utils.time_utils import now_eastern
class FacilityScoreAlert(db.Model):
"""Records each score-trend alert sent for a facility.
Used by send_score_alerts() to deduplicate cron notifications:
if an alert row exists for a facility within the last 24 hours,
no new alert is sent even if the score is still below threshold.
"""
__tablename__ = 'facility_score_alerts'
id = db.Column(db.Integer, primary_key=True)
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id', ondelete='CASCADE'), nullable=False)
sent_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
current_avg = db.Column(db.Numeric(5, 2), nullable=False)
prior_avg = db.Column(db.Numeric(5, 2), nullable=False)
delta = db.Column(db.Numeric(5, 2), nullable=False)
facility = db.relationship('Facility', foreign_keys=[facility_id])
def __repr__(self):
return f'<FacilityScoreAlert facility={self.facility_id} delta={self.delta} sent={self.sent_at}>'
+41
View File
@@ -0,0 +1,41 @@
from app import db
from app.utils.time_utils import now_eastern
class SupportTicket(db.Model):
__tablename__ = 'support_tickets'
id = db.Column(db.Integer, primary_key=True)
customer_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id', ondelete='SET NULL'), nullable=True)
subject = db.Column(db.String(200), nullable=False)
body = db.Column(db.Text, nullable=False)
status = db.Column(db.String(20), nullable=False, default='open') # open / answered / closed
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
customer = db.relationship('User', foreign_keys=[customer_id], backref='support_tickets')
facility = db.relationship('Facility', foreign_keys=[facility_id], backref='support_tickets')
replies = db.relationship(
'SupportTicketReply', backref='ticket',
cascade='all, delete-orphan',
order_by='SupportTicketReply.created_at',
lazy='dynamic',
)
def __repr__(self):
return f'<SupportTicket {self.id} [{self.status}]>'
class SupportTicketReply(db.Model):
__tablename__ = 'support_ticket_replies'
id = db.Column(db.Integer, primary_key=True)
ticket_id = db.Column(db.Integer, db.ForeignKey('support_tickets.id', ondelete='CASCADE'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
body = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
author = db.relationship('User', foreign_keys=[user_id])
def __repr__(self):
return f'<SupportTicketReply {self.id} ticket={self.ticket_id}>'
+100
View File
@@ -0,0 +1,100 @@
from app import db, login_manager
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app.utils.time_utils import now_eastern
@login_manager.user_loader
def load_user(user_id):
from app import db
return db.session.get(User, int(user_id))
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
full_name = db.Column(db.String(150), nullable=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(
# Phase 11 migration complete — 'supervisor' removed from both the DB
# ENUM and this Python-side declaration. Director is the canonical role.
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer'),
nullable=False
)
created_at = db.Column(db.DateTime, default=now_eastern)
active = db.Column(db.Boolean, default=True, nullable=False)
# ── Customer password-setup workflow ──────────────────────────────────
# password_set: False for newly created customer accounts until they
# complete the set-password flow via emailed link.
# Always True for internal users created via UserForm.
password_set = db.Column(db.Boolean, nullable=False, default=True)
set_password_token = db.Column(db.String(64), nullable=True, index=True)
set_password_token_expires = db.Column(db.DateTime, nullable=True)
# Relationships
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
# ── Flask-Login integration ────────────────────────────────────────────
# Override UserMixin.is_active so that disabled accounts are rejected
# automatically by login_required and login_user() without any extra code.
@property
def is_active(self):
return self.active
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
@property
def display_name(self):
"""Return full name if set, otherwise fall back to username."""
return self.full_name.strip() if self.full_name and self.full_name.strip() else self.username
def generate_set_password_token(self, expires_hours=72):
"""Create a one-time set-password token valid for `expires_hours` hours."""
import secrets
from datetime import timedelta
self.set_password_token = secrets.token_hex(32) # 64 hex chars
self.set_password_token_expires = now_eastern() + timedelta(hours=expires_hours)
return self.set_password_token
def clear_set_password_token(self):
"""Invalidate the token after use."""
self.set_password_token = None
self.set_password_token_expires = None
@staticmethod
def verify_set_password_token(token):
"""Return the User whose token matches, or None if invalid/expired.
The final token comparison uses hmac.compare_digest so that the
comparison runs in constant time regardless of how many characters
match, preventing timing-based token enumeration attacks.
"""
import hmac
if not token:
return None
# Primary lookup is via DB index — compare_digest is a defense-in-depth
# guard applied after the row is retrieved to harden the string comparison.
user = User.query.filter_by(set_password_token=token).first()
if user is None:
return None
if user.set_password_token_expires is None:
return None
if now_eastern() > user.set_password_token_expires:
return None
# Constant-time comparison — prevents timing oracle on the stored token.
# Wrapped in try/except to guard against unexpected type mismatches.
try:
if not hmac.compare_digest(user.set_password_token, token):
return None
except (TypeError, ValueError):
return None
return user
def __repr__(self):
return f'<User {self.username}>'