05/16 Fix bugs 2

This commit is contained in:
Nguyen Ngo
2026-05-16 14:02:53 -04:00
parent 0675acb8ec
commit edd81f4c59
7 changed files with 104 additions and 6 deletions
+1
View File
@@ -50,6 +50,7 @@ def _user_payload(user: User) -> dict:
return { return {
'id': user.id, 'id': user.id,
'username': user.username, 'username': user.username,
'full_name': user.full_name or '',
'email': user.email, 'email': user.email,
'role': user.role, 'role': user.role,
'created_at': user.created_at.isoformat() if user.created_at else None, 'created_at': user.created_at.isoformat() if user.created_at else None,
+14 -5
View File
@@ -105,8 +105,16 @@ def list_issues():
query = Issue.query query = Issue.query
if user.role == 'inspector': if user.role == 'inspector':
# Inspectors see only issues assigned to them # Inspectors see issues assigned to them OR issues they reported.
query = query.filter(Issue.assigned_to == user.id) # The reported_by path covers issues created on the iPad that haven't
# been assigned yet (assigned_to is NULL until a director assigns them).
# Pre-phase18 rows with reported_by = NULL still surface via assigned_to.
query = query.filter(
db.or_(
Issue.assigned_to == user.id,
Issue.reported_by == user.id,
)
)
else: else:
# Broader roles: exclude resolved by default so the list stays manageable # Broader roles: exclude resolved by default so the list stays manageable
status_filter = request.args.get('status') status_filter = request.args.get('status')
@@ -204,6 +212,7 @@ def create_issue():
photo_path = data.get('photo_path') or None, photo_path = data.get('photo_path') or None,
status = 'open', status = 'open',
reported_at = now_eastern(), reported_at = now_eastern(),
reported_by = user.id,
mobile_local_id = mobile_local_id, mobile_local_id = mobile_local_id,
) )
db.session.add(issue) db.session.add(issue)
@@ -264,7 +273,7 @@ def get_issue(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
if user.role == 'inspector' and issue.assigned_to != user.id: if user.role == 'inspector' and issue.assigned_to != user.id and issue.reported_by != user.id:
return api_error('Access denied', 403) return api_error('Access denied', 403)
return api_ok(_issue_payload(issue)) return api_ok(_issue_payload(issue))
@@ -294,8 +303,8 @@ def update_issue_status(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
if user.role == 'inspector' and issue.assigned_to != user.id: if user.role == 'inspector' and issue.assigned_to != user.id and issue.reported_by != user.id:
return api_error('Access denied — you can only update issues assigned to you', 403) return api_error('Access denied — you can only update issues assigned to or reported by you', 403)
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
new_status = (data.get('status') or '').strip().lower() new_status = (data.get('status') or '').strip().lower()
+6
View File
@@ -54,6 +54,11 @@ class Issue(db.Model):
photo_path = db.Column(db.String(255)) photo_path = db.Column(db.String(255))
status = db.Column(db.Enum('open', 'in_progress', 'resolved', 'pending_verification'), default='open') status = db.Column(db.Enum('open', 'in_progress', 'resolved', 'pending_verification'), default='open')
assigned_to = db.Column(db.Integer, db.ForeignKey('users.id')) assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'))
# Set at creation time to the user who filed the issue (inspector or admin).
# Nullable for backward compatibility — pre-phase18 rows will be NULL.
# Used by the mobile API to return issues the inspector created but hasn't
# been assigned yet (assigned_to is NULL until a director assigns them).
reported_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
reported_at = db.Column(db.DateTime, default=now_eastern) reported_at = db.Column(db.DateTime, default=now_eastern)
resolved_at = db.Column(db.DateTime) resolved_at = db.Column(db.DateTime)
result_notes = db.Column(db.Text) result_notes = db.Column(db.Text)
@@ -75,6 +80,7 @@ class Issue(db.Model):
# with that backref at mapper configuration time (CLAUDE.md rule 31 revised). # with that backref at mapper configuration time (CLAUDE.md rule 31 revised).
facility = db.relationship('Facility', foreign_keys=[facility_id], backref='direct_issues') 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')
reporter = db.relationship('User', foreign_keys=[reported_by], backref='reported_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',
order_by='IssueComment.created_at', order_by='IssueComment.created_at',
+6 -1
View File
@@ -110,9 +110,14 @@ def index():
recent_inspections = recent_q.limit(5).all() recent_inspections = recent_q.limit(5).all()
# ── Pending follow-up inspections ──────────────────────────────────── # ── Pending follow-up inspections ────────────────────────────────────
# follow_ups is a lazy='dynamic' relationship — comparing it to None does
# NOT produce a "has no rows" predicate for dynamic relationships. The
# correct idiom is ~.any(), which generates EXISTS (SELECT 1 FROM inspections
# WHERE parent_inspection_id = inspections.id). This matches the identical
# filter used in routes/inspections.py:follow_up_filter.
followup_q = Inspection.query.filter_by( followup_q = Inspection.query.filter_by(
follow_up_required=True, status='completed' follow_up_required=True, status='completed'
).filter(Inspection.follow_ups == None) # noqa: E711 — SQLAlchemy usage ).filter(~Inspection.follow_ups.any())
if is_inspector: if is_inspector:
followup_q = followup_q.filter(Inspection.inspector_id == current_user.id) followup_q = followup_q.filter(Inspection.inspector_id == current_user.id)
elif is_customer: elif is_customer:
+1
View File
@@ -730,6 +730,7 @@ def flag_issue(inspection_id):
status = 'open', status = 'open',
assigned_to = form.assigned_to.data or None, assigned_to = form.assigned_to.data or None,
reported_at = now_eastern(), reported_at = now_eastern(),
reported_by = current_user.id,
) )
db.session.add(issue) db.session.add(issue)
+1
View File
@@ -447,6 +447,7 @@ def create():
status = 'open', status = 'open',
assigned_to = form.assigned_to.data or None, assigned_to = form.assigned_to.data or None,
reported_at = now_eastern(), reported_at = now_eastern(),
reported_by = current_user.id,
) )
db.session.add(issue) db.session.add(issue)
db.session.commit() db.session.commit()
@@ -0,0 +1,75 @@
"""phase18 — add reported_by column to issues table
Background
----------
Issues created on the iPad by an inspector have no `assigned_to` value until
a director assigns them via the web portal. The mobile API's list_issues
endpoint filtered inspectors to `assigned_to == user.id`, so their own
newly-submitted issues were invisible on the iPad until assigned.
This migration adds `reported_by INT NULL FK → users.id` so the API can
return issues the inspector either created OR was assigned to, without
a join to the inspections table.
The column is nullable for backward compatibility: existing issues created
before this migration will have reported_by = NULL and continue to surface
only via the assigned_to path.
Uses INFORMATION_SCHEMA existence check — safe to re-run (CLAUDE.md rules 16, 17).
Revision ID: phase18_issue_reported_by
Revises: phase17_notification_event_type
"""
revision = 'phase18_issue_reported_by'
down_revision = 'phase17_notification_event_type'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def _column_exists(conn, table, column):
result = conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :table AND COLUMN_NAME = :col"
), {"table": table, "col": column})
return result.scalar() > 0
def _fk_exists(conn, table, constraint_name):
result = conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :table AND CONSTRAINT_NAME = :name"
), {"table": table, "name": constraint_name})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
if not _column_exists(bind, 'issues', 'reported_by'):
op.execute(sa.text(
"ALTER TABLE issues "
"ADD COLUMN reported_by INT NULL, "
"ADD CONSTRAINT fk_issues_reported_by "
" FOREIGN KEY (reported_by) REFERENCES users(id) "
" ON DELETE SET NULL"
))
def downgrade():
bind = op.get_bind()
if _fk_exists(bind, 'issues', 'fk_issues_reported_by'):
op.execute(sa.text(
"ALTER TABLE issues DROP FOREIGN KEY fk_issues_reported_by"
))
if _column_exists(bind, 'issues', 'reported_by'):
op.execute(sa.text(
"ALTER TABLE issues DROP COLUMN reported_by"
))