First commit

This commit is contained in:
2026-06-26 09:04:34 -04:00
commit 77678ed724
166 changed files with 34842 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
Single-database configuration for Flask.
+50
View File
@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+113
View File
@@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
@@ -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
+37
View File
@@ -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")
+109
View File
@@ -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')
+91
View File
@@ -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"))