151 lines
7.9 KiB
Python
151 lines
7.9 KiB
Python
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)
|
|
|
|
# ── Who handles the issue (phase35) ──────────────────────────────────
|
|
# internal = our staff (assigned_to); facility = the facility's own staff
|
|
# (facility_handler_* below); vendor = external contractor (vendor_* above).
|
|
# assigned_to remains the internal follow-up owner in ALL cases.
|
|
handler_type = db.Column(
|
|
db.Enum('internal', 'facility', 'vendor'),
|
|
nullable=False, default='internal',
|
|
)
|
|
facility_handler_name = db.Column(db.String(100), nullable=True)
|
|
facility_handler_contact = db.Column(db.String(200), nullable=True) # phone or email
|
|
facility_handler_notes = db.Column(db.Text, nullable=True)
|
|
# Free-text name of the janitorial staff member who will handle the issue,
|
|
# used when handler_type == 'internal'. Distinct from assigned_to (the JQC
|
|
# User who owns follow-up): the actual crew member may not be a system user.
|
|
internal_handler_name = db.Column(db.String(100), 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
|
|
|
|
# Human-readable label for the handler category (phase35).
|
|
# Perspective-neutral wording so it reads the same for staff and customers.
|
|
HANDLER_LABELS = {
|
|
'internal': 'Janitorial Staff',
|
|
'facility': 'Facility Staff',
|
|
'vendor': 'External Vendor',
|
|
}
|
|
HANDLER_DESCRIPTIONS = {
|
|
'internal': 'Our janitorial crew handles it.',
|
|
'facility': "The facility's own on-site staff handle it.",
|
|
'vendor': 'An outside contractor handles it.',
|
|
}
|
|
|
|
@property
|
|
def handler_label(self):
|
|
return self.HANDLER_LABELS.get(self.handler_type or 'internal', 'Janitorial Staff')
|
|
|
|
@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}>' |