05/04 Update code to replace Area related to Facility

This commit is contained in:
2026-05-04 13:46:37 -04:00
parent 2bdd6c1a26
commit a0136bd99b
17 changed files with 174 additions and 95 deletions
+14 -15
View File
@@ -14,7 +14,7 @@ import logging
from flask import Blueprint, request, g, current_app from flask import Blueprint, request, g, current_app
from app import db from app import db
from app.models.issue import Issue from app.models.issue import Issue
from app.models.facility import Area from app.models.facility import Facility, Area
from app.models.inspection import Inspection from app.models.inspection import Inspection
from app.api.errors import api_ok, api_error from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required from app.api.decorators import jwt_required
@@ -45,7 +45,7 @@ def create_issue():
------------ ------------
{ {
"inspection_id": 42, // optional "inspection_id": 42, // optional
"area_id": 12, // required "facility_id": 5, // required
"severity": "high", // "low"|"medium"|"high"|"critical" "severity": "high", // "low"|"medium"|"high"|"critical"
"description": "...", // required "description": "...", // required
"photo_path": "uploads/...",// optional — already uploaded via /photos/upload "photo_path": "uploads/...",// optional — already uploaded via /photos/upload
@@ -73,20 +73,20 @@ def create_issue():
return api_ok({'issue_id': existing.id, 'duplicate': True}) return api_ok({'issue_id': existing.id, 'duplicate': True})
# ── Validate ────────────────────────────────────────────────────────── # ── Validate ──────────────────────────────────────────────────────────
area_id = data.get('area_id') facility_id = data.get('facility_id')
severity = data.get('severity', '').lower() severity = data.get('severity', '').lower()
description = (data.get('description') or '').strip() description = (data.get('description') or '').strip()
if not area_id: if not facility_id:
return api_error('area_id is required', 400) return api_error('facility_id is required', 400)
if severity not in _VALID_SEVERITY: if severity not in _VALID_SEVERITY:
return api_error(f'severity must be one of: {", ".join(sorted(_VALID_SEVERITY))}', 400) return api_error(f'severity must be one of: {", ".join(sorted(_VALID_SEVERITY))}', 400)
if not description: if not description:
return api_error('description is required', 400) return api_error('description is required', 400)
area = db.session.get(Area, area_id) facility = db.session.get(Facility, facility_id)
if area is None: if facility is None:
return api_error('Area not found', 404) return api_error('Facility not found', 404)
inspection_id = data.get('inspection_id') inspection_id = data.get('inspection_id')
if inspection_id: if inspection_id:
@@ -99,7 +99,7 @@ def create_issue():
# ── Create ──────────────────────────────────────────────────────────── # ── Create ────────────────────────────────────────────────────────────
issue = Issue( issue = Issue(
inspection_id = inspection_id, inspection_id = inspection_id,
area_id = area_id, facility_id = facility_id,
severity = severity, severity = severity,
description = description, description = description,
photo_path = data.get('photo_path') or None, photo_path = data.get('photo_path') or None,
@@ -111,7 +111,6 @@ def create_issue():
db.session.flush() # get issue.id db.session.flush() # get issue.id
# ── Notifications ───────────────────────────────────────────────────── # ── Notifications ─────────────────────────────────────────────────────
facility_id = area.facility_id
try: try:
from flask import url_for from flask import url_for
issue_link = url_for('issues.view', issue_id=issue.id, _external=False) issue_link = url_for('issues.view', issue_id=issue.id, _external=False)
@@ -123,8 +122,8 @@ def create_issue():
event_type = 'issue_flagged', event_type = 'issue_flagged',
title = f'New Issue #{issue.id} (Mobile)', title = f'New Issue #{issue.id} (Mobile)',
body = ( body = (
f'A new {severity.title()}-severity issue was logged in ' f'A new {severity.title()}-severity issue was logged at '
f'{area.name} during {inspection_ref}. ' f'{facility.name} during {inspection_ref}. '
f'Description: {description[:120]}' f'Description: {description[:120]}'
f'{"" if len(description) > 120 else ""}' f'{"" if len(description) > 120 else ""}'
), ),
@@ -137,11 +136,11 @@ def create_issue():
db.session.commit() db.session.commit()
log_action(ACTION_CREATE, 'Issue', issue.id, log_action(ACTION_CREATE, 'Issue', issue.id,
f'{severity} issue in {area.name}', f'{severity} issue at {facility.name}',
f'source=mobile; inspection_id={inspection_id}; ' f'source=mobile; inspection_id={inspection_id}; '
f'local_id={mobile_local_id}') f'local_id={mobile_local_id}')
logger.info('API ISSUES | created | issue_id=%d | area=%s | severity=%s | user=%s', logger.info('API ISSUES | created | issue_id=%d | facility=%s | severity=%s | user=%s',
issue.id, area.name, severity, user.username) issue.id, facility.name, severity, user.username)
return api_ok({'issue_id': issue.id, 'duplicate': False}) return api_ok({'issue_id': issue.id, 'duplicate': False})
+15 -1
View File
@@ -47,7 +47,8 @@ class Issue(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id')) inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id'))
area_id = db.Column(db.Integer, db.ForeignKey('areas.id'), nullable=False) 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) severity = db.Column(db.Enum('low', 'medium', 'high', 'critical'), nullable=False)
description = db.Column(db.Text, nullable=False) description = db.Column(db.Text, nullable=False)
photo_path = db.Column(db.String(255)) photo_path = db.Column(db.String(255))
@@ -69,6 +70,7 @@ class Issue(db.Model):
mobile_local_id = db.Column(db.String(64), nullable=True, index=True) # idempotency key for mobile submissions mobile_local_id = db.Column(db.String(64), nullable=True, index=True) # idempotency key for mobile submissions
# Relationships # Relationships
facility = db.relationship('Facility', foreign_keys=[facility_id], backref='direct_issues')
assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues') assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues')
verifier = db.relationship('User', foreign_keys=[verified_by], backref='verified_issues') verifier = db.relationship('User', foreign_keys=[verified_by], backref='verified_issues')
comments = db.relationship('IssueComment', backref='issue', lazy='dynamic', comments = db.relationship('IssueComment', backref='issue', lazy='dynamic',
@@ -81,5 +83,17 @@ class Issue(db.Model):
"""Return True if the given user is currently following this issue.""" """Return True if the given user is currently following this issue."""
return self.followers.filter_by(user_id=user.id).first() is not None 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): def __repr__(self):
return f'<Issue {self.id} - {self.severity}>' return f'<Issue {self.id} - {self.severity}>'
+9 -3
View File
@@ -66,9 +66,12 @@ def index():
open_issues_q = open_issues_q.filter(False) open_issues_q = open_issues_q.filter(False)
else: else:
from app.models.facility import Area from app.models.facility import Area
open_issues_q = open_issues_q.join( open_issues_q = open_issues_q.outerjoin(
Area, Issue.area_id == Area.id Area, Issue.area_id == Area.id
).filter(Area.facility_id.in_(customer_facility_ids)) ).filter(db.or_(
Issue.facility_id.in_(customer_facility_ids),
Area.facility_id.in_(customer_facility_ids)
))
# Single query — derive count from the list to avoid hitting the DB twice # Single query — derive count from the list to avoid hitting the DB twice
open_issues_all = open_issues_q.all() open_issues_all = open_issues_q.all()
@@ -136,9 +139,12 @@ def index():
sla_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress'])) sla_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
if is_customer and customer_facility_ids: if is_customer and customer_facility_ids:
from app.models.facility import Area from app.models.facility import Area
sla_q = sla_q.join(Area, Issue.area_id == Area.id).filter( sla_q = sla_q.outerjoin(Area, Issue.area_id == Area.id).filter(
db.or_(
Issue.facility_id.in_(customer_facility_ids),
Area.facility_id.in_(customer_facility_ids) Area.facility_id.in_(customer_facility_ids)
) )
)
all_open_issues = sla_q.all() if not is_customer or customer_facility_ids else [] all_open_issues = sla_q.all() if not is_customer or customer_facility_ids else []
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached') sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk') sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
+3 -4
View File
@@ -679,17 +679,16 @@ def flag_issue(inspection_id):
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
form = IssueForm() form = IssueForm()
areas = Area.query.filter_by(facility_id=inspection.facility_id).order_by(Area.name).all()
staff = User.query.filter(User.role.in_(['director', 'inspector'])).order_by(User.username).all() staff = User.query.filter(User.role.in_(['director', 'inspector'])).order_by(User.username).all()
form.area_id.choices = [(a.id, a.name) for a in areas] form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)]
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff] form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
if form.validate_on_submit(): if form.validate_on_submit():
photo_path = _save_photo(form.photo.data, subfolder='issue_photos') photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
issue = Issue( issue = Issue(
inspection_id = inspection_id, inspection_id = inspection_id,
area_id = form.area_id.data, facility_id = inspection.facility_id,
severity = form.severity.data, severity = form.severity.data,
description = form.description.data, description = form.description.data,
photo_path = photo_path, photo_path = photo_path,
@@ -733,7 +732,7 @@ def flag_issue(inspection_id):
title = f'New Issue #{issue.id} at {inspection.facility.name}', title = f'New Issue #{issue.id} at {inspection.facility.name}',
body = ( body = (
f'A new {issue.severity.title()}-severity issue has been logged ' f'A new {issue.severity.title()}-severity issue has been logged '
f'in {issue.area.name} at {inspection.facility.name} ' f'at {inspection.facility.name} '
f'during inspection #{inspection_id}. ' f'during inspection #{inspection_id}. '
f'Description: {issue.description[:120]}' f'Description: {issue.description[:120]}'
f'{"" if len(issue.description) > 120 else ""}' f'{"" if len(issue.description) > 120 else ""}'
+44 -41
View File
@@ -80,9 +80,17 @@ def index():
if not customer_facility_ids: if not customer_facility_ids:
q = q.filter(False) q = q.filter(False)
else: else:
q = q.join(Area, Issue.area_id == Area.id).filter( # Issues may have facility via direct facility_id (new) or via area_id (legacy/flagged)
q = q.filter(
db.or_(
Issue.facility_id.in_(customer_facility_ids),
db.and_(
Issue.area_id.isnot(None),
Issue.area_id == Area.id,
Area.facility_id.in_(customer_facility_ids) Area.facility_id.in_(customer_facility_ids)
) )
)
).outerjoin(Area, Issue.area_id == Area.id)
severity_filter = request.args.get('severity', '') severity_filter = request.args.get('severity', '')
status_filter = request.args.get('status', '') status_filter = request.args.get('status', '')
@@ -94,10 +102,14 @@ def index():
if status_filter: if status_filter:
q = q.filter(Issue.status == status_filter) q = q.filter(Issue.status == status_filter)
if facility_filter: if facility_filter:
q = q.join(Area, Issue.area_id == Area.id, isouter=True).filter( fid = int(facility_filter)
Area.facility_id == int(facility_filter) q = q.filter(
db.or_(
Issue.facility_id == fid,
db.and_(Issue.area_id.isnot(None),
Area.facility_id == fid)
)
) )
# SLA filter — SLA status is computed in Python (not a DB column). # SLA filter — SLA status is computed in Python (not a DB column).
# When active: load all matching rows, filter in Python, wrap in a # When active: load all matching rows, filter in Python, wrap in a
# single-page compatible object so the template interface is unchanged. # single-page compatible object so the template interface is unchanged.
@@ -159,10 +171,9 @@ def view(issue_id):
flash('Access denied. You can only view issues assigned to you.', 'danger') flash('Access denied. You can only view issues assigned to you.', 'danger')
return redirect(url_for('issues.index')) return redirect(url_for('issues.index'))
if current_user.role == 'customer': if current_user.role == 'customer':
from app.models.facility import Area
cids = get_customer_scope(current_user) or [] cids = get_customer_scope(current_user) or []
area = db.session.get(Area, issue.area_id) facility = issue.resolved_facility
if not area or area.facility_id not in cids: if not facility or facility.id not in cids:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('issues.index')) return redirect(url_for('issues.index'))
@@ -227,7 +238,7 @@ def view(issue_id):
recipient = assignee, recipient = assignee,
title = f'Issue #{issue.id} Status Updated', title = f'Issue #{issue.id} Status Updated',
body = ( body = (
f'Issue in {issue.area.name} was updated from ' f'Issue in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''} was updated from '
f'"{old_status.replace("_", " ").title()}" to ' f'"{old_status.replace("_", " ").title()}" to '
f'"{issue.status.replace("_", " ").title()}" ' f'"{issue.status.replace("_", " ").title()}" '
f'by {current_user.username}.' f'by {current_user.username}.'
@@ -247,7 +258,7 @@ def view(issue_id):
title = f'Issue #{issue.id} Assigned to You', title = f'Issue #{issue.id} Assigned to You',
body = ( body = (
f'You have been assigned Issue #{issue.id} ' f'You have been assigned Issue #{issue.id} '
f'({issue.severity.title()} severity) in {issue.area.name}. ' f'({issue.severity.title()} severity) in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}. '
f'Current status: {issue.status.replace("_", " ").title()}.' f'Current status: {issue.status.replace("_", " ").title()}.'
), ),
link = issue_link, link = issue_link,
@@ -265,7 +276,7 @@ def view(issue_id):
title = f'Issue #{issue.id} Unassigned', title = f'Issue #{issue.id} Unassigned',
body = ( body = (
f'You have been removed from Issue #{issue.id} ' f'You have been removed from Issue #{issue.id} '
f'in {issue.area.name} by {current_user.username}.' f'in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''} by {current_user.username}.'
), ),
link = issue_link, link = issue_link,
issue_id = issue.id, issue_id = issue.id,
@@ -315,22 +326,22 @@ def view(issue_id):
issue = issue, issue = issue,
title = f'Issue #{issue.id} Updated', title = f'Issue #{issue.id} Updated',
body = ( body = (
f'Issue #{issue.id} in {issue.area.name} was updated by ' f'Issue #{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''} was updated by '
f'{current_user.username}: {"; ".join(changes)}.' f'{current_user.username}: {"; ".join(changes)}.'
), ),
exclude_user_ids = exclude_ids, exclude_user_ids = exclude_ids,
) )
# ── Notify via matrix (issue_updated_customer) ─────────────── # ── Notify via matrix (issue_updated_customer) ───────────────
facility_id = issue.area.facility_id if issue.area else None facility_id = issue.resolved_facility.id if issue.resolved_facility else None
if facility_id and changes: if facility_id and changes:
changes_summary = '; '.join(changes) changes_summary = '; '.join(changes)
notify_by_matrix( notify_by_matrix(
event_type = 'issue_updated_customer', event_type = 'issue_updated_customer',
title = f'Issue #{issue.id} Updated at {issue.area.facility.name}', title = f'Issue #{issue.id} Updated at {issue.resolved_facility.name if issue.resolved_facility else ''}',
body = ( body = (
f'Issue #{issue.id} ({issue.severity.title()} severity) ' f'Issue #{issue.id} ({issue.severity.title()} severity) '
f'in {issue.area.name} was updated: {changes_summary}. ' f'in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''} was updated: {changes_summary}. '
f'Current status: {issue.status.replace("_", " ").title()}.' f'Current status: {issue.status.replace("_", " ").title()}.'
), ),
link = url_for('issues.view', issue_id=issue.id), link = url_for('issues.view', issue_id=issue.id),
@@ -340,7 +351,7 @@ def view(issue_id):
) )
db.session.commit() # Commit all notifications db.session.commit() # Commit all notifications
log_action(ACTION_UPDATE, 'Issue', issue.id, log_action(ACTION_UPDATE, 'Issue', issue.id,
f'#{issue.id} in {issue.area.name}', f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}',
f'status={issue.status}; assigned_to={issue.assigned_to}') f'status={issue.status}; assigned_to={issue.assigned_to}')
flash('Issue updated.', 'success') flash('Issue updated.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(url_for('issues.view', issue_id=issue_id))
@@ -419,18 +430,8 @@ def create():
from app.routes.inspections import _save_photo from app.routes.inspections import _save_photo
photo_path = _save_photo(form.photo.data, subfolder='issue_photos') photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
# Derive area_id from the selected facility — use its first active area.
# area_id is required on the Issue model so we must resolve it here.
area = (Area.query
.filter_by(facility_id=form.facility_id.data)
.order_by(Area.name)
.first())
if area is None:
form.facility_id.errors.append('Selected facility has no areas. Please add an area first.')
return render_template('issues/form.html', form=form, title='Log New Issue')
issue = Issue( issue = Issue(
area_id = area.id, facility_id = form.facility_id.data,
severity = form.severity.data, severity = form.severity.data,
description = form.description.data, description = form.description.data,
photo_path = photo_path, photo_path = photo_path,
@@ -441,11 +442,11 @@ def create():
db.session.add(issue) db.session.add(issue)
db.session.commit() db.session.commit()
current_app.logger.info( current_app.logger.info(
'ISSUE CREATED | id=%s | severity=%s | facility_id=%s | area_id=%s | assigned_to=%s | created_by=%s', 'ISSUE CREATED | id=%s | severity=%s | facility_id=%s | assigned_to=%s | created_by=%s',
issue.id, issue.severity, form.facility_id.data, issue.area_id, issue.assigned_to, current_user.username issue.id, issue.severity, issue.facility_id, issue.assigned_to, current_user.username
) )
log_action(ACTION_CREATE, 'Issue', issue.id, log_action(ACTION_CREATE, 'Issue', issue.id,
f'#{issue.id} {issue.severity} in {issue.area.facility.name}', f'#{issue.id} {issue.severity} at {issue.facility.name}',
f'severity={issue.severity}; assigned_to={issue.assigned_to}') f'severity={issue.severity}; assigned_to={issue.assigned_to}')
if issue.assigned_to: if issue.assigned_to:
@@ -456,7 +457,7 @@ def create():
title = f'New Issue #{issue.id} Assigned to You', title = f'New Issue #{issue.id} Assigned to You',
body = ( body = (
f'A new {issue.severity.title()}-severity issue has been logged ' f'A new {issue.severity.title()}-severity issue has been logged '
f'in {issue.area.name} and assigned to you. ' f'at {issue.facility.name} and assigned to you. '
f'Description: {issue.description[:120]}' f'Description: {issue.description[:120]}'
f'{"" if len(issue.description) > 120 else ""}' f'{"" if len(issue.description) > 120 else ""}'
), ),
@@ -468,20 +469,20 @@ def create():
db.session.commit() db.session.commit()
# ── Notify via matrix (issue_created) ──────────────────────── # ── Notify via matrix (issue_created) ────────────────────────
area = db.session.get(Area, issue.area_id) facility = issue.resolved_facility
if area: if facility:
notify_by_matrix( notify_by_matrix(
event_type = 'issue_created', event_type = 'issue_created',
title = f'New Issue #{issue.id} at {area.facility.name}', title = f'New Issue #{issue.id} at {facility.name}',
body = ( body = (
f'A new {issue.severity.title()}-severity issue has been logged ' f'A new {issue.severity.title()}-severity issue has been logged '
f'in {area.name} at {area.facility.name}. ' f'at {facility.name}. '
f'Description: {issue.description[:120]}' f'Description: {issue.description[:120]}'
f'{"" if len(issue.description) > 120 else ""}' f'{"" if len(issue.description) > 120 else ""}'
), ),
link = url_for('issues.view', issue_id=issue.id), link = url_for('issues.view', issue_id=issue.id),
issue_id = issue.id, issue_id = issue.id,
facility_id = area.facility_id, facility_id = facility.id,
exclude_user_ids = {current_user.id}, exclude_user_ids = {current_user.id},
) )
db.session.commit() db.session.commit()
@@ -521,7 +522,7 @@ def verify(issue_id):
issue_id, current_user.username, note, issue_id, current_user.username, note,
) )
log_action(ACTION_UPDATE, 'Issue', issue_id, log_action(ACTION_UPDATE, 'Issue', issue_id,
f'#{issue_id} in {issue.area.name}', f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}',
f'verified_by={current_user.username}') f'verified_by={current_user.username}')
flash(f'Issue #{issue_id} verified and closed.', 'success') flash(f'Issue #{issue_id} verified and closed.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(url_for('issues.view', issue_id=issue_id))
@@ -560,7 +561,7 @@ def request_verification(issue_id):
issue_id, current_user.username, issue_id, current_user.username,
) )
log_action(ACTION_UPDATE, 'Issue', issue_id, log_action(ACTION_UPDATE, 'Issue', issue_id,
f'#{issue_id} in {issue.area.name}', f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}',
f'status=pending_verification; requested_by={current_user.username}') f'status=pending_verification; requested_by={current_user.username}')
# Notify via matrix (verification_requested) # Notify via matrix (verification_requested)
@@ -569,7 +570,7 @@ def request_verification(issue_id):
title = f'Issue #{issue_id} Awaiting Verification', title = f'Issue #{issue_id} Awaiting Verification',
body = ( body = (
f'{current_user.username} has marked Issue #{issue_id} ' f'{current_user.username} has marked Issue #{issue_id} '
f'({issue.severity.title()} severity) in {issue.area.name} ' f'({issue.severity.title()} severity) in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''} '
f'as pending your verification.' f'as pending your verification.'
), ),
link = url_for('issues.view', issue_id=issue_id), link = url_for('issues.view', issue_id=issue_id),
@@ -602,7 +603,9 @@ def verification_queue():
from collections import defaultdict from collections import defaultdict
by_facility = defaultdict(list) by_facility = defaultdict(list)
for issue in pending: for issue in pending:
by_facility[issue.area.facility].append(issue) f = issue.resolved_facility
if f:
by_facility[f].append(issue)
# Sort facilities alphabetically # Sort facilities alphabetically
grouped = sorted(by_facility.items(), key=lambda x: x[0].name) grouped = sorted(by_facility.items(), key=lambda x: x[0].name)
@@ -638,8 +641,8 @@ def delete(issue_id):
# Snapshot fields needed for logging before deletion # Snapshot fields needed for logging before deletion
issue_id_snap = issue.id issue_id_snap = issue.id
issue_desc = issue.description[:80] issue_desc = issue.description[:80]
area_name = issue.area.name if issue.area else f'area_id={issue.area_id}' area_name = issue.area.name if issue.area else ''
facility_name = issue.area.facility.name if issue.area else '' facility_name = issue.resolved_facility.name if issue.resolved_facility else ''
severity = issue.severity severity = issue.severity
# Collect photo paths to clean up from disk after DB delete # Collect photo paths to clean up from disk after DB delete
+9 -3
View File
@@ -64,9 +64,12 @@ def index():
if customer_facility_ids is not None: if customer_facility_ids is not None:
if not customer_facility_ids: if not customer_facility_ids:
return q.filter(False) return q.filter(False)
return q.join(Area, Issue.area_id == Area.id).filter( return q.outerjoin(Area, Issue.area_id == Area.id).filter(
db.or_(
Issue.facility_id.in_(customer_facility_ids),
Area.facility_id.in_(customer_facility_ids) Area.facility_id.in_(customer_facility_ids)
) )
)
return q return q
base = _scope_insp(Inspection.query.filter( base = _scope_insp(Inspection.query.filter(
@@ -450,8 +453,11 @@ def export_issues():
Issue.status, Issue.status,
Issue.resolved_at, Issue.resolved_at,
User.username.label('assigned_to'), User.username.label('assigned_to'),
).join(Area, Issue.area_id == Area.id)\ ).outerjoin(Area, Issue.area_id == Area.id)\
.join(Facility, Area.facility_id == Facility.id)\ .outerjoin(Facility, db.or_(
Facility.id == Area.facility_id,
Facility.id == Issue.facility_id
))\
.outerjoin(User, Issue.assigned_to == User.id)\ .outerjoin(User, Issue.assigned_to == User.id)\
.filter( .filter(
Issue.reported_at >= start, Issue.reported_at >= start,
+14 -4
View File
@@ -92,7 +92,12 @@ def _build_report_data(report: ScheduledReport, start: datetime, end: datetime)
def _iq(q): def _iq(q):
if fid_filter: if fid_filter:
return q.join(Area, Issue.area_id == Area.id).filter(Area.facility_id.in_(fid_filter)) return q.outerjoin(Area, Issue.area_id == Area.id).filter(
db.or_(
Issue.facility_id.in_(fid_filter),
Area.facility_id.in_(fid_filter)
)
)
return q return q
if report.report_type in ('summary', 'facility'): if report.report_type in ('summary', 'facility'):
@@ -156,13 +161,18 @@ def _build_csv(report: ScheduledReport, start: datetime, end: datetime) -> bytes
'Description', 'Status', 'Assigned To']) 'Description', 'Status', 'Assigned To'])
q = Issue.query q = Issue.query
if fid_filter: if fid_filter:
q = q.join(Area, Issue.area_id == Area.id).filter(Area.facility_id.in_(fid_filter)) q = q.outerjoin(Area, Issue.area_id == Area.id).filter(
db.or_(
Issue.facility_id.in_(fid_filter),
Area.facility_id.in_(fid_filter)
)
)
for i in q.filter(Issue.status != 'resolved').order_by(Issue.reported_at.desc()).all(): for i in q.filter(Issue.status != 'resolved').order_by(Issue.reported_at.desc()).all():
writer.writerow([ writer.writerow([
i.id, i.id,
i.reported_at.strftime('%Y-%m-%d %H:%M'), i.reported_at.strftime('%Y-%m-%d %H:%M'),
i.area.facility.name, i.resolved_facility.name if i.resolved_facility else '',
i.area.name, i.area.name if i.area else '',
i.severity, i.severity,
i.description.replace('\n', ' '), i.description.replace('\n', ' '),
i.status, i.status,
+1 -5
View File
@@ -14,11 +14,7 @@
</div> </div>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{{ form.facility_id(type="hidden") }}
<div class="mb-3">
{{ form.area_id.label(class="form-label fw-semibold") }}
{{ form.area_id(class="form-select") }}
</div>
<div class="mb-3"> <div class="mb-3">
{{ form.severity.label(class="form-label fw-semibold") }} {{ form.severity.label(class="form-label fw-semibold") }}
{{ form.severity(class="form-select") }} {{ form.severity(class="form-select") }}
+2 -2
View File
@@ -77,8 +77,8 @@
</span> </span>
</td> </td>
<td> <td>
{{ issue.area.facility.name }}<br> {{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
<small class="text-muted">{{ issue.area.name }}</small> <small class="text-muted">{{ issue.area.name if issue.area else '—' }}</small>
</td> </td>
<td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td> <td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td>
<td> <td>
+2 -2
View File
@@ -29,10 +29,10 @@
{% endif %} {% endif %}
<dt class="col-sm-3">Facility</dt> <dt class="col-sm-3">Facility</dt>
<dd class="col-sm-9">{{ issue.area.facility.name }}</dd> <dd class="col-sm-9">{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</dd>
<dt class="col-sm-3">Area</dt> <dt class="col-sm-3">Area</dt>
<dd class="col-sm-9">{{ issue.area.name }}</dd> <dd class="col-sm-9">{{ issue.area.name if issue.area else '—' }}</dd>
{% if issue.inspection %} {% if issue.inspection %}
<dt class="col-sm-3">Inspection</dt> <dt class="col-sm-3">Inspection</dt>
+2 -2
View File
@@ -77,8 +77,8 @@
</span> </span>
</td> </td>
<td> <td>
{{ issue.area.facility.name }}<br> {{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
<small class="text-muted">{{ issue.area.name }}</small> <small class="text-muted">{{ issue.area.name if issue.area else '—' }}</small>
</td> </td>
<td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td> <td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td>
<td> <td>
+1 -1
View File
@@ -77,7 +77,7 @@
{# Area / Description #} {# Area / Description #}
<td class="align-middle"> <td class="align-middle">
<div class="fw-semibold small">{{ issue.area.name }}</div> <div class="fw-semibold small">{{ issue.area.name if issue.area else '—' }}</div>
<div class="text-muted small"> <div class="text-muted small">
{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %} {{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}
</div> </div>
+3 -3
View File
@@ -18,10 +18,10 @@
<dd class="col-sm-9">{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</dd> <dd class="col-sm-9">{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</dd>
<dt class="col-sm-3">Facility</dt> <dt class="col-sm-3">Facility</dt>
<dd class="col-sm-9">{{ issue.area.facility.name }}</dd> <dd class="col-sm-9">{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</dd>
<dt class="col-sm-3">Area</dt> <dt class="col-sm-3">Area</dt>
<dd class="col-sm-9">{{ issue.area.name }}</dd> <dd class="col-sm-9">{{ issue.area.name if issue.area else '—' }}</dd>
{% if issue.inspection %} {% if issue.inspection %}
<dt class="col-sm-3">Inspection</dt> <dt class="col-sm-3">Inspection</dt>
@@ -245,7 +245,7 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>You are about to permanently delete:</p> <p>You are about to permanently delete:</p>
<p class="fw-bold">Issue #{{ issue.id }} — {{ issue.severity|title }} severity in {{ issue.area.name }}</p> <p class="fw-bold">Issue #{{ issue.id }} — {{ issue.severity|title }} severity in {{ issue.area.name if issue.area else '—' }}</p>
<div class="alert alert-warning mb-0"> <div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle-fill"></i> <i class="bi bi-exclamation-triangle-fill"></i>
This action is <strong>irreversible</strong>. All comments, photos, and This action is <strong>irreversible</strong>. All comments, photos, and
+1 -1
View File
@@ -575,7 +575,7 @@ def _issues_section(issues):
rows.append([ rows.append([
str(i), str(i),
issue.severity.title(), issue.severity.title(),
issue.area.name, issue.area.name if issue.area else (issue.resolved_facility.name if issue.resolved_facility else ''),
desc, desc,
issue.status.replace('_', ' ').title(), issue.status.replace('_', ' ').title(),
]) ])
+2 -2
View File
@@ -137,7 +137,7 @@ def send_sla_alerts():
if status == 'breached': if status == 'breached':
title = f'🚨 SLA Breached — Issue #{issue.id} ({issue.severity.title()})' title = f'🚨 SLA Breached — Issue #{issue.id} ({issue.severity.title()})'
body = ( body = (
f'Issue #{issue.id} at {issue.area.facility.name} ' f'Issue #{issue.id} at {issue.resolved_facility.name if issue.resolved_facility else ''} '
f'has breached its SLA deadline. ' f'has breached its SLA deadline. '
f'Severity: {issue.severity.title()}. ' f'Severity: {issue.severity.title()}. '
f'Deadline was {deadline.strftime("%Y-%m-%d %H:%M") if deadline else "N/A"}. ' f'Deadline was {deadline.strftime("%Y-%m-%d %H:%M") if deadline else "N/A"}. '
@@ -146,7 +146,7 @@ def send_sla_alerts():
else: # at_risk else: # at_risk
title = f'⚠️ SLA At Risk — Issue #{issue.id} ({issue.severity.title()})' title = f'⚠️ SLA At Risk — Issue #{issue.id} ({issue.severity.title()})'
body = ( body = (
f'Issue #{issue.id} at {issue.area.facility.name} ' f'Issue #{issue.id} at {issue.resolved_facility.name if issue.resolved_facility else ''} '
f'is approaching its SLA deadline with approximately ' f'is approaching its SLA deadline with approximately '
f'{abs(hrs):.1f}h remaining. ' f'{abs(hrs):.1f}h remaining. '
f'Severity: {issue.severity.title()}. ' f'Severity: {issue.severity.title()}. '
@@ -0,0 +1,46 @@
"""phase13 — add facility_id to issues, make area_id nullable
Revision ID: phase13_issue_facility
Revises: phase12_performance_indexes
"""
revision = 'phase13_issue_facility'
down_revision = 'phase12_performance_indexes'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
# 1. Add facility_id column (nullable FK to facilities)
with op.batch_alter_table('issues') as batch_op:
batch_op.add_column(
sa.Column('facility_id', sa.Integer(),
sa.ForeignKey('facilities.id', ondelete='SET NULL'),
nullable=True)
)
# 2. Back-fill facility_id for all existing issues that have an area
op.execute("""
UPDATE issues
JOIN areas ON issues.area_id = areas.id
SET issues.facility_id = areas.facility_id
WHERE issues.area_id IS NOT NULL
""")
# 3. Make area_id nullable (was nullable=False)
with op.batch_alter_table('issues') as batch_op:
batch_op.alter_column('area_id',
existing_type=sa.Integer(),
nullable=True)
def downgrade():
# Restore area_id to non-nullable (requires no NULL rows)
with op.batch_alter_table('issues') as batch_op:
batch_op.alter_column('area_id',
existing_type=sa.Integer(),
nullable=False)
batch_op.drop_column('facility_id')