fix(api): record approval gate timeline events
Some checks failed
CD Pipeline / tests (push) Successful in 1m25s
Code Review / ai-code-review (push) Successful in 15s
CD Pipeline / build-and-deploy (push) Successful in 4m12s
CD Pipeline / post-deploy-checks (push) Failing after 11s

This commit is contained in:
Your Name
2026-06-04 15:26:56 +08:00
parent 6be8dfa0f5
commit 1ae8f809af
6 changed files with 277 additions and 4 deletions

View File

@@ -175,6 +175,149 @@ def approval_request_to_record_data(
}
def _record_value(value: Any) -> str:
if hasattr(value, "value"):
value = value.value
text = str(value or "").strip()
if "." in text:
text = text.rsplit(".", 1)[-1]
return text.lower()
def _record_int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def _approval_gate_stage(record: ApprovalRecord) -> str:
status = _record_value(record.status)
current = _record_int(getattr(record, "current_signatures", 0))
required = _record_int(getattr(record, "required_signatures", 0))
if status == "pending":
return "approval_required"
if status == "approved" and current == 0 and required == 0:
return "approval_auto_approved"
if status == "approved":
return "approval_approved"
if status == "execution_success":
return "execution_verified"
if status == "execution_failed":
return "execution_failed"
if status in {"rejected", "expired"}:
return f"approval_{status}"
return "approval_status_recorded"
def _approval_gate_status(record: ApprovalRecord) -> str:
status = _record_value(record.status)
if status == "pending":
return "warning"
if status in {"approved", "execution_success"}:
return "success"
if status in {"rejected", "expired", "execution_failed"}:
return "error"
return "info"
def _approval_needs_human(record: ApprovalRecord) -> bool:
status = _record_value(record.status)
current = _record_int(getattr(record, "current_signatures", 0))
required = _record_int(getattr(record, "required_signatures", 0))
return status == "pending" and current < required
def _approval_next_action(record: ApprovalRecord) -> str:
status = _record_value(record.status)
if status == "pending":
return (
"operator_approve_or_reject"
if _approval_needs_human(record)
else "execute_or_verify"
)
if status == "approved":
return "execute_or_verify"
if status == "execution_success":
return "verify_or_close"
if status == "execution_failed":
return "manual_fix_or_rollback"
if status in {"rejected", "expired"}:
return "review_or_close"
return "review_status_chain"
def _approval_blocked_reason(record: ApprovalRecord) -> str:
status = _record_value(record.status)
if status == "pending" and _approval_needs_human(record):
return "waiting_for_required_signatures"
if status == "execution_failed":
return "execution_failed"
if status == "rejected":
return "operator_rejected"
if status == "expired":
return "approval_expired"
return "none"
def _approval_decision_mode(record: ApprovalRecord) -> str:
current = _record_int(getattr(record, "current_signatures", 0))
required = _record_int(getattr(record, "required_signatures", 0))
risk_level = _record_value(record.risk_level)
if _approval_needs_human(record) or current > 0:
return "manual"
if risk_level == "low" and required == 0:
return "auto"
return "manual"
def build_approval_created_timeline_event(record: ApprovalRecord) -> TimelineEvent:
"""Create the raw audit rail event that mirrors a newly-created approval gate."""
current = _record_int(getattr(record, "current_signatures", 0))
required = _record_int(getattr(record, "required_signatures", 0))
risk_level = _record_value(record.risk_level)
needs_human = _approval_needs_human(record)
stage = _approval_gate_stage(record)
next_action = _approval_next_action(record)
mode = _approval_decision_mode(record)
description = "; ".join(
[
f"stage={stage}",
f"next_action={next_action}",
f"blocked_reason={_approval_blocked_reason(record)}",
f"auto_or_manual={mode}",
f"needs_human={'yes' if needs_human else 'no'}",
f"risk_level={risk_level}",
f"signatures={current}/{required}",
f"action={str(getattr(record, 'action', '') or '')[:240]}",
]
)
title = (
"Approval gate waiting for human decision"
if needs_human
else "Approval gate passed"
)
return TimelineEvent(
event_type="human",
status=_approval_gate_status(record),
title=title,
description=description,
actor=getattr(record, "requested_by", None),
actor_role="approval_gate",
risk_level=risk_level,
approval_id=str(record.id),
incident_id=getattr(record, "incident_id", None),
)
def add_approval_created_timeline_event(db: Any, record: ApprovalRecord) -> TimelineEvent:
event = build_approval_created_timeline_event(record)
db.add(event)
return event
# =============================================================================
# Database Approval Service
# =============================================================================
@@ -224,6 +367,7 @@ class ApprovalDBService:
db.add(record)
await db.flush()
await db.refresh(record)
add_approval_created_timeline_event(db, record)
logger.info(
"approval_created_db",
@@ -275,6 +419,7 @@ class ApprovalDBService:
db.add(record)
await db.flush()
await db.refresh(record)
add_approval_created_timeline_event(db, record)
logger.info(
"approval_created_with_fingerprint",

View File

@@ -36,6 +36,7 @@ from src.core.unit_of_work import UnitOfWork
from src.db.models import ApprovalRecord, IncidentRecord
from src.models.approval import ApprovalRequestCreate, ApprovalStatus
from src.models.incident import IncidentStatus
from src.services.approval_db import add_approval_created_timeline_event
if TYPE_CHECKING:
from redis.asyncio import Redis
@@ -167,6 +168,7 @@ class IncidentApprovalService:
)
uow.session.add(approval_record)
await uow.flush() # 取得 ID
add_approval_created_timeline_event(uow.session, approval_record)
# 更新 Incident metadata 連結 Approval
if link_metadata: