First commit
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"""Phase 10: Customer password-setup workflow
|
||||
|
||||
Adds three columns to the users table to support the
|
||||
invitation-based customer account creation flow:
|
||||
|
||||
password_set — False until the customer completes set-password
|
||||
set_password_token — one-time URL token (64-char hex, nullable)
|
||||
set_password_token_expires — UTC expiry datetime (nullable)
|
||||
|
||||
Revision ID: phase10_customer_password_setup
|
||||
Revises: phase9_user_full_name
|
||||
Create Date: 2026-04-02
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'phase10_customer_password_setup'
|
||||
down_revision = 'phase9_user_full_name'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = [c['name'] for c in inspector.get_columns('users')]
|
||||
|
||||
if 'password_set' not in columns:
|
||||
op.add_column('users',
|
||||
sa.Column('password_set', sa.Boolean, nullable=False, server_default='1'))
|
||||
|
||||
if 'set_password_token' not in columns:
|
||||
op.add_column('users',
|
||||
sa.Column('set_password_token', sa.String(64), nullable=True))
|
||||
|
||||
if 'set_password_token_expires' not in columns:
|
||||
op.add_column('users',
|
||||
sa.Column('set_password_token_expires', sa.DateTime, nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('users', 'set_password_token_expires')
|
||||
op.drop_column('users', 'set_password_token')
|
||||
op.drop_column('users', 'password_set')
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Phase 11: Rename supervisor role to director
|
||||
|
||||
Revision ID: phase11_director_role
|
||||
Revises: phase10_customer_password_setup
|
||||
Create Date: 2026-04-09
|
||||
|
||||
Changes
|
||||
-------
|
||||
1. Adds 'director' to the users.role ENUM.
|
||||
2. Migrates all existing role='supervisor' users to role='director'.
|
||||
3. Removes 'supervisor' from the ENUM once no rows use it.
|
||||
4. Migrates notification_matrix rows keyed role_key='supervisor' → 'director'.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'phase11_director_role'
|
||||
down_revision = 'phase10_customer_password_setup'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Step 1 — Expand ENUM to include both values (required before UPDATE)
|
||||
op.execute(
|
||||
"ALTER TABLE users MODIFY COLUMN role "
|
||||
"ENUM('admin','supervisor','director','inspector','project_manager','customer') "
|
||||
"NOT NULL"
|
||||
)
|
||||
|
||||
# Step 2 — Migrate all supervisor users to director
|
||||
op.execute("UPDATE users SET role = 'director' WHERE role = 'supervisor'")
|
||||
|
||||
# Step 3 — Remove 'supervisor' from the ENUM now that no rows reference it
|
||||
op.execute(
|
||||
"ALTER TABLE users MODIFY COLUMN role "
|
||||
"ENUM('admin','director','inspector','project_manager','customer') "
|
||||
"NOT NULL"
|
||||
)
|
||||
|
||||
# Step 4 — Migrate notification_matrix role_key rows
|
||||
op.execute(
|
||||
"UPDATE notification_matrix SET role_key = 'director' WHERE role_key = 'supervisor'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Step 1 — Expand ENUM to allow supervisor again
|
||||
op.execute(
|
||||
"ALTER TABLE users MODIFY COLUMN role "
|
||||
"ENUM('admin','supervisor','director','inspector','project_manager','customer') "
|
||||
"NOT NULL"
|
||||
)
|
||||
|
||||
# Step 2 — Revert director users back to supervisor
|
||||
op.execute("UPDATE users SET role = 'supervisor' WHERE role = 'director'")
|
||||
|
||||
# Step 3 — Remove 'director' from the ENUM
|
||||
op.execute(
|
||||
"ALTER TABLE users MODIFY COLUMN role "
|
||||
"ENUM('admin','supervisor','inspector','project_manager','customer') "
|
||||
"NOT NULL"
|
||||
)
|
||||
|
||||
# Step 4 — Revert notification_matrix role_key rows
|
||||
op.execute(
|
||||
"UPDATE notification_matrix SET role_key = 'supervisor' WHERE role_key = 'director'"
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Phase 12: Add performance indexes on high-filter columns
|
||||
|
||||
Revision ID: phase12_performance_indexes
|
||||
Revises: phase11_director_role
|
||||
Create Date: 2026-04-25
|
||||
|
||||
Rationale
|
||||
---------
|
||||
The following columns are filtered or ordered on every page load but had no
|
||||
DB index, causing full table scans as row counts grow:
|
||||
|
||||
inspections
|
||||
- status — filtered on list/dashboard/SLA queries
|
||||
- facility_id — filtered for customer-scoped views and reports
|
||||
- inspector_id — filtered for inspector-scoped views
|
||||
- inspection_date — used in all trend/score queries (ORDER BY, range filter)
|
||||
|
||||
issues
|
||||
- status — filtered on every issues list/dashboard load
|
||||
- severity — filtered in dashboard breakdown and issues list
|
||||
- assigned_to — filtered for inspector-scoped views
|
||||
- reported_at — used for ordering
|
||||
|
||||
Existence checks use information_schema so the migration is safe to re-run
|
||||
on any MySQL version (compatible back to 5.7).
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
def _index_exists(conn, table: str, index_name: str) -> bool:
|
||||
"""Return True if the named index already exists on the given table."""
|
||||
result = conn.execute(text(
|
||||
"SELECT COUNT(*) FROM information_schema.statistics "
|
||||
"WHERE table_schema = DATABASE() "
|
||||
" AND table_name = :table "
|
||||
" AND index_name = :index"
|
||||
), {'table': table, 'index': index_name})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
revision = 'phase12_performance_indexes'
|
||||
down_revision = 'phase11_director_role'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# (table, index_name, column)
|
||||
INDEXES = [
|
||||
('inspections', 'ix_inspections_status', 'status'),
|
||||
('inspections', 'ix_inspections_facility_id', 'facility_id'),
|
||||
('inspections', 'ix_inspections_inspector_id', 'inspector_id'),
|
||||
('inspections', 'ix_inspections_inspection_date', 'inspection_date'),
|
||||
('issues', 'ix_issues_status', 'status'),
|
||||
('issues', 'ix_issues_severity', 'severity'),
|
||||
('issues', 'ix_issues_assigned_to', 'assigned_to'),
|
||||
('issues', 'ix_issues_reported_at', 'reported_at'),
|
||||
]
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
for table, index_name, column in INDEXES:
|
||||
if not _index_exists(conn, table, index_name):
|
||||
op.execute(text(
|
||||
f'CREATE INDEX {index_name} ON {table} ({column})'
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
for table, index_name, _column in INDEXES:
|
||||
if _index_exists(conn, table, index_name):
|
||||
op.execute(text(
|
||||
f'DROP INDEX {index_name} ON {table}'
|
||||
))
|
||||
@@ -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 = 'phase_b_mobile_local_id'
|
||||
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')
|
||||
@@ -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')
|
||||
@@ -0,0 +1,62 @@
|
||||
"""phase15 — add indexes on audit_logs action and entity_type
|
||||
|
||||
The audit log filter UI allows filtering by action and entity_type.
|
||||
Without indexes, every filter invocation performs a full table scan.
|
||||
As the log grows toward the 180/365-day purge threshold this degrades
|
||||
noticeably. This migration adds individual indexes on both columns.
|
||||
|
||||
A composite index on (action, entity_type, created_at) would be ideal
|
||||
for the combined-filter case, but individual indexes are added here to
|
||||
keep the migration additive and safe for re-run. created_at already
|
||||
has an index from the model definition.
|
||||
|
||||
Existence checks use information_schema so the migration is safe to
|
||||
re-run on any MySQL version (compatible back to 5.7).
|
||||
|
||||
Revision ID: phase15_audit_log_indexes
|
||||
Revises: phase14_facility_created_at
|
||||
"""
|
||||
|
||||
revision = 'phase15_audit_log_indexes'
|
||||
down_revision = 'phase14_facility_created_at'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
def _index_exists(conn, table: str, index_name: str) -> bool:
|
||||
"""Return True if the named index already exists on the given table."""
|
||||
result = conn.execute(text(
|
||||
"SELECT COUNT(*) FROM information_schema.statistics "
|
||||
"WHERE table_schema = DATABASE() "
|
||||
" AND table_name = :table "
|
||||
" AND index_name = :index"
|
||||
), {'table': table, 'index': index_name})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
# (table, index_name, column)
|
||||
INDEXES = [
|
||||
('audit_logs', 'ix_audit_logs_action', 'action'),
|
||||
('audit_logs', 'ix_audit_logs_entity_type', 'entity_type'),
|
||||
]
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
for table, index_name, column in INDEXES:
|
||||
if not _index_exists(conn, table, index_name):
|
||||
op.execute(text(
|
||||
f'CREATE INDEX {index_name} ON {table} ({column})'
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
for table, index_name, _column in INDEXES:
|
||||
if _index_exists(conn, table, index_name):
|
||||
op.execute(text(
|
||||
f'DROP INDEX {index_name} ON {table}'
|
||||
))
|
||||
@@ -0,0 +1,100 @@
|
||||
"""phase16 — ensure digest_pending and inspection_id columns on notifications
|
||||
|
||||
Background
|
||||
----------
|
||||
The `notifications` table was created before the Alembic migration chain was
|
||||
established (pre-phase1 baseline schema). Two columns added to the model
|
||||
after the initial creation were never covered by a migration:
|
||||
|
||||
digest_pending BOOLEAN NOT NULL DEFAULT 0 (used by the digest email system)
|
||||
inspection_id INT NULL FK → inspections.id ON DELETE CASCADE
|
||||
|
||||
Without this migration, any instance whose `notifications` table was created
|
||||
from the original baseline (rather than from the current model) will raise
|
||||
`OperationalError: Unknown column 'notifications.digest_pending'` the first
|
||||
time a notification is created, and the digest email cron will fail entirely.
|
||||
|
||||
All checks use INFORMATION_SCHEMA so the migration is safe to re-run on any
|
||||
MySQL version ≥ 5.7 (CLAUDE.md rules 16, 17).
|
||||
|
||||
Revision ID: phase16_notifications_columns
|
||||
Revises: phase15_audit_log_indexes
|
||||
"""
|
||||
|
||||
revision = 'phase16_notifications_columns'
|
||||
down_revision = 'phase15_audit_log_indexes'
|
||||
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 _index_exists(conn, table, index_name):
|
||||
result = conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = :table AND INDEX_NAME = :idx"
|
||||
), {"table": table, "idx": index_name})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
# 1. digest_pending — Boolean NOT NULL DEFAULT 0
|
||||
# Used by notify() to flag notifications for digest delivery, and by
|
||||
# send_pending_digests() to find and clear them after delivery.
|
||||
if not _column_exists(bind, 'notifications', 'digest_pending'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE notifications "
|
||||
"ADD COLUMN digest_pending TINYINT(1) NOT NULL DEFAULT 0"
|
||||
))
|
||||
|
||||
# 2. Index on digest_pending — the digest cron filters on this column
|
||||
if not _index_exists(bind, 'notifications', 'ix_notifications_digest_pending'):
|
||||
op.execute(sa.text(
|
||||
"CREATE INDEX ix_notifications_digest_pending "
|
||||
"ON notifications (digest_pending)"
|
||||
))
|
||||
|
||||
# 3. inspection_id — nullable FK to inspections, CASCADE on delete
|
||||
# Allows the notification bell to link directly to an inspection.
|
||||
if not _column_exists(bind, 'notifications', 'inspection_id'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE notifications "
|
||||
"ADD COLUMN inspection_id INT NULL, "
|
||||
"ADD CONSTRAINT fk_notifications_inspection_id "
|
||||
" FOREIGN KEY (inspection_id) REFERENCES inspections(id) "
|
||||
" ON DELETE CASCADE"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if _column_exists(bind, 'notifications', 'inspection_id'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE notifications "
|
||||
"DROP FOREIGN KEY fk_notifications_inspection_id, "
|
||||
"DROP COLUMN inspection_id"
|
||||
))
|
||||
|
||||
if _index_exists(bind, 'notifications', 'ix_notifications_digest_pending'):
|
||||
op.execute(sa.text(
|
||||
"DROP INDEX ix_notifications_digest_pending ON notifications"
|
||||
))
|
||||
|
||||
if _column_exists(bind, 'notifications', 'digest_pending'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE notifications DROP COLUMN digest_pending"
|
||||
))
|
||||
@@ -0,0 +1,53 @@
|
||||
"""phase17 — add event_type column to notifications table
|
||||
|
||||
Background
|
||||
----------
|
||||
The `notifications` table has no `event_type` column, but the mobile API
|
||||
endpoint GET /api/v1/notifications references `n.event_type`, causing an
|
||||
AttributeError (500) on every poll — silently breaking iPad notifications.
|
||||
|
||||
This migration adds `event_type VARCHAR(50) NULL` so the column is stored
|
||||
at creation time and returned correctly to the mobile poller.
|
||||
|
||||
The `notify()` utility is updated separately to pass event_type when creating
|
||||
Notification records.
|
||||
|
||||
Uses INFORMATION_SCHEMA existence check — safe to re-run (CLAUDE.md rules 16, 17).
|
||||
|
||||
Revision ID: phase17_notification_event_type
|
||||
Revises: phase16_notifications_columns
|
||||
"""
|
||||
|
||||
revision = 'phase17_notification_event_type'
|
||||
down_revision = 'phase16_notifications_columns'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _column_exists(bind, table, column):
|
||||
result = bind.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
|
||||
), {'t': table, 'c': column})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _column_exists(bind, 'notifications', 'event_type'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE notifications "
|
||||
"ADD COLUMN event_type VARCHAR(50) NULL"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _column_exists(bind, 'notifications', 'event_type'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE notifications DROP COLUMN event_type"
|
||||
))
|
||||
@@ -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"
|
||||
))
|
||||
@@ -0,0 +1,46 @@
|
||||
"""phase19 — add mobile_photo_paths column to issues table
|
||||
|
||||
Background
|
||||
----------
|
||||
Issues created on the iPad can have multiple evidence photos. The first photo
|
||||
is stored in `photo_path` (existing single-string column). Additional photos
|
||||
were previously stored in `result_photos` (intended for resolution photos),
|
||||
causing them to appear under "Resolution Details" on the web instead of
|
||||
"Photo Evidence".
|
||||
|
||||
This migration adds `mobile_photo_paths JSON NULL` to store the extra
|
||||
evidence photos from the iPad separately from resolution photos.
|
||||
|
||||
Safe to re-run — uses INFORMATION_SCHEMA existence check.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'phase19_issue_mobile_photos'
|
||||
down_revision = 'phase18_issue_reported_by'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(bind, table, column):
|
||||
result = bind.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
|
||||
), {"t": table, "c": column})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _column_exists(bind, 'issues', 'mobile_photo_paths'):
|
||||
op.add_column('issues', sa.Column(
|
||||
'mobile_photo_paths', sa.JSON(), nullable=True
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _column_exists(bind, 'issues', 'mobile_photo_paths'):
|
||||
op.execute(sa.text("ALTER TABLE issues DROP COLUMN mobile_photo_paths"))
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Phase 1: Add projects table, customer_assignments table, project_id on facilities, extend user role enum
|
||||
|
||||
Revision ID: phase1_projects_roles
|
||||
Revises: (set this to your current DB head before running)
|
||||
Create Date: 2026-03-04
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# --- IMPORTANT: set down_revision to your live DB's current head ---
|
||||
revision = 'phase1_projects_roles'
|
||||
down_revision = '0003_add_user_active'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
# ── 1. Create `projects` table ─────────────────────────────────────────
|
||||
if 'projects' not in existing_tables:
|
||||
op.create_table(
|
||||
'projects',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('project_manager_id', sa.Integer(), nullable=True),
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_manager_id'], ['users.id'], name='fk_project_manager'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
# ── 2. Create `customer_assignments` table ─────────────────────────────
|
||||
if 'customer_assignments' not in existing_tables:
|
||||
op.create_table(
|
||||
'customer_assignments',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('project_id', sa.Integer(), nullable=False),
|
||||
sa.Column('facility_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['facility_id'], ['facilities.id'],
|
||||
name='fk_ca_facility', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'],
|
||||
name='fk_ca_project', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'],
|
||||
name='fk_ca_user', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'project_id', 'facility_id',
|
||||
name='uq_customer_assignment'),
|
||||
)
|
||||
op.create_index('ix_customer_assignments_user_id',
|
||||
'customer_assignments', ['user_id'])
|
||||
op.create_index('ix_customer_assignments_project_id',
|
||||
'customer_assignments', ['project_id'])
|
||||
op.create_index('ix_customer_assignments_facility_id',
|
||||
'customer_assignments', ['facility_id'])
|
||||
|
||||
# ── 3. Add `project_id` column to `facilities` ─────────────────────────
|
||||
existing_facility_cols = [c['name'] for c in inspector.get_columns('facilities')]
|
||||
if 'project_id' not in existing_facility_cols:
|
||||
op.add_column(
|
||||
'facilities',
|
||||
sa.Column('project_id', sa.Integer(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
'fk_facility_project',
|
||||
'facilities', 'projects',
|
||||
['project_id'], ['id'],
|
||||
ondelete='SET NULL'
|
||||
)
|
||||
op.create_index('ix_facilities_project_id', 'facilities', ['project_id'])
|
||||
|
||||
# ── 4. Extend `users.role` Enum with new values ────────────────────────
|
||||
# MySQL requires ALTER COLUMN to modify an ENUM.
|
||||
op.alter_column(
|
||||
'users', 'role',
|
||||
existing_type=mysql.ENUM('admin', 'supervisor', 'inspector'),
|
||||
type_=mysql.ENUM('admin', 'supervisor', 'inspector', 'project_manager', 'customer'),
|
||||
existing_nullable=False,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ── Reverse order of operations ────────────────────────────────────────
|
||||
|
||||
# 4. Revert users.role Enum
|
||||
op.alter_column(
|
||||
'users', 'role',
|
||||
existing_type=mysql.ENUM('admin', 'supervisor', 'inspector', 'project_manager', 'customer'),
|
||||
type_=mysql.ENUM('admin', 'supervisor', 'inspector'),
|
||||
existing_nullable=False,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 3. Remove project_id from facilities
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
existing_facility_cols = [c['name'] for c in inspector.get_columns('facilities')]
|
||||
if 'project_id' in existing_facility_cols:
|
||||
op.drop_constraint('fk_facility_project', 'facilities', type_='foreignkey')
|
||||
op.drop_index('ix_facilities_project_id', table_name='facilities')
|
||||
op.drop_column('facilities', 'project_id')
|
||||
|
||||
# 2. Drop customer_assignments
|
||||
existing_tables = inspector.get_table_names()
|
||||
if 'customer_assignments' in existing_tables:
|
||||
op.drop_table('customer_assignments')
|
||||
|
||||
# 1. Drop projects
|
||||
if 'projects' in existing_tables:
|
||||
op.drop_table('projects')
|
||||
@@ -0,0 +1,47 @@
|
||||
"""phase20 — inspector contract assignments
|
||||
|
||||
Adds inspector_assignments table so each inspector can be scoped to one or
|
||||
more contracts (projects). Inspectors with no assignments see nothing.
|
||||
|
||||
Safe to re-run — uses INFORMATION_SCHEMA existence check.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'phase20_inspector_assignments'
|
||||
down_revision = 'phase19_issue_mobile_photos'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table):
|
||||
result = bind.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
|
||||
), {"t": table})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, 'inspector_assignments'):
|
||||
op.create_table(
|
||||
'inspector_assignments',
|
||||
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('project_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.UniqueConstraint('user_id', 'project_id', name='uq_inspector_project'),
|
||||
)
|
||||
op.create_index('ix_inspector_assignments_user_id',
|
||||
'inspector_assignments', ['user_id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, 'inspector_assignments'):
|
||||
op.drop_table('inspector_assignments')
|
||||
@@ -0,0 +1,67 @@
|
||||
"""phase21 — composite performance indexes
|
||||
|
||||
Adds composite (multi-column) indexes on the highest-traffic query patterns.
|
||||
Phase 12 already covers single-column indexes; these target the multi-column
|
||||
WHERE clauses that appear on every Reports, Inspections list, and Issues list
|
||||
page load.
|
||||
|
||||
inspections (facility_id, inspection_date)
|
||||
— facility-scoped date-range queries on every list page and report
|
||||
|
||||
inspections (inspector_id, inspection_date)
|
||||
— inspector-scoped date-range queries on the Performance page and API stats
|
||||
|
||||
inspections (status, inspection_date)
|
||||
— "completed inspections in date range" pattern used by all score aggregations
|
||||
|
||||
issues (facility_id, status)
|
||||
— "open issues at this facility" pattern used by reports and dashboard
|
||||
|
||||
All existence checks use INFORMATION_SCHEMA.STATISTICS — safe to re-run on
|
||||
any MySQL version (compatible back to 5.7).
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'phase21_performance_indexes'
|
||||
down_revision = 'phase21_template_active'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _index_exists(bind, table: str, index_name: str) -> bool:
|
||||
result = bind.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.statistics "
|
||||
"WHERE table_schema = DATABASE() "
|
||||
" AND table_name = :table "
|
||||
" AND index_name = :index"
|
||||
), {'table': table, 'index': index_name})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
# (table, index_name, columns)
|
||||
INDEXES = [
|
||||
('inspections', 'ix_inspections_facility_date', 'facility_id, inspection_date'),
|
||||
('inspections', 'ix_inspections_inspector_date', 'inspector_id, inspection_date'),
|
||||
('inspections', 'ix_inspections_status_date', 'status, inspection_date'),
|
||||
('issues', 'ix_issues_facility_status', 'facility_id, status'),
|
||||
]
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
for table, index_name, columns in INDEXES:
|
||||
if not _index_exists(bind, table, index_name):
|
||||
op.execute(sa.text(
|
||||
f'CREATE INDEX {index_name} ON {table} ({columns})'
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
for table, index_name, _columns in INDEXES:
|
||||
if _index_exists(bind, table, index_name):
|
||||
op.execute(sa.text(
|
||||
f'DROP INDEX {index_name} ON {table}'
|
||||
))
|
||||
@@ -0,0 +1,39 @@
|
||||
"""phase21 — template active flag
|
||||
|
||||
Adds `active` boolean column to `inspection_templates` so templates can be
|
||||
deactivated without deletion. Inactive templates are hidden from the
|
||||
inspection-start form but remain accessible in the template management UI.
|
||||
|
||||
Safe to re-run — uses INFORMATION_SCHEMA column existence check.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'phase21_template_active'
|
||||
down_revision = 'phase20_inspector_assignments'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(bind, table, column):
|
||||
result = bind.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND COLUMN_NAME = :c"
|
||||
), {"t": table, "c": column})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _column_exists(bind, 'inspection_templates', 'active'):
|
||||
op.add_column(
|
||||
'inspection_templates',
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true())
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _column_exists(bind, 'inspection_templates', 'active'):
|
||||
op.drop_column('inspection_templates', 'active')
|
||||
@@ -0,0 +1,41 @@
|
||||
"""phase22 — add is_customer_visible to issue_comments
|
||||
|
||||
Staff comments default to hidden from customers (is_customer_visible=FALSE).
|
||||
Staff can tick a checkbox to share a comment with the customer.
|
||||
Customer comments are always visible (is_customer_visible=TRUE, set at write time).
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'phase22_comment_visibility'
|
||||
down_revision = 'phase21_performance_indexes'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(bind, table: str, column: str) -> bool:
|
||||
result = bind.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns "
|
||||
"WHERE table_schema = DATABASE() "
|
||||
" AND table_name = :table "
|
||||
" AND column_name = :column"
|
||||
), {'table': table, 'column': column})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _column_exists(bind, 'issue_comments', 'is_customer_visible'):
|
||||
op.execute(sa.text(
|
||||
'ALTER TABLE issue_comments '
|
||||
'ADD COLUMN is_customer_visible BOOLEAN NOT NULL DEFAULT FALSE'
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _column_exists(bind, 'issue_comments', 'is_customer_visible'):
|
||||
op.execute(sa.text(
|
||||
'ALTER TABLE issue_comments DROP COLUMN is_customer_visible'
|
||||
))
|
||||
@@ -0,0 +1,70 @@
|
||||
"""phase23 — support tickets
|
||||
|
||||
Creates two tables:
|
||||
support_tickets — customer-submitted help requests
|
||||
support_ticket_replies — admin/staff replies to those tickets
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'phase23_support_tickets'
|
||||
down_revision = 'phase22_comment_visibility'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table: str) -> bool:
|
||||
result = bind.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.tables "
|
||||
"WHERE table_schema = DATABASE() AND table_name = :t"
|
||||
), {'t': table})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, 'support_tickets'):
|
||||
op.execute(sa.text("""
|
||||
CREATE TABLE support_tickets (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
customer_id INT NULL,
|
||||
facility_id INT NULL,
|
||||
subject VARCHAR(200) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
created_at DATETIME NOT NULL,
|
||||
CONSTRAINT fk_st_customer FOREIGN KEY (customer_id)
|
||||
REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_st_facility FOREIGN KEY (facility_id)
|
||||
REFERENCES facilities(id) ON DELETE SET NULL,
|
||||
INDEX ix_support_tickets_customer (customer_id),
|
||||
INDEX ix_support_tickets_status (status),
|
||||
INDEX ix_support_tickets_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
"""))
|
||||
|
||||
if not _table_exists(bind, 'support_ticket_replies'):
|
||||
op.execute(sa.text("""
|
||||
CREATE TABLE support_ticket_replies (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id INT NOT NULL,
|
||||
user_id INT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
CONSTRAINT fk_str_ticket FOREIGN KEY (ticket_id)
|
||||
REFERENCES support_tickets(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_str_user FOREIGN KEY (user_id)
|
||||
REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX ix_support_replies_ticket (ticket_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, 'support_ticket_replies'):
|
||||
op.execute(sa.text('DROP TABLE support_ticket_replies'))
|
||||
if _table_exists(bind, 'support_tickets'):
|
||||
op.execute(sa.text('DROP TABLE support_tickets'))
|
||||
@@ -0,0 +1,32 @@
|
||||
"""phase24 — enable issue_created notifications for admin and director by default
|
||||
|
||||
Sets enabled=True for ('issue_created', 'admin') and ('issue_created', 'director')
|
||||
in the notification_matrix table if those rows already exist (created when an admin
|
||||
previously saved the matrix page). Rows that do not exist are left alone — the
|
||||
updated MATRIX_DEFAULTS in notification_matrix.py covers those at runtime.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = 'phase24_notify_defaults'
|
||||
down_revision = 'phase23_support_tickets'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("""
|
||||
UPDATE notification_matrix
|
||||
SET enabled = 1
|
||||
WHERE event_type = 'issue_created'
|
||||
AND role_key IN ('admin', 'director')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("""
|
||||
UPDATE notification_matrix
|
||||
SET enabled = 0
|
||||
WHERE event_type = 'issue_created'
|
||||
AND role_key IN ('admin', 'director')
|
||||
""")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""phase25 — add GPS coordinates to inspections
|
||||
|
||||
Adds submit_latitude and submit_longitude columns to the inspections table.
|
||||
Populated at web submission time via browser Geolocation API.
|
||||
Null for all existing inspections and mobile submissions (handled separately).
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'phase25_inspection_gps'
|
||||
down_revision = 'phase24_notify_defaults'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
has_lat = conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
" AND TABLE_NAME = 'inspections' "
|
||||
" AND COLUMN_NAME = 'submit_latitude'"
|
||||
)).scalar()
|
||||
|
||||
if not has_lat:
|
||||
op.add_column('inspections',
|
||||
sa.Column('submit_latitude', sa.Numeric(10, 7), nullable=True))
|
||||
op.add_column('inspections',
|
||||
sa.Column('submit_longitude', sa.Numeric(10, 7), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('inspections', 'submit_longitude')
|
||||
op.drop_column('inspections', 'submit_latitude')
|
||||
@@ -0,0 +1,50 @@
|
||||
"""phase26 — vendor/contractor columns on issues
|
||||
|
||||
Adds three nullable columns to the issues table:
|
||||
vendor_name VARCHAR(100) — name of the external contractor or vendor
|
||||
vendor_contact VARCHAR(200) — phone number or email for the vendor
|
||||
vendor_notes TEXT — notes about what the vendor is handling
|
||||
|
||||
These columns are populated only when a third-party contractor is
|
||||
assigned to resolve an issue, separate from the internal assigned_to staff user.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'phase26_issue_vendor'
|
||||
down_revision = 'phase25_inspection_gps'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _col_exists(bind, table: str, column: str) -> bool:
|
||||
result = bind.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
" AND TABLE_NAME = :t "
|
||||
" AND COLUMN_NAME = :c"
|
||||
), {'t': table, 'c': column})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _col_exists(bind, 'issues', 'vendor_name'):
|
||||
op.add_column('issues',
|
||||
sa.Column('vendor_name', sa.String(100), nullable=True))
|
||||
|
||||
if not _col_exists(bind, 'issues', 'vendor_contact'):
|
||||
op.add_column('issues',
|
||||
sa.Column('vendor_contact', sa.String(200), nullable=True))
|
||||
|
||||
if not _col_exists(bind, 'issues', 'vendor_notes'):
|
||||
op.add_column('issues',
|
||||
sa.Column('vendor_notes', sa.Text, nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('issues', 'vendor_notes')
|
||||
op.drop_column('issues', 'vendor_contact')
|
||||
op.drop_column('issues', 'vendor_name')
|
||||
@@ -0,0 +1,48 @@
|
||||
"""phase27 — facility score alert tracking table
|
||||
|
||||
Creates facility_score_alerts table used by the score-trend cron job to
|
||||
deduplicate notifications: once an alert fires for a facility, a row is
|
||||
inserted here. The cron skips the facility if an alert was sent within
|
||||
the last 24 hours, preventing alert storms on persistent score drops.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'phase27_score_alerts'
|
||||
down_revision = 'phase26_issue_vendor'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table: str) -> bool:
|
||||
result = bind.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.tables "
|
||||
"WHERE table_schema = DATABASE() AND table_name = :t"
|
||||
), {'t': table})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, 'facility_score_alerts'):
|
||||
op.execute(sa.text("""
|
||||
CREATE TABLE facility_score_alerts (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
facility_id INT NOT NULL,
|
||||
sent_at DATETIME NOT NULL,
|
||||
current_avg DECIMAL(5,2) NOT NULL,
|
||||
prior_avg DECIMAL(5,2) NOT NULL,
|
||||
delta DECIMAL(5,2) NOT NULL,
|
||||
CONSTRAINT fk_fsa_facility FOREIGN KEY (facility_id)
|
||||
REFERENCES facilities(id) ON DELETE CASCADE,
|
||||
INDEX ix_fsa_facility_sent (facility_id, sent_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, 'facility_score_alerts'):
|
||||
op.execute(sa.text('DROP TABLE facility_score_alerts'))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""phase28 — restore inspection_completed director notification
|
||||
|
||||
The notification_matrix row for ('inspection_completed', 'director') was set
|
||||
to enabled=False at some point — likely when an admin saved the notification
|
||||
matrix page with the director checkbox accidentally unchecked.
|
||||
|
||||
This migration resets that row to enabled=True (matching MATRIX_DEFAULTS) so
|
||||
the director receives email and in-app notifications whenever an inspector
|
||||
submits a completed inspection, from both the web UI and the mobile API.
|
||||
|
||||
Also resets ('inspection_completed', 'admin') and ('inspection_completed', 'customer')
|
||||
to True for the same reason — any of these could have been inadvertently disabled.
|
||||
Rows that do not yet exist in the DB are left alone; MATRIX_DEFAULTS covers
|
||||
those at runtime.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = 'phase28_fix_inspection_notify'
|
||||
down_revision = 'phase27_score_alerts'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("""
|
||||
UPDATE notification_matrix
|
||||
SET enabled = 1
|
||||
WHERE event_type = 'inspection_completed'
|
||||
AND role_key IN ('admin', 'director', 'customer')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# No safe rollback — we don't know what the values were before.
|
||||
pass
|
||||
@@ -0,0 +1,37 @@
|
||||
"""phase29 — broadcasts table for admin push notifications to iOS
|
||||
|
||||
Adds the `broadcasts` table. Each row records an admin-composed message,
|
||||
the roles it targeted, who sent it, and how many Notification rows were
|
||||
created. The Notification rows themselves are written at send-time using
|
||||
the existing notify() utility — no schema changes to that table are needed.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'phase29_broadcasts'
|
||||
down_revision = 'phase28_fix_inspection_notify'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS broadcasts (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
target_roles JSON NOT NULL,
|
||||
sent_by_id INT NULL,
|
||||
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
recipient_count INT NOT NULL DEFAULT 0,
|
||||
CONSTRAINT fk_broadcast_sender
|
||||
FOREIGN KEY (sent_by_id) REFERENCES users(id)
|
||||
ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("DROP TABLE IF EXISTS broadcasts")
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Phase 6: Scheduled reports, re-inspection workflow, issue verification
|
||||
|
||||
Revision ID: phase6_features
|
||||
Revises: phase1_projects_roles
|
||||
Create Date: 2026-03-04
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
revision = 'phase6_features'
|
||||
down_revision = 'phase1_projects_roles' # <-- set to your current DB head
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
# ── 1. scheduled_reports ─────────────────────────────────────────────────
|
||||
if 'scheduled_reports' not in tables:
|
||||
op.create_table(
|
||||
'scheduled_reports',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('report_type', sa.Enum('summary', 'facility', 'issues'),
|
||||
nullable=False, server_default='summary'),
|
||||
sa.Column('frequency', sa.Enum('daily', 'weekly', 'monthly'),
|
||||
nullable=False),
|
||||
sa.Column('facility_id', sa.Integer(),
|
||||
sa.ForeignKey('facilities.id', ondelete='SET NULL'),
|
||||
nullable=True),
|
||||
sa.Column('recipients', sa.JSON(), nullable=False), # list of email strings
|
||||
sa.Column('include_pdf', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('include_csv', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('created_by', sa.Integer(),
|
||||
sa.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_sent_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('next_send_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
# ── 2. inspections: parent_inspection_id + follow_up columns ────────────
|
||||
insp_cols = {c['name'] for c in inspector.get_columns('inspections')}
|
||||
|
||||
if 'parent_inspection_id' not in insp_cols:
|
||||
op.add_column('inspections',
|
||||
sa.Column('parent_inspection_id', sa.Integer(),
|
||||
sa.ForeignKey('inspections.id', ondelete='SET NULL'),
|
||||
nullable=True))
|
||||
|
||||
if 'follow_up_required' not in insp_cols:
|
||||
op.add_column('inspections',
|
||||
sa.Column('follow_up_required', sa.Boolean(),
|
||||
nullable=False, server_default='0'))
|
||||
|
||||
if 'follow_up_note' not in insp_cols:
|
||||
op.add_column('inspections',
|
||||
sa.Column('follow_up_note', sa.Text(), nullable=True))
|
||||
|
||||
# ── 3. issues: verification columns + extend status enum ────────────────
|
||||
issue_cols = {c['name'] for c in inspector.get_columns('issues')}
|
||||
|
||||
if 'verified_by' not in issue_cols:
|
||||
op.add_column('issues',
|
||||
sa.Column('verified_by', sa.Integer(),
|
||||
sa.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True))
|
||||
|
||||
if 'verified_at' not in issue_cols:
|
||||
op.add_column('issues',
|
||||
sa.Column('verified_at', sa.DateTime(), nullable=True))
|
||||
|
||||
if 'verification_note' not in issue_cols:
|
||||
op.add_column('issues',
|
||||
sa.Column('verification_note', sa.Text(), nullable=True))
|
||||
|
||||
# Extend the status ENUM to include 'pending_verification'
|
||||
# MySQL requires modifying the column definition directly
|
||||
op.execute(
|
||||
"ALTER TABLE issues MODIFY COLUMN status "
|
||||
"ENUM('open','in_progress','resolved','pending_verification') "
|
||||
"NOT NULL DEFAULT 'open'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Revert status enum
|
||||
op.execute(
|
||||
"ALTER TABLE issues MODIFY COLUMN status "
|
||||
"ENUM('open','in_progress','resolved') "
|
||||
"NOT NULL DEFAULT 'open'"
|
||||
)
|
||||
|
||||
issue_cols = {c['name'] for c in sa.inspect(op.get_bind()).get_columns('issues')}
|
||||
for col in ('verified_by', 'verified_at', 'verification_note'):
|
||||
if col in issue_cols:
|
||||
op.drop_column('issues', col)
|
||||
|
||||
insp_cols = {c['name'] for c in sa.inspect(op.get_bind()).get_columns('inspections')}
|
||||
for col in ('parent_inspection_id', 'follow_up_required', 'follow_up_note'):
|
||||
if col in insp_cols:
|
||||
op.drop_column('inspections', col)
|
||||
|
||||
op.drop_table('scheduled_reports')
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
migrations/versions/phase7_mobile_api.py
|
||||
-----------------------------------------
|
||||
Phase 7 — Mobile API: JWT refresh tokens and APNs device tokens.
|
||||
|
||||
Revision ID : phase7_mobile_api
|
||||
Revises : phase6_features
|
||||
Create Date : 2026-03-17
|
||||
|
||||
New tables
|
||||
----------
|
||||
api_refresh_tokens
|
||||
Stores server-side refresh token hashes for mobile sessions.
|
||||
Enables instant revocation by deleting the row.
|
||||
|
||||
api_device_tokens
|
||||
Stores APNs device tokens for push notification delivery.
|
||||
One row per (user_id, device_id) — upserted on every app launch.
|
||||
|
||||
All columns include safe IF NOT EXISTS / IF EXISTS guards so the
|
||||
migration is idempotent and safe to re-run.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'phase7_mobile_api'
|
||||
down_revision = 'phase6_features'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
# ── 1. api_refresh_tokens ─────────────────────────────────────────────
|
||||
if 'api_refresh_tokens' not in tables:
|
||||
op.create_table(
|
||||
'api_refresh_tokens',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(),
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False),
|
||||
sa.Column('token_hash', sa.String(64), nullable=False, unique=True),
|
||||
sa.Column('device_id', sa.String(64), nullable=True),
|
||||
sa.Column('device_name', sa.String(100), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('revoked', sa.Boolean(), nullable=False,
|
||||
server_default='0'),
|
||||
)
|
||||
op.create_index('ix_api_refresh_tokens_user_id',
|
||||
'api_refresh_tokens', ['user_id'])
|
||||
op.create_index('ix_api_refresh_tokens_token_hash',
|
||||
'api_refresh_tokens', ['token_hash'], unique=True)
|
||||
|
||||
# ── 2. api_device_tokens ──────────────────────────────────────────────
|
||||
if 'api_device_tokens' not in tables:
|
||||
op.create_table(
|
||||
'api_device_tokens',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(),
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False),
|
||||
sa.Column('device_id', sa.String(64), nullable=False),
|
||||
sa.Column('apns_token', sa.String(200), nullable=False),
|
||||
sa.Column('device_name', sa.String(100), nullable=True),
|
||||
sa.Column('app_version', sa.String(20), nullable=True),
|
||||
sa.Column('registered_at', sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_api_device_tokens_user_id',
|
||||
'api_device_tokens', ['user_id'])
|
||||
op.create_unique_constraint(
|
||||
'uq_device_token_user_device',
|
||||
'api_device_tokens',
|
||||
['user_id', 'device_id'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'api_device_tokens' in tables:
|
||||
op.drop_table('api_device_tokens')
|
||||
|
||||
if 'api_refresh_tokens' in tables:
|
||||
op.drop_table('api_refresh_tokens')
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Phase 8: Notification matrix — admin-controlled per-event recipient settings
|
||||
|
||||
Revision ID: phase8_notification_matrix
|
||||
Revises: phase7_mobile_api
|
||||
Create Date: 2026-04-02
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'phase8_notification_matrix'
|
||||
down_revision = 'phase7_mobile_api'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'notification_matrix' not in tables:
|
||||
op.create_table(
|
||||
'notification_matrix',
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('event_type', sa.String(50), nullable=False),
|
||||
sa.Column('role_key', sa.String(30), nullable=False),
|
||||
# enabled: whether this role receives notifications for this event
|
||||
sa.Column('enabled', sa.Boolean, nullable=False, server_default='1'),
|
||||
# custom_emails: JSON list of extra email addresses (role_key='custom')
|
||||
sa.Column('custom_emails', sa.Text, nullable=True),
|
||||
sa.UniqueConstraint('event_type', 'role_key',
|
||||
name='uq_notif_matrix_event_role'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('notification_matrix')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Phase 9: Add full_name column to users table
|
||||
|
||||
Revision ID: phase9_user_full_name
|
||||
Revises: phase8_notification_matrix
|
||||
Create Date: 2026-04-02
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'phase9_user_full_name'
|
||||
down_revision = 'phase8_notification_matrix'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = [c['name'] for c in inspector.get_columns('users')]
|
||||
|
||||
if 'full_name' not in columns:
|
||||
op.add_column(
|
||||
'users',
|
||||
sa.Column('full_name', sa.String(150), nullable=True, default=None),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('users', 'full_name')
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Add mobile_local_id to inspections and issues for mobile sync idempotency.
|
||||
|
||||
Phase B — iPad offline inspection submission.
|
||||
|
||||
Each inspection or issue submitted from the iPad app carries a UUID generated
|
||||
on the device (mobile_local_id). The server checks this field before creating
|
||||
a new record so that network retries never produce duplicate rows.
|
||||
|
||||
Revision ID: phase_b_mobile_local_id
|
||||
Revises: phase12_performance_indexes
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'phase_b_mobile_local_id'
|
||||
down_revision = 'phase12_performance_indexes'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
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 = :column"
|
||||
), {"table": table, "column": column})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def _index_exists(conn, table, index):
|
||||
result = conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = :table AND INDEX_NAME = :index"
|
||||
), {"table": table, "index": index})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── inspections.mobile_local_id ───────────────────────────────────────
|
||||
if not _column_exists(conn, 'inspections', 'mobile_local_id'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE inspections ADD COLUMN mobile_local_id VARCHAR(64) NULL"
|
||||
))
|
||||
|
||||
if not _index_exists(conn, 'inspections', 'idx_inspections_mobile_local_id'):
|
||||
op.execute(sa.text(
|
||||
"CREATE INDEX idx_inspections_mobile_local_id ON inspections(mobile_local_id)"
|
||||
))
|
||||
|
||||
# ── issues.mobile_local_id ────────────────────────────────────────────
|
||||
if not _column_exists(conn, 'issues', 'mobile_local_id'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE issues ADD COLUMN mobile_local_id VARCHAR(64) NULL"
|
||||
))
|
||||
|
||||
if not _index_exists(conn, 'issues', 'idx_issues_mobile_local_id'):
|
||||
op.execute(sa.text(
|
||||
"CREATE INDEX idx_issues_mobile_local_id ON issues(mobile_local_id)"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
if _index_exists(conn, 'inspections', 'idx_inspections_mobile_local_id'):
|
||||
op.execute(sa.text("DROP INDEX idx_inspections_mobile_local_id ON inspections"))
|
||||
|
||||
if _column_exists(conn, 'inspections', 'mobile_local_id'):
|
||||
op.execute(sa.text("ALTER TABLE inspections DROP COLUMN mobile_local_id"))
|
||||
|
||||
if _index_exists(conn, 'issues', 'idx_issues_mobile_local_id'):
|
||||
op.execute(sa.text("DROP INDEX idx_issues_mobile_local_id ON issues"))
|
||||
|
||||
if _column_exists(conn, 'issues', 'mobile_local_id'):
|
||||
op.execute(sa.text("ALTER TABLE issues DROP COLUMN mobile_local_id"))
|
||||
Reference in New Issue
Block a user