"""Lightweight schema sync for additive/structural changes. `Base.metadata.create_all` creates NEW tables but never alters existing ones, so this module applies the missing pieces at startup: 1. `drop_legacy()` — tables whose shape changed or whose feature was removed (including all legacy cache & sync tables). 2. `ensure_schema()` — additive columns and new enum values. """ import logging from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncConnection logger = logging.getLogger("pm.schema") # Tables dropped on startup (idempotent): # - legacy cache & sync tables completely purged # - legacy inspection / activity template tables LEGACY_TABLES: list[str] = [ "material_requirements", "reservations", "milestones", "purchase_requests", "inspection_results", "inspections", "activity_template_materials", "activity_templates", "projects_cache", "task_refs", "sync_events", "integration_logs", "webhook_deliveries", "sync_state", ] LEGACY_COLUMNS: list[tuple[str, str]] = [ ("projects", "client"), ("projects", "code"), ("projects", "erp_project_id"), ("tasks", "planned_progress"), ("projects", "legacy_op_project_id"), ("tasks", "legacy_op_wp_id"), ("task_qc_definitions", "task_op_wp_id"), ("defects", "task_op_wp_id"), ("project_assignments", "op_project_id"), ("tasks", "equipment"), ] ADDITIVE_COLUMNS: list[tuple[str, str, str]] = [ # (table, column, type) # RBAC: users that predate roles keep their previous (manager) access ("pm_users", "role", "VARCHAR(32) NOT NULL DEFAULT 'project_manager'"), # Native PM Platform linking ("task_qc_definitions", "task_id", "UUID"), ("defects", "task_id", "UUID"), ("project_assignments", "project_id", "UUID"), ("tasks", "human_resources", "JSONB DEFAULT '[]'"), ("tasks", "materials", "JSONB DEFAULT '[]'"), ("task_qc_results", "photo_path", "VARCHAR(512)"), ] ENUM_VALUES: list[tuple[str, str]] = [ # (enum type, value) — e.g. the pre-execution QC stage ("qc_phase", "pre"), ] async def drop_legacy(conn: AsyncConnection) -> None: # Migrate any existing equipment data to materials before dropping the equipment column try: check = await conn.execute( text("SELECT 1 FROM information_schema.columns WHERE table_name = 'tasks' AND column_name = 'equipment'") ) if check.scalar(): await conn.execute( text(""" UPDATE tasks SET materials = COALESCE(materials, '[]'::jsonb) || ( SELECT COALESCE(jsonb_agg( jsonb_build_object( 'name', elem->>'name', 'unit', COALESCE(elem->>'unit', 'دستگاه'), 'specifications', COALESCE(elem->'specifications', '[]'::jsonb), 'count', COALESCE((elem->>'count')::numeric, 1) ) ), '[]'::jsonb) FROM jsonb_array_elements(equipment) AS elem WHERE elem->>'name' IS NOT NULL AND elem->>'name' != '' ) WHERE equipment IS NOT NULL AND jsonb_array_length(equipment) > 0; """) ) logger.info("Migrated equipment data into materials in tasks table.") except Exception as exc: # noqa: BLE001 logger.warning("equipment migration skipped: %s", exc) for table in LEGACY_TABLES: try: await conn.execute(text(f"DROP TABLE IF EXISTS {table} CASCADE")) except Exception as exc: # noqa: BLE001 — never block startup on this logger.warning("legacy drop skipped %s: %s", table, exc) for table, column in LEGACY_COLUMNS: try: await conn.execute(text(f"ALTER TABLE IF EXISTS {table} DROP COLUMN IF EXISTS {column} CASCADE")) except Exception as exc: # noqa: BLE001 logger.warning("legacy column drop skipped %s.%s: %s", table, column, exc) async def ensure_schema(conn: AsyncConnection) -> None: for enum_type, value in ENUM_VALUES: try: await conn.execute(text(f"ALTER TYPE {enum_type} ADD VALUE IF NOT EXISTS '{value}'")) except Exception as exc: # noqa: BLE001 logger.warning("enum sync skipped %s.%s: %s", enum_type, value, exc) for table, column, coltype in ADDITIVE_COLUMNS: try: await conn.execute(text(f"ALTER TABLE IF EXISTS {table} ADD COLUMN IF NOT EXISTS {column} {coltype}")) except Exception as exc: # noqa: BLE001 logger.warning("schema sync skipped %s.%s: %s", table, column, exc) try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS task_progress_logs ( id UUID PRIMARY KEY, task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, progress_before INTEGER DEFAULT 0, progress_after INTEGER NOT NULL, human_resources_used JSONB DEFAULT '[]', materials_used JSONB DEFAULT '[]', description TEXT DEFAULT '', logged_by VARCHAR(255) DEFAULT '', created_at TIMESTAMPTZ DEFAULT NOW() ) """)) await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_progress_logs_task ON task_progress_logs (task_id)")) await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_progress_logs_created ON task_progress_logs (created_at)")) except Exception as exc: # noqa: BLE001 logger.warning("task_progress_logs table sync skipped: %s", exc)