diff --git a/app/models/facility.py b/app/models/facility.py index 75d901b..6634224 100644 --- a/app/models/facility.py +++ b/app/models/facility.py @@ -1,5 +1,5 @@ from app import db -from datetime import datetime +from app.utils.time_utils import now_eastern class Facility(db.Model): __tablename__ = 'facilities' @@ -10,6 +10,7 @@ class Facility(db.Model): contact_person = db.Column(db.String(100)) contact_phone = db.Column(db.String(20)) active = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=now_eastern, nullable=True) # Phase 1: link facility to a project (nullable for backward compatibility) project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='SET NULL'), @@ -35,4 +36,4 @@ class Area(db.Model): issues = db.relationship('Issue', backref='area', lazy='dynamic') def __repr__(self): - return f'' + return f'' \ No newline at end of file diff --git a/app/routes/auth.py b/app/routes/auth.py index afdcf1a..92012d3 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -210,8 +210,9 @@ def delete_user(user_id): return redirect(url_for('auth.list_users')) # Guard: block deletion if user has related records that would orphan data - # or violate FK constraints (inspections they conducted, issues assigned to them, - # or templates they created). + # or violate FK constraints. Issue.assigned_to and IssueComment.user_id carry + # no ondelete clause, so MySQL defaults to RESTRICT — the DELETE would fail at + # the DB level without these application-level checks and clear user-facing messages. if user.inspections.count() > 0: flash( f'Cannot delete "{user.username}" — they have existing inspection records. ' @@ -220,6 +221,32 @@ def delete_user(user_id): ) return redirect(url_for('auth.list_users')) + if user.assigned_issues.count() > 0: + flash( + f'Cannot delete "{user.username}" — they have issues assigned to them. ' + 'Reassign or resolve those issues first, then deactivate the account.', + 'danger' + ) + return redirect(url_for('auth.list_users')) + + from app.models.issue import IssueComment + if IssueComment.query.filter_by(user_id=user.id).count() > 0: + flash( + f'Cannot delete "{user.username}" — they have authored issue comments. ' + 'Deactivate the account instead.', + 'danger' + ) + return redirect(url_for('auth.list_users')) + + from app.models.inspection import InspectionTemplate + if InspectionTemplate.query.filter_by(created_by=user.id).count() > 0: + flash( + f'Cannot delete "{user.username}" — they have created inspection templates. ' + 'Deactivate the account instead.', + 'danger' + ) + return redirect(url_for('auth.list_users')) + username = user.username user_id = user.id db.session.delete(user) @@ -307,4 +334,4 @@ def notification_matrix(): matrix_roles = MATRIX_ROLES, defaults = MATRIX_DEFAULTS, state = state, - ) + ) \ No newline at end of file diff --git a/app/routes/issues.py b/app/routes/issues.py index 394b69b..c457096 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -196,6 +196,12 @@ def view(issue_id): issue.sla_notified = None # clear so alerts fire again if re-opened elif form.status.data != 'resolved': issue.resolved_at = None + # Reset SLA notification state whenever re-opening from resolved so + # the SLA cron re-evaluates from scratch and fires fresh alerts. + # Without this, sla_notified retains its previous 'at_risk'/'breached' + # value and the cron skips the issue indefinitely. + if old_status == 'resolved': + issue.sla_notified = None issue.result_notes = form.result_notes.data or None @@ -552,8 +558,8 @@ def request_verification(issue_id): flash('Access denied.', 'danger') return redirect(url_for('issues.view', issue_id=issue_id)) - if issue.status not in ('in_progress', 'resolved'): - flash('Issue must be in progress or resolved to request verification.', 'warning') + if issue.status not in ('in_progress',): + flash('Issue must be in progress to request verification.', 'warning') return redirect(url_for('issues.view', issue_id=issue_id)) issue.status = 'pending_verification' diff --git a/migrations/versions/phase14_facility_created_at.py b/migrations/versions/phase14_facility_created_at.py new file mode 100644 index 0000000..fba6e35 --- /dev/null +++ b/migrations/versions/phase14_facility_created_at.py @@ -0,0 +1,31 @@ +"""phase14 — add created_at to facilities + +Adds a nullable DateTime column to the facilities table so that facility +creation time is tracked consistently with every other core model. + +Existing rows receive NULL (unknown creation time) — nullable=True is +intentional for backward compatibility with pre-existing data. + +Revision ID: phase14_facility_created_at +Revises: phase13_issue_facility +""" + +revision = 'phase14_facility_created_at' +down_revision = 'phase13_issue_facility' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + with op.batch_alter_table('facilities') as batch_op: + batch_op.add_column( + sa.Column('created_at', sa.DateTime(), nullable=True) + ) + + +def downgrade(): + with op.batch_alter_table('facilities') as batch_op: + batch_op.drop_column('created_at')