diff --git a/app/models/tenant_settings.py b/app/models/tenant_settings.py index a20c135..22238ff 100644 --- a/app/models/tenant_settings.py +++ b/app/models/tenant_settings.py @@ -7,11 +7,14 @@ One row per tenant DB. Created on first save; reads fall back to defaults when no row exists so tenant-zero needs zero data migration. """ +import logging import sqlalchemy.exc from app import db from app.utils.time_utils import now_eastern +logger = logging.getLogger(__name__) + class TenantSettings(db.Model): __tablename__ = 'tenant_settings' @@ -46,9 +49,11 @@ class TenantSettings(db.Model): """ try: row = cls.query.first() - except (sqlalchemy.exc.ProgrammingError, sqlalchemy.exc.OperationalError): + except (sqlalchemy.exc.ProgrammingError, sqlalchemy.exc.OperationalError) as exc: # Table 'tenant_settings' doesn't exist — phase33 migration not yet applied. # Any other DB error (connection failure, permission error) is re-raised. + logger.warning('TenantSettings.get_or_default: table missing — %s: %s', + type(exc).__name__, exc) return None if row is not None: return row diff --git a/control/tenant_migrate.py b/control/tenant_migrate.py index 771903a..c3b18f3 100644 --- a/control/tenant_migrate.py +++ b/control/tenant_migrate.py @@ -247,6 +247,33 @@ def _cmd_heads(_args): return 0 +def _cmd_stamp(args): + """Stamp tenant DB(s) to a specific revision without running migrations. + + Use this to rewind a mistakenly-stamped tenant so a subsequent `upgrade` + will actually replay the missing migration. Example: + + python -m control.tenant_migrate stamp --tenant gov phase32_device_token_columns + python -m control.tenant_migrate upgrade --tenant gov + """ + 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: + cfg = _make_config(t.db_uri) + with contextlib.redirect_stdout(io.StringIO()): + command.stamp(cfg, args.revision) + rev = current_revision(t.db_uri) + print(f' [ok] {t.slug} (id={t.id}) stamped -> {rev}') + 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 main(argv=None): parser = argparse.ArgumentParser(prog='control.tenant_migrate', description='Per-tenant migration runner') @@ -265,6 +292,11 @@ def main(argv=None): p_boot.add_argument('--tenant', required=True, help="'all', or a tenant id or slug") p_boot.set_defaults(func=_cmd_bootstrap) + p_stamp = sub.add_parser('stamp', help='Stamp tenant DB(s) to a specific revision (no migrations run)') + p_stamp.add_argument('--tenant', required=True, help="'all', or a tenant id or slug") + p_stamp.add_argument('revision', help='Alembic revision id (e.g. phase32_device_token_columns)') + p_stamp.set_defaults(func=_cmd_stamp) + sub.add_parser('heads', help='Show the chain head revision').set_defaults(func=_cmd_heads) args = parser.parse_args(argv) diff --git a/migrations/versions/0003_add_user_active.py b/migrations/versions/0003_add_user_active.py index f565746..33f5dd6 100644 --- a/migrations/versions/0003_add_user_active.py +++ b/migrations/versions/0003_add_user_active.py @@ -469,10 +469,26 @@ def upgrade(): if not _index_exists(bind, 'notifications', 'ix_notifications_user_id'): op.execute(sa.text("CREATE INDEX ix_notifications_user_id ON notifications (user_id)")) + # phase33 — per-tenant branding settings + if not _table_exists(bind, 'tenant_settings'): + op.execute(sa.text("""CREATE TABLE tenant_settings ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + company_name VARCHAR(150) NULL, + logo_url VARCHAR(500) NULL, + primary_color VARCHAR(7) NOT NULL DEFAULT '#1a56db', + accent_color VARCHAR(7) NOT NULL DEFAULT '#16a34a', + support_email VARCHAR(255) NULL, + updated_at DATETIME NOT NULL, + updated_by INT NULL, + CONSTRAINT fk_tenant_settings_user + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")) def downgrade(): bind = op.get_bind() + if _table_exists(bind, 'tenant_settings'): + op.execute(sa.text('DROP TABLE tenant_settings')) if _table_exists(bind, 'notifications'): op.execute(sa.text('DROP TABLE notifications')) if _table_exists(bind, 'issue_followers'):