```
refactor: 移除审批和回写功能模块 移除了整个审批(approvals)和官方回写(writebacks)功能模块, 包括相关模型、路由、服务和配置项。更新了数据库迁移文件, 删除了相关的审批请求表和官方回写运行表。同时从API路由器中 移除了相应的路由,并调整了安全常量和字段验证器以匹配变更。 ```
This commit is contained in:
@@ -5,13 +5,11 @@ from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db_base import Base
|
||||
from app.modules.approvals import models as approval_models
|
||||
from app.modules.audit import models as audit_models
|
||||
from app.modules.business import models as business_models
|
||||
from app.modules.events import models as event_models
|
||||
from app.modules.feishu import models as feishu_models
|
||||
from app.modules.workflows import models as workflow_models
|
||||
from app.modules.writebacks import models as writeback_models
|
||||
|
||||
config = context.config
|
||||
|
||||
@@ -23,13 +21,11 @@ settings = get_settings()
|
||||
|
||||
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
|
||||
_REGISTERED_MODEL_MODULES = (
|
||||
approval_models,
|
||||
audit_models,
|
||||
business_models,
|
||||
event_models,
|
||||
feishu_models,
|
||||
workflow_models,
|
||||
writeback_models,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,35 +16,6 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"approval_requests",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||
sa.Column("ticket_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("domain", sa.String(length=128), nullable=False),
|
||||
sa.Column("record_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("action", sa.String(length=128), nullable=False),
|
||||
sa.Column("applicant", sa.String(length=128), nullable=False),
|
||||
sa.Column("approver", sa.String(length=128), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("payload", sa.Text(), nullable=True),
|
||||
sa.Column("decision_comment", sa.Text(), nullable=True),
|
||||
sa.Column("used_by", sa.String(length=128), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("decided_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("used_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index(op.f("ix_approval_requests_action"), "approval_requests", ['action'], unique=False)
|
||||
op.create_index(op.f("ix_approval_requests_applicant"), "approval_requests", ['applicant'], unique=False)
|
||||
op.create_index(op.f("ix_approval_requests_approver"), "approval_requests", ['approver'], unique=False)
|
||||
op.create_index(op.f("ix_approval_requests_created_at"), "approval_requests", ['created_at'], unique=False)
|
||||
op.create_index(op.f("ix_approval_requests_domain"), "approval_requests", ['domain'], unique=False)
|
||||
op.create_index(op.f("ix_approval_requests_record_id"), "approval_requests", ['record_id'], unique=False)
|
||||
op.create_index(op.f("ix_approval_requests_status"), "approval_requests", ['status'], unique=False)
|
||||
op.create_index(op.f("ix_approval_requests_ticket_id"), "approval_requests", ['ticket_id'], unique=True)
|
||||
op.create_index(op.f("ix_approval_requests_used_by"), "approval_requests", ['used_by'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"attendance_records",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||
@@ -205,37 +176,6 @@ def upgrade() -> None:
|
||||
op.create_index(op.f("ix_legacy_sync_runs_started_at"), "legacy_sync_runs", ['started_at'], unique=False)
|
||||
op.create_index(op.f("ix_legacy_sync_runs_status"), "legacy_sync_runs", ['status'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"official_writeback_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||
sa.Column("code", sa.String(length=64), nullable=False),
|
||||
sa.Column("domain", sa.String(length=128), nullable=False),
|
||||
sa.Column("record_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("action", sa.String(length=128), nullable=False),
|
||||
sa.Column("actor", sa.String(length=128), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("approval_ticket_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
|
||||
sa.Column("request_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("provider_response", sa.JSON(), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("sent_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index(op.f("ix_official_writeback_runs_action"), "official_writeback_runs", ['action'], unique=False)
|
||||
op.create_index(op.f("ix_official_writeback_runs_actor"), "official_writeback_runs", ['actor'], unique=False)
|
||||
op.create_index(op.f("ix_official_writeback_runs_approval_ticket_id"), "official_writeback_runs", ['approval_ticket_id'], unique=False)
|
||||
op.create_index(op.f("ix_official_writeback_runs_code"), "official_writeback_runs", ['code'], unique=True)
|
||||
op.create_index(op.f("ix_official_writeback_runs_created_at"), "official_writeback_runs", ['created_at'], unique=False)
|
||||
op.create_index(op.f("ix_official_writeback_runs_domain"), "official_writeback_runs", ['domain'], unique=False)
|
||||
op.create_index(op.f("ix_official_writeback_runs_idempotency_key"), "official_writeback_runs", ['idempotency_key'], unique=True)
|
||||
op.create_index(op.f("ix_official_writeback_runs_record_id"), "official_writeback_runs", ['record_id'], unique=False)
|
||||
op.create_index(op.f("ix_official_writeback_runs_sent_at"), "official_writeback_runs", ['sent_at'], unique=False)
|
||||
op.create_index(op.f("ix_official_writeback_runs_status"), "official_writeback_runs", ['status'], unique=False)
|
||||
op.create_index(op.f("ix_official_writeback_runs_submitted_at"), "official_writeback_runs", ['submitted_at'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"performance_metrics",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||
|
||||
@@ -1,57 +1,19 @@
|
||||
"""Add approval ticket consumption fields.
|
||||
"""Remove obsolete approval ticket migration step.
|
||||
|
||||
Revision ID: 202607060002
|
||||
Revises: 202607060001
|
||||
Create Date: 2026-07-06
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "202607060002"
|
||||
down_revision = "202607060001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
APPROVAL_REQUESTS_TABLE = "approval_requests"
|
||||
USED_BY_COLUMN = "used_by"
|
||||
USED_AT_COLUMN = "used_at"
|
||||
|
||||
|
||||
def _column_names() -> set[str]:
|
||||
inspector = inspect(op.get_bind())
|
||||
return {
|
||||
column["name"]
|
||||
for column in inspector.get_columns(APPROVAL_REQUESTS_TABLE)
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
columns = _column_names()
|
||||
if USED_BY_COLUMN not in columns:
|
||||
op.add_column(
|
||||
APPROVAL_REQUESTS_TABLE,
|
||||
sa.Column(USED_BY_COLUMN, sa.String(length=128), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_approval_requests_used_by"),
|
||||
APPROVAL_REQUESTS_TABLE,
|
||||
[USED_BY_COLUMN],
|
||||
unique=False,
|
||||
)
|
||||
if USED_AT_COLUMN not in columns:
|
||||
op.add_column(
|
||||
APPROVAL_REQUESTS_TABLE,
|
||||
sa.Column(USED_AT_COLUMN, sa.DateTime(), nullable=True),
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
columns = _column_names()
|
||||
if USED_BY_COLUMN in columns:
|
||||
op.drop_index(op.f("ix_approval_requests_used_by"), table_name=APPROVAL_REQUESTS_TABLE)
|
||||
op.drop_column(APPROVAL_REQUESTS_TABLE, USED_BY_COLUMN)
|
||||
if USED_AT_COLUMN in columns:
|
||||
op.drop_column(APPROVAL_REQUESTS_TABLE, USED_AT_COLUMN)
|
||||
pass
|
||||
|
||||
@@ -19,7 +19,6 @@ AUDIT_LOGS_TABLE = "audit_logs"
|
||||
DOMAIN_EVENTS_TABLE = "domain_events"
|
||||
WORKFLOW_INSTANCES_TABLE = "workflow_instances"
|
||||
WORKFLOW_ACTIONS_TABLE = "workflow_actions"
|
||||
OFFICIAL_WRITEBACK_RUNS_TABLE = "official_writeback_runs"
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
@@ -154,48 +153,10 @@ def upgrade() -> None:
|
||||
],
|
||||
)
|
||||
|
||||
if not _table_exists(OFFICIAL_WRITEBACK_RUNS_TABLE):
|
||||
op.create_table(
|
||||
OFFICIAL_WRITEBACK_RUNS_TABLE,
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("code", sa.String(length=64), nullable=False),
|
||||
sa.Column("domain", sa.String(length=128), nullable=False),
|
||||
sa.Column("record_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("action", sa.String(length=128), nullable=False),
|
||||
sa.Column("actor", sa.String(length=128), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("approval_ticket_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
|
||||
sa.Column("request_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("provider_response", sa.JSON(), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("sent_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_create_indexes(
|
||||
OFFICIAL_WRITEBACK_RUNS_TABLE,
|
||||
[
|
||||
("code", True),
|
||||
("domain", False),
|
||||
("record_id", False),
|
||||
("action", False),
|
||||
("actor", False),
|
||||
("status", False),
|
||||
("approval_ticket_id", False),
|
||||
("idempotency_key", True),
|
||||
("created_at", False),
|
||||
("submitted_at", False),
|
||||
("sent_at", False),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table_name in [
|
||||
OFFICIAL_WRITEBACK_RUNS_TABLE,
|
||||
WORKFLOW_ACTIONS_TABLE,
|
||||
WORKFLOW_INSTANCES_TABLE,
|
||||
DOMAIN_EVENTS_TABLE,
|
||||
|
||||
@@ -2,7 +2,6 @@ from fastapi import APIRouter
|
||||
|
||||
from app.core.constants import ApiResponseKey, ApiStatus
|
||||
from app.modules.ai_agent.routes import router as ai_router
|
||||
from app.modules.approvals.routes import router as approvals_router
|
||||
from app.modules.audit.routes import router as audit_router
|
||||
from app.modules.business.routes import router as business_router
|
||||
from app.modules.dashboard.routes import router as dashboard_router
|
||||
@@ -13,7 +12,6 @@ from app.modules.observability.routes import router as observability_router
|
||||
from app.modules.reports.routes import router as reports_router
|
||||
from app.modules.risk.routes import router as risk_router
|
||||
from app.modules.workflows.routes import router as workflows_router
|
||||
from app.modules.writebacks.routes import router as writebacks_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -30,11 +28,9 @@ api_router.include_router(dashboard_router, prefix="/dashboard", tags=["dashboar
|
||||
api_router.include_router(legacy_mysql_router, prefix="/integrations/mysql", tags=["mysql"])
|
||||
api_router.include_router(feishu_router, prefix="/integrations/feishu", tags=["feishu"])
|
||||
api_router.include_router(ai_router, prefix="/ai", tags=["ai"])
|
||||
api_router.include_router(approvals_router, prefix="/approvals", tags=["approvals"])
|
||||
api_router.include_router(reports_router, prefix="/reports", tags=["reports"])
|
||||
api_router.include_router(risk_router, prefix="/risks", tags=["risks"])
|
||||
api_router.include_router(audit_router, prefix="/audit", tags=["audit"])
|
||||
api_router.include_router(events_router, prefix="/events", tags=["events"])
|
||||
api_router.include_router(workflows_router, prefix="/workflows", tags=["workflows"])
|
||||
api_router.include_router(writebacks_router, prefix="/writebacks", tags=["writebacks"])
|
||||
api_router.include_router(observability_router, tags=["observability"])
|
||||
|
||||
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
|
||||
app_name: str = "Company AI Management Platform"
|
||||
app_env: str = "local"
|
||||
debug: bool = False
|
||||
read_only_mode: bool = True
|
||||
api_prefix: str = "/api/v1"
|
||||
api_key: str | None = None
|
||||
api_actor: str = ActorValue.API
|
||||
@@ -28,9 +29,6 @@ class Settings(BaseSettings):
|
||||
audit_api_key: str | None = None
|
||||
audit_api_actor: str = ActorValue.AUDITOR
|
||||
audit_api_keys: list[dict[str, Any]] = Field(default_factory=list)
|
||||
approval_api_key: str | None = None
|
||||
approval_api_actor: str = ActorValue.APPROVER
|
||||
approval_api_keys: list[dict[str, Any]] = Field(default_factory=list)
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
||||
mask_sensitive_responses: bool = True
|
||||
masked_response_fields: list[str] = Field(default_factory=list)
|
||||
@@ -50,8 +48,6 @@ class Settings(BaseSettings):
|
||||
feishu_verification_token: str | None = None
|
||||
feishu_encrypt_key: str | None = None
|
||||
feishu_default_chat_id: str | None = None
|
||||
feishu_approval_approver_ids: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
||||
|
||||
model_provider: str = DEFAULT_MODEL_PROVIDER
|
||||
openclaw_base_url: str = "http://127.0.0.1:2070"
|
||||
openclaw_http_url: str | None = None
|
||||
@@ -84,11 +80,6 @@ class Settings(BaseSettings):
|
||||
legacy_project_sync_cron_minute: int = 0
|
||||
legacy_task_sync_cron_hour: int = 2
|
||||
legacy_task_sync_cron_minute: int = 30
|
||||
official_writeback_enabled: bool = False
|
||||
official_api_base_url: str | None = None
|
||||
official_api_token: str | None = None
|
||||
official_api_timeout_seconds: float = 10.0
|
||||
|
||||
@field_validator("cors_origins", mode="before")
|
||||
@classmethod
|
||||
def parse_cors_origins(cls, value: Any) -> list[str]:
|
||||
@@ -107,7 +98,6 @@ class Settings(BaseSettings):
|
||||
@field_validator(
|
||||
"openclaw_allowed_tools",
|
||||
"openclaw_allowed_actions",
|
||||
"feishu_approval_approver_ids",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
@@ -143,7 +133,7 @@ class Settings(BaseSettings):
|
||||
return [str(item).strip() for item in data if str(item).strip()]
|
||||
return [item.strip() for item in text.split(",") if item.strip()]
|
||||
|
||||
@field_validator("api_keys", "audit_api_keys", "approval_api_keys", mode="before")
|
||||
@field_validator("api_keys", "audit_api_keys", mode="before")
|
||||
@classmethod
|
||||
def parse_service_keys(cls, value: Any) -> list[dict[str, Any]]:
|
||||
if value is None or value == "":
|
||||
|
||||
@@ -4,7 +4,6 @@ from enum import StrEnum
|
||||
class ActorValue(StrEnum):
|
||||
API = "api"
|
||||
AUDITOR = "auditor"
|
||||
APPROVER = "approver"
|
||||
SYSTEM = "system"
|
||||
SCHEDULER = "scheduler"
|
||||
FEISHU = "feishu"
|
||||
@@ -14,7 +13,6 @@ class HttpHeader(StrEnum):
|
||||
AUTHORIZATION = "Authorization"
|
||||
X_API_KEY = "X-API-Key"
|
||||
X_AUDIT_API_KEY = "X-Audit-API-Key"
|
||||
X_APPROVAL_API_KEY = "X-Approval-API-Key"
|
||||
X_REQUEST_ID = "X-Request-ID"
|
||||
|
||||
|
||||
@@ -29,8 +27,6 @@ class ApiStatus(StrEnum):
|
||||
class SecurityErrorDetail(StrEnum):
|
||||
API_KEY_REQUIRED = "API_KEY is required"
|
||||
INVALID_API_KEY = "Invalid API key"
|
||||
APPROVAL_API_KEY_REQUIRED = "APPROVAL_API_KEY is required"
|
||||
INVALID_APPROVAL_API_KEY = "Invalid approval API key"
|
||||
AUDIT_API_KEY_REQUIRED = "AUDIT_API_KEY is required"
|
||||
INVALID_AUDIT_API_KEY = "Invalid audit API key"
|
||||
|
||||
|
||||
16
app/core/operation_guard.py
Normal file
16
app/core/operation_guard.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
READ_ONLY_OPERATION_DISABLED = "This service is read-only; data mutation operations are disabled"
|
||||
|
||||
|
||||
def require_operations_enabled(detail: str = READ_ONLY_OPERATION_DISABLED) -> None:
|
||||
"""Reject mutation-oriented endpoints when the product is running read-only."""
|
||||
|
||||
if get_settings().read_only_mode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||
detail=detail,
|
||||
)
|
||||
@@ -40,34 +40,6 @@ def require_api_key(
|
||||
return principal
|
||||
|
||||
|
||||
def require_approval_api_key(
|
||||
x_approval_api_key: str | None = Header(
|
||||
default=None,
|
||||
alias=HttpHeader.X_APPROVAL_API_KEY,
|
||||
),
|
||||
) -> ApiPrincipal:
|
||||
"""Validate the approval API key and return the approval principal."""
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.approval_api_key and not _has_enabled_keys(settings.approval_api_keys):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=SecurityErrorDetail.APPROVAL_API_KEY_REQUIRED,
|
||||
)
|
||||
principal = _match_service_key(
|
||||
x_approval_api_key,
|
||||
settings.approval_api_key,
|
||||
settings.approval_api_actor,
|
||||
settings.approval_api_keys,
|
||||
)
|
||||
if principal is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=SecurityErrorDetail.INVALID_APPROVAL_API_KEY,
|
||||
)
|
||||
return principal
|
||||
|
||||
|
||||
def require_audit_api_key(
|
||||
x_audit_api_key: str | None = Header(
|
||||
default=None,
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.operation_guard import require_operations_enabled
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.audit.constants import AuditSource
|
||||
from app.modules.ai_agent.schemas import (
|
||||
@@ -44,6 +45,7 @@ def invoke_openclaw_tool(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return AIService(db).invoke_openclaw_tool(
|
||||
tool=payload.tool,
|
||||
action=payload.action,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Approval workflow module."""
|
||||
@@ -1,38 +0,0 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ApprovalStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
USED = "used"
|
||||
|
||||
|
||||
class ApprovalActionValue(StrEnum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
WRITEBACK = "writeback"
|
||||
|
||||
|
||||
class ApprovalErrorDetail(StrEnum):
|
||||
NOT_FOUND = "Approval ticket not found"
|
||||
ALREADY_DECIDED = "Approval ticket already decided"
|
||||
SELF_APPROVAL = "Approval applicant cannot approve their own ticket"
|
||||
NOT_APPROVED = "Approval ticket is not approved for this change"
|
||||
PAYLOAD_MISMATCH = "Approval ticket payload does not match this change"
|
||||
|
||||
|
||||
class ApprovalPayloadKey(StrEnum):
|
||||
TICKET_ID = "ticket_id"
|
||||
STATUS = "status"
|
||||
COMMENT = "comment"
|
||||
RECORD_ID = "record_id"
|
||||
USED_BY = "used_by"
|
||||
USED_AT = "used_at"
|
||||
|
||||
|
||||
APPROVAL_ACTION_SEPARATOR = ":"
|
||||
|
||||
|
||||
def approval_action(action: ApprovalActionValue | str, domain: str) -> str:
|
||||
return f"{action}{APPROVAL_ACTION_SEPARATOR}{domain}"
|
||||
@@ -1,34 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.db_base import Base
|
||||
from app.core.time import utc_now
|
||||
from app.modules.approvals.constants import ApprovalStatus
|
||||
|
||||
|
||||
class ApprovalRequest(Base):
|
||||
__tablename__ = "approval_requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ticket_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
domain: Mapped[str] = mapped_column(String(128), index=True)
|
||||
record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||
applicant: Mapped[str] = mapped_column(String(128), default=ActorValue.API, index=True)
|
||||
approver: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default=ApprovalStatus.PENDING, index=True)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decision_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
used_by: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
@@ -1,91 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key, require_approval_api_key
|
||||
from app.modules.approvals.schemas import (
|
||||
ApprovalCreate,
|
||||
ApprovalDecision,
|
||||
ApprovalRead,
|
||||
PushApprovalCardRequest,
|
||||
)
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
from app.modules.feishu.constants import FeishuResponseKey
|
||||
from app.modules.feishu.service import FeishuService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.post("", response_model=ApprovalRead)
|
||||
def create_approval(
|
||||
payload: ApprovalCreate,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
):
|
||||
return ApprovalService(db).create(payload, applicant=principal.actor)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ApprovalRead])
|
||||
def list_approvals(
|
||||
status: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_approval_api_key),
|
||||
):
|
||||
_ = principal
|
||||
return ApprovalService(db).list(status_filter=status, limit=limit)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=ApprovalRead)
|
||||
def get_approval(
|
||||
ticket_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_approval_api_key),
|
||||
):
|
||||
_ = principal
|
||||
return ApprovalService(db).get_by_ticket(ticket_id)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/approve", response_model=ApprovalRead)
|
||||
def approve(
|
||||
ticket_id: str,
|
||||
payload: ApprovalDecision,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_approval_api_key),
|
||||
):
|
||||
return ApprovalService(db).decide(ticket_id, principal.actor, True, payload.comment)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/reject", response_model=ApprovalRead)
|
||||
def reject(
|
||||
ticket_id: str,
|
||||
payload: ApprovalDecision,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_approval_api_key),
|
||||
):
|
||||
return ApprovalService(db).decide(ticket_id, principal.actor, False, payload.comment)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/push-feishu-card")
|
||||
def push_feishu_approval_card(
|
||||
ticket_id: str,
|
||||
payload: PushApprovalCardRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
ticket = ApprovalService(db).get_by_ticket(ticket_id)
|
||||
lines = [
|
||||
f"- 单号:{ticket.ticket_id}",
|
||||
f"- 领域:{ticket.domain}",
|
||||
f"- 动作:{ticket.action}",
|
||||
f"- 申请人:{ticket.applicant}",
|
||||
f"- 理由:{ticket.reason or '-'}",
|
||||
]
|
||||
card = FeishuService.build_approval_card("审批请求", lines, ticket.ticket_id)
|
||||
result = FeishuService(db).send_card(
|
||||
card,
|
||||
receive_id=payload.receive_id,
|
||||
receive_id_type=payload.receive_id_type,
|
||||
actor=principal.actor,
|
||||
)
|
||||
return {FeishuResponseKey.OK: True, FeishuResponseKey.PROVIDER_RESPONSE: result}
|
||||
@@ -1,49 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.feishu.constants import FeishuReceiveIdType
|
||||
|
||||
|
||||
class ApprovalCreate(BaseModel):
|
||||
domain: str
|
||||
record_id: str | None = None
|
||||
action: str
|
||||
applicant: str = ActorValue.API
|
||||
reason: str | None = None
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ApprovalDecision(BaseModel):
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class PushApprovalCardRequest(BaseModel):
|
||||
receive_id: str | None = Field(
|
||||
default=None,
|
||||
description="chat_id or open_id depending on receive_id_type.",
|
||||
)
|
||||
receive_id_type: str = FeishuReceiveIdType.CHAT_ID
|
||||
|
||||
|
||||
class ApprovalRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
ticket_id: str
|
||||
domain: str
|
||||
record_id: str | None
|
||||
action: str
|
||||
applicant: str
|
||||
approver: str | None
|
||||
status: str
|
||||
reason: str | None
|
||||
payload: str | None
|
||||
decision_comment: str | None
|
||||
used_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
decided_at: datetime | None
|
||||
used_at: datetime | None
|
||||
@@ -1,248 +0,0 @@
|
||||
import json
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.pagination import bounded_limit
|
||||
from app.core.time import utc_now
|
||||
from app.modules.approvals.constants import (
|
||||
ApprovalActionValue,
|
||||
ApprovalErrorDetail,
|
||||
ApprovalPayloadKey,
|
||||
ApprovalStatus,
|
||||
approval_action,
|
||||
)
|
||||
from app.modules.approvals.models import ApprovalRequest
|
||||
from app.modules.approvals.schemas import ApprovalCreate
|
||||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.events.constants import (
|
||||
EventAggregateType,
|
||||
EventPayloadKey,
|
||||
EventSource,
|
||||
EventType,
|
||||
)
|
||||
from app.modules.events.service import EventService
|
||||
|
||||
|
||||
class ApprovalService:
|
||||
"""Create, decide, and validate approval tickets for guarded actions."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
|
||||
def create(self, payload: ApprovalCreate, applicant: str) -> ApprovalRequest:
|
||||
ticket = ApprovalRequest(
|
||||
ticket_id=f"APR-{uuid.uuid4().hex[:12].upper()}",
|
||||
domain=payload.domain,
|
||||
record_id=payload.record_id,
|
||||
action=payload.action,
|
||||
applicant=applicant,
|
||||
reason=payload.reason,
|
||||
payload=json.dumps(payload.payload, ensure_ascii=False, default=str),
|
||||
)
|
||||
self.db.add(ticket)
|
||||
self.db.commit()
|
||||
self.db.refresh(ticket)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=applicant,
|
||||
source=AuditSource.APPROVAL,
|
||||
action=AuditAction.APPROVAL_CREATE,
|
||||
target_type=payload.domain,
|
||||
target_id=payload.record_id,
|
||||
risk_level=AuditRiskLevel.MEDIUM,
|
||||
request_payload=payload.model_dump(),
|
||||
response_payload={
|
||||
ApprovalPayloadKey.TICKET_ID: ticket.ticket_id,
|
||||
ApprovalPayloadKey.STATUS: ticket.status,
|
||||
},
|
||||
)
|
||||
)
|
||||
return ticket
|
||||
|
||||
def list(self, status_filter: str | None = None, limit: int = 100) -> list[ApprovalRequest]:
|
||||
stmt = (
|
||||
select(ApprovalRequest)
|
||||
.order_by(ApprovalRequest.id.desc())
|
||||
.limit(bounded_limit(limit))
|
||||
)
|
||||
if status_filter:
|
||||
stmt = stmt.where(ApprovalRequest.status == status_filter)
|
||||
return list(self.db.execute(stmt).scalars())
|
||||
|
||||
def get_by_ticket(self, ticket_id: str) -> ApprovalRequest:
|
||||
ticket = self.db.execute(
|
||||
select(ApprovalRequest).where(ApprovalRequest.ticket_id == ticket_id)
|
||||
).scalar_one_or_none()
|
||||
if ticket is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ApprovalErrorDetail.NOT_FOUND,
|
||||
)
|
||||
return ticket
|
||||
|
||||
def decide(
|
||||
self,
|
||||
ticket_id: str,
|
||||
approver: str,
|
||||
approved: bool,
|
||||
comment: str | None,
|
||||
) -> ApprovalRequest:
|
||||
ticket = self.get_by_ticket(ticket_id)
|
||||
if ticket.status != ApprovalStatus.PENDING:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=ApprovalErrorDetail.ALREADY_DECIDED,
|
||||
)
|
||||
if approved and approver == ticket.applicant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ApprovalErrorDetail.SELF_APPROVAL,
|
||||
)
|
||||
ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
|
||||
ticket.approver = approver
|
||||
ticket.decision_comment = comment
|
||||
ticket.decided_at = utc_now()
|
||||
self.db.commit()
|
||||
self.db.refresh(ticket)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=approver,
|
||||
source=AuditSource.APPROVAL,
|
||||
action=AuditAction.APPROVAL_APPROVE if approved else AuditAction.APPROVAL_REJECT,
|
||||
target_type=ticket.domain,
|
||||
target_id=ticket.record_id,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
request_payload={
|
||||
ApprovalPayloadKey.TICKET_ID: ticket_id,
|
||||
ApprovalPayloadKey.COMMENT: comment,
|
||||
},
|
||||
response_payload={ApprovalPayloadKey.STATUS: ticket.status},
|
||||
)
|
||||
)
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.APPROVAL_DECIDED,
|
||||
source=EventSource.APPROVAL,
|
||||
aggregate_type=EventAggregateType.APPROVAL,
|
||||
aggregate_id=ticket.ticket_id,
|
||||
actor=approver,
|
||||
payload={
|
||||
EventPayloadKey.TICKET_ID: ticket.ticket_id,
|
||||
EventPayloadKey.DOMAIN: ticket.domain,
|
||||
EventPayloadKey.RECORD_ID: ticket.record_id,
|
||||
EventPayloadKey.ACTION: ticket.action,
|
||||
EventPayloadKey.STATUS: ticket.status,
|
||||
EventPayloadKey.APPROVED: approved,
|
||||
EventPayloadKey.COMMENT: comment,
|
||||
},
|
||||
idempotency_key=f"approval:{ticket.ticket_id}:{ticket.status}",
|
||||
)
|
||||
return ticket
|
||||
|
||||
def consume_for(
|
||||
self,
|
||||
ticket_id: str,
|
||||
domain: str,
|
||||
record_id: str | int | None,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
actor: str,
|
||||
) -> ApprovalRequest:
|
||||
ticket = self.get_by_ticket(ticket_id)
|
||||
if not self._is_ticket_scope_valid(ticket, domain, record_id, action):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ApprovalErrorDetail.NOT_APPROVED,
|
||||
)
|
||||
if not _payload_matches(ticket.payload, payload):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ApprovalErrorDetail.PAYLOAD_MISMATCH,
|
||||
)
|
||||
used_at = utc_now()
|
||||
values: dict[str, Any] = {
|
||||
ApprovalPayloadKey.STATUS: ApprovalStatus.USED,
|
||||
ApprovalPayloadKey.USED_BY: actor,
|
||||
ApprovalPayloadKey.USED_AT: used_at,
|
||||
}
|
||||
if record_id is not None and not ticket.record_id:
|
||||
values[ApprovalPayloadKey.RECORD_ID] = str(record_id)
|
||||
result = self.db.execute(
|
||||
update(ApprovalRequest)
|
||||
.where(
|
||||
ApprovalRequest.ticket_id == ticket_id,
|
||||
ApprovalRequest.status == ApprovalStatus.APPROVED,
|
||||
)
|
||||
.values(**values)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ApprovalErrorDetail.NOT_APPROVED,
|
||||
)
|
||||
for key, value in values.items():
|
||||
setattr(ticket, key, value)
|
||||
return ticket
|
||||
|
||||
def is_approved_for(
|
||||
self,
|
||||
ticket_id: str,
|
||||
domain: str,
|
||||
record_id: str | int | None,
|
||||
action: str,
|
||||
) -> bool:
|
||||
ticket = self.get_by_ticket(ticket_id)
|
||||
return self._is_ticket_scope_valid(ticket, domain, record_id, action)
|
||||
|
||||
@staticmethod
|
||||
def _is_ticket_scope_valid(
|
||||
ticket: ApprovalRequest,
|
||||
domain: str,
|
||||
record_id: str | int | None,
|
||||
action: str,
|
||||
) -> bool:
|
||||
if ticket.status != ApprovalStatus.APPROVED:
|
||||
return False
|
||||
if ticket.domain != domain:
|
||||
return False
|
||||
if ticket.record_id and (
|
||||
record_id is None or str(ticket.record_id) != str(record_id)
|
||||
):
|
||||
return False
|
||||
return ticket.action in {
|
||||
action,
|
||||
ApprovalActionValue.UPDATE,
|
||||
approval_action(ApprovalActionValue.UPDATE, domain),
|
||||
}
|
||||
|
||||
|
||||
def _payload_matches(approved_payload: str | None, requested_payload: dict[str, Any]) -> bool:
|
||||
try:
|
||||
parsed_payload = json.loads(approved_payload or "{}")
|
||||
except json.JSONDecodeError:
|
||||
parsed_payload = {}
|
||||
return _canonical_payload(parsed_payload) == _canonical_payload(requested_payload)
|
||||
|
||||
|
||||
def _canonical_payload(value: Any) -> str:
|
||||
return json.dumps(_json_safe(value), ensure_ascii=False, sort_keys=True, default=str)
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_json_safe(item) for item in value]
|
||||
return value
|
||||
@@ -10,15 +10,10 @@ class AuditAction(StrEnum):
|
||||
FEISHU_LONG_CONNECTION_EVENT = "long_connection_event"
|
||||
FEISHU_SEND_TEXT = "send_text"
|
||||
FEISHU_SEND_CARD = "send_card"
|
||||
APPROVAL_CREATE = "approval.create"
|
||||
APPROVAL_APPROVE = "approval.approve"
|
||||
APPROVAL_REJECT = "approval.reject"
|
||||
LEGACY_SYNC_PROJECTS = "sync_projects"
|
||||
LEGACY_SYNC_TASKS = "sync_tasks"
|
||||
RISK_EVENT_ACTION = "risk_event_action"
|
||||
REPORT_PUSH = "report_push"
|
||||
WRITEBACK_CREATE = "writeback.create"
|
||||
WRITEBACK_SUBMIT = "writeback.submit"
|
||||
|
||||
|
||||
class AuditRiskLevel(StrEnum):
|
||||
@@ -32,10 +27,8 @@ class AuditSource(StrEnum):
|
||||
OPENCLAW = "openclaw"
|
||||
RISK = "risk"
|
||||
FEISHU = "feishu"
|
||||
APPROVAL = "approval"
|
||||
LEGACY_MYSQL = "legacy_mysql"
|
||||
REPORTS = "reports"
|
||||
WRITEBACK = "writeback"
|
||||
|
||||
|
||||
class AuditTargetType(StrEnum):
|
||||
|
||||
@@ -86,11 +86,6 @@ class BusinessResponseKey(StrEnum):
|
||||
DATA = "data"
|
||||
|
||||
|
||||
class BusinessPayloadKey(StrEnum):
|
||||
DATA = "data"
|
||||
APPROVAL_TICKET_ID = "approval_ticket_id"
|
||||
|
||||
|
||||
class BusinessField(StrEnum):
|
||||
ID = "id"
|
||||
STATUS = "status"
|
||||
@@ -98,7 +93,6 @@ class BusinessField(StrEnum):
|
||||
|
||||
class BusinessErrorDetail(StrEnum):
|
||||
RECORD_NOT_FOUND = "Record not found"
|
||||
HIGH_RISK_APPROVAL_REQUIRED = "High-risk domain change requires approval_ticket_id"
|
||||
|
||||
|
||||
UNKNOWN_FIELD_TEMPLATE = "Unknown field '{field}'"
|
||||
|
||||
@@ -4,10 +4,10 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.masking import mask_configured
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.core.security import require_api_key
|
||||
from app.modules.business.constants import BusinessField, BusinessResponseKey
|
||||
from app.modules.business.registry import supported_domain_values
|
||||
from app.modules.business.schemas import DomainListRead, DomainRecordCreate, DomainRecordUpdate
|
||||
from app.modules.business.schemas import DomainListRead
|
||||
from app.modules.business.service import BusinessService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
@@ -53,49 +53,3 @@ def get_record(
|
||||
}
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{domain}")
|
||||
def create_record(
|
||||
domain: str,
|
||||
payload: DomainRecordCreate,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
try:
|
||||
data = BusinessService(db).create_record(
|
||||
domain,
|
||||
payload.data,
|
||||
principal.actor,
|
||||
payload.approval_ticket_id,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
return {
|
||||
BusinessResponseKey.DOMAIN: domain,
|
||||
BusinessResponseKey.DATA: mask_configured(data, domain=domain),
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{domain}/{record_id}")
|
||||
def update_record(
|
||||
domain: str,
|
||||
record_id: int,
|
||||
payload: DomainRecordUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
try:
|
||||
data = BusinessService(db).update_record(
|
||||
domain,
|
||||
record_id,
|
||||
payload.data,
|
||||
actor=principal.actor,
|
||||
approval_ticket_id=payload.approval_ticket_id,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
return {
|
||||
BusinessResponseKey.DOMAIN: domain,
|
||||
BusinessResponseKey.DATA: mask_configured(data, domain=domain),
|
||||
}
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
|
||||
|
||||
class DomainRecordCreate(BaseModel):
|
||||
data: dict[str, Any] = Field(..., description="Domain fields to create.")
|
||||
actor: str = ActorValue.API
|
||||
approval_ticket_id: str | None = Field(
|
||||
default=None,
|
||||
description="Required by policy for business record creates.",
|
||||
)
|
||||
|
||||
|
||||
class DomainRecordUpdate(BaseModel):
|
||||
data: dict[str, Any] = Field(..., description="Domain fields to update.")
|
||||
actor: str = ActorValue.API
|
||||
approval_ticket_id: str | None = Field(
|
||||
default=None,
|
||||
description="Required by policy for business record updates.",
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DomainRecordRead(BaseModel):
|
||||
|
||||
@@ -10,21 +10,14 @@ from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.sql.schema import Column
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.pagination import bounded_limit, bounded_offset
|
||||
from app.modules.audit.constants import AuditRiskLevel, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.approvals.constants import ApprovalActionValue, approval_action
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
from app.modules.business.registry import get_domain_model, get_writable_fields, is_high_risk_domain
|
||||
from app.modules.business.registry import get_domain_model, get_writable_fields
|
||||
from app.modules.business.constants import (
|
||||
INVALID_FIELD_VALUE_TEMPLATE,
|
||||
READ_ONLY_FIELD_TEMPLATE,
|
||||
UNKNOWN_FIELD_TEMPLATE,
|
||||
BusinessErrorDetail,
|
||||
BusinessField,
|
||||
BusinessPayloadKey,
|
||||
)
|
||||
|
||||
|
||||
@@ -90,11 +83,10 @@ def _model_payload(domain: str, model: Any, data: dict[str, Any]) -> dict[str, A
|
||||
|
||||
|
||||
class BusinessService:
|
||||
"""Manage generic CRUD operations across registered business domains."""
|
||||
"""Read business records across registered domains."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
|
||||
def list_records(
|
||||
self,
|
||||
@@ -124,116 +116,3 @@ class BusinessService:
|
||||
detail=BusinessErrorDetail.RECORD_NOT_FOUND,
|
||||
)
|
||||
return serialize_model(record)
|
||||
|
||||
def create_record(
|
||||
self,
|
||||
domain: str,
|
||||
data: dict[str, Any],
|
||||
actor: str = ActorValue.API,
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
model = get_domain_model(domain)
|
||||
high_risk = is_high_risk_domain(domain)
|
||||
payload = _model_payload(domain, model, data)
|
||||
record = model(**payload)
|
||||
self.db.add(record)
|
||||
if high_risk:
|
||||
self.db.flush()
|
||||
self._consume_approval(
|
||||
approval_ticket_id,
|
||||
domain,
|
||||
record.id,
|
||||
approval_action(ApprovalActionValue.CREATE, domain),
|
||||
data,
|
||||
actor,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
result = serialize_model(record)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.API,
|
||||
action=approval_action(ApprovalActionValue.CREATE, domain),
|
||||
target_type=domain,
|
||||
target_id=str(record.id),
|
||||
risk_level=AuditRiskLevel.HIGH if high_risk else AuditRiskLevel.LOW,
|
||||
request_payload={
|
||||
BusinessPayloadKey.DATA: data,
|
||||
BusinessPayloadKey.APPROVAL_TICKET_ID: approval_ticket_id,
|
||||
},
|
||||
response_payload=result,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def update_record(
|
||||
self,
|
||||
domain: str,
|
||||
record_id: int,
|
||||
data: dict[str, Any],
|
||||
actor: str = ActorValue.API,
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
model = get_domain_model(domain)
|
||||
high_risk = is_high_risk_domain(domain)
|
||||
record = self.db.get(model, record_id)
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=BusinessErrorDetail.RECORD_NOT_FOUND,
|
||||
)
|
||||
payload = _model_payload(domain, model, data)
|
||||
if high_risk:
|
||||
self._consume_approval(
|
||||
approval_ticket_id,
|
||||
domain,
|
||||
record_id,
|
||||
approval_action(ApprovalActionValue.UPDATE, domain),
|
||||
data,
|
||||
actor,
|
||||
)
|
||||
for key, value in payload.items():
|
||||
setattr(record, key, value)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
result = serialize_model(record)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.API,
|
||||
action=approval_action(ApprovalActionValue.UPDATE, domain),
|
||||
target_type=domain,
|
||||
target_id=str(record.id),
|
||||
risk_level=AuditRiskLevel.HIGH if high_risk else AuditRiskLevel.LOW,
|
||||
request_payload={
|
||||
BusinessPayloadKey.DATA: data,
|
||||
BusinessPayloadKey.APPROVAL_TICKET_ID: approval_ticket_id,
|
||||
},
|
||||
response_payload=result,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def _consume_approval(
|
||||
self,
|
||||
approval_ticket_id: str | None,
|
||||
domain: str,
|
||||
record_id: str | int | None,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
actor: str,
|
||||
) -> None:
|
||||
if not approval_ticket_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=BusinessErrorDetail.HIGH_RISK_APPROVAL_REQUIRED,
|
||||
)
|
||||
ApprovalService(self.db).consume_for(
|
||||
approval_ticket_id,
|
||||
domain,
|
||||
record_id,
|
||||
action,
|
||||
payload,
|
||||
actor,
|
||||
)
|
||||
|
||||
@@ -3,8 +3,6 @@ from typing import Any
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.approvals.constants import ApprovalStatus
|
||||
from app.modules.approvals.models import ApprovalRequest
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.business.constants import DONE_STATUSES, PROJECT_CLOSED_STATUSES, StatusValue
|
||||
from app.modules.business.models import (
|
||||
@@ -22,8 +20,6 @@ from app.modules.reports.constants import ReportPushStatus
|
||||
from app.modules.risk.service import RiskService
|
||||
from app.modules.workflows.constants import WorkflowStatus
|
||||
from app.modules.workflows.models import WorkflowInstance
|
||||
from app.modules.writebacks.constants import WritebackStatus
|
||||
from app.modules.writebacks.models import OfficialWritebackRun
|
||||
|
||||
|
||||
class DashboardService:
|
||||
@@ -36,10 +32,6 @@ class DashboardService:
|
||||
def summary(self) -> dict[str, Any]:
|
||||
active_projects = self._count(Project, Project.status.notin_(PROJECT_CLOSED_STATUSES))
|
||||
open_tasks = self._count(WorkTask, WorkTask.status.notin_(DONE_STATUSES))
|
||||
pending_approvals = self._count(
|
||||
ApprovalRequest,
|
||||
ApprovalRequest.status == ApprovalStatus.PENDING,
|
||||
)
|
||||
open_risk_events = self._count(RiskEvent, RiskEvent.status == StatusValue.OPEN)
|
||||
unassigned_open_risks = self._count(
|
||||
RiskEvent,
|
||||
@@ -57,14 +49,6 @@ class DashboardService:
|
||||
WorkflowInstance,
|
||||
WorkflowInstance.status == WorkflowStatus.FAILED,
|
||||
)
|
||||
disabled_writebacks = self._count(
|
||||
OfficialWritebackRun,
|
||||
OfficialWritebackRun.status == WritebackStatus.DISABLED,
|
||||
)
|
||||
failed_writebacks = self._count(
|
||||
OfficialWritebackRun,
|
||||
OfficialWritebackRun.status == WritebackStatus.FAILED,
|
||||
)
|
||||
latest_reports = self.db.execute(
|
||||
select(WorkReport).order_by(WorkReport.id.desc()).limit(5)
|
||||
).scalars()
|
||||
@@ -82,7 +66,6 @@ class DashboardService:
|
||||
"metrics": {
|
||||
"active_projects": active_projects,
|
||||
"open_tasks": open_tasks,
|
||||
"pending_approvals": pending_approvals,
|
||||
"open_risk_events": open_risk_events,
|
||||
"unassigned_open_risks": unassigned_open_risks,
|
||||
"failed_push_runs": failed_push_runs,
|
||||
@@ -90,8 +73,6 @@ class DashboardService:
|
||||
"failed_events": failed_events,
|
||||
"running_workflows": running_workflows,
|
||||
"failed_workflows": failed_workflows,
|
||||
"disabled_writebacks": disabled_writebacks,
|
||||
"failed_writebacks": failed_writebacks,
|
||||
"risk_level": risk_summary["risk_level"],
|
||||
"risk_score": float(risk_summary["risk_score"]),
|
||||
},
|
||||
|
||||
@@ -8,30 +8,23 @@ class EventStatus(StrEnum):
|
||||
|
||||
|
||||
class EventType(StrEnum):
|
||||
APPROVAL_DECIDED = "approval.decided"
|
||||
RISK_ACTION_RECORDED = "risk.action_recorded"
|
||||
REPORT_PUSH_SUCCEEDED = "report.push_succeeded"
|
||||
REPORT_PUSH_FAILED = "report.push_failed"
|
||||
LEGACY_SYNC_COMPLETED = "legacy.sync_completed"
|
||||
WRITEBACK_REQUESTED = "writeback.requested"
|
||||
WRITEBACK_SUBMITTED = "writeback.submitted"
|
||||
|
||||
|
||||
class EventSource(StrEnum):
|
||||
APPROVAL = "approval"
|
||||
RISK = "risk"
|
||||
REPORTS = "reports"
|
||||
LEGACY_MYSQL = "legacy_mysql"
|
||||
WRITEBACK = "writeback"
|
||||
API = "api"
|
||||
|
||||
|
||||
class EventAggregateType(StrEnum):
|
||||
APPROVAL = "approval"
|
||||
RISK_EVENT = "risk-event"
|
||||
REPORT_PUSH_RUN = "report-push-run"
|
||||
LEGACY_SYNC_RUN = "legacy-sync-run"
|
||||
WRITEBACK_RUN = "writeback-run"
|
||||
|
||||
|
||||
class EventResponseKey(StrEnum):
|
||||
@@ -43,10 +36,8 @@ class EventResponseKey(StrEnum):
|
||||
class EventPayloadKey(StrEnum):
|
||||
ACTION = "action"
|
||||
STATUS = "status"
|
||||
TICKET_ID = "ticket_id"
|
||||
DOMAIN = "domain"
|
||||
RECORD_ID = "record_id"
|
||||
APPROVED = "approved"
|
||||
COMMENT = "comment"
|
||||
CODE = "code"
|
||||
CREATED = "created"
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.operation_guard import require_operations_enabled
|
||||
from app.core.security import require_api_key
|
||||
from app.modules.events.constants import EventResponseKey
|
||||
from app.modules.events.service import EventService, _serialize_event
|
||||
@@ -30,6 +31,7 @@ def dispatch_event(
|
||||
event_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return {EventResponseKey.EVENT: _serialize_event(EventService(db).dispatch_event(event_id))}
|
||||
|
||||
|
||||
@@ -38,4 +40,5 @@ def dispatch_pending(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return {EventResponseKey.ITEMS: EventService(db).dispatch_pending(limit=limit)}
|
||||
|
||||
@@ -131,9 +131,6 @@ class EventService:
|
||||
def _handle_event(self, record: DomainEvent) -> None:
|
||||
if record.event_type == EventType.RISK_ACTION_RECORDED:
|
||||
self._handle_risk_action(record)
|
||||
return
|
||||
if record.event_type in {EventType.WRITEBACK_REQUESTED, EventType.WRITEBACK_SUBMITTED}:
|
||||
self._handle_writeback(record)
|
||||
|
||||
def _handle_risk_action(self, record: DomainEvent) -> None:
|
||||
from app.modules.risk.constants import RiskEventActionValue
|
||||
@@ -157,28 +154,3 @@ class EventService:
|
||||
actor=record.actor,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
def _handle_writeback(self, record: DomainEvent) -> None:
|
||||
from app.modules.workflows.service import WorkflowService
|
||||
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
|
||||
from app.modules.writebacks.constants import WritebackStatus
|
||||
|
||||
payload = record.payload or {}
|
||||
run_status = str(payload.get(EventPayloadKey.STATUS) or "")
|
||||
if run_status == WritebackStatus.SENT:
|
||||
workflow_status = WorkflowStatus.COMPLETED
|
||||
elif run_status == WritebackStatus.FAILED:
|
||||
workflow_status = WorkflowStatus.FAILED
|
||||
elif run_status == WritebackStatus.DISABLED:
|
||||
workflow_status = WorkflowStatus.BLOCKED
|
||||
else:
|
||||
workflow_status = WorkflowStatus.WAITING_APPROVAL
|
||||
WorkflowService(self.db).start_or_update(
|
||||
workflow_type=WorkflowType.OFFICIAL_WRITEBACK,
|
||||
aggregate_type=EventAggregateType.WRITEBACK_RUN,
|
||||
aggregate_id=record.aggregate_id,
|
||||
status_value=workflow_status,
|
||||
action=record.event_type,
|
||||
actor=record.actor,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
@@ -98,18 +98,6 @@ class FeishuEventReceiptKey(StrEnum):
|
||||
MESSAGE_ID = "message_id"
|
||||
|
||||
|
||||
class FeishuApprovalAction(StrEnum):
|
||||
APPROVE = "approve"
|
||||
REJECT = "reject"
|
||||
|
||||
|
||||
class FeishuApprovalValueKey(StrEnum):
|
||||
TICKET_ID = "ticket_id"
|
||||
DECISION = "decision"
|
||||
ACTION = "action"
|
||||
COMMENT = "comment"
|
||||
|
||||
|
||||
class FeishuCardKey(StrEnum):
|
||||
ACTIONS = "actions"
|
||||
BUTTON_TYPE = "type"
|
||||
@@ -123,10 +111,6 @@ FEISHU_AUTH_MISSING = "Feishu app credentials are not configured"
|
||||
FEISHU_VERIFICATION_TOKEN_REQUIRED = "FEISHU_VERIFICATION_TOKEN is required"
|
||||
FEISHU_INVALID_TOKEN = "Invalid Feishu token"
|
||||
FEISHU_RECEIVE_ID_MISSING = "receive_id or FEISHU_DEFAULT_CHAT_ID is required"
|
||||
FEISHU_APPROVAL_ACTION_INVALID = "Invalid Feishu approval action payload"
|
||||
FEISHU_APPROVAL_APPROVER_IDS_REQUIRED = "FEISHU_APPROVAL_APPROVER_IDS is required"
|
||||
FEISHU_APPROVER_NOT_ALLOWED = "Feishu approver is not allowed"
|
||||
FEISHU_APPROVAL_CARD_ACTION_TARGET = "approval_card_action"
|
||||
FEISHU_SUCCESS_CODE = 0
|
||||
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
|
||||
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.config import get_settings
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
from app.modules.audit.constants import AuditAction, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.feishu.commands import FeishuCommandService
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_APPROVAL_ACTION_INVALID,
|
||||
FEISHU_APPROVAL_APPROVER_IDS_REQUIRED,
|
||||
FEISHU_APPROVAL_CARD_ACTION_TARGET,
|
||||
FEISHU_APPROVER_NOT_ALLOWED,
|
||||
FeishuApprovalAction,
|
||||
FeishuApprovalValueKey,
|
||||
FeishuCardKey,
|
||||
FeishuCommandKey,
|
||||
FeishuEventReceiptKey,
|
||||
FeishuEventSource,
|
||||
@@ -90,86 +79,6 @@ class FeishuEventService:
|
||||
FeishuResponseKey.RESULT: result,
|
||||
}
|
||||
|
||||
def handle_approval_card_action(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle Feishu interactive-card approval button callbacks."""
|
||||
|
||||
self.feishu.verify_event(payload)
|
||||
value = _approval_action_value(payload)
|
||||
ticket_id = str(value.get(FeishuApprovalValueKey.TICKET_ID) or "").strip()
|
||||
decision = str(
|
||||
value.get(FeishuApprovalValueKey.DECISION)
|
||||
or value.get(FeishuApprovalValueKey.ACTION)
|
||||
or ""
|
||||
).lower()
|
||||
if not ticket_id or decision not in set(FeishuApprovalAction):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=FEISHU_APPROVAL_ACTION_INVALID,
|
||||
)
|
||||
comment = value.get(FeishuApprovalValueKey.COMMENT)
|
||||
actor = _approval_operator(payload)
|
||||
try:
|
||||
_ensure_approval_operator_allowed(actor)
|
||||
except HTTPException as exc:
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
action=AuditAction.FEISHU_WEBHOOK_EVENT,
|
||||
target_type=FEISHU_APPROVAL_CARD_ACTION_TARGET,
|
||||
target_id=ticket_id,
|
||||
request_payload=payload,
|
||||
response_payload={
|
||||
FeishuResponseKey.OK: False,
|
||||
"status_code": exc.status_code,
|
||||
"detail": exc.detail,
|
||||
},
|
||||
)
|
||||
)
|
||||
raise
|
||||
event_identity = _approval_event_identity(payload, ticket_id, decision, actor)
|
||||
if not self._register_event(event_identity):
|
||||
ticket = ApprovalService(self.db).get_by_ticket(ticket_id)
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: True,
|
||||
FeishuResponseKey.DUPLICATE: True,
|
||||
FeishuResponseKey.RESULT: {
|
||||
FeishuApprovalValueKey.TICKET_ID: ticket.ticket_id,
|
||||
FeishuResponseKey.STATUS: ticket.status,
|
||||
FeishuResponseKey.APPROVER: ticket.approver,
|
||||
},
|
||||
}
|
||||
ticket = ApprovalService(self.db).decide(
|
||||
ticket_id,
|
||||
actor,
|
||||
approved=decision == FeishuApprovalAction.APPROVE,
|
||||
comment=str(comment) if comment is not None else None,
|
||||
)
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
action=AuditAction.FEISHU_WEBHOOK_EVENT,
|
||||
target_type=FEISHU_APPROVAL_CARD_ACTION_TARGET,
|
||||
target_id=ticket_id,
|
||||
request_payload=payload,
|
||||
response_payload={
|
||||
FeishuResponseKey.STATUS: ticket.status,
|
||||
FeishuApprovalValueKey.DECISION: decision,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: True,
|
||||
FeishuResponseKey.RESULT: {
|
||||
FeishuApprovalValueKey.TICKET_ID: ticket.ticket_id,
|
||||
FeishuResponseKey.STATUS: ticket.status,
|
||||
FeishuResponseKey.APPROVER: ticket.approver,
|
||||
},
|
||||
}
|
||||
|
||||
def _register_event(self, event_identity: dict[str, str | None]) -> bool:
|
||||
receipt = FeishuEventReceipt(
|
||||
event_key=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]),
|
||||
@@ -214,71 +123,3 @@ def _event_identity(
|
||||
FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None,
|
||||
FeishuEventReceiptKey.MESSAGE_ID: str(message_id) if message_id else None,
|
||||
}
|
||||
|
||||
|
||||
def _approval_action_value(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
action = payload.get(FeishuApprovalValueKey.ACTION) or {}
|
||||
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||
event_action = event.get(FeishuApprovalValueKey.ACTION) or {}
|
||||
value = (
|
||||
action.get(FeishuCardKey.VALUE)
|
||||
or event_action.get(FeishuCardKey.VALUE)
|
||||
or payload.get(FeishuCardKey.VALUE)
|
||||
or {}
|
||||
)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _approval_operator(payload: dict[str, Any]) -> str:
|
||||
operator = payload.get("operator") or (payload.get(FeishuPayloadKey.EVENT) or {}).get(
|
||||
"operator"
|
||||
) or {}
|
||||
operator_id = operator.get("operator_id") or {}
|
||||
return (
|
||||
operator_id.get(FeishuPayloadKey.OPEN_ID)
|
||||
or operator_id.get(FeishuPayloadKey.USER_ID)
|
||||
or operator.get(FeishuPayloadKey.OPEN_ID)
|
||||
or operator.get(FeishuPayloadKey.USER_ID)
|
||||
or ActorValue.FEISHU
|
||||
)
|
||||
|
||||
|
||||
def _ensure_approval_operator_allowed(actor: str) -> None:
|
||||
allowed_ids = {
|
||||
item.strip()
|
||||
for item in get_settings().feishu_approval_approver_ids
|
||||
if item.strip()
|
||||
}
|
||||
if not allowed_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=FEISHU_APPROVAL_APPROVER_IDS_REQUIRED,
|
||||
)
|
||||
if actor not in allowed_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=FEISHU_APPROVER_NOT_ALLOWED,
|
||||
)
|
||||
|
||||
|
||||
def _approval_event_identity(
|
||||
payload: dict[str, Any],
|
||||
ticket_id: str,
|
||||
decision: str,
|
||||
actor: str,
|
||||
) -> dict[str, str | None]:
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
event_id = header.get(FeishuPayloadKey.EVENT_ID)
|
||||
stable_id = event_id or f"{ticket_id}:{decision}:{actor}"
|
||||
return {
|
||||
FeishuEventReceiptKey.EVENT_KEY: f"{FeishuEventSource.WEBHOOK}:approval:{stable_id}",
|
||||
FeishuEventReceiptKey.SOURCE: FeishuEventSource.WEBHOOK,
|
||||
FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None,
|
||||
FeishuEventReceiptKey.MESSAGE_ID: None,
|
||||
}
|
||||
|
||||
@@ -30,17 +30,6 @@ async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dic
|
||||
)
|
||||
|
||||
|
||||
@router.post("/approval-card-action")
|
||||
async def feishu_approval_card_action(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Handle Feishu interactive-card approval actions."""
|
||||
|
||||
payload = await request.json()
|
||||
return FeishuEventService(db).handle_approval_card_action(payload)
|
||||
|
||||
|
||||
@router.post("/send-text", response_model=FeishuSendResult)
|
||||
def send_text(
|
||||
payload: FeishuTextMessage,
|
||||
|
||||
@@ -14,9 +14,6 @@ from app.modules.feishu.constants import (
|
||||
FEISHU_EMPTY_CARD_TEXT,
|
||||
FEISHU_INVALID_TOKEN,
|
||||
FEISHU_VERIFICATION_TOKEN_REQUIRED,
|
||||
FeishuApprovalAction,
|
||||
FeishuApprovalValueKey,
|
||||
FeishuCardKey,
|
||||
FeishuPayloadKey,
|
||||
FeishuReceiveIdType,
|
||||
)
|
||||
@@ -112,43 +109,3 @@ class FeishuService:
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def build_approval_card(
|
||||
title: str,
|
||||
lines: list[str],
|
||||
ticket_id: str,
|
||||
) -> dict[str, Any]:
|
||||
card = FeishuService.build_basic_card(title, lines)
|
||||
card[FeishuPayloadKey.ELEMENTS].append(
|
||||
{
|
||||
FeishuPayloadKey.TAG: FeishuApprovalValueKey.ACTION,
|
||||
FeishuCardKey.ACTIONS: [
|
||||
{
|
||||
FeishuPayloadKey.TAG: "button",
|
||||
FeishuPayloadKey.TEXT: {
|
||||
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
|
||||
FeishuPayloadKey.CONTENT: "批准",
|
||||
},
|
||||
FeishuCardKey.BUTTON_TYPE: "primary",
|
||||
FeishuCardKey.VALUE: {
|
||||
FeishuApprovalValueKey.TICKET_ID: ticket_id,
|
||||
FeishuApprovalValueKey.DECISION: FeishuApprovalAction.APPROVE,
|
||||
},
|
||||
},
|
||||
{
|
||||
FeishuPayloadKey.TAG: "button",
|
||||
FeishuPayloadKey.TEXT: {
|
||||
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
|
||||
FeishuPayloadKey.CONTENT: "拒绝",
|
||||
},
|
||||
FeishuCardKey.BUTTON_TYPE: "danger",
|
||||
FeishuCardKey.VALUE: {
|
||||
FeishuApprovalValueKey.TICKET_ID: ticket_id,
|
||||
FeishuApprovalValueKey.DECISION: FeishuApprovalAction.REJECT,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
return card
|
||||
|
||||
@@ -3,6 +3,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.masking import mask_configured
|
||||
from app.core.operation_guard import require_operations_enabled
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.core.task_queue import enqueue_legacy_project_sync, enqueue_legacy_task_sync
|
||||
from app.modules.legacy_mysql.schemas import (
|
||||
@@ -60,6 +61,7 @@ def sync_projects(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
result = LegacyMySQLService(db).sync_projects(
|
||||
source_query=payload.source_query,
|
||||
source_query_name=payload.source_query_name,
|
||||
@@ -76,6 +78,7 @@ def enqueue_sync_projects(
|
||||
payload: LegacyProjectSyncRequest,
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return enqueue_legacy_project_sync(
|
||||
source_query=payload.source_query,
|
||||
source_query_name=payload.source_query_name,
|
||||
@@ -92,6 +95,7 @@ def sync_tasks(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
result = LegacyMySQLService(db).sync_tasks(
|
||||
source_query=payload.source_query,
|
||||
source_query_name=payload.source_query_name,
|
||||
@@ -108,6 +112,7 @@ def enqueue_sync_tasks(
|
||||
payload: LegacyTaskSyncRequest,
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return enqueue_legacy_task_sync(
|
||||
source_query=payload.source_query,
|
||||
source_query_name=payload.source_query_name,
|
||||
|
||||
@@ -9,7 +9,6 @@ class ObservabilityKey(StrEnum):
|
||||
REDIS = "redis"
|
||||
EVENTS = "events"
|
||||
WORKFLOWS = "workflows"
|
||||
WRITEBACKS = "writebacks"
|
||||
|
||||
|
||||
class ObservabilityStatus(StrEnum):
|
||||
@@ -24,4 +23,3 @@ class ObservabilityMetricKey(StrEnum):
|
||||
PENDING = "pending"
|
||||
FAILED = "failed"
|
||||
RUNNING = "running"
|
||||
DISABLED = "disabled"
|
||||
|
||||
@@ -13,8 +13,6 @@ from app.modules.observability.constants import (
|
||||
)
|
||||
from app.modules.workflows.constants import WorkflowStatus
|
||||
from app.modules.workflows.service import WorkflowService
|
||||
from app.modules.writebacks.constants import WritebackStatus
|
||||
from app.modules.writebacks.service import WritebackService
|
||||
|
||||
|
||||
class ObservabilityService:
|
||||
@@ -32,7 +30,6 @@ class ObservabilityService:
|
||||
ObservabilityKey.REDIS: self._redis_check(),
|
||||
ObservabilityKey.EVENTS: self._events_check(),
|
||||
ObservabilityKey.WORKFLOWS: self._workflows_check(),
|
||||
ObservabilityKey.WRITEBACKS: self._writebacks_check(),
|
||||
}
|
||||
degraded = any(
|
||||
item[ObservabilityKey.STATUS]
|
||||
@@ -51,7 +48,6 @@ class ObservabilityService:
|
||||
ObservabilityKey.METRICS: {
|
||||
ObservabilityKey.EVENTS: EventService(self.db).count_by_status(),
|
||||
ObservabilityKey.WORKFLOWS: WorkflowService(self.db).count_by_status(),
|
||||
ObservabilityKey.WRITEBACKS: WritebackService(self.db).count_by_status(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,14 +97,3 @@ class ObservabilityService:
|
||||
ObservabilityMetricKey.RUNNING: counts.get(WorkflowStatus.RUNNING, 0),
|
||||
ObservabilityMetricKey.FAILED: failed,
|
||||
}
|
||||
|
||||
def _writebacks_check(self) -> dict[str, Any]:
|
||||
counts = WritebackService(self.db).count_by_status()
|
||||
failed = counts.get(WritebackStatus.FAILED, 0)
|
||||
return {
|
||||
ObservabilityKey.STATUS: (
|
||||
ObservabilityStatus.DEGRADED if failed else ObservabilityStatus.OK
|
||||
),
|
||||
ObservabilityMetricKey.DISABLED: counts.get(WritebackStatus.DISABLED, 0),
|
||||
ObservabilityMetricKey.FAILED: failed,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.operation_guard import require_operations_enabled
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.core.task_queue import enqueue_daily_brief_push, enqueue_project_weekly_push
|
||||
from app.modules.reports.schemas import (
|
||||
@@ -82,6 +83,8 @@ def generate_work_report(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
if payload.persist:
|
||||
require_operations_enabled()
|
||||
return ReportService(db).generate_work_report(
|
||||
report_type=payload.report_type,
|
||||
reporter=payload.reporter,
|
||||
|
||||
@@ -26,5 +26,5 @@ class WorkReportGenerateRequest(BaseModel):
|
||||
project_code: str | None = None
|
||||
period_start: date | None = None
|
||||
period_end: date | None = None
|
||||
persist: bool = True
|
||||
persist: bool = False
|
||||
actor: str = ActorValue.API
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.operation_guard import require_operations_enabled
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.core.task_queue import enqueue_risk_event_generation
|
||||
from app.modules.risk.constants import RiskEventActionKey, RiskGenerationResultKey
|
||||
@@ -94,6 +95,7 @@ def assign_risk_event(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return RiskService(db).assign_event(
|
||||
event_id,
|
||||
assigned_to=payload.assigned_to,
|
||||
@@ -109,6 +111,7 @@ def comment_risk_event(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return RiskService(db).comment_event(
|
||||
event_id,
|
||||
comment=payload.comment,
|
||||
@@ -124,6 +127,7 @@ def resolve_risk_event(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return RiskService(db).resolve_event(
|
||||
event_id,
|
||||
comment=payload.comment,
|
||||
@@ -139,12 +143,12 @@ def close_risk_event(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return RiskService(db).close_event(
|
||||
event_id,
|
||||
closed_reason=payload.closed_reason,
|
||||
review_summary=payload.review_summary,
|
||||
actor=principal.actor,
|
||||
approval_ticket_id=payload.approval_ticket_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -155,11 +159,11 @@ def reopen_risk_event(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return RiskService(db).reopen_event(
|
||||
event_id,
|
||||
comment=payload.comment,
|
||||
actor=principal.actor,
|
||||
approval_ticket_id=payload.approval_ticket_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -168,6 +172,7 @@ def generate_risk_events(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return RiskService(db).generate_events(actor=principal.actor)
|
||||
|
||||
|
||||
@@ -175,4 +180,5 @@ def generate_risk_events(
|
||||
def enqueue_risk_events(
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return enqueue_risk_event_generation(actor=principal.actor)
|
||||
|
||||
@@ -21,9 +21,7 @@ class RiskResolveRequest(BaseModel):
|
||||
class RiskCloseRequest(BaseModel):
|
||||
closed_reason: str = Field(..., min_length=1)
|
||||
review_summary: str | None = None
|
||||
approval_ticket_id: str | None = None
|
||||
|
||||
|
||||
class RiskReopenRequest(BaseModel):
|
||||
comment: str | None = None
|
||||
approval_ticket_id: str | None = None
|
||||
|
||||
@@ -2,7 +2,6 @@ from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -17,15 +16,12 @@ from app.modules.audit.constants import (
|
||||
)
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.approvals.constants import ApprovalActionValue, approval_action
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
from app.modules.business.constants import (
|
||||
CLOSED_RISK_STATUSES,
|
||||
DONE_STATUSES,
|
||||
GENERATED_RISK_EVENT_TYPES,
|
||||
PROJECT_CLOSED_STATUSES,
|
||||
SUPPLIER_RISK_LEVELS,
|
||||
BusinessErrorDetail,
|
||||
BusinessDomain,
|
||||
RiskEventType,
|
||||
RiskLevel,
|
||||
@@ -203,20 +199,8 @@ class RiskService:
|
||||
closed_reason: str,
|
||||
review_summary: str | None = None,
|
||||
actor: str = ActorValue.API,
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
record = self._get_event(risk_event_id)
|
||||
approval_payload = {
|
||||
RiskEventPayloadKey.STATUS: StatusValue.CLOSED,
|
||||
"closed_reason": closed_reason,
|
||||
RiskEventActionKey.REVIEW_SUMMARY: review_summary,
|
||||
}
|
||||
self._consume_risk_approval(
|
||||
approval_ticket_id,
|
||||
risk_event_id,
|
||||
approval_payload,
|
||||
actor,
|
||||
)
|
||||
from_status = record.status
|
||||
now = utc_now()
|
||||
record.status = StatusValue.CLOSED
|
||||
@@ -244,19 +228,8 @@ class RiskService:
|
||||
risk_event_id: int,
|
||||
comment: str | None = None,
|
||||
actor: str = ActorValue.API,
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
record = self._get_event(risk_event_id)
|
||||
approval_payload = {
|
||||
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
|
||||
RiskEventActionKey.COMMENT: comment,
|
||||
}
|
||||
self._consume_risk_approval(
|
||||
approval_ticket_id,
|
||||
risk_event_id,
|
||||
approval_payload,
|
||||
actor,
|
||||
)
|
||||
from_status = record.status
|
||||
record.status = StatusValue.OPEN
|
||||
record.resolved_at = None
|
||||
@@ -384,27 +357,6 @@ class RiskService:
|
||||
RiskEventActionKey.ACTION_RECORD: serialize_model(action),
|
||||
}
|
||||
|
||||
def _consume_risk_approval(
|
||||
self,
|
||||
approval_ticket_id: str | None,
|
||||
risk_event_id: int,
|
||||
payload: dict[str, Any],
|
||||
actor: str,
|
||||
) -> None:
|
||||
if not approval_ticket_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=BusinessErrorDetail.HIGH_RISK_APPROVAL_REQUIRED,
|
||||
)
|
||||
ApprovalService(self.db).consume_for(
|
||||
approval_ticket_id,
|
||||
BusinessDomain.RISK_EVENTS,
|
||||
risk_event_id,
|
||||
approval_action(ApprovalActionValue.UPDATE, BusinessDomain.RISK_EVENTS),
|
||||
payload,
|
||||
actor,
|
||||
)
|
||||
|
||||
def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]:
|
||||
"""Generate or refresh risk-event ledger entries from current signals."""
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@ from enum import StrEnum
|
||||
|
||||
class WorkflowType(StrEnum):
|
||||
RISK_EVENT_REVIEW = "risk_event_review"
|
||||
OFFICIAL_WRITEBACK = "official_writeback"
|
||||
|
||||
|
||||
class WorkflowStatus(StrEnum):
|
||||
WAITING_APPROVAL = "waiting_approval"
|
||||
WAITING_REVIEW = "waiting_review"
|
||||
RUNNING = "running"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
from typing import Any, Protocol
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader
|
||||
from app.modules.writebacks.constants import (
|
||||
WRITEBACK_DISABLED_MESSAGE,
|
||||
WritebackErrorDetail,
|
||||
WritebackPayloadKey,
|
||||
WritebackStatus,
|
||||
)
|
||||
from app.modules.writebacks.models import OfficialWritebackRun
|
||||
|
||||
|
||||
class WritebackAdapter(Protocol):
|
||||
def submit(self, run: OfficialWritebackRun) -> dict[str, Any]:
|
||||
"""Submit one writeback run to the configured official integration."""
|
||||
|
||||
|
||||
class DisabledWritebackAdapter:
|
||||
def submit(self, run: OfficialWritebackRun) -> dict[str, Any]:
|
||||
return {
|
||||
WritebackPayloadKey.STATUS: WritebackStatus.DISABLED,
|
||||
WritebackPayloadKey.ERROR_MESSAGE: WRITEBACK_DISABLED_MESSAGE,
|
||||
WritebackPayloadKey.CODE: run.code,
|
||||
}
|
||||
|
||||
|
||||
class HttpWritebackAdapter:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
|
||||
def submit(self, run: OfficialWritebackRun) -> dict[str, Any]:
|
||||
if not self.settings.official_api_base_url or not self.settings.official_api_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=WritebackErrorDetail.OFFICIAL_API_NOT_CONFIGURED,
|
||||
)
|
||||
url = f"{self.settings.official_api_base_url.rstrip('/')}/writebacks/{run.domain}"
|
||||
headers = {
|
||||
HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(
|
||||
token=self.settings.official_api_token
|
||||
)
|
||||
}
|
||||
payload = {
|
||||
WritebackPayloadKey.CODE: run.code,
|
||||
WritebackPayloadKey.DOMAIN: run.domain,
|
||||
WritebackPayloadKey.RECORD_ID: run.record_id,
|
||||
WritebackPayloadKey.ACTION: run.action,
|
||||
WritebackPayloadKey.PAYLOAD: run.request_payload or {},
|
||||
}
|
||||
with httpx.Client(timeout=self.settings.official_api_timeout_seconds) as client:
|
||||
response = client.post(url, json=payload, headers=headers)
|
||||
if response.status_code >= status.HTTP_400_BAD_REQUEST:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=response.text,
|
||||
)
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
data = {"text": response.text}
|
||||
return {
|
||||
WritebackPayloadKey.STATUS: WritebackStatus.SENT,
|
||||
WritebackPayloadKey.PROVIDER_RESPONSE: data,
|
||||
}
|
||||
|
||||
|
||||
def get_writeback_adapter(settings: Settings) -> WritebackAdapter:
|
||||
if settings.official_writeback_enabled:
|
||||
return HttpWritebackAdapter(settings)
|
||||
return DisabledWritebackAdapter()
|
||||
@@ -1,42 +0,0 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class WritebackStatus(StrEnum):
|
||||
DRAFT = "draft"
|
||||
PENDING_APPROVAL = "pending_approval"
|
||||
DISABLED = "disabled"
|
||||
QUEUED = "queued"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class WritebackActionValue(StrEnum):
|
||||
WRITEBACK = "writeback"
|
||||
|
||||
|
||||
class WritebackPayloadKey(StrEnum):
|
||||
CODE = "code"
|
||||
STATUS = "status"
|
||||
DOMAIN = "domain"
|
||||
RECORD_ID = "record_id"
|
||||
ACTION = "action"
|
||||
PAYLOAD = "payload"
|
||||
APPROVAL_TICKET_ID = "approval_ticket_id"
|
||||
PROVIDER_RESPONSE = "provider_response"
|
||||
ERROR_MESSAGE = "error_message"
|
||||
|
||||
|
||||
class WritebackResponseKey(StrEnum):
|
||||
ITEMS = "items"
|
||||
DATA = "data"
|
||||
|
||||
|
||||
class WritebackErrorDetail(StrEnum):
|
||||
RUN_NOT_FOUND = "Writeback run not found"
|
||||
APPROVAL_TICKET_REQUIRED = "approval_ticket_id is required for official writeback"
|
||||
OFFICIAL_API_NOT_CONFIGURED = "Official API is not configured"
|
||||
|
||||
|
||||
WRITEBACK_CODE_PREFIX = "WB"
|
||||
WRITEBACK_DISABLED_MESSAGE = "Official writeback is disabled"
|
||||
WRITEBACK_DEFAULT_ACTION = "sync"
|
||||
@@ -1,39 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.db_base import Base
|
||||
from app.core.time import utc_now
|
||||
from app.modules.writebacks.constants import WritebackStatus
|
||||
|
||||
|
||||
class OfficialWritebackRun(Base):
|
||||
__tablename__ = "official_writeback_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
domain: Mapped[str] = mapped_column(String(128), index=True)
|
||||
record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.API, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default=WritebackStatus.DRAFT, index=True)
|
||||
approval_ticket_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
provider_response: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
@@ -1,66 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.writebacks.constants import WritebackResponseKey
|
||||
from app.modules.writebacks.schemas import WritebackCreateRequest, WritebackSubmitRequest
|
||||
from app.modules.writebacks.service import WritebackService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_writebacks(
|
||||
status: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {
|
||||
WritebackResponseKey.ITEMS: WritebackService(db).list_runs(
|
||||
status_filter=status,
|
||||
limit=limit,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_writeback(
|
||||
payload: WritebackCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return {
|
||||
WritebackResponseKey.DATA: WritebackService(db).create_run(
|
||||
domain=payload.domain,
|
||||
record_id=payload.record_id,
|
||||
action=payload.action,
|
||||
payload=payload.payload,
|
||||
actor=principal.actor,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{code}")
|
||||
def get_writeback(
|
||||
code: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {WritebackResponseKey.DATA: WritebackService(db).get_run(code)}
|
||||
|
||||
|
||||
@router.post("/{code}/submit")
|
||||
def submit_writeback(
|
||||
code: str,
|
||||
payload: WritebackSubmitRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return {
|
||||
WritebackResponseKey.DATA: WritebackService(db).submit_run(
|
||||
code,
|
||||
approval_ticket_id=payload.approval_ticket_id,
|
||||
actor=principal.actor,
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.modules.writebacks.constants import WRITEBACK_DEFAULT_ACTION
|
||||
|
||||
|
||||
class WritebackCreateRequest(BaseModel):
|
||||
domain: str = Field(..., min_length=1)
|
||||
record_id: str | None = None
|
||||
action: str = Field(default=WRITEBACK_DEFAULT_ACTION, min_length=1)
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
idempotency_key: str | None = None
|
||||
|
||||
|
||||
class WritebackSubmitRequest(BaseModel):
|
||||
approval_ticket_id: str | None = None
|
||||
|
||||
|
||||
class WritebackRead(BaseModel):
|
||||
code: str
|
||||
domain: str
|
||||
record_id: str | None
|
||||
action: str
|
||||
actor: str
|
||||
status: str
|
||||
approval_ticket_id: str | None
|
||||
idempotency_key: str | None
|
||||
request_payload: dict[str, Any] | None
|
||||
provider_response: dict[str, Any] | None
|
||||
error_message: str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
submitted_at: str | None
|
||||
sent_at: str | None
|
||||
@@ -1,232 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.pagination import bounded_limit
|
||||
from app.core.time import utc_now
|
||||
from app.modules.approvals.constants import approval_action
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.business.service import serialize_model
|
||||
from app.modules.events.constants import EventAggregateType, EventSource, EventType
|
||||
from app.modules.events.service import EventService
|
||||
from app.modules.writebacks.adapters import get_writeback_adapter
|
||||
from app.modules.writebacks.constants import (
|
||||
WRITEBACK_CODE_PREFIX,
|
||||
WRITEBACK_DISABLED_MESSAGE,
|
||||
WritebackActionValue,
|
||||
WritebackErrorDetail,
|
||||
WritebackPayloadKey,
|
||||
WritebackStatus,
|
||||
)
|
||||
from app.modules.writebacks.models import OfficialWritebackRun
|
||||
|
||||
|
||||
class WritebackService:
|
||||
"""Create and submit approved official-system writeback runs."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
|
||||
def create_run(
|
||||
self,
|
||||
domain: str,
|
||||
record_id: str | None,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
actor: str = ActorValue.API,
|
||||
idempotency_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if idempotency_key:
|
||||
existing = self.db.execute(
|
||||
select(OfficialWritebackRun).where(
|
||||
OfficialWritebackRun.idempotency_key == idempotency_key
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return serialize_model(existing)
|
||||
|
||||
record = OfficialWritebackRun(
|
||||
code=f"{WRITEBACK_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
|
||||
domain=domain,
|
||||
record_id=record_id,
|
||||
action=action,
|
||||
actor=actor,
|
||||
request_payload=payload,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.WRITEBACK_REQUESTED,
|
||||
source=EventSource.WRITEBACK,
|
||||
aggregate_type=EventAggregateType.WRITEBACK_RUN,
|
||||
aggregate_id=record.code,
|
||||
actor=actor,
|
||||
payload=self._event_payload(record),
|
||||
idempotency_key=f"{record.code}:requested",
|
||||
dispatch=True,
|
||||
)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.API,
|
||||
action=AuditAction.WRITEBACK_CREATE,
|
||||
target_type=domain,
|
||||
target_id=record_id,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
request_payload=payload,
|
||||
response_payload={WritebackPayloadKey.CODE: record.code},
|
||||
)
|
||||
)
|
||||
return serialize_model(record)
|
||||
|
||||
def submit_run(
|
||||
self,
|
||||
code: str,
|
||||
approval_ticket_id: str | None,
|
||||
actor: str = ActorValue.API,
|
||||
) -> dict[str, Any]:
|
||||
if not approval_ticket_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=WritebackErrorDetail.APPROVAL_TICKET_REQUIRED,
|
||||
)
|
||||
record = self._get_run(code)
|
||||
settings = get_settings()
|
||||
approval_action_name = approval_action(WritebackActionValue.WRITEBACK, record.domain)
|
||||
if settings.official_writeback_enabled:
|
||||
ApprovalService(self.db).consume_for(
|
||||
approval_ticket_id,
|
||||
record.domain,
|
||||
record.record_id,
|
||||
approval_action_name,
|
||||
record.request_payload or {},
|
||||
actor,
|
||||
)
|
||||
elif not ApprovalService(self.db).is_approved_for(
|
||||
approval_ticket_id,
|
||||
record.domain,
|
||||
record.record_id,
|
||||
approval_action_name,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=WritebackErrorDetail.APPROVAL_TICKET_REQUIRED,
|
||||
)
|
||||
|
||||
record.approval_ticket_id = approval_ticket_id
|
||||
record.submitted_at = utc_now()
|
||||
record.status = WritebackStatus.PENDING_APPROVAL
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
|
||||
adapter = get_writeback_adapter(settings)
|
||||
try:
|
||||
result = adapter.submit(record)
|
||||
except Exception as exc:
|
||||
record.status = WritebackStatus.FAILED
|
||||
record.error_message = str(exc)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
self._emit_submitted(record, actor)
|
||||
self._audit_submit(record, actor)
|
||||
return serialize_model(record)
|
||||
|
||||
if result.get(WritebackPayloadKey.STATUS) == WritebackStatus.DISABLED:
|
||||
record.status = WritebackStatus.DISABLED
|
||||
record.error_message = WRITEBACK_DISABLED_MESSAGE
|
||||
else:
|
||||
record.status = WritebackStatus.SENT
|
||||
record.provider_response = result.get(WritebackPayloadKey.PROVIDER_RESPONSE) or result
|
||||
record.sent_at = utc_now()
|
||||
record.error_message = None
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
self._emit_submitted(record, actor)
|
||||
self._audit_submit(record, actor)
|
||||
return serialize_model(record)
|
||||
|
||||
def list_runs(
|
||||
self,
|
||||
status_filter: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(OfficialWritebackRun)
|
||||
.order_by(OfficialWritebackRun.id.desc())
|
||||
.limit(bounded_limit(limit))
|
||||
)
|
||||
if status_filter:
|
||||
stmt = stmt.where(OfficialWritebackRun.status == status_filter)
|
||||
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||
|
||||
def get_run(self, code: str) -> dict[str, Any]:
|
||||
return serialize_model(self._get_run(code))
|
||||
|
||||
def count_by_status(self) -> dict[str, int]:
|
||||
rows = self.db.execute(
|
||||
select(OfficialWritebackRun.status, func.count()).group_by(
|
||||
OfficialWritebackRun.status
|
||||
)
|
||||
).all()
|
||||
return {str(status_value): int(count) for status_value, count in rows}
|
||||
|
||||
def _get_run(self, code: str) -> OfficialWritebackRun:
|
||||
record = self.db.execute(
|
||||
select(OfficialWritebackRun).where(OfficialWritebackRun.code == code)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=WritebackErrorDetail.RUN_NOT_FOUND,
|
||||
)
|
||||
return record
|
||||
|
||||
def _emit_submitted(self, record: OfficialWritebackRun, actor: str) -> None:
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.WRITEBACK_SUBMITTED,
|
||||
source=EventSource.WRITEBACK,
|
||||
aggregate_type=EventAggregateType.WRITEBACK_RUN,
|
||||
aggregate_id=record.code,
|
||||
actor=actor,
|
||||
payload=self._event_payload(record),
|
||||
idempotency_key=f"{record.code}:submitted:{record.status}",
|
||||
dispatch=True,
|
||||
)
|
||||
|
||||
def _audit_submit(self, record: OfficialWritebackRun, actor: str) -> None:
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.API,
|
||||
action=AuditAction.WRITEBACK_SUBMIT,
|
||||
target_type=record.domain,
|
||||
target_id=record.record_id,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
request_payload={
|
||||
WritebackPayloadKey.CODE: record.code,
|
||||
WritebackPayloadKey.APPROVAL_TICKET_ID: record.approval_ticket_id,
|
||||
},
|
||||
response_payload=self._event_payload(record),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _event_payload(record: OfficialWritebackRun) -> dict[str, Any]:
|
||||
return {
|
||||
WritebackPayloadKey.CODE: record.code,
|
||||
WritebackPayloadKey.STATUS: record.status,
|
||||
WritebackPayloadKey.DOMAIN: record.domain,
|
||||
WritebackPayloadKey.RECORD_ID: record.record_id,
|
||||
WritebackPayloadKey.ACTION: record.action,
|
||||
WritebackPayloadKey.ERROR_MESSAGE: record.error_message,
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
from app.core.database import Base, engine
|
||||
from app.modules.approvals.models import ApprovalRequest
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.business.models import (
|
||||
AttendanceRecord,
|
||||
@@ -21,10 +20,8 @@ from app.modules.business.models import (
|
||||
from app.modules.feishu.models import FeishuEventReceipt
|
||||
from app.modules.events.models import DomainEvent
|
||||
from app.modules.workflows.models import WorkflowAction, WorkflowInstance
|
||||
from app.modules.writebacks.models import OfficialWritebackRun
|
||||
|
||||
_MODELS = [
|
||||
ApprovalRequest,
|
||||
AuditLog,
|
||||
FeishuEventReceipt,
|
||||
Project,
|
||||
@@ -45,7 +42,6 @@ _MODELS = [
|
||||
DomainEvent,
|
||||
WorkflowInstance,
|
||||
WorkflowAction,
|
||||
OfficialWritebackRun,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ db.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite:///" + db.name.replace("\\", "/")
|
||||
os.environ["API_KEY"] = "test-key"
|
||||
os.environ["APPROVAL_API_KEY"] = "approval-key"
|
||||
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
|
||||
os.environ["LEGACY_DATABASE_URL"] = ""
|
||||
os.environ["LEGACY_PROJECT_QUERY"] = ""
|
||||
@@ -21,9 +20,11 @@ os.environ["SCHEDULER_ENABLED"] = "false"
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.constants import HttpHeader
|
||||
from app.core.database import Base, engine
|
||||
from app.core.database import Base, SessionLocal, engine
|
||||
from app.main import app
|
||||
from app.modules.business.constants import BusinessField, StatusValue
|
||||
from app.modules.business.registry import get_domain_model
|
||||
from app.modules.business.service import _model_payload, serialize_model
|
||||
|
||||
|
||||
def request(method: str, url: str, **kwargs):
|
||||
@@ -35,26 +36,17 @@ def request(method: str, url: str, **kwargs):
|
||||
return response
|
||||
|
||||
|
||||
def approve_change(domain: str, action: str, payload: dict, record_id: str | None = None) -> str:
|
||||
approval_payload: dict[str, object] = {
|
||||
"domain": domain,
|
||||
"action": action,
|
||||
"reason": "smoke verification",
|
||||
"payload": payload,
|
||||
}
|
||||
if record_id is not None:
|
||||
approval_payload["record_id"] = record_id
|
||||
ticket = request("post", "/api/v1/approvals", json=approval_payload).json()["ticket_id"]
|
||||
request(
|
||||
"post",
|
||||
f"/api/v1/approvals/{ticket}/approve",
|
||||
headers={
|
||||
HttpHeader.X_API_KEY: "test-key",
|
||||
HttpHeader.X_APPROVAL_API_KEY: "approval-key",
|
||||
},
|
||||
json={"comment": "smoke approved"},
|
||||
)
|
||||
return ticket
|
||||
def seed_business_record(domain: str, payload: dict) -> dict:
|
||||
db_session = SessionLocal()
|
||||
try:
|
||||
model = get_domain_model(domain)
|
||||
record = model(**_model_payload(domain, model, payload))
|
||||
db_session.add(record)
|
||||
db_session.commit()
|
||||
db_session.refresh(record)
|
||||
return serialize_model(record)
|
||||
finally:
|
||||
db_session.close()
|
||||
|
||||
|
||||
try:
|
||||
@@ -68,17 +60,8 @@ try:
|
||||
"budget_amount": 1000,
|
||||
"actual_amount": 200,
|
||||
}
|
||||
approval_ticket_id = approve_change("projects", "create:projects", project_payload)
|
||||
project = request(
|
||||
"post",
|
||||
"/api/v1/business/projects",
|
||||
json={
|
||||
"actor": "smoke",
|
||||
"approval_ticket_id": approval_ticket_id,
|
||||
"data": project_payload,
|
||||
},
|
||||
).json()
|
||||
print("project:", project["data"]["code"])
|
||||
project = seed_business_record("projects", project_payload)
|
||||
print("project:", project["code"])
|
||||
|
||||
report = request("get", "/api/v1/reports/daily-brief").json()
|
||||
print("report:", report["title"])
|
||||
|
||||
@@ -9,7 +9,7 @@ from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
|
||||
from app.modules.business.constants import StatusValue
|
||||
from app.modules.business.constants import BusinessResponseKey, StatusValue
|
||||
|
||||
_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
||||
_db.close()
|
||||
@@ -18,12 +18,9 @@ os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
|
||||
os.environ["API_KEY"] = "test-key"
|
||||
os.environ["AUDIT_API_KEY"] = "audit-key"
|
||||
os.environ["AUDIT_API_ACTOR"] = "audit-manager"
|
||||
os.environ["APPROVAL_API_KEY"] = "approval-key"
|
||||
os.environ["APPROVAL_API_ACTOR"] = "approval-manager"
|
||||
os.environ["FEISHU_APP_ID"] = ""
|
||||
os.environ["FEISHU_APP_SECRET"] = ""
|
||||
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
|
||||
os.environ["FEISHU_APPROVAL_APPROVER_IDS"] = json.dumps(["ou_card_approver"])
|
||||
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
|
||||
os.environ["LEGACY_DATABASE_URL"] = ""
|
||||
os.environ["LEGACY_PROJECT_QUERY"] = ""
|
||||
@@ -33,9 +30,9 @@ os.environ["SCHEDULER_ENABLED"] = "false"
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.database import Base, engine
|
||||
from app.core.database import Base, SessionLocal, engine
|
||||
from app.core.pagination import bounded_limit, bounded_offset
|
||||
from app.core.security import require_api_key, require_approval_api_key, require_audit_api_key
|
||||
from app.core.security import require_api_key, require_audit_api_key
|
||||
from app.main import _allow_cors_credentials, app
|
||||
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
|
||||
from app.modules.events.constants import (
|
||||
@@ -46,10 +43,8 @@ from app.modules.events.constants import (
|
||||
EventType,
|
||||
)
|
||||
from app.modules.events.service import EventService
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_APPROVAL_APPROVER_IDS_REQUIRED,
|
||||
FEISHU_APPROVER_NOT_ALLOWED,
|
||||
)
|
||||
from app.modules.business.registry import get_domain_model
|
||||
from app.modules.business.service import _model_payload, serialize_model
|
||||
from app.modules.legacy_mysql.service import LegacyMySQLService
|
||||
from app.modules.reports.constants import (
|
||||
LifecycleAttentionKey,
|
||||
@@ -63,50 +58,40 @@ from app.modules.reports.constants import (
|
||||
from app.modules.risk.constants import RiskEventActionValue
|
||||
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
|
||||
from app.modules.workflows.models import WorkflowInstance
|
||||
from app.modules.writebacks.constants import WritebackStatus
|
||||
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
client = TestClient(app)
|
||||
headers = {"X-API-Key": "test-key"}
|
||||
audit_headers = {"X-API-Key": "test-key", "X-Audit-API-Key": "audit-key"}
|
||||
approval_headers = {"X-API-Key": "test-key", "X-Approval-API-Key": "approval-key"}
|
||||
|
||||
|
||||
def approve_change(
|
||||
domain: str,
|
||||
action: str,
|
||||
payload: dict,
|
||||
record_id: str | int | None = None,
|
||||
) -> str:
|
||||
request_payload: dict[str, object] = {
|
||||
"domain": domain,
|
||||
"action": action,
|
||||
"reason": "pytest approval",
|
||||
"payload": payload,
|
||||
class SeedResponse:
|
||||
def __init__(self, payload: dict, status_code: int = 200):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._payload
|
||||
|
||||
|
||||
def create_business_record(domain: str, data: dict, actor: str = "pytest") -> SeedResponse:
|
||||
_ = actor
|
||||
db = SessionLocal()
|
||||
try:
|
||||
model = get_domain_model(domain)
|
||||
record = model(**_model_payload(domain, model, data))
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return SeedResponse(
|
||||
{
|
||||
BusinessResponseKey.DOMAIN: domain,
|
||||
BusinessResponseKey.DATA: serialize_model(record),
|
||||
}
|
||||
if record_id is not None:
|
||||
request_payload["record_id"] = str(record_id)
|
||||
response = client.post("/api/v1/approvals", headers=headers, json=request_payload)
|
||||
assert response.status_code == 200
|
||||
ticket_id = response.json()["ticket_id"]
|
||||
|
||||
approve_response = client.post(
|
||||
f"/api/v1/approvals/{ticket_id}/approve",
|
||||
headers=approval_headers,
|
||||
json={"comment": "pytest approved"},
|
||||
)
|
||||
assert approve_response.status_code == 200
|
||||
return ticket_id
|
||||
|
||||
|
||||
def create_business_record(domain: str, data: dict, actor: str = "pytest"):
|
||||
ticket_id = approve_change(domain, f"create:{domain}", data)
|
||||
return client.post(
|
||||
f"/api/v1/business/{domain}",
|
||||
headers=headers,
|
||||
json={"actor": actor, "approval_ticket_id": ticket_id, "data": data},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def teardown_module() -> None:
|
||||
@@ -243,7 +228,7 @@ def test_v3_event_idempotency_and_workflow_dispatch() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_v3_risk_action_creates_workflow() -> None:
|
||||
def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||
response = create_business_record(
|
||||
"risk-events",
|
||||
{
|
||||
@@ -261,22 +246,10 @@ def test_v3_risk_action_creates_workflow() -> None:
|
||||
headers=headers,
|
||||
json={"assigned_to": "risk-owner", "comment": "route to owner"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows",
|
||||
headers=headers,
|
||||
params={"workflow_type": WorkflowType.RISK_EVENT_REVIEW},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
workflows = response.json()["items"]
|
||||
assert any(
|
||||
item["aggregate_id"] == str(risk_id) and item["status"] == WorkflowStatus.RUNNING
|
||||
for item in workflows
|
||||
)
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
def test_v3_writeback_disabled_requires_approval_without_consuming_ticket() -> None:
|
||||
def test_writeback_and_approval_routes_are_removed() -> None:
|
||||
response = client.post(
|
||||
"/api/v1/writebacks",
|
||||
headers=headers,
|
||||
@@ -287,15 +260,7 @@ def test_v3_writeback_disabled_requires_approval_without_consuming_ticket() -> N
|
||||
"payload": {"code": "P-V3-WB", "name": "Writeback target"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
writeback_code = response.json()["data"]["code"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/writebacks/{writeback_code}/submit",
|
||||
headers=headers,
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.status_code == 404
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/approvals",
|
||||
@@ -308,39 +273,7 @@ def test_v3_writeback_disabled_requires_approval_without_consuming_ticket() -> N
|
||||
"payload": {"code": "P-V3-WB", "name": "Writeback target"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ticket_id = response.json()["ticket_id"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/approvals/{ticket_id}/approve",
|
||||
headers=approval_headers,
|
||||
json={"comment": "approved for disabled adapter test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/writebacks/{writeback_code}/submit",
|
||||
headers=headers,
|
||||
json={"approval_ticket_id": ticket_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["status"] == WritebackStatus.DISABLED
|
||||
|
||||
response = client.get(f"/api/v1/approvals/{ticket_id}", headers=approval_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "approved"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows",
|
||||
headers=headers,
|
||||
params={"workflow_type": WorkflowType.OFFICIAL_WRITEBACK},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
workflows = response.json()["items"]
|
||||
assert any(
|
||||
item["aggregate_id"] == writeback_code and item["status"] == WorkflowStatus.BLOCKED
|
||||
for item in workflows
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_feishu_webhook_challenge_uses_event_service_verification() -> None:
|
||||
@@ -373,12 +306,6 @@ def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
monkeypatch.setenv("APPROVAL_API_KEY", "")
|
||||
get_settings.cache_clear()
|
||||
with pytest.raises(HTTPException) as approval_exc_info:
|
||||
require_approval_api_key("approval-key")
|
||||
assert approval_exc_info.value.status_code == 503
|
||||
|
||||
monkeypatch.setenv("AUDIT_API_KEY", "")
|
||||
get_settings.cache_clear()
|
||||
with pytest.raises(HTTPException) as audit_exc_info:
|
||||
@@ -387,7 +314,6 @@ def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
|
||||
finally:
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
monkeypatch.setenv("AUDIT_API_KEY", "audit-key")
|
||||
monkeypatch.setenv("APPROVAL_API_KEY", "approval-key")
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@@ -472,7 +398,6 @@ def test_configured_domain_response_masking(monkeypatch) -> None:
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["amount"] == "[MASKED]"
|
||||
|
||||
list_response = client.get("/api/v1/business/expenses", headers=headers)
|
||||
assert list_response.status_code == 200
|
||||
@@ -487,8 +412,8 @@ def test_configured_domain_response_masking(monkeypatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_business_writes_require_approval_and_reject_read_only_fields() -> None:
|
||||
blocked_response = client.post(
|
||||
def test_business_write_routes_are_disabled_in_read_only_mode() -> None:
|
||||
create_response = client.post(
|
||||
"/api/v1/business/projects",
|
||||
headers=headers,
|
||||
json={
|
||||
@@ -498,187 +423,21 @@ def test_business_writes_require_approval_and_reject_read_only_fields() -> None:
|
||||
},
|
||||
},
|
||||
)
|
||||
assert blocked_response.status_code == 409
|
||||
|
||||
readonly_payload = {
|
||||
"code": "P-READONLY-001",
|
||||
"name": "Readonly project",
|
||||
"created_at": "2026-07-08T00:00:00",
|
||||
}
|
||||
ticket_id = approve_change("projects", "create:projects", readonly_payload)
|
||||
readonly_response = client.post(
|
||||
"/api/v1/business/projects",
|
||||
headers=headers,
|
||||
json={
|
||||
"approval_ticket_id": ticket_id,
|
||||
"data": readonly_payload,
|
||||
},
|
||||
)
|
||||
assert readonly_response.status_code == 422
|
||||
assert readonly_response.json()["detail"] == "Field 'created_at' is read-only"
|
||||
|
||||
|
||||
def test_approval_gate_for_high_risk_update() -> None:
|
||||
create_payload = {
|
||||
"code": "FUND-SMOKE-001",
|
||||
"name": "Main Account",
|
||||
"current_balance": 1000,
|
||||
"safety_line": 500,
|
||||
}
|
||||
blocked_create_response = client.post(
|
||||
"/api/v1/business/fund-accounts",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "spoofed-user",
|
||||
"data": {
|
||||
"code": "FUND-SMOKE-BLOCKED",
|
||||
"name": "Blocked Account",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert blocked_create_response.status_code == 409
|
||||
|
||||
create_approval_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
json={
|
||||
"domain": "fund-accounts",
|
||||
"action": "create:fund-accounts",
|
||||
"applicant": "spoofed-user",
|
||||
"reason": "Smoke test account creation",
|
||||
"payload": create_payload,
|
||||
},
|
||||
)
|
||||
assert create_approval_response.status_code == 200
|
||||
assert create_approval_response.json()["applicant"] == "api"
|
||||
create_ticket_id = create_approval_response.json()["ticket_id"]
|
||||
|
||||
approval_list_without_approval_key_response = client.get("/api/v1/approvals", headers=headers)
|
||||
assert approval_list_without_approval_key_response.status_code == 401
|
||||
|
||||
approval_detail_without_approval_key_response = client.get(
|
||||
f"/api/v1/approvals/{create_ticket_id}",
|
||||
headers=headers,
|
||||
)
|
||||
assert approval_detail_without_approval_key_response.status_code == 401
|
||||
|
||||
approval_detail_response = client.get(
|
||||
f"/api/v1/approvals/{create_ticket_id}",
|
||||
headers=approval_headers,
|
||||
)
|
||||
assert approval_detail_response.status_code == 200
|
||||
|
||||
approve_create_response = client.post(
|
||||
f"/api/v1/approvals/{create_ticket_id}/approve",
|
||||
headers=approval_headers,
|
||||
json={"comment": "ok"},
|
||||
)
|
||||
assert approve_create_response.status_code == 200
|
||||
assert approve_create_response.json()["approver"] == "approval-manager"
|
||||
|
||||
create_response = client.post(
|
||||
"/api/v1/business/fund-accounts",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "spoofed-user",
|
||||
"approval_ticket_id": create_ticket_id,
|
||||
"data": create_payload,
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
record_id = create_response.json()["data"]["id"]
|
||||
|
||||
reuse_create_response = client.post(
|
||||
"/api/v1/business/fund-accounts",
|
||||
headers=headers,
|
||||
json={
|
||||
"approval_ticket_id": create_ticket_id,
|
||||
"data": {
|
||||
"code": "FUND-SMOKE-REUSE",
|
||||
"name": "Reuse Account",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert reuse_create_response.status_code == 403
|
||||
|
||||
blocked_response = client.patch(
|
||||
f"/api/v1/business/fund-accounts/{record_id}",
|
||||
headers=headers,
|
||||
json={"actor": "spoofed-user", "data": {"current_balance": 100}},
|
||||
)
|
||||
assert blocked_response.status_code == 409
|
||||
|
||||
approval_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
json={
|
||||
"domain": "fund-accounts",
|
||||
"record_id": str(record_id),
|
||||
"action": "update:fund-accounts",
|
||||
"applicant": "spoofed-user",
|
||||
"reason": "Smoke test balance adjustment",
|
||||
"payload": {"current_balance": 100},
|
||||
},
|
||||
)
|
||||
assert approval_response.status_code == 200
|
||||
ticket_id = approval_response.json()["ticket_id"]
|
||||
|
||||
pending_response = client.patch(
|
||||
f"/api/v1/business/fund-accounts/{record_id}",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "spoofed-user",
|
||||
"approval_ticket_id": ticket_id,
|
||||
"data": {"current_balance": 100},
|
||||
},
|
||||
)
|
||||
assert pending_response.status_code == 403
|
||||
|
||||
approve_response = client.post(
|
||||
f"/api/v1/approvals/{ticket_id}/approve",
|
||||
headers=approval_headers,
|
||||
json={"approver": "spoofed-manager", "comment": "ok"},
|
||||
)
|
||||
assert approve_response.status_code == 200
|
||||
assert approve_response.json()["status"] == "approved"
|
||||
assert approve_response.json()["approver"] == "approval-manager"
|
||||
|
||||
mismatch_response = client.patch(
|
||||
f"/api/v1/business/fund-accounts/{record_id}",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "spoofed-user",
|
||||
"approval_ticket_id": ticket_id,
|
||||
"data": {"current_balance": 101},
|
||||
},
|
||||
)
|
||||
assert mismatch_response.status_code == 403
|
||||
assert create_response.status_code == 405
|
||||
|
||||
update_response = client.patch(
|
||||
f"/api/v1/business/fund-accounts/{record_id}",
|
||||
"/api/v1/business/projects/1",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "spoofed-user",
|
||||
"approval_ticket_id": ticket_id,
|
||||
"data": {"current_balance": 100},
|
||||
"data": {
|
||||
"name": "Blocked update",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["data"]["current_balance"] == 100.0
|
||||
|
||||
reuse_update_response = client.patch(
|
||||
f"/api/v1/business/fund-accounts/{record_id}",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "spoofed-user",
|
||||
"approval_ticket_id": ticket_id,
|
||||
"data": {"current_balance": 100},
|
||||
},
|
||||
)
|
||||
assert reuse_update_response.status_code == 403
|
||||
assert update_response.status_code == 405
|
||||
|
||||
|
||||
def test_feishu_approval_card_action_approves_ticket() -> None:
|
||||
def test_approval_and_feishu_approval_card_routes_are_removed() -> None:
|
||||
approval_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
@@ -690,8 +449,7 @@ def test_feishu_approval_card_action_approves_ticket() -> None:
|
||||
"payload": {"current_balance": 300},
|
||||
},
|
||||
)
|
||||
assert approval_response.status_code == 200
|
||||
ticket_id = approval_response.json()["ticket_id"]
|
||||
assert approval_response.status_code == 404
|
||||
|
||||
callback_response = client.post(
|
||||
"/api/v1/integrations/feishu/approval-card-action",
|
||||
@@ -700,104 +458,14 @@ def test_feishu_approval_card_action_approves_ticket() -> None:
|
||||
"operator": {"operator_id": {"open_id": "ou_card_approver"}},
|
||||
"action": {
|
||||
"value": {
|
||||
"ticket_id": ticket_id,
|
||||
"ticket_id": "APR-DISABLED",
|
||||
"decision": "approve",
|
||||
"comment": "approved from card",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
assert callback_response.status_code == 200
|
||||
assert callback_response.json()["result"]["status"] == "approved"
|
||||
assert callback_response.json()["result"]["approver"] == "ou_card_approver"
|
||||
|
||||
duplicate_response = client.post(
|
||||
"/api/v1/integrations/feishu/approval-card-action",
|
||||
json={
|
||||
"token": "test-feishu-token",
|
||||
"operator": {"operator_id": {"open_id": "ou_card_approver"}},
|
||||
"action": {
|
||||
"value": {
|
||||
"ticket_id": ticket_id,
|
||||
"decision": "approve",
|
||||
"comment": "approved from card",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
assert duplicate_response.status_code == 200
|
||||
assert duplicate_response.json()["duplicate"] is True
|
||||
assert duplicate_response.json()["result"]["status"] == "approved"
|
||||
|
||||
|
||||
def test_feishu_approval_card_action_requires_approver_allowlist(monkeypatch) -> None:
|
||||
monkeypatch.setenv("FEISHU_APPROVAL_APPROVER_IDS", "")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
approval_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
json={
|
||||
"domain": "fund-accounts",
|
||||
"record_id": "feishu-card-missing-allowlist",
|
||||
"action": "update:fund-accounts",
|
||||
"reason": "Card action missing allowlist test",
|
||||
"payload": {"current_balance": 301},
|
||||
},
|
||||
)
|
||||
assert approval_response.status_code == 200
|
||||
ticket_id = approval_response.json()["ticket_id"]
|
||||
|
||||
callback_response = client.post(
|
||||
"/api/v1/integrations/feishu/approval-card-action",
|
||||
json={
|
||||
"token": "test-feishu-token",
|
||||
"operator": {"operator_id": {"open_id": "ou_card_approver"}},
|
||||
"action": {
|
||||
"value": {
|
||||
"ticket_id": ticket_id,
|
||||
"decision": "approve",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
assert callback_response.status_code == 503
|
||||
assert callback_response.json()["detail"] == FEISHU_APPROVAL_APPROVER_IDS_REQUIRED
|
||||
finally:
|
||||
monkeypatch.setenv("FEISHU_APPROVAL_APPROVER_IDS", "ou_card_approver")
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_feishu_approval_card_action_rejects_unlisted_approver() -> None:
|
||||
approval_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
json={
|
||||
"domain": "fund-accounts",
|
||||
"record_id": "feishu-card-unlisted-approver",
|
||||
"action": "update:fund-accounts",
|
||||
"reason": "Card action allowlist test",
|
||||
"payload": {"current_balance": 302},
|
||||
},
|
||||
)
|
||||
assert approval_response.status_code == 200
|
||||
ticket_id = approval_response.json()["ticket_id"]
|
||||
|
||||
callback_response = client.post(
|
||||
"/api/v1/integrations/feishu/approval-card-action",
|
||||
json={
|
||||
"token": "test-feishu-token",
|
||||
"operator": {"operator_id": {"open_id": "ou_not_allowed"}},
|
||||
"action": {
|
||||
"value": {
|
||||
"ticket_id": ticket_id,
|
||||
"decision": "approve",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
assert callback_response.status_code == 403
|
||||
assert callback_response.json()["detail"] == FEISHU_APPROVER_NOT_ALLOWED
|
||||
assert callback_response.status_code == 404
|
||||
|
||||
|
||||
def test_new_ledgers_reports_and_risk_events() -> None:
|
||||
@@ -843,23 +511,21 @@ def test_new_ledgers_reports_and_risk_events() -> None:
|
||||
json={"report_type": ReportType.DAILY, "reporter": "pytest", "actor": "pytest"},
|
||||
)
|
||||
assert report_response.status_code == 200
|
||||
assert report_response.json()["data"]["report_type"] == ReportType.DAILY
|
||||
assert report_response.json()["data"] is None
|
||||
assert report_response.json()["report"]["report_type"] == ReportType.DAILY
|
||||
|
||||
risk_response = client.post(
|
||||
"/api/v1/risks/events/generate?actor=pytest",
|
||||
headers=headers,
|
||||
)
|
||||
assert risk_response.status_code == 200
|
||||
assert risk_response.json()["created"] >= 1
|
||||
assert risk_response.status_code == 405
|
||||
|
||||
enqueue_response = client.post("/api/v1/risks/events/enqueue", headers=headers)
|
||||
assert enqueue_response.status_code == 200
|
||||
assert enqueue_response.json()["queued"] is False
|
||||
assert "result" in enqueue_response.json()
|
||||
assert enqueue_response.status_code == 405
|
||||
|
||||
events_response = client.get("/api/v1/risks/events?status=open", headers=headers)
|
||||
assert events_response.status_code == 200
|
||||
assert any(item["risk_type"] == "overdue_task" for item in events_response.json()["items"])
|
||||
overdue_response = client.get("/api/v1/risks/overdue-tasks", headers=headers)
|
||||
assert overdue_response.status_code == 200
|
||||
assert any(item["code"] == "TASK-RISK-001" for item in overdue_response.json()["items"])
|
||||
|
||||
|
||||
def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None:
|
||||
@@ -1027,7 +693,7 @@ def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
|
||||
assert metrics["expenses_pending"] == 1
|
||||
|
||||
|
||||
def test_legacy_task_sync_creates_and_updates_internal_tasks(monkeypatch) -> None:
|
||||
def test_legacy_task_read_query_allowed_but_sync_disabled(monkeypatch) -> None:
|
||||
rows = [
|
||||
{
|
||||
"id": 9001,
|
||||
@@ -1047,6 +713,15 @@ def test_legacy_task_sync_creates_and_updates_internal_tasks(monkeypatch) -> Non
|
||||
fake_execute_allowed_query,
|
||||
)
|
||||
|
||||
query_response = client.post(
|
||||
"/api/v1/integrations/mysql/query",
|
||||
headers=headers,
|
||||
json={"query_name": "legacy_tasks"},
|
||||
)
|
||||
assert query_response.status_code == 200
|
||||
assert query_response.json()["row_count"] == 1
|
||||
assert query_response.json()["rows"][0]["task_name"] == "Legacy task one"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/integrations/mysql/tasks/sync",
|
||||
headers=headers,
|
||||
@@ -1055,31 +730,10 @@ def test_legacy_task_sync_creates_and_updates_internal_tasks(monkeypatch) -> Non
|
||||
"field_map": {"title": "task_name"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["created"] == 1
|
||||
assert response.json()["items"][0]["task"]["external_id"] == "9001"
|
||||
|
||||
rows[0]["task_name"] = "Legacy task one updated"
|
||||
second_response = client.post(
|
||||
"/api/v1/integrations/mysql/tasks/sync",
|
||||
headers=headers,
|
||||
json={
|
||||
"dry_run": False,
|
||||
"field_map": {"title": "task_name"},
|
||||
},
|
||||
)
|
||||
assert second_response.status_code == 200
|
||||
assert second_response.json()["updated"] == 1
|
||||
|
||||
tasks_response = client.get("/api/v1/business/tasks", headers=headers)
|
||||
assert tasks_response.status_code == 200
|
||||
item = next(
|
||||
item for item in tasks_response.json()["items"] if item["external_id"] == "9001"
|
||||
)
|
||||
assert item["title"] == "Legacy task one updated"
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
def test_risk_event_workflow_records_actions() -> None:
|
||||
def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||
create_response = create_business_record(
|
||||
"risk-events",
|
||||
{
|
||||
@@ -1100,57 +754,21 @@ def test_risk_event_workflow_records_actions() -> None:
|
||||
headers=headers,
|
||||
json={"assigned_to": "risk-owner", "comment": "please handle"},
|
||||
)
|
||||
assert assign_response.status_code == 200
|
||||
assert assign_response.json()["risk_event"]["assigned_to"] == "risk-owner"
|
||||
assert assign_response.status_code == 405
|
||||
|
||||
comment_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/comment",
|
||||
headers=headers,
|
||||
json={"comment": "working on it", "payload": {"step": 1}},
|
||||
)
|
||||
assert comment_response.status_code == 200
|
||||
assert comment_response.status_code == 405
|
||||
|
||||
resolve_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/resolve",
|
||||
headers=headers,
|
||||
json={"comment": "resolved"},
|
||||
)
|
||||
assert resolve_response.status_code == 200
|
||||
assert resolve_response.json()["risk_event"]["status"] == "resolved"
|
||||
|
||||
blocked_close_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/close",
|
||||
headers=headers,
|
||||
json={
|
||||
"closed_reason": "verified",
|
||||
"review_summary": "handled",
|
||||
},
|
||||
)
|
||||
assert blocked_close_response.status_code == 409
|
||||
|
||||
close_ticket_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
json={
|
||||
"domain": "risk-events",
|
||||
"record_id": str(event_id),
|
||||
"action": "update:risk-events",
|
||||
"reason": "Close risk event",
|
||||
"payload": {
|
||||
"status": "closed",
|
||||
"closed_reason": "verified",
|
||||
"review_summary": "handled",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert close_ticket_response.status_code == 200
|
||||
close_ticket_id = close_ticket_response.json()["ticket_id"]
|
||||
approve_close_response = client.post(
|
||||
f"/api/v1/approvals/{close_ticket_id}/approve",
|
||||
headers=approval_headers,
|
||||
json={"comment": "risk close approved"},
|
||||
)
|
||||
assert approve_close_response.status_code == 200
|
||||
assert resolve_response.status_code == 405
|
||||
|
||||
close_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/close",
|
||||
@@ -1158,54 +776,23 @@ def test_risk_event_workflow_records_actions() -> None:
|
||||
json={
|
||||
"closed_reason": "verified",
|
||||
"review_summary": "handled",
|
||||
"approval_ticket_id": close_ticket_id,
|
||||
},
|
||||
)
|
||||
assert close_response.status_code == 200
|
||||
assert close_response.json()["risk_event"]["status"] == "closed"
|
||||
assert close_response.json()["risk_event"]["closed_reason"] == "verified"
|
||||
|
||||
blocked_reopen_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/reopen",
|
||||
headers=headers,
|
||||
json={"comment": "recheck"},
|
||||
)
|
||||
assert blocked_reopen_response.status_code == 409
|
||||
|
||||
reopen_ticket_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
json={
|
||||
"domain": "risk-events",
|
||||
"record_id": str(event_id),
|
||||
"action": "update:risk-events",
|
||||
"reason": "Reopen risk event",
|
||||
"payload": {"status": "open", "comment": "recheck"},
|
||||
},
|
||||
)
|
||||
assert reopen_ticket_response.status_code == 200
|
||||
reopen_ticket_id = reopen_ticket_response.json()["ticket_id"]
|
||||
approve_reopen_response = client.post(
|
||||
f"/api/v1/approvals/{reopen_ticket_id}/approve",
|
||||
headers=approval_headers,
|
||||
json={"comment": "risk reopen approved"},
|
||||
)
|
||||
assert approve_reopen_response.status_code == 200
|
||||
assert close_response.status_code == 405
|
||||
|
||||
reopen_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/reopen",
|
||||
headers=headers,
|
||||
json={"comment": "recheck", "approval_ticket_id": reopen_ticket_id},
|
||||
json={"comment": "recheck"},
|
||||
)
|
||||
assert reopen_response.status_code == 200
|
||||
assert reopen_response.json()["risk_event"]["status"] == "open"
|
||||
assert reopen_response.status_code == 405
|
||||
|
||||
actions_response = client.get(
|
||||
f"/api/v1/risks/events/{event_id}/actions",
|
||||
headers=headers,
|
||||
)
|
||||
assert actions_response.status_code == 200
|
||||
assert len(actions_response.json()["items"]) >= 5
|
||||
assert actions_response.json()["items"] == []
|
||||
|
||||
|
||||
def test_report_push_failure_is_recorded() -> None:
|
||||
|
||||
Reference in New Issue
Block a user