Files
MH-DashBoard-organization/backend/app/db.py

566 lines
20 KiB
Python
Executable File

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