from contextlib import contextmanager import time from typing import Iterator from psycopg.rows import dict_row import psycopg from .config import DATABASE_URL SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS members ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, employee_id TEXT, company TEXT, rank TEXT, role TEXT, department TEXT, grp TEXT, division TEXT, team TEXT, cell TEXT, work_status TEXT, work_time TEXT, phone TEXT, email TEXT, seat_label TEXT, photo_url TEXT, sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS member_overrides ( id SERIAL PRIMARY KEY, employee_id TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', company TEXT, rank TEXT, role TEXT, department TEXT, grp TEXT, division TEXT, team TEXT, cell TEXT, work_status TEXT, work_time TEXT, phone TEXT, email TEXT, seat_label TEXT, photo_url TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS member_retirements ( id SERIAL PRIMARY KEY, employee_id TEXT, name TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (name) ); CREATE TABLE IF NOT EXISTS member_aliases ( id SERIAL PRIMARY KEY, alias_name TEXT NOT NULL UNIQUE, canonical_name TEXT NOT NULL, employee_id TEXT, note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS seat_maps ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, image_url TEXT NOT NULL, source_type TEXT NOT NULL DEFAULT 'image', source_url TEXT, preview_svg TEXT, view_box_min_x DOUBLE PRECISION, view_box_min_y DOUBLE PRECISION, view_box_width DOUBLE PRECISION, view_box_height DOUBLE PRECISION, image_width INTEGER, image_height INTEGER, grid_rows INTEGER NOT NULL, grid_cols INTEGER NOT NULL, cell_gap INTEGER NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS seat_positions ( member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE, seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE, seat_slot_id INTEGER, row_index INTEGER NOT NULL DEFAULT 0, col_index INTEGER NOT NULL DEFAULT 0, seat_label TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS seat_slots ( id SERIAL PRIMARY KEY, seat_map_id INTEGER NOT NULL REFERENCES seat_maps(id) ON DELETE CASCADE, slot_key TEXT NOT NULL, label TEXT NOT NULL, x DOUBLE PRECISION NOT NULL, y DOUBLE PRECISION NOT NULL, rotation DOUBLE PRECISION NOT NULL DEFAULT 0, layer_name TEXT NOT NULL DEFAULT 'chair', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (seat_map_id, slot_key) ); CREATE TABLE IF NOT EXISTS integration_import_batches ( id SERIAL PRIMARY KEY, source_key TEXT NOT NULL UNIQUE, source_name TEXT NOT NULL, source_path TEXT NOT NULL, imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), row_count INTEGER NOT NULL DEFAULT 0, meta_json JSONB NOT NULL DEFAULT '{}'::jsonb ); CREATE TABLE IF NOT EXISTS integration_raw_organization_rows ( id SERIAL PRIMARY KEY, batch_id INTEGER NOT NULL REFERENCES integration_import_batches(id) ON DELETE CASCADE, row_index INTEGER NOT NULL, row_json JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS integration_raw_mh_rows ( id SERIAL PRIMARY KEY, batch_id INTEGER NOT NULL REFERENCES integration_import_batches(id) ON DELETE CASCADE, row_index INTEGER NOT NULL, row_json JSONB NOT NULL, row_values_json JSONB NOT NULL DEFAULT '[]'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS integration_raw_mh_pm_rows ( id SERIAL PRIMARY KEY, batch_id INTEGER NOT NULL REFERENCES integration_import_batches(id) ON DELETE CASCADE, row_index INTEGER NOT NULL, row_values_json JSONB NOT NULL DEFAULT '[]'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS integration_raw_payment_rows ( id SERIAL PRIMARY KEY, batch_id INTEGER NOT NULL REFERENCES integration_import_batches(id) ON DELETE CASCADE, row_index INTEGER NOT NULL, row_json JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS integration_projects ( id SERIAL PRIMARY KEY, project_code TEXT NOT NULL UNIQUE, project_name TEXT NOT NULL, display_name TEXT NOT NULL DEFAULT '', intranet_name TEXT NOT NULL DEFAULT '', business_area TEXT NOT NULL DEFAULT '', business_subarea TEXT NOT NULL DEFAULT '', project_nature TEXT NOT NULL DEFAULT '', main_category TEXT NOT NULL DEFAULT '', middle_category TEXT NOT NULL DEFAULT '', sub_category TEXT NOT NULL DEFAULT '', updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS integration_project_aliases ( id SERIAL PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES integration_projects(id) ON DELETE CASCADE, alias_name TEXT NOT NULL, alias_type TEXT NOT NULL DEFAULT 'name', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (project_id, alias_name, alias_type) ); CREATE TABLE IF NOT EXISTS integration_project_category_mappings ( id SERIAL PRIMARY KEY, source_key TEXT NOT NULL DEFAULT 'ptj_csv', project_name TEXT NOT NULL, normalized_project_key TEXT NOT NULL, mapped_d1 TEXT NOT NULL DEFAULT '', mapped_d2 TEXT NOT NULL DEFAULT '', mapped_d3 TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (source_key, normalized_project_key) ); CREATE TABLE IF NOT EXISTS integration_project_pm_assignments ( id SERIAL PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES integration_projects(id) ON DELETE CASCADE, member_id INTEGER REFERENCES members(id) ON DELETE SET NULL, pm_name TEXT NOT NULL, source_label TEXT NOT NULL DEFAULT 'mh_sheet2', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (project_id, source_label) ); CREATE TABLE IF NOT EXISTS integration_work_logs ( id SERIAL PRIMARY KEY, work_date DATE NOT NULL, employee_id TEXT NOT NULL, member_id INTEGER REFERENCES members(id) ON DELETE SET NULL, member_name TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', team_category TEXT NOT NULL DEFAULT '', team_name TEXT NOT NULL DEFAULT '', user_state TEXT NOT NULL DEFAULT '', shift_hours NUMERIC(10, 2) NOT NULL DEFAULT 0, weekend_late_flag TEXT NOT NULL DEFAULT '', review_status TEXT NOT NULL DEFAULT '', source_row_index INTEGER NOT NULL DEFAULT 0, raw_batch_id INTEGER REFERENCES integration_import_batches(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (work_date, employee_id, source_row_index) ); CREATE TABLE IF NOT EXISTS integration_work_log_segments ( id SERIAL PRIMARY KEY, work_log_id INTEGER NOT NULL REFERENCES integration_work_logs(id) ON DELETE CASCADE, slot_name TEXT NOT NULL, project_id INTEGER REFERENCES integration_projects(id) ON DELETE SET NULL, project_code TEXT NOT NULL DEFAULT '', project_name TEXT NOT NULL DEFAULT '', business_type TEXT NOT NULL DEFAULT '', activity_code TEXT NOT NULL DEFAULT '', hours NUMERIC(10, 2) NOT NULL DEFAULT 0, overtime_hours_raw NUMERIC(10, 2) NOT NULL DEFAULT 0, overtime_hours_adjusted NUMERIC(10, 2) NOT NULL DEFAULT 0, is_overtime BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS integration_vouchers ( id SERIAL PRIMARY KEY, accounting_company TEXT NOT NULL DEFAULT '', claim_date DATE, issue_date DATE, issue_month TEXT NOT NULL DEFAULT '', account_code TEXT NOT NULL DEFAULT '', management_account_code TEXT NOT NULL DEFAULT '', account_name TEXT NOT NULL DEFAULT '', project_id INTEGER REFERENCES integration_projects(id) ON DELETE SET NULL, project_code TEXT NOT NULL DEFAULT '', project_name TEXT NOT NULL DEFAULT '', display_project_name TEXT NOT NULL DEFAULT '', intranet_project_name TEXT NOT NULL DEFAULT '', business_area TEXT NOT NULL DEFAULT '', business_subarea TEXT NOT NULL DEFAULT '', planning_dev_sales TEXT NOT NULL DEFAULT '', main_category TEXT NOT NULL DEFAULT '', middle_category TEXT NOT NULL DEFAULT '', sub_category TEXT NOT NULL DEFAULT '', department_name TEXT NOT NULL DEFAULT '', team_name TEXT NOT NULL DEFAULT '', customer_name TEXT NOT NULL DEFAULT '', summary_text TEXT NOT NULL DEFAULT '', debit_supply_amount NUMERIC(14, 2) NOT NULL DEFAULT 0, credit_supply_amount NUMERIC(14, 2) NOT NULL DEFAULT 0, expense_amount NUMERIC(14, 2) NOT NULL DEFAULT 0, income_amount NUMERIC(14, 2) NOT NULL DEFAULT 0, voucher_type TEXT NOT NULL DEFAULT '', project_nature TEXT NOT NULL DEFAULT '', raw_batch_id INTEGER REFERENCES integration_import_batches(id) ON DELETE SET NULL, source_row_index INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE SCHEMA IF NOT EXISTS auth; CREATE TABLE IF NOT EXISTS auth.users ( id BIGSERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'admin', member_id INTEGER NULL REFERENCES members(id) ON DELETE SET NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_from TEXT NOT NULL DEFAULT 'manual', last_login_at TIMESTAMPTZ, password_changed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS auth.sessions ( id UUID PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, ip_address INET, user_agent TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS auth.login_audit_logs ( id BIGSERIAL PRIMARY KEY, username TEXT NOT NULL, user_id BIGINT NULL REFERENCES auth.users(id) ON DELETE SET NULL, success BOOLEAN NOT NULL, failure_reason TEXT, ip_address INET, user_agent TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); """ MIGRATION_SQL = """ ALTER TABLE members ADD COLUMN IF NOT EXISTS employee_id TEXT; ALTER TABLE members ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0; CREATE TABLE IF NOT EXISTS member_overrides ( id SERIAL PRIMARY KEY, employee_id TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', company TEXT, rank TEXT, role TEXT, department TEXT, grp TEXT, division TEXT, team TEXT, cell TEXT, work_status TEXT, work_time TEXT, phone TEXT, email TEXT, seat_label TEXT, photo_url TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS member_retirements ( id SERIAL PRIMARY KEY, employee_id TEXT, name TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (name) ); CREATE TABLE IF NOT EXISTS member_aliases ( id SERIAL PRIMARY KEY, alias_name TEXT NOT NULL UNIQUE, canonical_name TEXT NOT NULL, employee_id TEXT, note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE; ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_slot_id INTEGER; ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS row_index INTEGER NOT NULL DEFAULT 0; ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS col_index INTEGER NOT NULL DEFAULT 0; ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_label TEXT; ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'image'; ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS source_url TEXT; ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS preview_svg TEXT; ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_min_x DOUBLE PRECISION; ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_min_y DOUBLE PRECISION; ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_width DOUBLE PRECISION; ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_height DOUBLE PRECISION; ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS image_width INTEGER; ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS image_height INTEGER; ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS cell_gap INTEGER NOT NULL DEFAULT 0; ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE seat_maps ALTER COLUMN image_url DROP NOT NULL; CREATE TABLE IF NOT EXISTS seat_slots ( id SERIAL PRIMARY KEY, seat_map_id INTEGER NOT NULL REFERENCES seat_maps(id) ON DELETE CASCADE, slot_key TEXT NOT NULL, label TEXT NOT NULL, x DOUBLE PRECISION NOT NULL, y DOUBLE PRECISION NOT NULL, rotation DOUBLE PRECISION NOT NULL DEFAULT 0, layer_name TEXT NOT NULL DEFAULT 'chair', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (seat_map_id, slot_key) ); DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'seat_positions' AND column_name = 'x' ) THEN EXECUTE 'UPDATE seat_positions SET row_index = COALESCE(y, row_index, 0), col_index = COALESCE(x, col_index, 0) WHERE seat_map_id IS NULL'; END IF; END $$; DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'seat_positions' AND column_name = 'floor_label' ) THEN EXECUTE 'UPDATE seat_positions SET seat_label = COALESCE(seat_label, floor_label) WHERE seat_label IS NULL'; END IF; END $$; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'integration_raw_mh_rows' AND column_name = 'row_values_json' ) THEN ALTER TABLE integration_raw_mh_rows ADD COLUMN row_values_json JSONB NOT NULL DEFAULT '[]'::jsonb; END IF; END $$; DROP INDEX IF EXISTS seat_positions_map_cell_idx; CREATE UNIQUE INDEX IF NOT EXISTS seat_positions_map_cell_idx ON seat_positions (seat_map_id, row_index, col_index) WHERE seat_map_id IS NOT NULL AND seat_slot_id IS NULL; CREATE UNIQUE INDEX IF NOT EXISTS member_overrides_employee_id_idx ON member_overrides (employee_id); CREATE UNIQUE INDEX IF NOT EXISTS member_retirements_name_idx ON member_retirements (name); CREATE UNIQUE INDEX IF NOT EXISTS member_aliases_alias_name_idx ON member_aliases (alias_name); CREATE UNIQUE INDEX IF NOT EXISTS seat_positions_slot_idx ON seat_positions (seat_slot_id) WHERE seat_slot_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS integration_raw_organization_rows_batch_row_idx ON integration_raw_organization_rows (batch_id, row_index); CREATE UNIQUE INDEX IF NOT EXISTS integration_raw_mh_rows_batch_row_idx ON integration_raw_mh_rows (batch_id, row_index); CREATE UNIQUE INDEX IF NOT EXISTS integration_raw_mh_pm_rows_batch_row_idx ON integration_raw_mh_pm_rows (batch_id, row_index); CREATE UNIQUE INDEX IF NOT EXISTS integration_raw_payment_rows_batch_row_idx ON integration_raw_payment_rows (batch_id, row_index); CREATE INDEX IF NOT EXISTS integration_work_logs_employee_idx ON integration_work_logs (employee_id, work_date); CREATE INDEX IF NOT EXISTS integration_work_log_segments_project_idx ON integration_work_log_segments (project_code, project_name); CREATE INDEX IF NOT EXISTS integration_vouchers_project_idx ON integration_vouchers (project_code, project_name); CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx ON integration_project_category_mappings (source_key, normalized_project_key); DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'seat_positions_seat_slot_id_fkey' AND table_name = 'seat_positions' ) THEN ALTER TABLE seat_positions ADD CONSTRAINT seat_positions_seat_slot_id_fkey FOREIGN KEY (seat_slot_id) REFERENCES seat_slots(id) ON DELETE CASCADE; END IF; END $$; CREATE SCHEMA IF NOT EXISTS auth; CREATE TABLE IF NOT EXISTS auth.users ( id BIGSERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'admin', member_id INTEGER NULL REFERENCES members(id) ON DELETE SET NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_from TEXT NOT NULL DEFAULT 'manual', last_login_at TIMESTAMPTZ, password_changed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS auth.sessions ( id UUID PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, ip_address INET, user_agent TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS auth.login_audit_logs ( id BIGSERIAL PRIMARY KEY, username TEXT NOT NULL, user_id BIGINT NULL REFERENCES auth.users(id) ON DELETE SET NULL, success BOOLEAN NOT NULL, failure_reason TEXT, ip_address INET, user_agent TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'admin'; ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS member_id INTEGER NULL REFERENCES members(id) ON DELETE SET NULL; ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE; ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS created_from TEXT NOT NULL DEFAULT 'manual'; ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ; ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ; ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS ip_address INET; ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS user_agent TEXT; ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_id BIGINT NULL REFERENCES auth.users(id) ON DELETE SET NULL; ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS failure_reason TEXT; ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS ip_address INET; ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_agent TEXT; """ @contextmanager def get_conn() -> Iterator[psycopg.Connection]: with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn: yield conn def init_db(max_retries: int = 20, retry_delay: float = 2.0) -> None: last_error: Exception | None = None for _ in range(max_retries): try: with get_conn() as conn: with conn.cursor() as cur: cur.execute(SCHEMA_SQL) cur.execute(MIGRATION_SQL) conn.commit() return except psycopg.OperationalError as exc: last_error = exc time.sleep(retry_delay) if last_error is not None: raise last_error