refactor: 移除审批和回写功能模块

移除了整个审批(approvals)和官方回写(writebacks)功能模块,
包括相关模型、路由、服务和配置项。更新了数据库迁移文件,
删除了相关的审批请求表和官方回写运行表。同时从API路由器中
移除了相应的路由,并调整了安全常量和字段验证器以匹配变更。
```
This commit is contained in:
2026-07-08 17:08:59 +08:00
parent 19e59e83cc
commit 0a153b264a
48 changed files with 135 additions and 2225 deletions

View File

@@ -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,
)

View File

@@ -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),

View File

@@ -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

View File

@@ -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,

View File

@@ -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"])

View File

@@ -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 == "":

View File

@@ -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"

View File

@@ -0,0 +1,16 @@
from fastapi import HTTPException, status
from app.core.config import get_settings
READ_ONLY_OPERATION_DISABLED = "This service is read-only; data mutation operations are disabled"
def require_operations_enabled(detail: str = READ_ONLY_OPERATION_DISABLED) -> None:
"""Reject mutation-oriented endpoints when the product is running read-only."""
if get_settings().read_only_mode:
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail=detail,
)

View File

@@ -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,

View File

@@ -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,

View File

@@ -1 +0,0 @@
"""Approval workflow module."""

View File

@@ -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}"

View File

@@ -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)

View File

@@ -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}

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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}'"

View File

@@ -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),
}

View File

@@ -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):

View File

@@ -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,
)

View File

@@ -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"]),
},

View File

@@ -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"

View File

@@ -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)}

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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"

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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."""

View File

@@ -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"

View File

@@ -1 +0,0 @@

View File

@@ -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()

View File

@@ -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"

View File

@@ -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)

View File

@@ -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,
)
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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,
]

View File

@@ -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"])

View File

@@ -9,7 +9,7 @@ from fastapi import HTTPException
from sqlalchemy import select
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
from app.modules.business.constants import StatusValue
from app.modules.business.constants import BusinessResponseKey, StatusValue
_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
_db.close()
@@ -18,12 +18,9 @@ os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
os.environ["API_KEY"] = "test-key"
os.environ["AUDIT_API_KEY"] = "audit-key"
os.environ["AUDIT_API_ACTOR"] = "audit-manager"
os.environ["APPROVAL_API_KEY"] = "approval-key"
os.environ["APPROVAL_API_ACTOR"] = "approval-manager"
os.environ["FEISHU_APP_ID"] = ""
os.environ["FEISHU_APP_SECRET"] = ""
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
os.environ["FEISHU_APPROVAL_APPROVER_IDS"] = json.dumps(["ou_card_approver"])
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
os.environ["LEGACY_DATABASE_URL"] = ""
os.environ["LEGACY_PROJECT_QUERY"] = ""
@@ -33,9 +30,9 @@ os.environ["SCHEDULER_ENABLED"] = "false"
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.core.database import Base, engine
from app.core.database import Base, SessionLocal, engine
from app.core.pagination import bounded_limit, bounded_offset
from app.core.security import require_api_key, require_approval_api_key, require_audit_api_key
from app.core.security import require_api_key, require_audit_api_key
from app.main import _allow_cors_credentials, app
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
from app.modules.events.constants import (
@@ -46,10 +43,8 @@ from app.modules.events.constants import (
EventType,
)
from app.modules.events.service import EventService
from app.modules.feishu.constants import (
FEISHU_APPROVAL_APPROVER_IDS_REQUIRED,
FEISHU_APPROVER_NOT_ALLOWED,
)
from app.modules.business.registry import get_domain_model
from app.modules.business.service import _model_payload, serialize_model
from app.modules.legacy_mysql.service import LegacyMySQLService
from app.modules.reports.constants import (
LifecycleAttentionKey,
@@ -63,50 +58,40 @@ from app.modules.reports.constants import (
from app.modules.risk.constants import RiskEventActionValue
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
from app.modules.workflows.models import WorkflowInstance
from app.modules.writebacks.constants import WritebackStatus
Base.metadata.create_all(bind=engine)
client = TestClient(app)
headers = {"X-API-Key": "test-key"}
audit_headers = {"X-API-Key": "test-key", "X-Audit-API-Key": "audit-key"}
approval_headers = {"X-API-Key": "test-key", "X-Approval-API-Key": "approval-key"}
def approve_change(
domain: str,
action: str,
payload: dict,
record_id: str | int | None = None,
) -> str:
request_payload: dict[str, object] = {
"domain": domain,
"action": action,
"reason": "pytest approval",
"payload": payload,
class SeedResponse:
def __init__(self, payload: dict, status_code: int = 200):
self.status_code = status_code
self._payload = payload
def json(self) -> dict:
return self._payload
def create_business_record(domain: str, data: dict, actor: str = "pytest") -> SeedResponse:
_ = actor
db = SessionLocal()
try:
model = get_domain_model(domain)
record = model(**_model_payload(domain, model, data))
db.add(record)
db.commit()
db.refresh(record)
return SeedResponse(
{
BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.DATA: serialize_model(record),
}
if record_id is not None:
request_payload["record_id"] = str(record_id)
response = client.post("/api/v1/approvals", headers=headers, json=request_payload)
assert response.status_code == 200
ticket_id = response.json()["ticket_id"]
approve_response = client.post(
f"/api/v1/approvals/{ticket_id}/approve",
headers=approval_headers,
json={"comment": "pytest approved"},
)
assert approve_response.status_code == 200
return ticket_id
def create_business_record(domain: str, data: dict, actor: str = "pytest"):
ticket_id = approve_change(domain, f"create:{domain}", data)
return client.post(
f"/api/v1/business/{domain}",
headers=headers,
json={"actor": actor, "approval_ticket_id": ticket_id, "data": data},
)
finally:
db.close()
def teardown_module() -> None:
@@ -243,7 +228,7 @@ def test_v3_event_idempotency_and_workflow_dispatch() -> None:
db.close()
def test_v3_risk_action_creates_workflow() -> None:
def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
response = create_business_record(
"risk-events",
{
@@ -261,22 +246,10 @@ def test_v3_risk_action_creates_workflow() -> None:
headers=headers,
json={"assigned_to": "risk-owner", "comment": "route to owner"},
)
assert response.status_code == 200
response = client.get(
"/api/v1/workflows",
headers=headers,
params={"workflow_type": WorkflowType.RISK_EVENT_REVIEW},
)
assert response.status_code == 200
workflows = response.json()["items"]
assert any(
item["aggregate_id"] == str(risk_id) and item["status"] == WorkflowStatus.RUNNING
for item in workflows
)
assert response.status_code == 405
def test_v3_writeback_disabled_requires_approval_without_consuming_ticket() -> None:
def test_writeback_and_approval_routes_are_removed() -> None:
response = client.post(
"/api/v1/writebacks",
headers=headers,
@@ -287,15 +260,7 @@ def test_v3_writeback_disabled_requires_approval_without_consuming_ticket() -> N
"payload": {"code": "P-V3-WB", "name": "Writeback target"},
},
)
assert response.status_code == 200
writeback_code = response.json()["data"]["code"]
response = client.post(
f"/api/v1/writebacks/{writeback_code}/submit",
headers=headers,
json={},
)
assert response.status_code == 409
assert response.status_code == 404
response = client.post(
"/api/v1/approvals",
@@ -308,39 +273,7 @@ def test_v3_writeback_disabled_requires_approval_without_consuming_ticket() -> N
"payload": {"code": "P-V3-WB", "name": "Writeback target"},
},
)
assert response.status_code == 200
ticket_id = response.json()["ticket_id"]
response = client.post(
f"/api/v1/approvals/{ticket_id}/approve",
headers=approval_headers,
json={"comment": "approved for disabled adapter test"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1/writebacks/{writeback_code}/submit",
headers=headers,
json={"approval_ticket_id": ticket_id},
)
assert response.status_code == 200
assert response.json()["data"]["status"] == WritebackStatus.DISABLED
response = client.get(f"/api/v1/approvals/{ticket_id}", headers=approval_headers)
assert response.status_code == 200
assert response.json()["status"] == "approved"
response = client.get(
"/api/v1/workflows",
headers=headers,
params={"workflow_type": WorkflowType.OFFICIAL_WRITEBACK},
)
assert response.status_code == 200
workflows = response.json()["items"]
assert any(
item["aggregate_id"] == writeback_code and item["status"] == WorkflowStatus.BLOCKED
for item in workflows
)
assert response.status_code == 404
def test_feishu_webhook_challenge_uses_event_service_verification() -> None:
@@ -373,12 +306,6 @@ def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
)
assert response.status_code == 401
monkeypatch.setenv("APPROVAL_API_KEY", "")
get_settings.cache_clear()
with pytest.raises(HTTPException) as approval_exc_info:
require_approval_api_key("approval-key")
assert approval_exc_info.value.status_code == 503
monkeypatch.setenv("AUDIT_API_KEY", "")
get_settings.cache_clear()
with pytest.raises(HTTPException) as audit_exc_info:
@@ -387,7 +314,6 @@ def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
finally:
monkeypatch.setenv("API_KEY", "test-key")
monkeypatch.setenv("AUDIT_API_KEY", "audit-key")
monkeypatch.setenv("APPROVAL_API_KEY", "approval-key")
get_settings.cache_clear()
@@ -472,7 +398,6 @@ def test_configured_domain_response_masking(monkeypatch) -> None:
},
)
assert response.status_code == 200
assert response.json()["data"]["amount"] == "[MASKED]"
list_response = client.get("/api/v1/business/expenses", headers=headers)
assert list_response.status_code == 200
@@ -487,8 +412,8 @@ def test_configured_domain_response_masking(monkeypatch) -> None:
get_settings.cache_clear()
def test_business_writes_require_approval_and_reject_read_only_fields() -> None:
blocked_response = client.post(
def test_business_write_routes_are_disabled_in_read_only_mode() -> None:
create_response = client.post(
"/api/v1/business/projects",
headers=headers,
json={
@@ -498,187 +423,21 @@ def test_business_writes_require_approval_and_reject_read_only_fields() -> None:
},
},
)
assert blocked_response.status_code == 409
readonly_payload = {
"code": "P-READONLY-001",
"name": "Readonly project",
"created_at": "2026-07-08T00:00:00",
}
ticket_id = approve_change("projects", "create:projects", readonly_payload)
readonly_response = client.post(
"/api/v1/business/projects",
headers=headers,
json={
"approval_ticket_id": ticket_id,
"data": readonly_payload,
},
)
assert readonly_response.status_code == 422
assert readonly_response.json()["detail"] == "Field 'created_at' is read-only"
def test_approval_gate_for_high_risk_update() -> None:
create_payload = {
"code": "FUND-SMOKE-001",
"name": "Main Account",
"current_balance": 1000,
"safety_line": 500,
}
blocked_create_response = client.post(
"/api/v1/business/fund-accounts",
headers=headers,
json={
"actor": "spoofed-user",
"data": {
"code": "FUND-SMOKE-BLOCKED",
"name": "Blocked Account",
},
},
)
assert blocked_create_response.status_code == 409
create_approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"action": "create:fund-accounts",
"applicant": "spoofed-user",
"reason": "Smoke test account creation",
"payload": create_payload,
},
)
assert create_approval_response.status_code == 200
assert create_approval_response.json()["applicant"] == "api"
create_ticket_id = create_approval_response.json()["ticket_id"]
approval_list_without_approval_key_response = client.get("/api/v1/approvals", headers=headers)
assert approval_list_without_approval_key_response.status_code == 401
approval_detail_without_approval_key_response = client.get(
f"/api/v1/approvals/{create_ticket_id}",
headers=headers,
)
assert approval_detail_without_approval_key_response.status_code == 401
approval_detail_response = client.get(
f"/api/v1/approvals/{create_ticket_id}",
headers=approval_headers,
)
assert approval_detail_response.status_code == 200
approve_create_response = client.post(
f"/api/v1/approvals/{create_ticket_id}/approve",
headers=approval_headers,
json={"comment": "ok"},
)
assert approve_create_response.status_code == 200
assert approve_create_response.json()["approver"] == "approval-manager"
create_response = client.post(
"/api/v1/business/fund-accounts",
headers=headers,
json={
"actor": "spoofed-user",
"approval_ticket_id": create_ticket_id,
"data": create_payload,
},
)
assert create_response.status_code == 200
record_id = create_response.json()["data"]["id"]
reuse_create_response = client.post(
"/api/v1/business/fund-accounts",
headers=headers,
json={
"approval_ticket_id": create_ticket_id,
"data": {
"code": "FUND-SMOKE-REUSE",
"name": "Reuse Account",
},
},
)
assert reuse_create_response.status_code == 403
blocked_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
headers=headers,
json={"actor": "spoofed-user", "data": {"current_balance": 100}},
)
assert blocked_response.status_code == 409
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"record_id": str(record_id),
"action": "update:fund-accounts",
"applicant": "spoofed-user",
"reason": "Smoke test balance adjustment",
"payload": {"current_balance": 100},
},
)
assert approval_response.status_code == 200
ticket_id = approval_response.json()["ticket_id"]
pending_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
headers=headers,
json={
"actor": "spoofed-user",
"approval_ticket_id": ticket_id,
"data": {"current_balance": 100},
},
)
assert pending_response.status_code == 403
approve_response = client.post(
f"/api/v1/approvals/{ticket_id}/approve",
headers=approval_headers,
json={"approver": "spoofed-manager", "comment": "ok"},
)
assert approve_response.status_code == 200
assert approve_response.json()["status"] == "approved"
assert approve_response.json()["approver"] == "approval-manager"
mismatch_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
headers=headers,
json={
"actor": "spoofed-user",
"approval_ticket_id": ticket_id,
"data": {"current_balance": 101},
},
)
assert mismatch_response.status_code == 403
assert create_response.status_code == 405
update_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
"/api/v1/business/projects/1",
headers=headers,
json={
"actor": "spoofed-user",
"approval_ticket_id": ticket_id,
"data": {"current_balance": 100},
"data": {
"name": "Blocked update",
},
},
)
assert update_response.status_code == 200
assert update_response.json()["data"]["current_balance"] == 100.0
reuse_update_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
headers=headers,
json={
"actor": "spoofed-user",
"approval_ticket_id": ticket_id,
"data": {"current_balance": 100},
},
)
assert reuse_update_response.status_code == 403
assert update_response.status_code == 405
def test_feishu_approval_card_action_approves_ticket() -> None:
def test_approval_and_feishu_approval_card_routes_are_removed() -> None:
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
@@ -690,8 +449,7 @@ def test_feishu_approval_card_action_approves_ticket() -> None:
"payload": {"current_balance": 300},
},
)
assert approval_response.status_code == 200
ticket_id = approval_response.json()["ticket_id"]
assert approval_response.status_code == 404
callback_response = client.post(
"/api/v1/integrations/feishu/approval-card-action",
@@ -700,104 +458,14 @@ def test_feishu_approval_card_action_approves_ticket() -> None:
"operator": {"operator_id": {"open_id": "ou_card_approver"}},
"action": {
"value": {
"ticket_id": ticket_id,
"ticket_id": "APR-DISABLED",
"decision": "approve",
"comment": "approved from card",
}
},
},
)
assert callback_response.status_code == 200
assert callback_response.json()["result"]["status"] == "approved"
assert callback_response.json()["result"]["approver"] == "ou_card_approver"
duplicate_response = client.post(
"/api/v1/integrations/feishu/approval-card-action",
json={
"token": "test-feishu-token",
"operator": {"operator_id": {"open_id": "ou_card_approver"}},
"action": {
"value": {
"ticket_id": ticket_id,
"decision": "approve",
"comment": "approved from card",
}
},
},
)
assert duplicate_response.status_code == 200
assert duplicate_response.json()["duplicate"] is True
assert duplicate_response.json()["result"]["status"] == "approved"
def test_feishu_approval_card_action_requires_approver_allowlist(monkeypatch) -> None:
monkeypatch.setenv("FEISHU_APPROVAL_APPROVER_IDS", "")
get_settings.cache_clear()
try:
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"record_id": "feishu-card-missing-allowlist",
"action": "update:fund-accounts",
"reason": "Card action missing allowlist test",
"payload": {"current_balance": 301},
},
)
assert approval_response.status_code == 200
ticket_id = approval_response.json()["ticket_id"]
callback_response = client.post(
"/api/v1/integrations/feishu/approval-card-action",
json={
"token": "test-feishu-token",
"operator": {"operator_id": {"open_id": "ou_card_approver"}},
"action": {
"value": {
"ticket_id": ticket_id,
"decision": "approve",
}
},
},
)
assert callback_response.status_code == 503
assert callback_response.json()["detail"] == FEISHU_APPROVAL_APPROVER_IDS_REQUIRED
finally:
monkeypatch.setenv("FEISHU_APPROVAL_APPROVER_IDS", "ou_card_approver")
get_settings.cache_clear()
def test_feishu_approval_card_action_rejects_unlisted_approver() -> None:
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"record_id": "feishu-card-unlisted-approver",
"action": "update:fund-accounts",
"reason": "Card action allowlist test",
"payload": {"current_balance": 302},
},
)
assert approval_response.status_code == 200
ticket_id = approval_response.json()["ticket_id"]
callback_response = client.post(
"/api/v1/integrations/feishu/approval-card-action",
json={
"token": "test-feishu-token",
"operator": {"operator_id": {"open_id": "ou_not_allowed"}},
"action": {
"value": {
"ticket_id": ticket_id,
"decision": "approve",
}
},
},
)
assert callback_response.status_code == 403
assert callback_response.json()["detail"] == FEISHU_APPROVER_NOT_ALLOWED
assert callback_response.status_code == 404
def test_new_ledgers_reports_and_risk_events() -> None:
@@ -843,23 +511,21 @@ def test_new_ledgers_reports_and_risk_events() -> None:
json={"report_type": ReportType.DAILY, "reporter": "pytest", "actor": "pytest"},
)
assert report_response.status_code == 200
assert report_response.json()["data"]["report_type"] == ReportType.DAILY
assert report_response.json()["data"] is None
assert report_response.json()["report"]["report_type"] == ReportType.DAILY
risk_response = client.post(
"/api/v1/risks/events/generate?actor=pytest",
headers=headers,
)
assert risk_response.status_code == 200
assert risk_response.json()["created"] >= 1
assert risk_response.status_code == 405
enqueue_response = client.post("/api/v1/risks/events/enqueue", headers=headers)
assert enqueue_response.status_code == 200
assert enqueue_response.json()["queued"] is False
assert "result" in enqueue_response.json()
assert enqueue_response.status_code == 405
events_response = client.get("/api/v1/risks/events?status=open", headers=headers)
assert events_response.status_code == 200
assert any(item["risk_type"] == "overdue_task" for item in events_response.json()["items"])
overdue_response = client.get("/api/v1/risks/overdue-tasks", headers=headers)
assert overdue_response.status_code == 200
assert any(item["code"] == "TASK-RISK-001" for item in overdue_response.json()["items"])
def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None:
@@ -1027,7 +693,7 @@ def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
assert metrics["expenses_pending"] == 1
def test_legacy_task_sync_creates_and_updates_internal_tasks(monkeypatch) -> None:
def test_legacy_task_read_query_allowed_but_sync_disabled(monkeypatch) -> None:
rows = [
{
"id": 9001,
@@ -1047,6 +713,15 @@ def test_legacy_task_sync_creates_and_updates_internal_tasks(monkeypatch) -> Non
fake_execute_allowed_query,
)
query_response = client.post(
"/api/v1/integrations/mysql/query",
headers=headers,
json={"query_name": "legacy_tasks"},
)
assert query_response.status_code == 200
assert query_response.json()["row_count"] == 1
assert query_response.json()["rows"][0]["task_name"] == "Legacy task one"
response = client.post(
"/api/v1/integrations/mysql/tasks/sync",
headers=headers,
@@ -1055,31 +730,10 @@ def test_legacy_task_sync_creates_and_updates_internal_tasks(monkeypatch) -> Non
"field_map": {"title": "task_name"},
},
)
assert response.status_code == 200
assert response.json()["created"] == 1
assert response.json()["items"][0]["task"]["external_id"] == "9001"
rows[0]["task_name"] = "Legacy task one updated"
second_response = client.post(
"/api/v1/integrations/mysql/tasks/sync",
headers=headers,
json={
"dry_run": False,
"field_map": {"title": "task_name"},
},
)
assert second_response.status_code == 200
assert second_response.json()["updated"] == 1
tasks_response = client.get("/api/v1/business/tasks", headers=headers)
assert tasks_response.status_code == 200
item = next(
item for item in tasks_response.json()["items"] if item["external_id"] == "9001"
)
assert item["title"] == "Legacy task one updated"
assert response.status_code == 405
def test_risk_event_workflow_records_actions() -> None:
def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None:
create_response = create_business_record(
"risk-events",
{
@@ -1100,57 +754,21 @@ def test_risk_event_workflow_records_actions() -> None:
headers=headers,
json={"assigned_to": "risk-owner", "comment": "please handle"},
)
assert assign_response.status_code == 200
assert assign_response.json()["risk_event"]["assigned_to"] == "risk-owner"
assert assign_response.status_code == 405
comment_response = client.post(
f"/api/v1/risks/events/{event_id}/comment",
headers=headers,
json={"comment": "working on it", "payload": {"step": 1}},
)
assert comment_response.status_code == 200
assert comment_response.status_code == 405
resolve_response = client.post(
f"/api/v1/risks/events/{event_id}/resolve",
headers=headers,
json={"comment": "resolved"},
)
assert resolve_response.status_code == 200
assert resolve_response.json()["risk_event"]["status"] == "resolved"
blocked_close_response = client.post(
f"/api/v1/risks/events/{event_id}/close",
headers=headers,
json={
"closed_reason": "verified",
"review_summary": "handled",
},
)
assert blocked_close_response.status_code == 409
close_ticket_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "risk-events",
"record_id": str(event_id),
"action": "update:risk-events",
"reason": "Close risk event",
"payload": {
"status": "closed",
"closed_reason": "verified",
"review_summary": "handled",
},
},
)
assert close_ticket_response.status_code == 200
close_ticket_id = close_ticket_response.json()["ticket_id"]
approve_close_response = client.post(
f"/api/v1/approvals/{close_ticket_id}/approve",
headers=approval_headers,
json={"comment": "risk close approved"},
)
assert approve_close_response.status_code == 200
assert resolve_response.status_code == 405
close_response = client.post(
f"/api/v1/risks/events/{event_id}/close",
@@ -1158,54 +776,23 @@ def test_risk_event_workflow_records_actions() -> None:
json={
"closed_reason": "verified",
"review_summary": "handled",
"approval_ticket_id": close_ticket_id,
},
)
assert close_response.status_code == 200
assert close_response.json()["risk_event"]["status"] == "closed"
assert close_response.json()["risk_event"]["closed_reason"] == "verified"
blocked_reopen_response = client.post(
f"/api/v1/risks/events/{event_id}/reopen",
headers=headers,
json={"comment": "recheck"},
)
assert blocked_reopen_response.status_code == 409
reopen_ticket_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "risk-events",
"record_id": str(event_id),
"action": "update:risk-events",
"reason": "Reopen risk event",
"payload": {"status": "open", "comment": "recheck"},
},
)
assert reopen_ticket_response.status_code == 200
reopen_ticket_id = reopen_ticket_response.json()["ticket_id"]
approve_reopen_response = client.post(
f"/api/v1/approvals/{reopen_ticket_id}/approve",
headers=approval_headers,
json={"comment": "risk reopen approved"},
)
assert approve_reopen_response.status_code == 200
assert close_response.status_code == 405
reopen_response = client.post(
f"/api/v1/risks/events/{event_id}/reopen",
headers=headers,
json={"comment": "recheck", "approval_ticket_id": reopen_ticket_id},
json={"comment": "recheck"},
)
assert reopen_response.status_code == 200
assert reopen_response.json()["risk_event"]["status"] == "open"
assert reopen_response.status_code == 405
actions_response = client.get(
f"/api/v1/risks/events/{event_id}/actions",
headers=headers,
)
assert actions_response.status_code == 200
assert len(actions_response.json()["items"]) >= 5
assert actions_response.json()["items"] == []
def test_report_push_failure_is_recorded() -> None: