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 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) ); """ 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; 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 $$; 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; CREATE UNIQUE INDEX IF NOT EXISTS seat_positions_slot_idx ON seat_positions (seat_slot_id) WHERE seat_slot_id IS NOT NULL; 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 $$; """ @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