diff --git a/app/api/issues.py b/app/api/issues.py index d0dd3be..43ca522 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -14,7 +14,7 @@ import logging from flask import Blueprint, request, g, current_app from app import db 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.api.errors import api_ok, api_error from app.api.decorators import jwt_required @@ -45,7 +45,7 @@ def create_issue(): ------------ { "inspection_id": 42, // optional - "area_id": 12, // required + "facility_id": 5, // required "severity": "high", // "low"|"medium"|"high"|"critical" "description": "...", // required "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}) # ── Validate ────────────────────────────────────────────────────────── - area_id = data.get('area_id') + facility_id = data.get('facility_id') severity = data.get('severity', '').lower() description = (data.get('description') or '').strip() - if not area_id: - return api_error('area_id is required', 400) + if not facility_id: + return api_error('facility_id is required', 400) if severity not in _VALID_SEVERITY: return api_error(f'severity must be one of: {", ".join(sorted(_VALID_SEVERITY))}', 400) if not description: return api_error('description is required', 400) - area = db.session.get(Area, area_id) - if area is None: - return api_error('Area not found', 404) + facility = db.session.get(Facility, facility_id) + if facility is None: + return api_error('Facility not found', 404) inspection_id = data.get('inspection_id') if inspection_id: @@ -99,7 +99,7 @@ def create_issue(): # ── Create ──────────────────────────────────────────────────────────── issue = Issue( inspection_id = inspection_id, - area_id = area_id, + facility_id = facility_id, severity = severity, description = description, photo_path = data.get('photo_path') or None, @@ -111,7 +111,6 @@ def create_issue(): db.session.flush() # get issue.id # ── Notifications ───────────────────────────────────────────────────── - facility_id = area.facility_id try: from flask import url_for issue_link = url_for('issues.view', issue_id=issue.id, _external=False) @@ -123,8 +122,8 @@ def create_issue(): event_type = 'issue_flagged', title = f'New Issue #{issue.id} (Mobile)', body = ( - f'A new {severity.title()}-severity issue was logged in ' - f'{area.name} during {inspection_ref}. ' + f'A new {severity.title()}-severity issue was logged at ' + f'{facility.name} during {inspection_ref}. ' f'Description: {description[:120]}' f'{"…" if len(description) > 120 else ""}' ), @@ -137,11 +136,11 @@ def create_issue(): db.session.commit() 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'local_id={mobile_local_id}') - logger.info('API ISSUES | created | issue_id=%d | area=%s | severity=%s | user=%s', - issue.id, area.name, severity, user.username) + logger.info('API ISSUES | created | issue_id=%d | facility=%s | severity=%s | user=%s', + issue.id, facility.name, severity, user.username) - return api_ok({'issue_id': issue.id, 'duplicate': False}) \ No newline at end of file + return api_ok({'issue_id': issue.id, 'duplicate': False}) diff --git a/app/models/issue.py b/app/models/issue.py index aaa36b4..058319c 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -47,7 +47,8 @@ class Issue(db.Model): 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=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) description = db.Column(db.Text, nullable=False) 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 # Relationships + facility = db.relationship('Facility', foreign_keys=[facility_id], backref='direct_issues') assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues') verifier = db.relationship('User', foreign_keys=[verified_by], backref='verified_issues') 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 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'' \ No newline at end of file diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index fc1a332..c0030d8 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -66,9 +66,12 @@ def index(): open_issues_q = open_issues_q.filter(False) else: 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 - ).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 open_issues_all = open_issues_q.all() @@ -136,8 +139,11 @@ def index(): sla_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress'])) if is_customer and customer_facility_ids: from app.models.facility import Area - sla_q = sla_q.join(Area, Issue.area_id == Area.id).filter( - Area.facility_id.in_(customer_facility_ids) + 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) + ) ) 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') diff --git a/app/routes/inspections.py b/app/routes/inspections.py index fe97352..fdb91e1 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -679,17 +679,16 @@ def flag_issue(inspection_id): return redirect(url_for('inspections.index')) 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() - 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] if form.validate_on_submit(): photo_path = _save_photo(form.photo.data, subfolder='issue_photos') issue = Issue( inspection_id = inspection_id, - area_id = form.area_id.data, + facility_id = inspection.facility_id, severity = form.severity.data, description = form.description.data, photo_path = photo_path, @@ -733,7 +732,7 @@ def flag_issue(inspection_id): title = f'New Issue #{issue.id} at {inspection.facility.name}', body = ( 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'Description: {issue.description[:120]}' f'{"…" if len(issue.description) > 120 else ""}' diff --git a/app/routes/issues.py b/app/routes/issues.py index 18cdf30..cd0b035 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -80,9 +80,17 @@ def index(): if not customer_facility_ids: q = q.filter(False) else: - q = q.join(Area, Issue.area_id == Area.id).filter( - Area.facility_id.in_(customer_facility_ids) - ) + # 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) + ) + ) + ).outerjoin(Area, Issue.area_id == Area.id) severity_filter = request.args.get('severity', '') status_filter = request.args.get('status', '') @@ -94,10 +102,14 @@ def index(): if status_filter: q = q.filter(Issue.status == status_filter) if facility_filter: - q = q.join(Area, Issue.area_id == Area.id, isouter=True).filter( - Area.facility_id == int(facility_filter) + fid = 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). # When active: load all matching rows, filter in Python, wrap in a # 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') return redirect(url_for('issues.index')) if current_user.role == 'customer': - from app.models.facility import Area cids = get_customer_scope(current_user) or [] - area = db.session.get(Area, issue.area_id) - if not area or area.facility_id not in cids: + facility = issue.resolved_facility + if not facility or facility.id not in cids: flash('Access denied.', 'danger') return redirect(url_for('issues.index')) @@ -227,7 +238,7 @@ def view(issue_id): recipient = assignee, title = f'Issue #{issue.id} Status Updated', 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'"{issue.status.replace("_", " ").title()}" ' f'by {current_user.username}.' @@ -247,7 +258,7 @@ def view(issue_id): title = f'Issue #{issue.id} Assigned to You', body = ( 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()}.' ), link = issue_link, @@ -265,7 +276,7 @@ def view(issue_id): title = f'Issue #{issue.id} Unassigned', body = ( 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, issue_id = issue.id, @@ -315,22 +326,22 @@ def view(issue_id): issue = issue, title = f'Issue #{issue.id} Updated', 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)}.' ), exclude_user_ids = exclude_ids, ) # ── 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: changes_summary = '; '.join(changes) notify_by_matrix( 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 = ( 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()}.' ), link = url_for('issues.view', issue_id=issue.id), @@ -340,7 +351,7 @@ def view(issue_id): ) db.session.commit() # Commit all notifications 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}') flash('Issue updated.', 'success') return redirect(url_for('issues.view', issue_id=issue_id)) @@ -419,18 +430,8 @@ def create(): from app.routes.inspections import _save_photo 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( - area_id = area.id, + facility_id = form.facility_id.data, severity = form.severity.data, description = form.description.data, photo_path = photo_path, @@ -441,11 +442,11 @@ def create(): db.session.add(issue) db.session.commit() current_app.logger.info( - 'ISSUE CREATED | id=%s | severity=%s | facility_id=%s | area_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 CREATED | id=%s | severity=%s | facility_id=%s | assigned_to=%s | created_by=%s', + issue.id, issue.severity, issue.facility_id, issue.assigned_to, current_user.username ) 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}') if issue.assigned_to: @@ -456,7 +457,7 @@ def create(): title = f'New Issue #{issue.id} Assigned to You', body = ( 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'{"…" if len(issue.description) > 120 else ""}' ), @@ -468,20 +469,20 @@ def create(): db.session.commit() # ── Notify via matrix (issue_created) ──────────────────────── - area = db.session.get(Area, issue.area_id) - if area: + facility = issue.resolved_facility + if facility: notify_by_matrix( event_type = 'issue_created', - title = f'New Issue #{issue.id} at {area.facility.name}', + title = f'New Issue #{issue.id} at {facility.name}', body = ( 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'{"…" if len(issue.description) > 120 else ""}' ), link = url_for('issues.view', issue_id=issue.id), issue_id = issue.id, - facility_id = area.facility_id, + facility_id = facility.id, exclude_user_ids = {current_user.id}, ) db.session.commit() @@ -521,7 +522,7 @@ def verify(issue_id): issue_id, current_user.username, note, ) 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}') flash(f'Issue #{issue_id} verified and closed.', 'success') return redirect(url_for('issues.view', issue_id=issue_id)) @@ -560,7 +561,7 @@ def request_verification(issue_id): issue_id, current_user.username, ) 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}') # Notify via matrix (verification_requested) @@ -569,7 +570,7 @@ def request_verification(issue_id): title = f'Issue #{issue_id} Awaiting Verification', body = ( 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.' ), link = url_for('issues.view', issue_id=issue_id), @@ -602,7 +603,9 @@ def verification_queue(): from collections import defaultdict by_facility = defaultdict(list) 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 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 issue_id_snap = issue.id issue_desc = issue.description[:80] - area_name = issue.area.name if issue.area else f'area_id={issue.area_id}' - facility_name = issue.area.facility.name if issue.area else '—' + area_name = issue.area.name if issue.area else '—' + facility_name = issue.resolved_facility.name if issue.resolved_facility else '—' severity = issue.severity # Collect photo paths to clean up from disk after DB delete diff --git a/app/routes/reports.py b/app/routes/reports.py index 107b7c8..21defe5 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -64,8 +64,11 @@ def index(): if customer_facility_ids is not None: if not customer_facility_ids: return q.filter(False) - return q.join(Area, Issue.area_id == Area.id).filter( - Area.facility_id.in_(customer_facility_ids) + 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) + ) ) return q @@ -450,8 +453,11 @@ def export_issues(): Issue.status, Issue.resolved_at, User.username.label('assigned_to'), - ).join(Area, Issue.area_id == Area.id)\ - .join(Facility, Area.facility_id == Facility.id)\ + ).outerjoin(Area, Issue.area_id == Area.id)\ + .outerjoin(Facility, db.or_( + Facility.id == Area.facility_id, + Facility.id == Issue.facility_id + ))\ .outerjoin(User, Issue.assigned_to == User.id)\ .filter( Issue.reported_at >= start, diff --git a/app/routes/scheduled_reports.py b/app/routes/scheduled_reports.py index b2ee39e..2304af5 100644 --- a/app/routes/scheduled_reports.py +++ b/app/routes/scheduled_reports.py @@ -92,7 +92,12 @@ def _build_report_data(report: ScheduledReport, start: datetime, end: datetime) def _iq(q): 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 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']) q = Issue.query 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(): writer.writerow([ i.id, i.reported_at.strftime('%Y-%m-%d %H:%M'), - i.area.facility.name, - i.area.name, + i.resolved_facility.name if i.resolved_facility else '—', + i.area.name if i.area else '—', i.severity, i.description.replace('\n', ' '), i.status, diff --git a/app/templates/inspections/flag_issue.html b/app/templates/inspections/flag_issue.html index f3da32e..3f2fabf 100644 --- a/app/templates/inspections/flag_issue.html +++ b/app/templates/inspections/flag_issue.html @@ -14,11 +14,7 @@
- -
- {{ form.area_id.label(class="form-label fw-semibold") }} - {{ form.area_id(class="form-select") }} -
+ {{ form.facility_id(type="hidden") }}
{{ form.severity.label(class="form-label fw-semibold") }} {{ form.severity(class="form-select") }} diff --git a/app/templates/issues/form.html b/app/templates/issues/form.html index 83bfbce..33c34af 100644 --- a/app/templates/issues/form.html +++ b/app/templates/issues/form.html @@ -26,4 +26,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/issues/issues_list.html b/app/templates/issues/issues_list.html index 42c113b..e0898a5 100644 --- a/app/templates/issues/issues_list.html +++ b/app/templates/issues/issues_list.html @@ -77,8 +77,8 @@ - {{ issue.area.facility.name }}
- {{ issue.area.name }} + {{ issue.resolved_facility.name if issue.resolved_facility else '—' }}
+ {{ issue.area.name if issue.area else '—' }} {{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %} diff --git a/app/templates/issues/issues_view.html b/app/templates/issues/issues_view.html index 26b4376..1490c21 100644 --- a/app/templates/issues/issues_view.html +++ b/app/templates/issues/issues_view.html @@ -29,10 +29,10 @@ {% endif %}
Facility
-
{{ issue.area.facility.name }}
+
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}
Area
-
{{ issue.area.name }}
+
{{ issue.area.name if issue.area else '—' }}
{% if issue.inspection %}
Inspection
diff --git a/app/templates/issues/list.html b/app/templates/issues/list.html index 8c61f67..b1e6297 100644 --- a/app/templates/issues/list.html +++ b/app/templates/issues/list.html @@ -77,8 +77,8 @@ - {{ issue.area.facility.name }}
- {{ issue.area.name }} + {{ issue.resolved_facility.name if issue.resolved_facility else '—' }}
+ {{ issue.area.name if issue.area else '—' }} {{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %} diff --git a/app/templates/issues/verification_queue.html b/app/templates/issues/verification_queue.html index 5015790..370ef61 100644 --- a/app/templates/issues/verification_queue.html +++ b/app/templates/issues/verification_queue.html @@ -77,7 +77,7 @@ {# Area / Description #} -
{{ issue.area.name }}
+
{{ issue.area.name if issue.area else '—' }}
{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}
diff --git a/app/templates/issues/view.html b/app/templates/issues/view.html index a612315..9581d43 100644 --- a/app/templates/issues/view.html +++ b/app/templates/issues/view.html @@ -18,10 +18,10 @@
{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}
Facility
-
{{ issue.area.facility.name }}
+
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}
Area
-
{{ issue.area.name }}
+
{{ issue.area.name if issue.area else '—' }}
{% if issue.inspection %}
Inspection
@@ -245,7 +245,7 @@