From 0a153b264a009e88c4fe48cf97083d72d77c33e1 Mon Sep 17 00:00:00 2001 From: JiuContinent Date: Wed, 8 Jul 2026 17:08:59 +0800 Subject: [PATCH] =?UTF-8?q?```=20refactor:=20=E7=A7=BB=E9=99=A4=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E5=92=8C=E5=9B=9E=E5=86=99=E5=8A=9F=E8=83=BD=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除了整个审批(approvals)和官方回写(writebacks)功能模块, 包括相关模型、路由、服务和配置项。更新了数据库迁移文件, 删除了相关的审批请求表和官方回写运行表。同时从API路由器中 移除了相应的路由,并调整了安全常量和字段验证器以匹配变更。 ``` --- alembic/env.py | 4 - .../versions/202607060001_initial_schema.py | 60 -- ...02607060002_approval_ticket_consumption.py | 44 +- ...002_v3_enterprise_automation_foundation.py | 39 -- app/api/router.py | 4 - app/core/config.py | 14 +- app/core/constants.py | 4 - app/core/operation_guard.py | 16 + app/core/security.py | 28 - app/modules/ai_agent/routes.py | 2 + app/modules/approvals/__init__.py | 1 - app/modules/approvals/constants.py | 38 -- app/modules/approvals/models.py | 34 -- app/modules/approvals/routes.py | 91 --- app/modules/approvals/schemas.py | 49 -- app/modules/approvals/service.py | 248 -------- app/modules/audit/constants.py | 7 - app/modules/business/constants.py | 6 - app/modules/business/routes.py | 50 +- app/modules/business/schemas.py | 22 +- app/modules/business/service.py | 125 +--- app/modules/dashboard/service.py | 19 - app/modules/events/constants.py | 9 - app/modules/events/routes.py | 3 + app/modules/events/service.py | 28 - app/modules/feishu/constants.py | 16 - app/modules/feishu/events.py | 159 ----- app/modules/feishu/routes.py | 11 - app/modules/feishu/service.py | 43 -- app/modules/legacy_mysql/routes.py | 5 + app/modules/observability/constants.py | 2 - app/modules/observability/service.py | 15 - app/modules/reports/routes.py | 3 + app/modules/reports/schemas.py | 2 +- app/modules/risk/routes.py | 10 +- app/modules/risk/schemas.py | 2 - app/modules/risk/service.py | 48 -- app/modules/workflows/constants.py | 2 - app/modules/writebacks/__init__.py | 1 - app/modules/writebacks/adapters.py | 74 --- app/modules/writebacks/constants.py | 42 -- app/modules/writebacks/models.py | 39 -- app/modules/writebacks/routes.py | 66 --- app/modules/writebacks/schemas.py | 35 -- app/modules/writebacks/service.py | 232 -------- app/tools/init_db.py | 4 - scripts/verify_smoke.py | 49 +- tests/test_smoke.py | 555 +++--------------- 48 files changed, 135 insertions(+), 2225 deletions(-) create mode 100644 app/core/operation_guard.py delete mode 100644 app/modules/approvals/__init__.py delete mode 100644 app/modules/approvals/constants.py delete mode 100644 app/modules/approvals/models.py delete mode 100644 app/modules/approvals/routes.py delete mode 100644 app/modules/approvals/schemas.py delete mode 100644 app/modules/approvals/service.py delete mode 100644 app/modules/writebacks/__init__.py delete mode 100644 app/modules/writebacks/adapters.py delete mode 100644 app/modules/writebacks/constants.py delete mode 100644 app/modules/writebacks/models.py delete mode 100644 app/modules/writebacks/routes.py delete mode 100644 app/modules/writebacks/schemas.py delete mode 100644 app/modules/writebacks/service.py diff --git a/alembic/env.py b/alembic/env.py index 8e771f4..bb1772c 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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, ) diff --git a/alembic/versions/202607060001_initial_schema.py b/alembic/versions/202607060001_initial_schema.py index c790e42..b6e3127 100644 --- a/alembic/versions/202607060001_initial_schema.py +++ b/alembic/versions/202607060001_initial_schema.py @@ -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), diff --git a/alembic/versions/202607060002_approval_ticket_consumption.py b/alembic/versions/202607060002_approval_ticket_consumption.py index a729b16..e5e6d64 100644 --- a/alembic/versions/202607060002_approval_ticket_consumption.py +++ b/alembic/versions/202607060002_approval_ticket_consumption.py @@ -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 diff --git a/alembic/versions/202607080002_v3_enterprise_automation_foundation.py b/alembic/versions/202607080002_v3_enterprise_automation_foundation.py index 4616f87..0d8ecdc 100644 --- a/alembic/versions/202607080002_v3_enterprise_automation_foundation.py +++ b/alembic/versions/202607080002_v3_enterprise_automation_foundation.py @@ -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, diff --git a/app/api/router.py b/app/api/router.py index d95e06f..0603031 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -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"]) diff --git a/app/core/config.py b/app/core/config.py index 332ea32..5ac9856 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 == "": diff --git a/app/core/constants.py b/app/core/constants.py index 5aac183..82d34ee 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -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" diff --git a/app/core/operation_guard.py b/app/core/operation_guard.py new file mode 100644 index 0000000..b2073a9 --- /dev/null +++ b/app/core/operation_guard.py @@ -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, + ) diff --git a/app/core/security.py b/app/core/security.py index 67a09c1..34fed96 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -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, diff --git a/app/modules/ai_agent/routes.py b/app/modules/ai_agent/routes.py index ce541cf..8b71f4a 100644 --- a/app/modules/ai_agent/routes.py +++ b/app/modules/ai_agent/routes.py @@ -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, diff --git a/app/modules/approvals/__init__.py b/app/modules/approvals/__init__.py deleted file mode 100644 index 17daab2..0000000 --- a/app/modules/approvals/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Approval workflow module.""" diff --git a/app/modules/approvals/constants.py b/app/modules/approvals/constants.py deleted file mode 100644 index 98cc1c3..0000000 --- a/app/modules/approvals/constants.py +++ /dev/null @@ -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}" diff --git a/app/modules/approvals/models.py b/app/modules/approvals/models.py deleted file mode 100644 index 9fb7c80..0000000 --- a/app/modules/approvals/models.py +++ /dev/null @@ -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) diff --git a/app/modules/approvals/routes.py b/app/modules/approvals/routes.py deleted file mode 100644 index eec750a..0000000 --- a/app/modules/approvals/routes.py +++ /dev/null @@ -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} diff --git a/app/modules/approvals/schemas.py b/app/modules/approvals/schemas.py deleted file mode 100644 index 5f78515..0000000 --- a/app/modules/approvals/schemas.py +++ /dev/null @@ -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 diff --git a/app/modules/approvals/service.py b/app/modules/approvals/service.py deleted file mode 100644 index 70590ff..0000000 --- a/app/modules/approvals/service.py +++ /dev/null @@ -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 diff --git a/app/modules/audit/constants.py b/app/modules/audit/constants.py index 88c7ded..3545850 100644 --- a/app/modules/audit/constants.py +++ b/app/modules/audit/constants.py @@ -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): diff --git a/app/modules/business/constants.py b/app/modules/business/constants.py index aee8462..3370717 100644 --- a/app/modules/business/constants.py +++ b/app/modules/business/constants.py @@ -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}'" diff --git a/app/modules/business/routes.py b/app/modules/business/routes.py index b5a3383..8b303b8 100644 --- a/app/modules/business/routes.py +++ b/app/modules/business/routes.py @@ -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), - } diff --git a/app/modules/business/schemas.py b/app/modules/business/schemas.py index bae2df7..ca4ac41 100644 --- a/app/modules/business/schemas.py +++ b/app/modules/business/schemas.py @@ -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): diff --git a/app/modules/business/service.py b/app/modules/business/service.py index ee08321..ac48d8f 100644 --- a/app/modules/business/service.py +++ b/app/modules/business/service.py @@ -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, - ) diff --git a/app/modules/dashboard/service.py b/app/modules/dashboard/service.py index abc1ccc..1dfead2 100644 --- a/app/modules/dashboard/service.py +++ b/app/modules/dashboard/service.py @@ -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"]), }, diff --git a/app/modules/events/constants.py b/app/modules/events/constants.py index e4778a6..8530215 100644 --- a/app/modules/events/constants.py +++ b/app/modules/events/constants.py @@ -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" diff --git a/app/modules/events/routes.py b/app/modules/events/routes.py index c1270db..1e71294 100644 --- a/app/modules/events/routes.py +++ b/app/modules/events/routes.py @@ -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)} diff --git a/app/modules/events/service.py b/app/modules/events/service.py index 95bd0bc..bc6b0bf 100644 --- a/app/modules/events/service.py +++ b/app/modules/events/service.py @@ -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, - ) diff --git a/app/modules/feishu/constants.py b/app/modules/feishu/constants.py index 5a94912..fdac48f 100644 --- a/app/modules/feishu/constants.py +++ b/app/modules/feishu/constants.py @@ -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 diff --git a/app/modules/feishu/events.py b/app/modules/feishu/events.py index 9004f5f..8934a87 100644 --- a/app/modules/feishu/events.py +++ b/app/modules/feishu/events.py @@ -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, - } diff --git a/app/modules/feishu/routes.py b/app/modules/feishu/routes.py index 19761ad..327b032 100644 --- a/app/modules/feishu/routes.py +++ b/app/modules/feishu/routes.py @@ -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, diff --git a/app/modules/feishu/service.py b/app/modules/feishu/service.py index edba4bb..b93b41c 100644 --- a/app/modules/feishu/service.py +++ b/app/modules/feishu/service.py @@ -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 diff --git a/app/modules/legacy_mysql/routes.py b/app/modules/legacy_mysql/routes.py index 6bcad56..53f1f18 100644 --- a/app/modules/legacy_mysql/routes.py +++ b/app/modules/legacy_mysql/routes.py @@ -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, diff --git a/app/modules/observability/constants.py b/app/modules/observability/constants.py index 30b22ba..82aa4a7 100644 --- a/app/modules/observability/constants.py +++ b/app/modules/observability/constants.py @@ -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" diff --git a/app/modules/observability/service.py b/app/modules/observability/service.py index 3256551..f85e65d 100644 --- a/app/modules/observability/service.py +++ b/app/modules/observability/service.py @@ -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, - } diff --git a/app/modules/reports/routes.py b/app/modules/reports/routes.py index 8ae10e3..0d19f9b 100644 --- a/app/modules/reports/routes.py +++ b/app/modules/reports/routes.py @@ -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, diff --git a/app/modules/reports/schemas.py b/app/modules/reports/schemas.py index 1b8d6d8..6038404 100644 --- a/app/modules/reports/schemas.py +++ b/app/modules/reports/schemas.py @@ -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 diff --git a/app/modules/risk/routes.py b/app/modules/risk/routes.py index 06d4f66..087fb31 100644 --- a/app/modules/risk/routes.py +++ b/app/modules/risk/routes.py @@ -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) diff --git a/app/modules/risk/schemas.py b/app/modules/risk/schemas.py index d2c5eb2..49102b5 100644 --- a/app/modules/risk/schemas.py +++ b/app/modules/risk/schemas.py @@ -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 diff --git a/app/modules/risk/service.py b/app/modules/risk/service.py index 2e5186b..53160cc 100644 --- a/app/modules/risk/service.py +++ b/app/modules/risk/service.py @@ -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.""" diff --git a/app/modules/workflows/constants.py b/app/modules/workflows/constants.py index 53572b3..c192cac 100644 --- a/app/modules/workflows/constants.py +++ b/app/modules/workflows/constants.py @@ -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" diff --git a/app/modules/writebacks/__init__.py b/app/modules/writebacks/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/app/modules/writebacks/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/modules/writebacks/adapters.py b/app/modules/writebacks/adapters.py deleted file mode 100644 index 9414349..0000000 --- a/app/modules/writebacks/adapters.py +++ /dev/null @@ -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() diff --git a/app/modules/writebacks/constants.py b/app/modules/writebacks/constants.py deleted file mode 100644 index 5a723da..0000000 --- a/app/modules/writebacks/constants.py +++ /dev/null @@ -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" diff --git a/app/modules/writebacks/models.py b/app/modules/writebacks/models.py deleted file mode 100644 index 1c6810c..0000000 --- a/app/modules/writebacks/models.py +++ /dev/null @@ -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) diff --git a/app/modules/writebacks/routes.py b/app/modules/writebacks/routes.py deleted file mode 100644 index a42869a..0000000 --- a/app/modules/writebacks/routes.py +++ /dev/null @@ -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, - ) - } diff --git a/app/modules/writebacks/schemas.py b/app/modules/writebacks/schemas.py deleted file mode 100644 index 19d0588..0000000 --- a/app/modules/writebacks/schemas.py +++ /dev/null @@ -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 diff --git a/app/modules/writebacks/service.py b/app/modules/writebacks/service.py deleted file mode 100644 index b3f8213..0000000 --- a/app/modules/writebacks/service.py +++ /dev/null @@ -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, - } diff --git a/app/tools/init_db.py b/app/tools/init_db.py index e5063ca..223f5de 100644 --- a/app/tools/init_db.py +++ b/app/tools/init_db.py @@ -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, ] diff --git a/scripts/verify_smoke.py b/scripts/verify_smoke.py index 654827a..7e9d737 100644 --- a/scripts/verify_smoke.py +++ b/scripts/verify_smoke.py @@ -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"]) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 3503fc9..169db46 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -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, - } - 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"] +class SeedResponse: + def __init__(self, payload: dict, status_code: int = 200): + self.status_code = status_code + self._payload = payload - 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 json(self) -> dict: + return self._payload -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}, - ) +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), + } + ) + 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: