Jun 26 MT-2 phase fixes migration script error
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
Reference in New Issue
Block a user