05/04 Update code to replace Area related to Facility
This commit is contained in:
+14
-15
@@ -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
@@ -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}>'
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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") }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
Reference in New Issue
Block a user