from __future__ import annotations import uuid from dataclasses import dataclass from typing import Any from psycopg2.extras import Json, RealDictCursor from .auth import CurrentUser from .db import get_connection from .settings import get_settings @dataclass(frozen=True) class ProposalActionInput: submission_key: uuid.UUID proposal_id: int action_code: str notes: str class ProposalError(RuntimeError): pass def json_safe(value: Any) -> Any: if isinstance(value, dict): return { str(key): json_safe(item) for key, item in value.items() } if isinstance(value, (list, tuple, set)): return [ json_safe(item) for item in value ] if hasattr(value, "isoformat"): return value.isoformat() return value def fetch_proposal_for_update( cursor: RealDictCursor, proposal_id: int, ) -> dict[str, Any]: cursor.execute( """ SELECT p.* FROM mv_loss_intelligence .context_governance_context_proposals raw JOIN mv_reports_ucepsa_prod .li_context_governance_context_proposals_v1 p ON p.proposal_id = raw.proposal_id WHERE raw.proposal_id = %s FOR UPDATE OF raw """, (proposal_id,), ) row = cursor.fetchone() if not row: raise ProposalError( "La propuesta no existe." ) return dict(row) def existing_action( cursor: RealDictCursor, settings, submission_key: uuid.UUID, ) -> dict[str, Any] | None: cursor.execute( """ SELECT * FROM mv_loss_intelligence .context_governance_context_proposal_actions WHERE tenant = %s AND site = %s AND request_id = %s """, ( settings.tenant, settings.site, str(submission_key), ), ) row = cursor.fetchone() return dict(row) if row else None def count_authoritative_overlaps( cursor: RealDictCursor, proposal: dict[str, Any], ) -> int: cursor.execute( """ SELECT COUNT(*) AS overlap_count FROM ( SELECT 1 FROM mv_loss_intelligence .production_context_sessions s WHERE s.machine_id = %s AND s.started_at < %s AND COALESCE( s.ended_at, 'infinity'::timestamptz ) > %s UNION ALL SELECT 1 FROM mv_loss_intelligence .odoo_shopfloor_sessions o WHERE o.machine_id = %s AND o.started_at IS NOT NULL AND o.started_at < %s AND COALESCE( o.ended_at, 'infinity'::timestamptz ) > %s AND o.session_status IN ( 'OPEN', 'CLOSED' ) ) overlaps """, ( proposal["machine_id"], proposal["ended_at"], proposal["first_seen_at"], proposal["machine_id"], proposal["ended_at"], proposal["first_seen_at"], ), ) return int( cursor.fetchone()["overlap_count"] ) def insert_proposal_action( cursor: RealDictCursor, settings, action: ProposalActionInput, proposal: dict[str, Any], user: CurrentUser, resulting_status: str, context_session_id: int | None, source_ip: str | None, user_agent: str | None, ) -> int: cursor.execute( """ INSERT INTO mv_loss_intelligence .context_governance_context_proposal_actions ( tenant, site, request_id, proposal_id, action_code, previous_status, resulting_status, actor_username, actor_display_name, actor_roles, notes, context_session_id, source_ip, user_agent, official_eligible ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, false ) RETURNING proposal_action_id """, ( settings.tenant, settings.site, str(action.submission_key), action.proposal_id, action.action_code, proposal["proposal_status"], resulting_status, user.username, user.display_name, sorted(user.roles), action.notes.strip() or None, context_session_id, source_ip, user_agent, ), ) return int( cursor.fetchone()[ "proposal_action_id" ] ) def approve_proposal( action: ProposalActionInput, user: CurrentUser, source_ip: str | None, user_agent: str | None, ) -> dict[str, Any]: settings = get_settings() with get_connection() as connection: connection.autocommit = False try: with connection.cursor( cursor_factory=RealDictCursor, ) as cursor: cursor.execute( """ SELECT pg_advisory_xact_lock( hashtext(%s) ) """, ( ( f"{settings.tenant}:" f"{settings.site}:" f"{action.submission_key}" ), ), ) replay = existing_action( cursor, settings, action.submission_key, ) if replay: connection.rollback() return { "idempotent_replay": True, "proposal_action_id": replay[ "proposal_action_id" ], "context_session_id": replay[ "context_session_id" ], } proposal = fetch_proposal_for_update( cursor, action.proposal_id, ) if proposal["proposal_status"] != "PENDING": raise ProposalError( "Solo pueden aprobarse propuestas pendientes." ) if ( proposal["case_status"] != "CLOSED" or proposal["ended_at"] is None ): raise ProposalError( "El intervalo todavía no ha terminado." ) overlap_count = count_authoritative_overlaps( cursor, proposal, ) if overlap_count > 0: raise ProposalError( "El intervalo se solapa con otro contexto autoritativo. " "La propuesta no puede aprobarse." ) source_ref = ( "GOVERNANCE_PROPOSAL:" f"{proposal['proposal_id']}" ) cursor.execute( """ SELECT context_session_id FROM mv_loss_intelligence .production_context_sessions WHERE source_ref = %s """, (source_ref,), ) existing_context = cursor.fetchone() if existing_context: context_session_id = int( existing_context[ "context_session_id" ] ) else: cursor.execute( """ INSERT INTO mv_loss_intelligence .production_context_sessions ( tenant, site, machine_id, source_type, source_ref, started_at, ended_at, status, authorization_status, confidence, official_eligible, authorized_by, authorized_at, closed_by, notes, evidence_json ) VALUES ( %s, %s, %s, %s, %s, %s, %s, 'CLOSED', 'AUTHORIZED', 'MEDIUM', false, %s, now(), %s, %s, %s ) RETURNING context_session_id """, ( settings.tenant, settings.site, proposal["machine_id"], proposal[ "proposed_source_type" ], source_ref, proposal["first_seen_at"], proposal["ended_at"], user.display_name, user.display_name, action.notes.strip() or proposal.get( "technical_review_notes" ) or "Aprobación técnica desde Governance Review UI.", Json({ "created_with": "governance_review_ui_v0315", "proposal_id": proposal["proposal_id"], "proposal_key": proposal["proposal_key"], "decision_group_key": proposal[ "decision_group_key" ], "case_id": proposal["case_id"], "answer_code": proposal["answer_code"], "proposed_by": proposal[ "proposed_by_display_name" ], "approved_by": user.display_name, "deployment_mode": "SHADOW", }), ), ) context_session_id = int( cursor.fetchone()[ "context_session_id" ] ) cursor.execute( """ UPDATE mv_loss_intelligence .context_governance_context_proposals SET proposal_status = 'APPROVED', technical_review_by_username = %s, technical_review_by_display_name = %s, technical_review_at = now(), technical_review_notes = %s, context_session_id = %s, updated_at = now() WHERE proposal_id = %s """, ( user.username, user.display_name, action.notes.strip() or None, context_session_id, action.proposal_id, ), ) proposal_action_id = insert_proposal_action( cursor, settings, action, proposal, user, "APPROVED", context_session_id, source_ip, user_agent, ) connection.commit() return { "idempotent_replay": False, "proposal_action_id": proposal_action_id, "context_session_id": context_session_id, } except Exception: connection.rollback() raise def change_proposal_status( action: ProposalActionInput, user: CurrentUser, source_ip: str | None, user_agent: str | None, ) -> dict[str, Any]: settings = get_settings() target_status = { "REJECT": "REJECTED", "REQUEST_CLARIFICATION": "NEEDS_CLARIFICATION", "CLARIFICATION_RESPONSE": "PENDING", }.get(action.action_code) if target_status is None: raise ProposalError( "Acción de propuesta no permitida." ) if not action.notes.strip(): raise ProposalError( "Debe escribir una explicación." ) with get_connection() as connection: connection.autocommit = False try: with connection.cursor( cursor_factory=RealDictCursor, ) as cursor: cursor.execute( """ SELECT pg_advisory_xact_lock( hashtext(%s) ) """, ( ( f"{settings.tenant}:" f"{settings.site}:" f"{action.submission_key}" ), ), ) replay = existing_action( cursor, settings, action.submission_key, ) if replay: connection.rollback() return { "idempotent_replay": True, "proposal_action_id": replay[ "proposal_action_id" ], "resulting_status": replay[ "resulting_status" ], } proposal = fetch_proposal_for_update( cursor, action.proposal_id, ) expected_status = ( "NEEDS_CLARIFICATION" if action.action_code == "CLARIFICATION_RESPONSE" else "PENDING" ) if proposal["proposal_status"] != expected_status: raise ProposalError( "La propuesta ha cambiado de estado. Recargue la página." ) cursor.execute( """ UPDATE mv_loss_intelligence .context_governance_context_proposals SET proposal_status = %s, technical_review_by_username = CASE WHEN %s = 'CLARIFICATION_RESPONSE' THEN technical_review_by_username ELSE %s END, technical_review_by_display_name = CASE WHEN %s = 'CLARIFICATION_RESPONSE' THEN technical_review_by_display_name ELSE %s END, technical_review_at = CASE WHEN %s = 'CLARIFICATION_RESPONSE' THEN technical_review_at ELSE now() END, technical_review_notes = CASE WHEN %s = 'CLARIFICATION_RESPONSE' THEN technical_review_notes ELSE %s END, updated_at = now() WHERE proposal_id = %s """, ( target_status, action.action_code, user.username, action.action_code, user.display_name, action.action_code, action.action_code, action.notes.strip(), action.proposal_id, ), ) proposal_action_id = insert_proposal_action( cursor, settings, action, proposal, user, target_status, None, source_ip, user_agent, ) connection.commit() return { "idempotent_replay": False, "proposal_action_id": proposal_action_id, "resulting_status": target_status, } except Exception: connection.rollback() raise