First commit
This commit is contained in:
@@ -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
|
||||
@@ -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}>'
|
||||
@@ -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}>')
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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}>')
|
||||
@@ -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()
|
||||
@@ -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}>')
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
Reference in New Issue
Block a user