598 lines
18 KiB
Python
598 lines
18 KiB
Python
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
|