Jun 26 MT-2 phase fixes migration script error

This commit is contained in:
2026-06-26 17:07:32 -04:00
parent 07af8ee35a
commit dd41c1fd59
3 changed files with 602 additions and 2 deletions
+3 -1
View File
@@ -160,7 +160,9 @@ Each phase additive; existing tenant-zero traffic keeps working throughout.
**MT-2 — Per-tenant migration runner. ✅ DONE (with blocker found).** Standalone `migrations_tenant/env.py` (reads URL from config, reuses `migrations/versions` via `version_locations`, no Flask) + `control/tenant_migrate.py` (`upgrade_tenant()`, `current_revision()`, `chain_head()`, CLI: `python -m control.tenant_migrate upgrade|current|heads --tenant <id|slug|all>`). Records `tenants.alembic_head` + a `ProvisioningJob('migrate')` per run. Existing `migrations/env.py` untouched (normal `flask db` still works).
> **⚠ Blocker found by MT-2:** the live `migrations/versions` chain has **no base** `phase1_projects_roles.down_revision = '0003_add_user_active'`, which is absent, and no revision has `down_revision = None`. Alembic cannot build the revision map, so `upgrade head` fails against any DB (even a no-op on an up-to-date one). The pre-`phase1` baseline migrations must be restored (or a guarded squashed baseline created) before MT-2 can run against real MySQL DBs and before MT-3 can provision fresh tenants.
> **⚠ Blocker found by MT-2 — RESOLVED.** The live `migrations/versions` chain had **no base**: `phase1_projects_roles.down_revision = '0003_add_user_active'` pointed at a missing revision, and 14/30 phase migrations are not idempotent. **Fix:** a guarded squashed baseline `migrations/versions/0003_add_user_active.py` (`down_revision = None`) that recreates the full current schema (generated from the models, 25 tables, INFORMATION_SCHEMA-guarded) — restoring the chain root. Fresh tenants are built via `bootstrap_tenant()` = upgrade to the baseline **then `stamp head`**, so the non-idempotent phase migrations are never replayed. Existing DBs (LT) are at a later head, so the baseline is treated as applied ancestry and never runs. Ongoing migrations (phase33+, which MUST be guarded) apply incrementally to all tenants via `upgrade_tenant()`.
>
> **Operational note:** any *fresh* database (including a new dev DB) must use the bootstrap flow, not a naive `flask db upgrade` from empty, because the historical phase replay still hits the unguarded migrations. Cross-check the baseline against `mysqldump --no-data` of LT and test `bootstrap` on a scratch MySQL before going live.
**MT-3 — Provisioning service.** Create DB → create **per-tenant MySQL user + password** + grant scoped to that DB only → upgrade to head → seed first tenant-admin → invite email. Creds encrypted into the `tenants` row. Idempotent, `provisioning_jobs`-logged.
+74 -1
View File
@@ -46,6 +46,9 @@ _DEFAULT_VERSION_LOCATIONS = os.path.join(_REPO_ROOT, 'migrations', 'versions')
# Everything except a deleted tenant should be kept schema-current.
MIGRATABLE_STATUSES = ('provisioning', 'active', 'suspended')
# Squashed baseline that builds the full schema (migrations/versions root).
BASELINE_REVISION = '0003_add_user_active'
TenantRef = namedtuple('TenantRef', ['id', 'slug', 'db_uri'])
@@ -125,7 +128,56 @@ def upgrade_tenant(tenant, script_location=None, version_locations=None, record_
raise
# ── CLI ────────────────────────────────────────────────────────────────────
def bootstrap_tenant(tenant, script_location=None, version_locations=None,
baseline_rev=BASELINE_REVISION, record_job=True):
"""Build a FRESH tenant database, then mark it current.
Runs the squashed baseline (full schema) only, then `stamp head` — the
historical phase migrations are NOT replayed (several are not idempotent and
would conflict with the full-schema baseline). Use this for brand-new tenant
databases; use upgrade_tenant() for ongoing incremental migrations.
Returns the stamped head revision.
"""
db_uri = tenant.db_uri
cfg = _make_config(db_uri, script_location, version_locations)
job_id = None
if record_job:
with control_session() as s:
job = ProvisioningJob(tenant_id=tenant.id, action='migrate',
status='running', created_at=now_eastern())
s.add(job)
s.flush()
job_id = job.id
try:
with contextlib.redirect_stdout(io.StringIO()):
command.upgrade(cfg, baseline_rev) # build full schema (baseline only)
command.stamp(cfg, 'head') # mark at head without replaying phases
applied = current_revision(db_uri)
with control_session() as s:
t = s.get(Tenant, tenant.id)
if t is not None:
t.alembic_head = applied
if job_id is not None:
j = s.get(ProvisioningJob, job_id)
if j is not None:
j.status = 'ok'
j.finished_at = now_eastern()
j.log = f'bootstrapped (baseline {baseline_rev}) + stamped {applied}'
return applied
except Exception as e:
if job_id is not None:
with control_session() as s:
j = s.get(ProvisioningJob, job_id)
if j is not None:
j.status = 'failed'
j.finished_at = now_eastern()
j.log = f'{type(e).__name__}: {e}'
raise
def _select_tenants(selector):
with control_session() as s:
@@ -174,6 +226,22 @@ def _cmd_current(args):
return 0
def _cmd_bootstrap(args):
tenants = _select_tenants(args.tenant)
if not tenants:
print(f'No matching tenants for --tenant {args.tenant}.')
return 0
failures = 0
for t in tenants:
try:
applied = bootstrap_tenant(t)
print(f' [ok] {t.slug} (id={t.id}) bootstrapped -> {applied}')
except Exception as e:
failures += 1
print(f' [FAIL] {t.slug} (id={t.id}): {type(e).__name__}: {e}')
return 1 if failures else 0
def _cmd_heads(_args):
print(chain_head())
return 0
@@ -192,6 +260,11 @@ def main(argv=None):
p_cur.add_argument('--tenant', default='all', help="'all', or a tenant id or slug")
p_cur.set_defaults(func=_cmd_current)
p_boot = sub.add_parser('bootstrap',
help='Build a FRESH tenant DB (baseline + stamp head)')
p_boot.add_argument('--tenant', required=True, help="'all', or a tenant id or slug")
p_boot.set_defaults(func=_cmd_bootstrap)
sub.add_parser('heads', help='Show the chain head revision').set_defaults(func=_cmd_heads)
args = parser.parse_args(argv)
+525
View File
@@ -0,0 +1,525 @@
"""baseline (squashed) — pre-phase1 core schema
Revision ID: 0003_add_user_active
Revises:
Create Date: 2026-06-26
Squashed baseline that restores the missing root of the tenant migration chain.
The original 0001/0002/0003_add_user_active migrations were lost; phase1's
down_revision pointed at a non-existent '0003_add_user_active', leaving the
chain with no base and unbuildable by Alembic.
This revision recreates the FULL current application schema (generated from the
SQLAlchemy models) with INFORMATION_SCHEMA existence guards (Rule 14), so it is:
* a complete no-op on existing databases (e.g. tenant-zero / LT, already at
a later head) — Alembic treats it as applied ancestry and never runs it;
* the schema builder for FRESH tenant databases.
IMPORTANT — fresh tenant provisioning uses "upgrade to THIS revision, then
stamp head" (see control/tenant_migrate.bootstrap_tenant). The historical
phase1..phaseN migrations are NOT replayed on fresh DBs (several are not
idempotent), so this baseline must represent the complete schema on its own.
"""
from alembic import op
import sqlalchemy as sa
revision = '0003_add_user_active'
down_revision = None
branch_labels = None
depends_on = None
def _table_exists(bind, table):
return bind.execute(sa.text(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema = DATABASE() AND table_name = :t"
), {'t': table}).scalar() > 0
def _index_exists(bind, table, index):
return bind.execute(sa.text(
"SELECT COUNT(*) FROM information_schema.statistics "
"WHERE table_schema = DATABASE() AND table_name = :t AND index_name = :i"
), {'t': table, 'i': index}).scalar() > 0
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, 'notification_matrix'):
op.execute(sa.text("""CREATE TABLE notification_matrix (
id INTEGER NOT NULL AUTO_INCREMENT,
event_type VARCHAR(50) NOT NULL,
role_key VARCHAR(30) NOT NULL,
enabled BOOL NOT NULL,
custom_emails TEXT,
PRIMARY KEY (id),
CONSTRAINT uq_notif_matrix_event_role UNIQUE (event_type, role_key)
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'users'):
op.execute(sa.text("""CREATE TABLE users (
id INTEGER NOT NULL AUTO_INCREMENT,
username VARCHAR(100) NOT NULL,
full_name VARCHAR(150),
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
`role` ENUM('admin','director','inspector','project_manager','customer') NOT NULL,
created_at DATETIME,
active BOOL NOT NULL,
password_set BOOL NOT NULL,
set_password_token VARCHAR(64),
set_password_token_expires DATETIME,
PRIMARY KEY (id)
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _index_exists(bind, 'users', 'ix_users_email'):
op.execute(sa.text("CREATE UNIQUE INDEX ix_users_email ON users (email)"))
if not _index_exists(bind, 'users', 'ix_users_set_password_token'):
op.execute(sa.text("CREATE INDEX ix_users_set_password_token ON users (set_password_token)"))
if not _index_exists(bind, 'users', 'ix_users_username'):
op.execute(sa.text("CREATE UNIQUE INDEX ix_users_username ON users (username)"))
if not _table_exists(bind, 'api_device_tokens'):
op.execute(sa.text("""CREATE TABLE api_device_tokens (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id INTEGER NOT NULL,
device_id VARCHAR(64) NOT NULL,
apns_token VARCHAR(200) NOT NULL,
device_name VARCHAR(100),
app_version VARCHAR(20),
ios_version VARCHAR(20),
registered_at DATETIME NOT NULL,
last_seen_at DATETIME,
PRIMARY KEY (id),
CONSTRAINT uq_device_token_user_device UNIQUE (user_id, device_id),
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _index_exists(bind, 'api_device_tokens', 'ix_api_device_tokens_user_id'):
op.execute(sa.text("CREATE INDEX ix_api_device_tokens_user_id ON api_device_tokens (user_id)"))
if not _table_exists(bind, 'api_refresh_tokens'):
op.execute(sa.text("""CREATE TABLE api_refresh_tokens (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id INTEGER NOT NULL,
token_hash VARCHAR(64) NOT NULL,
device_id VARCHAR(64),
device_name VARCHAR(100),
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
revoked BOOL NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _index_exists(bind, 'api_refresh_tokens', 'ix_api_refresh_tokens_token_hash'):
op.execute(sa.text("CREATE UNIQUE INDEX ix_api_refresh_tokens_token_hash ON api_refresh_tokens (token_hash)"))
if not _index_exists(bind, 'api_refresh_tokens', 'ix_api_refresh_tokens_user_id'):
op.execute(sa.text("CREATE INDEX ix_api_refresh_tokens_user_id ON api_refresh_tokens (user_id)"))
if not _table_exists(bind, 'audit_logs'):
op.execute(sa.text("""CREATE TABLE audit_logs (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id INTEGER,
username VARCHAR(100) NOT NULL,
user_role VARCHAR(20) NOT NULL,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id INTEGER,
entity_label VARCHAR(255),
details TEXT,
created_at DATETIME NOT NULL,
ip_address VARCHAR(45),
PRIMARY KEY (id),
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _index_exists(bind, 'audit_logs', 'ix_audit_logs_action'):
op.execute(sa.text("CREATE INDEX ix_audit_logs_action ON audit_logs (action)"))
if not _index_exists(bind, 'audit_logs', 'ix_audit_logs_created_at'):
op.execute(sa.text("CREATE INDEX ix_audit_logs_created_at ON audit_logs (created_at)"))
if not _index_exists(bind, 'audit_logs', 'ix_audit_logs_entity_type'):
op.execute(sa.text("CREATE INDEX ix_audit_logs_entity_type ON audit_logs (entity_type)"))
if not _table_exists(bind, 'broadcasts'):
op.execute(sa.text("""CREATE TABLE broadcasts (
id INTEGER NOT NULL AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
target_roles JSON NOT NULL,
sent_by_id INTEGER,
sent_at DATETIME NOT NULL,
recipient_count INTEGER NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(sent_by_id) REFERENCES users (id) ON DELETE SET NULL
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'device_registrations'):
op.execute(sa.text("""CREATE TABLE device_registrations (
id INTEGER NOT NULL AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL,
user_id INTEGER NOT NULL,
device_name VARCHAR(255) NOT NULL,
app_version VARCHAR(32) NOT NULL,
ios_version VARCHAR(32) NOT NULL,
registered_at DATETIME NOT NULL,
last_seen_at DATETIME NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(user_id) REFERENCES users (id)
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _index_exists(bind, 'device_registrations', 'ix_device_registrations_device_id'):
op.execute(sa.text("CREATE UNIQUE INDEX ix_device_registrations_device_id ON device_registrations (device_id)"))
if not _index_exists(bind, 'device_registrations', 'ix_device_registrations_user_id'):
op.execute(sa.text("CREATE INDEX ix_device_registrations_user_id ON device_registrations (user_id)"))
if not _table_exists(bind, 'inspection_templates'):
op.execute(sa.text("""CREATE TABLE inspection_templates (
id INTEGER NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
description TEXT,
frequency ENUM('daily','weekly','monthly','quarterly'),
created_by INTEGER,
created_at DATETIME,
form_schema JSON,
active BOOL NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(created_by) REFERENCES users (id)
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'notification_preferences'):
op.execute(sa.text("""CREATE TABLE notification_preferences (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id INTEGER NOT NULL,
event_type VARCHAR(50) NOT NULL,
email_enabled BOOL NOT NULL,
digest_mode BOOL NOT NULL,
digest_frequency VARCHAR(10) NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uq_notif_pref_user_event UNIQUE (user_id, event_type),
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _index_exists(bind, 'notification_preferences', 'ix_notification_preferences_user_id'):
op.execute(sa.text("CREATE INDEX ix_notification_preferences_user_id ON notification_preferences (user_id)"))
if not _table_exists(bind, 'projects'):
op.execute(sa.text("""CREATE TABLE projects (
id INTEGER NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
description TEXT,
project_manager_id INTEGER,
active BOOL NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(project_manager_id) REFERENCES users (id)
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'checklist_items'):
op.execute(sa.text("""CREATE TABLE checklist_items (
id INTEGER NOT NULL AUTO_INCREMENT,
template_id INTEGER NOT NULL,
category VARCHAR(100),
item_description TEXT NOT NULL,
scoring_type ENUM('pass_fail','rating_5','rating_10'),
weight NUMERIC(3, 2),
requires_photo BOOL,
display_order INTEGER,
PRIMARY KEY (id),
FOREIGN KEY(template_id) REFERENCES inspection_templates (id)
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'facilities'):
op.execute(sa.text("""CREATE TABLE facilities (
id INTEGER NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
address TEXT,
contact_person VARCHAR(100),
contact_phone VARCHAR(20),
active BOOL,
created_at DATETIME,
project_id INTEGER,
PRIMARY KEY (id),
FOREIGN KEY(project_id) REFERENCES projects (id) ON DELETE SET NULL
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _index_exists(bind, 'facilities', 'ix_facilities_project_id'):
op.execute(sa.text("CREATE INDEX ix_facilities_project_id ON facilities (project_id)"))
if not _table_exists(bind, 'inspector_assignments'):
op.execute(sa.text("""CREATE TABLE inspector_assignments (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uq_inspector_project UNIQUE (user_id, project_id),
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects (id) ON DELETE CASCADE
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _index_exists(bind, 'inspector_assignments', 'ix_inspector_assignments_user_id'):
op.execute(sa.text("CREATE INDEX ix_inspector_assignments_user_id ON inspector_assignments (user_id)"))
if not _table_exists(bind, 'areas'):
op.execute(sa.text("""CREATE TABLE areas (
id INTEGER NOT NULL AUTO_INCREMENT,
facility_id INTEGER NOT NULL,
name VARCHAR(255) NOT NULL,
area_type VARCHAR(50),
PRIMARY KEY (id),
FOREIGN KEY(facility_id) REFERENCES facilities (id)
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'customer_assignments'):
op.execute(sa.text("""CREATE TABLE customer_assignments (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
facility_id INTEGER,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uq_customer_assignment UNIQUE (user_id, project_id, facility_id),
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects (id) ON DELETE CASCADE,
FOREIGN KEY(facility_id) REFERENCES facilities (id) ON DELETE CASCADE
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _index_exists(bind, 'customer_assignments', 'ix_customer_assignments_facility_id'):
op.execute(sa.text("CREATE INDEX ix_customer_assignments_facility_id ON customer_assignments (facility_id)"))
if not _index_exists(bind, 'customer_assignments', 'ix_customer_assignments_project_id'):
op.execute(sa.text("CREATE INDEX ix_customer_assignments_project_id ON customer_assignments (project_id)"))
if not _index_exists(bind, 'customer_assignments', 'ix_customer_assignments_user_id'):
op.execute(sa.text("CREATE INDEX ix_customer_assignments_user_id ON customer_assignments (user_id)"))
if not _table_exists(bind, 'facility_score_alerts'):
op.execute(sa.text("""CREATE TABLE facility_score_alerts (
id INTEGER NOT NULL AUTO_INCREMENT,
facility_id INTEGER NOT NULL,
sent_at DATETIME NOT NULL,
current_avg NUMERIC(5, 2) NOT NULL,
prior_avg NUMERIC(5, 2) NOT NULL,
delta NUMERIC(5, 2) NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(facility_id) REFERENCES facilities (id) ON DELETE CASCADE
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'scheduled_reports'):
op.execute(sa.text("""CREATE TABLE scheduled_reports (
id INTEGER NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
report_type ENUM('summary','facility','issues') NOT NULL,
frequency ENUM('daily','weekly','monthly') NOT NULL,
facility_id INTEGER,
recipients JSON NOT NULL,
include_pdf BOOL NOT NULL,
include_csv BOOL NOT NULL,
active BOOL NOT NULL,
created_by INTEGER,
created_at DATETIME NOT NULL,
last_sent_at DATETIME,
next_send_at DATETIME,
PRIMARY KEY (id),
FOREIGN KEY(facility_id) REFERENCES facilities (id) ON DELETE SET NULL,
FOREIGN KEY(created_by) REFERENCES users (id) ON DELETE SET NULL
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'support_tickets'):
op.execute(sa.text("""CREATE TABLE support_tickets (
id INTEGER NOT NULL AUTO_INCREMENT,
customer_id INTEGER,
facility_id INTEGER,
subject VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(customer_id) REFERENCES users (id) ON DELETE SET NULL,
FOREIGN KEY(facility_id) REFERENCES facilities (id) ON DELETE SET NULL
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'inspections'):
op.execute(sa.text("""CREATE TABLE inspections (
id INTEGER NOT NULL AUTO_INCREMENT,
template_id INTEGER NOT NULL,
facility_id INTEGER NOT NULL,
area_id INTEGER,
inspector_id INTEGER NOT NULL,
inspection_date DATETIME NOT NULL,
overall_score NUMERIC(5, 2),
status ENUM('in_progress','completed','flagged'),
notes TEXT,
form_data JSON,
completed_at DATETIME,
mobile_local_id VARCHAR(64),
submit_latitude NUMERIC(10, 7),
submit_longitude NUMERIC(10, 7),
parent_inspection_id INTEGER,
follow_up_required BOOL NOT NULL,
follow_up_note TEXT,
PRIMARY KEY (id),
FOREIGN KEY(template_id) REFERENCES inspection_templates (id),
FOREIGN KEY(facility_id) REFERENCES facilities (id),
FOREIGN KEY(area_id) REFERENCES areas (id),
FOREIGN KEY(inspector_id) REFERENCES users (id),
FOREIGN KEY(parent_inspection_id) REFERENCES inspections (id) ON DELETE SET NULL
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _index_exists(bind, 'inspections', 'ix_inspections_mobile_local_id'):
op.execute(sa.text("CREATE INDEX ix_inspections_mobile_local_id ON inspections (mobile_local_id)"))
if not _table_exists(bind, 'support_ticket_replies'):
op.execute(sa.text("""CREATE TABLE support_ticket_replies (
id INTEGER NOT NULL AUTO_INCREMENT,
ticket_id INTEGER NOT NULL,
user_id INTEGER,
body TEXT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(ticket_id) REFERENCES support_tickets (id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'inspection_results'):
op.execute(sa.text("""CREATE TABLE inspection_results (
id INTEGER NOT NULL AUTO_INCREMENT,
inspection_id INTEGER NOT NULL,
checklist_item_id INTEGER NOT NULL,
score NUMERIC(5, 2),
passed BOOL,
comments TEXT,
photo_path VARCHAR(255),
PRIMARY KEY (id),
FOREIGN KEY(inspection_id) REFERENCES inspections (id),
FOREIGN KEY(checklist_item_id) REFERENCES checklist_items (id)
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'issues'):
op.execute(sa.text("""CREATE TABLE issues (
id INTEGER NOT NULL AUTO_INCREMENT,
inspection_id INTEGER,
area_id INTEGER,
facility_id INTEGER,
severity ENUM('low','medium','high','critical') NOT NULL,
description TEXT NOT NULL,
photo_path VARCHAR(255),
status ENUM('open','in_progress','resolved','pending_verification'),
assigned_to INTEGER,
reported_by INTEGER,
reported_at DATETIME,
resolved_at DATETIME,
result_notes TEXT,
result_photos JSON,
mobile_photo_paths JSON,
verified_by INTEGER,
verified_at DATETIME,
verification_note TEXT,
sla_notified VARCHAR(10),
mobile_local_id VARCHAR(64),
vendor_name VARCHAR(100),
vendor_contact VARCHAR(200),
vendor_notes TEXT,
PRIMARY KEY (id),
FOREIGN KEY(inspection_id) REFERENCES inspections (id),
FOREIGN KEY(area_id) REFERENCES areas (id),
FOREIGN KEY(facility_id) REFERENCES facilities (id),
FOREIGN KEY(assigned_to) REFERENCES users (id),
FOREIGN KEY(reported_by) REFERENCES users (id) ON DELETE SET NULL,
FOREIGN KEY(verified_by) REFERENCES users (id) ON DELETE SET NULL
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _index_exists(bind, 'issues', 'ix_issues_mobile_local_id'):
op.execute(sa.text("CREATE INDEX ix_issues_mobile_local_id ON issues (mobile_local_id)"))
if not _table_exists(bind, 'issue_comments'):
op.execute(sa.text("""CREATE TABLE issue_comments (
id INTEGER NOT NULL AUTO_INCREMENT,
issue_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
status_at_time VARCHAR(20),
body TEXT NOT NULL,
created_at DATETIME NOT NULL,
is_customer_visible BOOL NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(issue_id) REFERENCES issues (id),
FOREIGN KEY(user_id) REFERENCES users (id)
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'issue_followers'):
op.execute(sa.text("""CREATE TABLE issue_followers (
id INTEGER NOT NULL AUTO_INCREMENT,
issue_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uq_issue_follower UNIQUE (issue_id, user_id),
FOREIGN KEY(issue_id) REFERENCES issues (id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _table_exists(bind, 'notifications'):
op.execute(sa.text("""CREATE TABLE notifications (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
link VARCHAR(512),
is_read BOOL NOT NULL,
created_at DATETIME NOT NULL,
issue_id INTEGER,
inspection_id INTEGER,
event_type VARCHAR(50),
digest_pending BOOL NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(user_id) REFERENCES users (id),
FOREIGN KEY(issue_id) REFERENCES issues (id) ON DELETE CASCADE,
FOREIGN KEY(inspection_id) REFERENCES inspections (id) ON DELETE CASCADE
)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB"""))
if not _index_exists(bind, 'notifications', 'ix_notifications_digest_pending'):
op.execute(sa.text("CREATE INDEX ix_notifications_digest_pending ON notifications (digest_pending)"))
if not _index_exists(bind, 'notifications', 'ix_notifications_user_id'):
op.execute(sa.text("CREATE INDEX ix_notifications_user_id ON notifications (user_id)"))
def downgrade():
bind = op.get_bind()
if _table_exists(bind, 'notifications'):
op.execute(sa.text('DROP TABLE notifications'))
if _table_exists(bind, 'issue_followers'):
op.execute(sa.text('DROP TABLE issue_followers'))
if _table_exists(bind, 'issue_comments'):
op.execute(sa.text('DROP TABLE issue_comments'))
if _table_exists(bind, 'issues'):
op.execute(sa.text('DROP TABLE issues'))
if _table_exists(bind, 'inspection_results'):
op.execute(sa.text('DROP TABLE inspection_results'))
if _table_exists(bind, 'support_ticket_replies'):
op.execute(sa.text('DROP TABLE support_ticket_replies'))
if _table_exists(bind, 'inspections'):
op.execute(sa.text('DROP TABLE inspections'))
if _table_exists(bind, 'support_tickets'):
op.execute(sa.text('DROP TABLE support_tickets'))
if _table_exists(bind, 'scheduled_reports'):
op.execute(sa.text('DROP TABLE scheduled_reports'))
if _table_exists(bind, 'facility_score_alerts'):
op.execute(sa.text('DROP TABLE facility_score_alerts'))
if _table_exists(bind, 'customer_assignments'):
op.execute(sa.text('DROP TABLE customer_assignments'))
if _table_exists(bind, 'areas'):
op.execute(sa.text('DROP TABLE areas'))
if _table_exists(bind, 'inspector_assignments'):
op.execute(sa.text('DROP TABLE inspector_assignments'))
if _table_exists(bind, 'facilities'):
op.execute(sa.text('DROP TABLE facilities'))
if _table_exists(bind, 'checklist_items'):
op.execute(sa.text('DROP TABLE checklist_items'))
if _table_exists(bind, 'projects'):
op.execute(sa.text('DROP TABLE projects'))
if _table_exists(bind, 'notification_preferences'):
op.execute(sa.text('DROP TABLE notification_preferences'))
if _table_exists(bind, 'inspection_templates'):
op.execute(sa.text('DROP TABLE inspection_templates'))
if _table_exists(bind, 'device_registrations'):
op.execute(sa.text('DROP TABLE device_registrations'))
if _table_exists(bind, 'broadcasts'):
op.execute(sa.text('DROP TABLE broadcasts'))
if _table_exists(bind, 'audit_logs'):
op.execute(sa.text('DROP TABLE audit_logs'))
if _table_exists(bind, 'api_refresh_tokens'):
op.execute(sa.text('DROP TABLE api_refresh_tokens'))
if _table_exists(bind, 'api_device_tokens'):
op.execute(sa.text('DROP TABLE api_device_tokens'))
if _table_exists(bind, 'users'):
op.execute(sa.text('DROP TABLE users'))
if _table_exists(bind, 'notification_matrix'):
op.execute(sa.text('DROP TABLE notification_matrix'))