```
feat(core): 添加API认证主体配置和安全验证 - 在Settings中添加api_actor字段,用于标识API调用方身份 - 创建ApiPrincipal数据类来表示服务主体 - 修改require_api_key函数返回认证的服务主体信息 - 更新配置文件引入ActorValue常量 feat(ai_agent): 增强OpenClaw工具调用的安全性检查 - 实现_openclaw_allowed_tools和openclaw_allowed_actions配置项 - 添加CSV列表解析验证器 - 实现工具和操作权限检查方法_ensure_tool_allowed - 在工具调用前验证允许的工具和操作类型 feat(security): 强化API密钥认证和审计安全性 - 更新require_api_key函数在缺少API_KEY时抛出异常 - 在AI代理、审批、飞书等模块的路由中统一使用ApiPrincipal获取调用方信息 - 替换硬编码的ActorValue.API为动态的principal.actor feat(audit): 实现安全审计负载脱敏处理 - 添加敏感键名集合AI_AUDIT_SENSITIVE_KEYS - 实现审计安全负载处理函数_audit_safe_payload - 支持深度遍历、文本截断、序列限制和敏感信息脱敏 - 在AI服务的审计日志中应用安全负载处理 feat(approval): 完善审批流程的申请人身份验证 - 更新审批创建接口使用认证主体作为申请人 - 使用utc_now替换datetime.utcnow确保时间一致性 - 修复审批逻辑中的条件判断问题 feat(business): 加强业务领域高风险操作的审批控制 - 为高风险域创建统一的审批验证方法_ensure_approved - 在创建和更新操作中强制要求审批票证 - 为项目同步功能添加认证主体参数 feat(config): 统一时间处理使用UTC时间函数 - 创建并使用utc_now函数替代datetime.utcnow - 在审批、审计、业务、遗留数据等模块中更新时间戳处理 feat(constants): 扩展风险事件类型和报告指标 - 添加新风险事件类型到GENERATED_RISK_EVENT_TYPES - 为报告模块添加外部开放和高风险事件指标 refactor(feishu): 增强飞书验证令牌安全检查 - 确保飞书验证令牌配置存在时才接受请求 - 修正令牌验证逻辑以提高安全性 ```
This commit is contained in:
39
alembic.ini
Normal file
39
alembic.ini
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
path_separator = os
|
||||||
|
sqlalchemy.url = sqlite:///./company_ai.db
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
54
alembic/env.py
Normal file
54
alembic/env.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.database 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
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
|
||||||
|
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=settings.database_url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
configuration = config.get_section(config.config_ini_section, {})
|
||||||
|
configuration["sqlalchemy.url"] = settings.database_url
|
||||||
|
connectable = engine_from_config(
|
||||||
|
configuration,
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
29
alembic/versions/202607060001_initial_schema.py
Normal file
29
alembic/versions/202607060001_initial_schema.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""Initial schema.
|
||||||
|
|
||||||
|
Revision ID: 202607060001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-07-06
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from app.core.database 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
|
||||||
|
|
||||||
|
revision = "202607060001"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
|
||||||
|
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
Base.metadata.create_all(bind=op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
Base.metadata.drop_all(bind=op.get_bind())
|
||||||
@@ -3,6 +3,8 @@ from functools import lru_cache
|
|||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from app.core.constants import ActorValue
|
||||||
|
|
||||||
DEFAULT_MODEL_PROVIDER = "noop"
|
DEFAULT_MODEL_PROVIDER = "noop"
|
||||||
|
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ class Settings(BaseSettings):
|
|||||||
debug: bool = False
|
debug: bool = False
|
||||||
api_prefix: str = "/api/v1"
|
api_prefix: str = "/api/v1"
|
||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
|
api_actor: str = ActorValue.API
|
||||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
||||||
|
|
||||||
database_url: str = "mysql+pymysql://root:password@127.0.0.1:3306/company_ai?charset=utf8mb4"
|
database_url: str = "mysql+pymysql://root:password@127.0.0.1:3306/company_ai?charset=utf8mb4"
|
||||||
@@ -37,6 +40,8 @@ class Settings(BaseSettings):
|
|||||||
openclaw_ws_url: str | None = None
|
openclaw_ws_url: str | None = None
|
||||||
openclaw_api_key: str | None = None
|
openclaw_api_key: str | None = None
|
||||||
openclaw_gateway_token: str | None = None
|
openclaw_gateway_token: str | None = None
|
||||||
|
openclaw_allowed_tools: list[str] = Field(default_factory=list)
|
||||||
|
openclaw_allowed_actions: list[str] = Field(default_factory=lambda: ["json"])
|
||||||
hermes_base_url: str = "http://127.0.0.1:2073/v1"
|
hermes_base_url: str = "http://127.0.0.1:2073/v1"
|
||||||
hermes_api_key: str | None = None
|
hermes_api_key: str | None = None
|
||||||
hermes_model: str = "hermes-agent"
|
hermes_model: str = "hermes-agent"
|
||||||
@@ -59,6 +64,13 @@ class Settings(BaseSettings):
|
|||||||
return value
|
return value
|
||||||
return [item.strip() for item in value.split(",") if item.strip()]
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
|
||||||
|
@field_validator("openclaw_allowed_tools", "openclaw_allowed_actions", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_csv_list(cls, value: str | list[str]) -> list[str]:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from fastapi import Header, HTTPException, status
|
from fastapi import Header, HTTPException, status
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
def require_api_key(x_api_key: str | None = Header(default=None)) -> None:
|
@dataclass(frozen=True)
|
||||||
"""Validate the optional internal API key header."""
|
class ApiPrincipal:
|
||||||
|
"""Authenticated service principal derived from server-side configuration."""
|
||||||
|
|
||||||
|
actor: str
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_key(x_api_key: str | None = Header(default=None)) -> ApiPrincipal:
|
||||||
|
"""Validate the internal API key header and return its service principal."""
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.api_key:
|
if not settings.api_key:
|
||||||
return
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="API_KEY is required",
|
||||||
|
)
|
||||||
if x_api_key != settings.api_key:
|
if x_api_key != settings.api_key:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
||||||
|
return ApiPrincipal(actor=settings.api_actor)
|
||||||
|
|||||||
7
app/core/time.py
Normal file
7
app/core/time.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
"""Return a naive UTC timestamp for existing DateTime columns."""
|
||||||
|
|
||||||
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
@@ -111,6 +111,7 @@ class OpenClawAdapter(AIAdapter):
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Invoke one OpenClaw Gateway tool through `/tools/invoke`."""
|
"""Invoke one OpenClaw Gateway tool through `/tools/invoke`."""
|
||||||
|
|
||||||
|
self._ensure_tool_allowed(tool, action)
|
||||||
payload = {
|
payload = {
|
||||||
AIHttpPayloadKey.TOOL: tool,
|
AIHttpPayloadKey.TOOL: tool,
|
||||||
AIHttpPayloadKey.ACTION: action,
|
AIHttpPayloadKey.ACTION: action,
|
||||||
@@ -135,6 +136,12 @@ class OpenClawAdapter(AIAdapter):
|
|||||||
AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format(token=token)
|
AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format(token=token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _ensure_tool_allowed(self, tool: str, action: str) -> None:
|
||||||
|
if tool not in set(self.settings.openclaw_allowed_tools):
|
||||||
|
raise HTTPException(status_code=403, detail="OpenClaw tool is not allowed")
|
||||||
|
if action not in set(self.settings.openclaw_allowed_actions):
|
||||||
|
raise HTTPException(status_code=403, detail="OpenClaw action is not allowed")
|
||||||
|
|
||||||
|
|
||||||
class HermesAdapter(AIAdapter):
|
class HermesAdapter(AIAdapter):
|
||||||
"""Adapter for the Hermes OpenAI-compatible agent endpoint."""
|
"""Adapter for the Hermes OpenAI-compatible agent endpoint."""
|
||||||
|
|||||||
@@ -126,3 +126,24 @@ NOOP_PROVIDER_ANSWER = (
|
|||||||
OPENCLAW_TOOL_COMPLETED_ANSWER = "OpenClaw tool invocation completed."
|
OPENCLAW_TOOL_COMPLETED_ANSWER = "OpenClaw tool invocation completed."
|
||||||
DIRECT_LLM_API_KEY_MISSING = "DIRECT_LLM_API_KEY is not configured"
|
DIRECT_LLM_API_KEY_MISSING = "DIRECT_LLM_API_KEY is not configured"
|
||||||
UNEXPECTED_HERMES_RESPONSE = "Unexpected chat completion response"
|
UNEXPECTED_HERMES_RESPONSE = "Unexpected chat completion response"
|
||||||
|
|
||||||
|
AI_AUDIT_REDACTED_VALUE = "[REDACTED]"
|
||||||
|
AI_AUDIT_TRUNCATED_VALUE = "[TRUNCATED]"
|
||||||
|
AI_AUDIT_MAX_TEXT_LENGTH = 1000
|
||||||
|
AI_AUDIT_MAX_SEQUENCE_ITEMS = 20
|
||||||
|
AI_AUDIT_MAX_DEPTH = 4
|
||||||
|
AI_AUDIT_SENSITIVE_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"authorization",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"access_token",
|
||||||
|
"tenant_access_token",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
"password",
|
||||||
|
"openclaw_gateway_token",
|
||||||
|
"hermes_api_key",
|
||||||
|
"direct_llm_api_key",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.constants import ActorValue
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import require_api_key
|
from app.core.security import ApiPrincipal, require_api_key
|
||||||
|
from app.modules.audit.constants import AuditSource
|
||||||
from app.modules.ai_agent.schemas import (
|
from app.modules.ai_agent.schemas import (
|
||||||
AIAskRequest,
|
AIAskRequest,
|
||||||
AIAskResponse,
|
AIAskResponse,
|
||||||
@@ -17,43 +17,64 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/ask", response_model=AIAskResponse)
|
@router.post("/ask", response_model=AIAskResponse)
|
||||||
def ask(payload: AIAskRequest, db: Session = Depends(get_db)) -> dict:
|
def ask(
|
||||||
return AIService(db).ask(payload.prompt, payload.context, payload.actor, payload.source)
|
payload: AIAskRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
|
return AIService(db).ask(
|
||||||
|
payload.prompt,
|
||||||
|
payload.context,
|
||||||
|
actor=principal.actor,
|
||||||
|
source=AuditSource.API,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/provider-health")
|
@router.get("/provider-health")
|
||||||
def provider_health(actor: str = ActorValue.API, db: Session = Depends(get_db)) -> dict:
|
def provider_health(
|
||||||
return AIService(db).provider_health(actor=actor)
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
|
return AIService(db).provider_health(actor=principal.actor)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/openclaw/tools/invoke")
|
@router.post("/openclaw/tools/invoke")
|
||||||
def invoke_openclaw_tool(
|
def invoke_openclaw_tool(
|
||||||
payload: OpenClawToolInvokeRequest,
|
payload: OpenClawToolInvokeRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return AIService(db).invoke_openclaw_tool(
|
return AIService(db).invoke_openclaw_tool(
|
||||||
tool=payload.tool,
|
tool=payload.tool,
|
||||||
action=payload.action,
|
action=payload.action,
|
||||||
args=payload.args,
|
args=payload.args,
|
||||||
session_key=payload.session_key,
|
session_key=payload.session_key,
|
||||||
actor=payload.actor,
|
actor=principal.actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/draft-policy", response_model=AIAskResponse)
|
@router.post("/draft-policy", response_model=AIAskResponse)
|
||||||
def draft_policy(payload: DraftPolicyRequest, db: Session = Depends(get_db)) -> dict:
|
def draft_policy(
|
||||||
|
payload: DraftPolicyRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
return AIService(db).draft_policy(
|
return AIService(db).draft_policy(
|
||||||
title=payload.title,
|
title=payload.title,
|
||||||
policy_type=payload.policy_type,
|
policy_type=payload.policy_type,
|
||||||
requirements=payload.requirements,
|
requirements=payload.requirements,
|
||||||
actor=payload.actor,
|
actor=principal.actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/investment-research", response_model=AIAskResponse)
|
@router.post("/investment-research", response_model=AIAskResponse)
|
||||||
def investment_research(payload: InvestmentResearchRequest, db: Session = Depends(get_db)) -> dict:
|
def investment_research(
|
||||||
|
payload: InvestmentResearchRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
return AIService(db).draft_investment_research(
|
return AIService(db).draft_investment_research(
|
||||||
symbol_or_topic=payload.symbol_or_topic,
|
symbol_or_topic=payload.symbol_or_topic,
|
||||||
risk_preference=payload.risk_preference,
|
risk_preference=payload.risk_preference,
|
||||||
actor=payload.actor,
|
actor=principal.actor,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ from app.core.config import get_settings
|
|||||||
from app.modules.ai_agent.adapters import HermesAdapter, OpenClawAdapter, get_adapter
|
from app.modules.ai_agent.adapters import HermesAdapter, OpenClawAdapter, get_adapter
|
||||||
from app.modules.ai_agent.constants import (
|
from app.modules.ai_agent.constants import (
|
||||||
AIDefault,
|
AIDefault,
|
||||||
|
AI_AUDIT_MAX_DEPTH,
|
||||||
|
AI_AUDIT_MAX_SEQUENCE_ITEMS,
|
||||||
|
AI_AUDIT_MAX_TEXT_LENGTH,
|
||||||
|
AI_AUDIT_REDACTED_VALUE,
|
||||||
|
AI_AUDIT_SENSITIVE_KEYS,
|
||||||
|
AI_AUDIT_TRUNCATED_VALUE,
|
||||||
AIProviderName,
|
AIProviderName,
|
||||||
AIRequestKey,
|
AIRequestKey,
|
||||||
AIResponseKey,
|
AIResponseKey,
|
||||||
@@ -50,11 +56,11 @@ class AIService:
|
|||||||
action=AuditAction.AI_ASK,
|
action=AuditAction.AI_ASK,
|
||||||
target_type=AuditTargetType.AI,
|
target_type=AuditTargetType.AI,
|
||||||
risk_level=AuditRiskLevel.MEDIUM,
|
risk_level=AuditRiskLevel.MEDIUM,
|
||||||
request_payload={
|
request_payload=_audit_safe_payload({
|
||||||
AIRequestKey.PROMPT: prompt,
|
AIRequestKey.PROMPT: prompt,
|
||||||
AIRequestKey.CONTEXT: context or {},
|
AIRequestKey.CONTEXT: context or {},
|
||||||
},
|
}),
|
||||||
response_payload=response,
|
response_payload=_audit_safe_payload(response),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
@@ -90,7 +96,7 @@ class AIService:
|
|||||||
action=AuditAction.AI_PROVIDER_HEALTH,
|
action=AuditAction.AI_PROVIDER_HEALTH,
|
||||||
target_type=AuditTargetType.AI,
|
target_type=AuditTargetType.AI,
|
||||||
risk_level=AuditRiskLevel.LOW,
|
risk_level=AuditRiskLevel.LOW,
|
||||||
response_payload=response,
|
response_payload=_audit_safe_payload(response),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
@@ -113,13 +119,13 @@ class AIService:
|
|||||||
target_type=AuditTargetType.OPENCLAW_TOOL,
|
target_type=AuditTargetType.OPENCLAW_TOOL,
|
||||||
target_id=tool,
|
target_id=tool,
|
||||||
risk_level=AuditRiskLevel.HIGH,
|
risk_level=AuditRiskLevel.HIGH,
|
||||||
request_payload={
|
request_payload=_audit_safe_payload({
|
||||||
"tool": tool,
|
"tool": tool,
|
||||||
"action": action,
|
"action": action,
|
||||||
"args": args or {},
|
"args": args or {},
|
||||||
"session_key": session_key,
|
"session_key": session_key,
|
||||||
},
|
}),
|
||||||
response_payload=result,
|
response_payload=_audit_safe_payload(result),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
@@ -167,3 +173,26 @@ class AIService:
|
|||||||
},
|
},
|
||||||
actor=actor,
|
actor=actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_safe_payload(value: Any, depth: int = 0) -> Any:
|
||||||
|
if depth >= AI_AUDIT_MAX_DEPTH:
|
||||||
|
return AI_AUDIT_TRUNCATED_VALUE
|
||||||
|
if isinstance(value, dict):
|
||||||
|
safe: dict[str, Any] = {}
|
||||||
|
for key, item in value.items():
|
||||||
|
key_text = str(key)
|
||||||
|
if key_text.lower() in AI_AUDIT_SENSITIVE_KEYS:
|
||||||
|
safe[key_text] = AI_AUDIT_REDACTED_VALUE
|
||||||
|
else:
|
||||||
|
safe[key_text] = _audit_safe_payload(item, depth + 1)
|
||||||
|
return safe
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
items = list(value[:AI_AUDIT_MAX_SEQUENCE_ITEMS])
|
||||||
|
safe_items = [_audit_safe_payload(item, depth + 1) for item in items]
|
||||||
|
if len(value) > AI_AUDIT_MAX_SEQUENCE_ITEMS:
|
||||||
|
safe_items.append(AI_AUDIT_TRUNCATED_VALUE)
|
||||||
|
return safe_items
|
||||||
|
if isinstance(value, str) and len(value) > AI_AUDIT_MAX_TEXT_LENGTH:
|
||||||
|
return value[:AI_AUDIT_MAX_TEXT_LENGTH] + AI_AUDIT_TRUNCATED_VALUE
|
||||||
|
return value
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column
|
|||||||
|
|
||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
from app.core.time import utc_now
|
||||||
from app.modules.approvals.constants import ApprovalStatus
|
from app.modules.approvals.constants import ApprovalStatus
|
||||||
|
|
||||||
|
|
||||||
@@ -22,10 +23,10 @@ class ApprovalRequest(Base):
|
|||||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
payload: 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)
|
decision_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
default=datetime.utcnow,
|
default=utc_now,
|
||||||
onupdate=datetime.utcnow,
|
onupdate=utc_now,
|
||||||
)
|
)
|
||||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
decided_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import require_api_key
|
from app.core.security import ApiPrincipal, require_api_key
|
||||||
from app.modules.approvals.schemas import ApprovalCreate, ApprovalDecision, ApprovalRead
|
from app.modules.approvals.schemas import ApprovalCreate, ApprovalDecision, ApprovalRead
|
||||||
from app.modules.approvals.service import ApprovalService
|
from app.modules.approvals.service import ApprovalService
|
||||||
|
|
||||||
@@ -10,8 +10,12 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ApprovalRead)
|
@router.post("", response_model=ApprovalRead)
|
||||||
def create_approval(payload: ApprovalCreate, db: Session = Depends(get_db)):
|
def create_approval(
|
||||||
return ApprovalService(db).create(payload)
|
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])
|
@router.get("", response_model=list[ApprovalRead])
|
||||||
@@ -29,10 +33,20 @@ def get_approval(ticket_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{ticket_id}/approve", response_model=ApprovalRead)
|
@router.post("/{ticket_id}/approve", response_model=ApprovalRead)
|
||||||
def approve(ticket_id: str, payload: ApprovalDecision, db: Session = Depends(get_db)):
|
def approve(
|
||||||
return ApprovalService(db).decide(ticket_id, payload.approver, True, payload.comment)
|
ticket_id: str,
|
||||||
|
payload: ApprovalDecision,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
return ApprovalService(db).decide(ticket_id, principal.actor, True, payload.comment)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{ticket_id}/reject", response_model=ApprovalRead)
|
@router.post("/{ticket_id}/reject", response_model=ApprovalRead)
|
||||||
def reject(ticket_id: str, payload: ApprovalDecision, db: Session = Depends(get_db)):
|
def reject(
|
||||||
return ApprovalService(db).decide(ticket_id, payload.approver, False, payload.comment)
|
ticket_id: str,
|
||||||
|
payload: ApprovalDecision,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
return ApprovalService(db).decide(ticket_id, principal.actor, False, payload.comment)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from fastapi import HTTPException, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.time import utc_now
|
||||||
from app.modules.approvals.constants import ApprovalActionValue, ApprovalStatus
|
from app.modules.approvals.constants import ApprovalActionValue, ApprovalStatus
|
||||||
from app.modules.approvals.models import ApprovalRequest
|
from app.modules.approvals.models import ApprovalRequest
|
||||||
from app.modules.approvals.schemas import ApprovalCreate
|
from app.modules.approvals.schemas import ApprovalCreate
|
||||||
@@ -20,13 +21,13 @@ class ApprovalService:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.audit = AuditService(db)
|
self.audit = AuditService(db)
|
||||||
|
|
||||||
def create(self, payload: ApprovalCreate) -> ApprovalRequest:
|
def create(self, payload: ApprovalCreate, applicant: str) -> ApprovalRequest:
|
||||||
ticket = ApprovalRequest(
|
ticket = ApprovalRequest(
|
||||||
ticket_id=f"APR-{uuid.uuid4().hex[:12].upper()}",
|
ticket_id=f"APR-{uuid.uuid4().hex[:12].upper()}",
|
||||||
domain=payload.domain,
|
domain=payload.domain,
|
||||||
record_id=payload.record_id,
|
record_id=payload.record_id,
|
||||||
action=payload.action,
|
action=payload.action,
|
||||||
applicant=payload.applicant,
|
applicant=applicant,
|
||||||
reason=payload.reason,
|
reason=payload.reason,
|
||||||
payload=json.dumps(payload.payload, ensure_ascii=False, default=str),
|
payload=json.dumps(payload.payload, ensure_ascii=False, default=str),
|
||||||
)
|
)
|
||||||
@@ -35,7 +36,7 @@ class ApprovalService:
|
|||||||
self.db.refresh(ticket)
|
self.db.refresh(ticket)
|
||||||
self.audit.log(
|
self.audit.log(
|
||||||
AuditLogCreate(
|
AuditLogCreate(
|
||||||
actor=payload.applicant,
|
actor=applicant,
|
||||||
source=AuditSource.APPROVAL,
|
source=AuditSource.APPROVAL,
|
||||||
action=AuditAction.APPROVAL_CREATE,
|
action=AuditAction.APPROVAL_CREATE,
|
||||||
target_type=payload.domain,
|
target_type=payload.domain,
|
||||||
@@ -77,9 +78,7 @@ class ApprovalService:
|
|||||||
ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
|
ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
|
||||||
ticket.approver = approver
|
ticket.approver = approver
|
||||||
ticket.decision_comment = comment
|
ticket.decision_comment = comment
|
||||||
from datetime import datetime
|
ticket.decided_at = utc_now()
|
||||||
|
|
||||||
ticket.decided_at = datetime.utcnow()
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(ticket)
|
self.db.refresh(ticket)
|
||||||
self.audit.log(
|
self.audit.log(
|
||||||
@@ -108,7 +107,9 @@ class ApprovalService:
|
|||||||
return False
|
return False
|
||||||
if ticket.domain != domain:
|
if ticket.domain != domain:
|
||||||
return False
|
return False
|
||||||
if ticket.record_id and record_id is not None and str(ticket.record_id) != str(record_id):
|
if ticket.record_id and (
|
||||||
|
record_id is None or str(ticket.record_id) != str(record_id)
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
return ticket.action in {
|
return ticket.action in {
|
||||||
action,
|
action,
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ class AuditSource(StrEnum):
|
|||||||
FEISHU = "feishu"
|
FEISHU = "feishu"
|
||||||
APPROVAL = "approval"
|
APPROVAL = "approval"
|
||||||
LEGACY_MYSQL = "legacy_mysql"
|
LEGACY_MYSQL = "legacy_mysql"
|
||||||
|
REPORTS = "reports"
|
||||||
|
|
||||||
|
|
||||||
class AuditTargetType(StrEnum):
|
class AuditTargetType(StrEnum):
|
||||||
AI = "ai"
|
AI = "ai"
|
||||||
OPENCLAW_TOOL = "openclaw_tool"
|
OPENCLAW_TOOL = "openclaw_tool"
|
||||||
RISK_EVENTS = "risk-events"
|
RISK_EVENTS = "risk-events"
|
||||||
|
WORK_REPORTS = "work-reports"
|
||||||
|
|
||||||
|
|
||||||
class AuditStatus(StrEnum):
|
class AuditStatus(StrEnum):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column
|
|||||||
|
|
||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
from app.core.time import utc_now
|
||||||
from app.modules.audit.constants import AuditRiskLevel, AuditSource, AuditStatus
|
from app.modules.audit.constants import AuditRiskLevel, AuditSource, AuditStatus
|
||||||
|
|
||||||
|
|
||||||
@@ -21,4 +22,4 @@ class AuditLog(Base):
|
|||||||
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
status: Mapped[str] = mapped_column(String(32), default=AuditStatus.SUCCESS, index=True)
|
status: Mapped[str] = mapped_column(String(32), default=AuditStatus.SUCCESS, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||||
|
|||||||
@@ -115,3 +115,12 @@ ATTENDANCE_ABNORMAL_STATUSES = frozenset(
|
|||||||
)
|
)
|
||||||
SUPPLIER_RISK_LEVELS = frozenset({RiskLevel.MEDIUM, RiskLevel.HIGH})
|
SUPPLIER_RISK_LEVELS = frozenset({RiskLevel.MEDIUM, RiskLevel.HIGH})
|
||||||
CLOSED_RISK_STATUSES = frozenset({StatusValue.CLOSED, StatusValue.RESOLVED})
|
CLOSED_RISK_STATUSES = frozenset({StatusValue.CLOSED, StatusValue.RESOLVED})
|
||||||
|
GENERATED_RISK_EVENT_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
RiskEventType.OVERDUE_TASK,
|
||||||
|
RiskEventType.DELAYED_PROJECT,
|
||||||
|
RiskEventType.OVER_BUDGET_PROJECT,
|
||||||
|
RiskEventType.FUND_SAFETY_LINE,
|
||||||
|
RiskEventType.SUPPLIER_RISK,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Mapped, mapped_column
|
|||||||
|
|
||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
from app.core.time import utc_now
|
||||||
from app.modules.business.constants import (
|
from app.modules.business.constants import (
|
||||||
AccountType,
|
AccountType,
|
||||||
PriorityValue,
|
PriorityValue,
|
||||||
@@ -17,9 +18,9 @@ from app.modules.business.constants import (
|
|||||||
|
|
||||||
|
|
||||||
class TimestampMixin:
|
class TimestampMixin:
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
DateTime, default=utc_now, onupdate=utc_now
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -230,7 +231,7 @@ class RiskEvent(Base, TimestampMixin):
|
|||||||
source_record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
source_record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
detected_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
detected_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
mitigation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
mitigation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
@@ -245,7 +246,7 @@ class LegacySyncRun(Base, TimestampMixin):
|
|||||||
domain: Mapped[str] = mapped_column(String(128), index=True)
|
domain: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
source_table: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
source_table: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
status: Mapped[str] = mapped_column(String(32), default=StatusValue.RUNNING, index=True)
|
status: Mapped[str] = mapped_column(String(32), default=StatusValue.RUNNING, index=True)
|
||||||
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
started_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
created_count: Mapped[int] = mapped_column(Integer, default=0)
|
created_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
updated_count: Mapped[int] = mapped_column(Integer, default=0)
|
updated_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import require_api_key
|
from app.core.security import ApiPrincipal, require_api_key
|
||||||
from app.modules.business.registry import DOMAIN_MODELS
|
from app.modules.business.registry import DOMAIN_MODELS
|
||||||
from app.modules.business.schemas import DomainListRead, DomainRecordCreate, DomainRecordUpdate
|
from app.modules.business.schemas import DomainListRead, DomainRecordCreate, DomainRecordUpdate
|
||||||
from app.modules.business.service import BusinessService
|
from app.modules.business.service import BusinessService
|
||||||
@@ -43,9 +43,15 @@ def create_record(
|
|||||||
domain: str,
|
domain: str,
|
||||||
payload: DomainRecordCreate,
|
payload: DomainRecordCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
try:
|
try:
|
||||||
data = BusinessService(db).create_record(domain, payload.data, payload.actor)
|
data = BusinessService(db).create_record(
|
||||||
|
domain,
|
||||||
|
payload.data,
|
||||||
|
principal.actor,
|
||||||
|
payload.approval_ticket_id,
|
||||||
|
)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
return {"domain": domain, "data": data}
|
return {"domain": domain, "data": data}
|
||||||
@@ -57,13 +63,14 @@ def update_record(
|
|||||||
record_id: int,
|
record_id: int,
|
||||||
payload: DomainRecordUpdate,
|
payload: DomainRecordUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
try:
|
try:
|
||||||
data = BusinessService(db).update_record(
|
data = BusinessService(db).update_record(
|
||||||
domain,
|
domain,
|
||||||
record_id,
|
record_id,
|
||||||
payload.data,
|
payload.data,
|
||||||
actor=payload.actor,
|
actor=principal.actor,
|
||||||
approval_ticket_id=payload.approval_ticket_id,
|
approval_ticket_id=payload.approval_ticket_id,
|
||||||
)
|
)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ from app.core.constants import ActorValue
|
|||||||
class DomainRecordCreate(BaseModel):
|
class DomainRecordCreate(BaseModel):
|
||||||
data: dict[str, Any] = Field(..., description="Domain fields to create.")
|
data: dict[str, Any] = Field(..., description="Domain fields to create.")
|
||||||
actor: str = ActorValue.API
|
actor: str = ActorValue.API
|
||||||
|
approval_ticket_id: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Required by policy for high-risk creates such as funds or performance.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DomainRecordUpdate(BaseModel):
|
class DomainRecordUpdate(BaseModel):
|
||||||
|
|||||||
@@ -102,7 +102,10 @@ class BusinessService:
|
|||||||
domain: str,
|
domain: str,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
actor: str = ActorValue.API,
|
actor: str = ActorValue.API,
|
||||||
|
approval_ticket_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
if domain in HIGH_RISK_DOMAINS:
|
||||||
|
self._ensure_approved(approval_ticket_id, domain, None, f"create:{domain}")
|
||||||
model = get_domain_model(domain)
|
model = get_domain_model(domain)
|
||||||
payload = _model_payload(model, data)
|
payload = _model_payload(model, data)
|
||||||
record = model(**payload)
|
record = model(**payload)
|
||||||
@@ -117,7 +120,10 @@ class BusinessService:
|
|||||||
action=f"create:{domain}",
|
action=f"create:{domain}",
|
||||||
target_type=domain,
|
target_type=domain,
|
||||||
target_id=str(record.id),
|
target_id=str(record.id),
|
||||||
request_payload=data,
|
risk_level=(
|
||||||
|
AuditRiskLevel.HIGH if domain in HIGH_RISK_DOMAINS else AuditRiskLevel.LOW
|
||||||
|
),
|
||||||
|
request_payload={"data": data, "approval_ticket_id": approval_ticket_id},
|
||||||
response_payload=result,
|
response_payload=result,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -132,21 +138,7 @@ class BusinessService:
|
|||||||
approval_ticket_id: str | None = None,
|
approval_ticket_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if domain in HIGH_RISK_DOMAINS:
|
if domain in HIGH_RISK_DOMAINS:
|
||||||
if not approval_ticket_id:
|
self._ensure_approved(approval_ticket_id, domain, record_id, f"update:{domain}")
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="High-risk domain update requires approval_ticket_id",
|
|
||||||
)
|
|
||||||
if not ApprovalService(self.db).is_approved_for(
|
|
||||||
approval_ticket_id,
|
|
||||||
domain,
|
|
||||||
record_id,
|
|
||||||
f"update:{domain}",
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Approval ticket is not approved for this update",
|
|
||||||
)
|
|
||||||
model = get_domain_model(domain)
|
model = get_domain_model(domain)
|
||||||
record = self.db.get(model, record_id)
|
record = self.db.get(model, record_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
@@ -174,3 +166,26 @@ class BusinessService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _ensure_approved(
|
||||||
|
self,
|
||||||
|
approval_ticket_id: str | None,
|
||||||
|
domain: str,
|
||||||
|
record_id: str | int | None,
|
||||||
|
action: str,
|
||||||
|
) -> None:
|
||||||
|
if not approval_ticket_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="High-risk domain change requires approval_ticket_id",
|
||||||
|
)
|
||||||
|
if not ApprovalService(self.db).is_approved_for(
|
||||||
|
approval_ticket_id,
|
||||||
|
domain,
|
||||||
|
record_id,
|
||||||
|
action,
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Approval ticket is not approved for this change",
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Request
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import require_api_key
|
from app.core.security import ApiPrincipal, require_api_key
|
||||||
from app.modules.feishu.commands import FeishuCommandService
|
from app.modules.feishu.commands import FeishuCommandService
|
||||||
from app.modules.feishu.events import FeishuEventService
|
from app.modules.feishu.events import FeishuEventService
|
||||||
from app.modules.feishu.schemas import (
|
from app.modules.feishu.schemas import (
|
||||||
@@ -30,21 +30,31 @@ async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dic
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/send-text", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
|
@router.post("/send-text", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
|
||||||
def send_text(payload: FeishuTextMessage, db: Session = Depends(get_db)) -> dict:
|
def send_text(
|
||||||
|
payload: FeishuTextMessage,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
result = FeishuService(db).send_text(
|
result = FeishuService(db).send_text(
|
||||||
payload.text,
|
payload.text,
|
||||||
receive_id=payload.receive_id,
|
receive_id=payload.receive_id,
|
||||||
receive_id_type=payload.receive_id_type,
|
receive_id_type=payload.receive_id_type,
|
||||||
|
actor=principal.actor,
|
||||||
)
|
)
|
||||||
return {"ok": result.get("code") == 0, "provider_response": result}
|
return {"ok": result.get("code") == 0, "provider_response": result}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send-card", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
|
@router.post("/send-card", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
|
||||||
def send_card(payload: FeishuCardMessage, db: Session = Depends(get_db)) -> dict:
|
def send_card(
|
||||||
|
payload: FeishuCardMessage,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
result = FeishuService(db).send_card(
|
result = FeishuService(db).send_card(
|
||||||
payload.card,
|
payload.card,
|
||||||
receive_id=payload.receive_id,
|
receive_id=payload.receive_id,
|
||||||
receive_id_type=payload.receive_id_type,
|
receive_id_type=payload.receive_id_type,
|
||||||
|
actor=principal.actor,
|
||||||
)
|
)
|
||||||
return {"ok": result.get("code") == 0, "provider_response": result}
|
return {"ok": result.get("code") == 0, "provider_response": result}
|
||||||
|
|
||||||
@@ -54,12 +64,16 @@ def send_card(payload: FeishuCardMessage, db: Session = Depends(get_db)) -> dict
|
|||||||
response_model=FeishuCommandResult,
|
response_model=FeishuCommandResult,
|
||||||
dependencies=[Depends(require_api_key)],
|
dependencies=[Depends(require_api_key)],
|
||||||
)
|
)
|
||||||
def preview_command(payload: FeishuCommandRequest, db: Session = Depends(get_db)) -> dict:
|
def preview_command(
|
||||||
|
payload: FeishuCommandRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
"""Preview local Feishu command routing without requiring webhook delivery."""
|
"""Preview local Feishu command routing without requiring webhook delivery."""
|
||||||
|
|
||||||
return FeishuCommandService(db).handle_text(
|
return FeishuCommandService(db).handle_text(
|
||||||
payload.text,
|
payload.text,
|
||||||
chat_id=payload.chat_id,
|
chat_id=payload.chat_id,
|
||||||
actor=payload.actor,
|
actor=principal.actor,
|
||||||
auto_reply=payload.auto_reply,
|
auto_reply=payload.auto_reply,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ class FeishuService:
|
|||||||
expected = settings.feishu_verification_token
|
expected = settings.feishu_verification_token
|
||||||
header = payload.get("header") or {}
|
header = payload.get("header") or {}
|
||||||
token = payload.get("token") or header.get("token")
|
token = payload.get("token") or header.get("token")
|
||||||
if expected and token and token != expected:
|
if not expected:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="FEISHU_VERIFICATION_TOKEN is required",
|
||||||
|
)
|
||||||
|
if not token or token != expected:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid Feishu token",
|
detail="Invalid Feishu token",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import require_api_key
|
from app.core.security import ApiPrincipal, require_api_key
|
||||||
from app.modules.legacy_mysql.schemas import (
|
from app.modules.legacy_mysql.schemas import (
|
||||||
LegacyProjectSyncRequest,
|
LegacyProjectSyncRequest,
|
||||||
LegacyProjectSyncResult,
|
LegacyProjectSyncResult,
|
||||||
@@ -40,11 +40,15 @@ def default_project_query(limit: int = 100, db: Session = Depends(get_db)) -> di
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/projects/sync", response_model=LegacyProjectSyncResult)
|
@router.post("/projects/sync", response_model=LegacyProjectSyncResult)
|
||||||
def sync_projects(payload: LegacyProjectSyncRequest, db: Session = Depends(get_db)) -> dict:
|
def sync_projects(
|
||||||
|
payload: LegacyProjectSyncRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
return LegacyMySQLService(db).sync_projects(
|
return LegacyMySQLService(db).sync_projects(
|
||||||
source_query=payload.source_query,
|
source_query=payload.source_query,
|
||||||
field_map=payload.field_map,
|
field_map=payload.field_map,
|
||||||
limit=payload.limit,
|
limit=payload.limit,
|
||||||
dry_run=payload.dry_run,
|
dry_run=payload.dry_run,
|
||||||
actor=payload.actor,
|
actor=principal.actor,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.database import legacy_engine
|
from app.core.database import legacy_engine
|
||||||
|
from app.core.time import utc_now
|
||||||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
|
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
|
||||||
from app.modules.audit.schemas import AuditLogCreate
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
from app.modules.audit.service import AuditService
|
from app.modules.audit.service import AuditService
|
||||||
@@ -151,7 +152,11 @@ class LegacyMySQLService:
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
external_id = self._value(row, field_map, "external_id", row.get("id"))
|
external_id = self._value(row, field_map, "external_id", row.get("id"))
|
||||||
raw_code = self._value(row, field_map, "code", None)
|
raw_code = self._value(row, field_map, "code", None)
|
||||||
code = str(raw_code) if raw_code else f"{settings.legacy_project_code_prefix}-{external_id}"
|
code = None
|
||||||
|
if raw_code:
|
||||||
|
code = str(raw_code)
|
||||||
|
elif external_id is not None:
|
||||||
|
code = f"{settings.legacy_project_code_prefix}-{external_id}"
|
||||||
return {
|
return {
|
||||||
"code": code,
|
"code": code,
|
||||||
"external_id": str(external_id) if external_id is not None else code,
|
"external_id": str(external_id) if external_id is not None else code,
|
||||||
@@ -260,11 +265,11 @@ class LegacyMySQLService:
|
|||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
sync_run = LegacySyncRun(
|
sync_run = LegacySyncRun(
|
||||||
code=f"SYNC-PROJECTS-{datetime.utcnow():%Y%m%d%H%M%S%f}",
|
code=f"SYNC-PROJECTS-{utc_now():%Y%m%d%H%M%S%f}",
|
||||||
domain=BusinessDomain.PROJECTS,
|
domain=BusinessDomain.PROJECTS,
|
||||||
source_table="LEGACY_PROJECT_QUERY",
|
source_table="LEGACY_PROJECT_QUERY",
|
||||||
status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS,
|
status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS,
|
||||||
finished_at=datetime.utcnow(),
|
finished_at=utc_now(),
|
||||||
created_count=created,
|
created_count=created,
|
||||||
updated_count=updated,
|
updated_count=updated,
|
||||||
skipped_count=skipped,
|
skipped_count=skipped,
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ class MetricKey(StrEnum):
|
|||||||
OVER_BUDGET_PROJECTS = "over_budget_projects"
|
OVER_BUDGET_PROJECTS = "over_budget_projects"
|
||||||
OPEN_EVENTS = "open_events"
|
OPEN_EVENTS = "open_events"
|
||||||
HIGH_EVENTS = "high_events"
|
HIGH_EVENTS = "high_events"
|
||||||
|
EXTERNAL_OPEN_EVENTS = "external_open_events"
|
||||||
|
EXTERNAL_HIGH_EVENTS = "external_high_events"
|
||||||
EVENTS_BY_TYPE = "events_by_type"
|
EVENTS_BY_TYPE = "events_by_type"
|
||||||
EVENTS_BY_LEVEL = "events_by_level"
|
EVENTS_BY_LEVEL = "events_by_level"
|
||||||
SCORE = "score"
|
SCORE = "score"
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ from datetime import date
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.constants import ActorValue
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import require_api_key
|
from app.core.security import ApiPrincipal, require_api_key
|
||||||
from app.modules.reports.schemas import (
|
from app.modules.reports.schemas import (
|
||||||
PushReportRequest,
|
PushReportRequest,
|
||||||
ReportResponse,
|
ReportResponse,
|
||||||
@@ -33,8 +32,8 @@ def project_lifecycle_report(
|
|||||||
period_start: date | None = None,
|
period_start: date | None = None,
|
||||||
period_end: date | None = None,
|
period_end: date | None = None,
|
||||||
include_ai: bool = False,
|
include_ai: bool = False,
|
||||||
actor: str = ActorValue.API,
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return ReportService(db).project_lifecycle_report(
|
return ReportService(db).project_lifecycle_report(
|
||||||
project_code=project_code,
|
project_code=project_code,
|
||||||
@@ -42,7 +41,7 @@ def project_lifecycle_report(
|
|||||||
period_start=period_start,
|
period_start=period_start,
|
||||||
period_end=period_end,
|
period_end=period_end,
|
||||||
include_ai=include_ai,
|
include_ai=include_ai,
|
||||||
actor=actor,
|
actor=principal.actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +57,7 @@ def attendance_summary(
|
|||||||
def generate_work_report(
|
def generate_work_report(
|
||||||
payload: WorkReportGenerateRequest,
|
payload: WorkReportGenerateRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return ReportService(db).generate_work_report(
|
return ReportService(db).generate_work_report(
|
||||||
report_type=payload.report_type,
|
report_type=payload.report_type,
|
||||||
@@ -67,27 +67,35 @@ def generate_work_report(
|
|||||||
period_start=payload.period_start,
|
period_start=payload.period_start,
|
||||||
period_end=payload.period_end,
|
period_end=payload.period_end,
|
||||||
persist=payload.persist,
|
persist=payload.persist,
|
||||||
actor=payload.actor,
|
actor=principal.actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/daily-brief/push")
|
@router.post("/daily-brief/push")
|
||||||
def push_daily_brief(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict:
|
def push_daily_brief(
|
||||||
|
payload: PushReportRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
report = ReportService(db).daily_brief()
|
report = ReportService(db).daily_brief()
|
||||||
return ReportService(db).push_report(
|
return ReportService(db).push_report(
|
||||||
report,
|
report,
|
||||||
payload.receive_id,
|
payload.receive_id,
|
||||||
payload.receive_id_type,
|
payload.receive_id_type,
|
||||||
payload.actor,
|
principal.actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/project-weekly/push")
|
@router.post("/project-weekly/push")
|
||||||
def push_project_weekly(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict:
|
def push_project_weekly(
|
||||||
|
payload: PushReportRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
report = ReportService(db).project_weekly()
|
report = ReportService(db).project_weekly()
|
||||||
return ReportService(db).push_report(
|
return ReportService(db).push_report(
|
||||||
report,
|
report,
|
||||||
payload.receive_id,
|
payload.receive_id,
|
||||||
payload.receive_id_type,
|
payload.receive_id_type,
|
||||||
payload.actor,
|
principal.actor,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ from sqlalchemy import func, or_, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
|
from app.core.time import utc_now
|
||||||
|
from app.modules.audit.constants import AuditSource, AuditTargetType
|
||||||
from app.modules.audit.schemas import AuditLogCreate
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
from app.modules.audit.service import AuditService
|
from app.modules.audit.service import AuditService
|
||||||
from app.modules.business.constants import (
|
from app.modules.business.constants import (
|
||||||
ATTENDANCE_ABNORMAL_STATUSES,
|
ATTENDANCE_ABNORMAL_STATUSES,
|
||||||
DONE_STATUSES,
|
DONE_STATUSES,
|
||||||
|
GENERATED_RISK_EVENT_TYPES,
|
||||||
PROJECT_CLOSED_STATUSES,
|
PROJECT_CLOSED_STATUSES,
|
||||||
PENDING_APPROVAL_STATUSES,
|
PENDING_APPROVAL_STATUSES,
|
||||||
SUPPLIER_RISK_LEVELS,
|
SUPPLIER_RISK_LEVELS,
|
||||||
@@ -69,7 +72,7 @@ def _json_safe(value: Any) -> Any:
|
|||||||
def _next_code(prefix: str) -> str:
|
def _next_code(prefix: str) -> str:
|
||||||
"""Build a compact unique code for generated report records."""
|
"""Build a compact unique code for generated report records."""
|
||||||
|
|
||||||
return f"{prefix}-{datetime.utcnow():%Y%m%d%H%M%S%f}"
|
return f"{prefix}-{utc_now():%Y%m%d%H%M%S%f}"
|
||||||
|
|
||||||
|
|
||||||
def _rate(numerator: int | Decimal, denominator: int | Decimal) -> float:
|
def _rate(numerator: int | Decimal, denominator: int | Decimal) -> float:
|
||||||
@@ -228,7 +231,14 @@ class ReportService:
|
|||||||
task_conditions,
|
task_conditions,
|
||||||
risk_conditions,
|
risk_conditions,
|
||||||
)
|
)
|
||||||
health = self._lifecycle_health(project_stats, task_stats, risk_stats, supplier_stats)
|
include_global_risk = not (project_code or owner)
|
||||||
|
health = self._lifecycle_health(
|
||||||
|
project_stats,
|
||||||
|
task_stats,
|
||||||
|
risk_stats,
|
||||||
|
supplier_stats,
|
||||||
|
include_global_risk,
|
||||||
|
)
|
||||||
attention = self._lifecycle_attention(project_conditions, task_conditions)
|
attention = self._lifecycle_attention(project_conditions, task_conditions)
|
||||||
recommendations = self._lifecycle_recommendations(
|
recommendations = self._lifecycle_recommendations(
|
||||||
project_stats,
|
project_stats,
|
||||||
@@ -238,6 +248,7 @@ class ReportService:
|
|||||||
fund_stats,
|
fund_stats,
|
||||||
supplier_stats,
|
supplier_stats,
|
||||||
risk_stats,
|
risk_stats,
|
||||||
|
include_global_risk,
|
||||||
)
|
)
|
||||||
|
|
||||||
metrics = {
|
metrics = {
|
||||||
@@ -533,12 +544,25 @@ class ReportService:
|
|||||||
RiskEvent.risk_level == RiskLevel.HIGH,
|
RiskEvent.risk_level == RiskLevel.HIGH,
|
||||||
*risk_conditions,
|
*risk_conditions,
|
||||||
)
|
)
|
||||||
|
external_open_events = self._count(
|
||||||
|
RiskEvent,
|
||||||
|
RiskEvent.status == StatusValue.OPEN,
|
||||||
|
RiskEvent.risk_type.notin_(GENERATED_RISK_EVENT_TYPES),
|
||||||
|
*risk_conditions,
|
||||||
|
)
|
||||||
|
external_high_events = self._count(
|
||||||
|
RiskEvent,
|
||||||
|
RiskEvent.status == StatusValue.OPEN,
|
||||||
|
RiskEvent.risk_level == RiskLevel.HIGH,
|
||||||
|
RiskEvent.risk_type.notin_(GENERATED_RISK_EVENT_TYPES),
|
||||||
|
*risk_conditions,
|
||||||
|
)
|
||||||
risk_score = (
|
risk_score = (
|
||||||
overdue_tasks * 1
|
overdue_tasks * 1
|
||||||
+ delayed_projects * 3
|
+ delayed_projects * 3
|
||||||
+ over_budget_projects * 4
|
+ over_budget_projects * 4
|
||||||
+ open_events * 2
|
+ external_open_events * 2
|
||||||
+ high_events * 3
|
+ external_high_events * 3
|
||||||
)
|
)
|
||||||
if risk_score >= 15:
|
if risk_score >= 15:
|
||||||
level = RiskLevel.HIGH
|
level = RiskLevel.HIGH
|
||||||
@@ -554,6 +578,8 @@ class ReportService:
|
|||||||
MetricKey.OVER_BUDGET_PROJECTS: over_budget_projects,
|
MetricKey.OVER_BUDGET_PROJECTS: over_budget_projects,
|
||||||
MetricKey.OPEN_EVENTS: open_events,
|
MetricKey.OPEN_EVENTS: open_events,
|
||||||
MetricKey.HIGH_EVENTS: high_events,
|
MetricKey.HIGH_EVENTS: high_events,
|
||||||
|
MetricKey.EXTERNAL_OPEN_EVENTS: external_open_events,
|
||||||
|
MetricKey.EXTERNAL_HIGH_EVENTS: external_high_events,
|
||||||
MetricKey.EVENTS_BY_TYPE: self._group_counts(
|
MetricKey.EVENTS_BY_TYPE: self._group_counts(
|
||||||
RiskEvent,
|
RiskEvent,
|
||||||
RiskEvent.risk_type,
|
RiskEvent.risk_type,
|
||||||
@@ -572,16 +598,18 @@ class ReportService:
|
|||||||
tasks: dict[str, Any],
|
tasks: dict[str, Any],
|
||||||
risks: dict[str, Any],
|
risks: dict[str, Any],
|
||||||
suppliers: dict[str, Any],
|
suppliers: dict[str, Any],
|
||||||
|
include_global_risk: bool,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
penalty = (
|
penalty = (
|
||||||
risks[MetricKey.OVERDUE_TASKS] * 3
|
risks[MetricKey.OVERDUE_TASKS] * 3
|
||||||
+ risks[MetricKey.DELAYED_PROJECTS] * 8
|
+ risks[MetricKey.DELAYED_PROJECTS] * 8
|
||||||
+ risks[MetricKey.OVER_BUDGET_PROJECTS] * 10
|
+ risks[MetricKey.OVER_BUDGET_PROJECTS] * 10
|
||||||
+ risks[MetricKey.HIGH_EVENTS] * 8
|
+ risks[MetricKey.EXTERNAL_HIGH_EVENTS] * 8
|
||||||
+ suppliers[MetricKey.BLACKLISTED] * 10
|
|
||||||
+ max(0, projects[MetricKey.BUDGET_USAGE_RATE] - 100) * 0.4
|
+ max(0, projects[MetricKey.BUDGET_USAGE_RATE] - 100) * 0.4
|
||||||
+ (100 - tasks[MetricKey.COMPLETION_RATE]) * 0.1
|
+ (100 - tasks[MetricKey.COMPLETION_RATE]) * 0.1
|
||||||
)
|
)
|
||||||
|
if include_global_risk:
|
||||||
|
penalty += suppliers[MetricKey.BLACKLISTED] * 10
|
||||||
score = max(0, min(100, round(100 - penalty, 2)))
|
score = max(0, min(100, round(100 - penalty, 2)))
|
||||||
if score >= 80:
|
if score >= 80:
|
||||||
level = HealthLevel.HEALTHY
|
level = HealthLevel.HEALTHY
|
||||||
@@ -641,6 +669,7 @@ class ReportService:
|
|||||||
funds: dict[str, Any],
|
funds: dict[str, Any],
|
||||||
suppliers: dict[str, Any],
|
suppliers: dict[str, Any],
|
||||||
risks: dict[str, Any],
|
risks: dict[str, Any],
|
||||||
|
include_global_risk: bool,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
recommendations: list[str] = []
|
recommendations: list[str] = []
|
||||||
if risks[MetricKey.DELAYED_PROJECTS]:
|
if risks[MetricKey.DELAYED_PROJECTS]:
|
||||||
@@ -654,9 +683,9 @@ class ReportService:
|
|||||||
or expenses[MetricKey.PENDING_APPROVAL]
|
or expenses[MetricKey.PENDING_APPROVAL]
|
||||||
):
|
):
|
||||||
recommendations.append(ReportText.RECOMMEND_APPROVALS)
|
recommendations.append(ReportText.RECOMMEND_APPROVALS)
|
||||||
if funds[MetricKey.RISK_ACCOUNTS]:
|
if include_global_risk and funds[MetricKey.RISK_ACCOUNTS]:
|
||||||
recommendations.append(ReportText.RECOMMEND_FUNDS)
|
recommendations.append(ReportText.RECOMMEND_FUNDS)
|
||||||
if suppliers[MetricKey.RISKY]:
|
if include_global_risk and suppliers[MetricKey.RISKY]:
|
||||||
recommendations.append(ReportText.RECOMMEND_SUPPLIERS)
|
recommendations.append(ReportText.RECOMMEND_SUPPLIERS)
|
||||||
if not recommendations:
|
if not recommendations:
|
||||||
recommendations.append(ReportText.RECOMMEND_STABLE)
|
recommendations.append(ReportText.RECOMMEND_STABLE)
|
||||||
@@ -835,9 +864,9 @@ class ReportService:
|
|||||||
AuditService(self.db).log(
|
AuditService(self.db).log(
|
||||||
AuditLogCreate(
|
AuditLogCreate(
|
||||||
actor=actor,
|
actor=actor,
|
||||||
source="reports",
|
source=AuditSource.REPORTS,
|
||||||
action=f"generate_{report_type}_report",
|
action=f"generate_{report_type}_report",
|
||||||
target_type="work-reports",
|
target_type=AuditTargetType.WORK_REPORTS,
|
||||||
target_id=str(record.id),
|
target_id=str(record.id),
|
||||||
response_payload=record_data,
|
response_payload=record_data,
|
||||||
)
|
)
|
||||||
@@ -871,17 +900,31 @@ class ReportService:
|
|||||||
WorkTask.due_date >= start,
|
WorkTask.due_date >= start,
|
||||||
WorkTask.due_date <= end,
|
WorkTask.due_date <= end,
|
||||||
]
|
]
|
||||||
procurement_filters = [Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES)]
|
start_at = datetime.combine(start, datetime.min.time())
|
||||||
expense_filters = [Expense.approval_status.in_(PENDING_APPROVAL_STATUSES)]
|
end_at = datetime.combine(end, datetime.max.time())
|
||||||
|
project_filters = []
|
||||||
|
risk_filters = [RiskEvent.status == StatusValue.OPEN]
|
||||||
|
procurement_filters = [
|
||||||
|
Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES),
|
||||||
|
Procurement.created_at >= start_at,
|
||||||
|
Procurement.created_at <= end_at,
|
||||||
|
]
|
||||||
|
expense_filters = [
|
||||||
|
Expense.approval_status.in_(PENDING_APPROVAL_STATUSES),
|
||||||
|
Expense.created_at >= start_at,
|
||||||
|
Expense.created_at <= end_at,
|
||||||
|
]
|
||||||
attendance_filters = [
|
attendance_filters = [
|
||||||
AttendanceRecord.work_date >= start,
|
AttendanceRecord.work_date >= start,
|
||||||
AttendanceRecord.work_date <= end,
|
AttendanceRecord.work_date <= end,
|
||||||
]
|
]
|
||||||
if project_code:
|
if project_code:
|
||||||
|
project_filters.append(Project.code == project_code)
|
||||||
task_filters.append(WorkTask.project_code == project_code)
|
task_filters.append(WorkTask.project_code == project_code)
|
||||||
procurement_filters.append(Procurement.project_code == project_code)
|
procurement_filters.append(Procurement.project_code == project_code)
|
||||||
expense_filters.append(Expense.project_code == project_code)
|
expense_filters.append(Expense.project_code == project_code)
|
||||||
attendance_filters.append(AttendanceRecord.project_code == project_code)
|
attendance_filters.append(AttendanceRecord.project_code == project_code)
|
||||||
|
risk_filters.append(RiskEvent.project_code == project_code)
|
||||||
if department:
|
if department:
|
||||||
expense_filters.append(Expense.department == department)
|
expense_filters.append(Expense.department == department)
|
||||||
attendance_filters.append(AttendanceRecord.department == department)
|
attendance_filters.append(AttendanceRecord.department == department)
|
||||||
@@ -894,10 +937,11 @@ class ReportService:
|
|||||||
*task_filters,
|
*task_filters,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"projects_total": self._count(Project),
|
"projects_total": self._count(Project, *project_filters),
|
||||||
"active_projects": self._count(
|
"active_projects": self._count(
|
||||||
Project,
|
Project,
|
||||||
Project.status.notin_(PROJECT_CLOSED_STATUSES),
|
Project.status.notin_(PROJECT_CLOSED_STATUSES),
|
||||||
|
*project_filters,
|
||||||
),
|
),
|
||||||
"tasks_total": self._count(WorkTask, *task_filters),
|
"tasks_total": self._count(WorkTask, *task_filters),
|
||||||
"tasks_completed": completed_tasks,
|
"tasks_completed": completed_tasks,
|
||||||
@@ -905,10 +949,7 @@ class ReportService:
|
|||||||
"procurements_pending": self._count(Procurement, *procurement_filters),
|
"procurements_pending": self._count(Procurement, *procurement_filters),
|
||||||
"expenses_pending": self._count(Expense, *expense_filters),
|
"expenses_pending": self._count(Expense, *expense_filters),
|
||||||
"attendance_total": self._count(AttendanceRecord, *attendance_filters),
|
"attendance_total": self._count(AttendanceRecord, *attendance_filters),
|
||||||
"open_risk_events": self._count(
|
"open_risk_events": self._count(RiskEvent, *risk_filters),
|
||||||
RiskEvent,
|
|
||||||
RiskEvent.status == StatusValue.OPEN,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _work_report_lines(
|
def _work_report_lines(
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.constants import ActorValue
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import require_api_key
|
from app.core.security import ApiPrincipal, require_api_key
|
||||||
from app.modules.risk.service import RiskService
|
from app.modules.risk.service import RiskService
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||||
@@ -49,5 +48,8 @@ def risk_events(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/events/generate")
|
@router.post("/events/generate")
|
||||||
def generate_risk_events(actor: str = ActorValue.API, db: Session = Depends(get_db)) -> dict:
|
def generate_risk_events(
|
||||||
return RiskService(db).generate_events(actor=actor)
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
|
return RiskService(db).generate_events(actor=principal.actor)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
|
from app.core.time import utc_now
|
||||||
from app.modules.audit.constants import (
|
from app.modules.audit.constants import (
|
||||||
AuditAction,
|
AuditAction,
|
||||||
AuditRiskLevel,
|
AuditRiskLevel,
|
||||||
@@ -17,6 +18,7 @@ from app.modules.audit.service import AuditService
|
|||||||
from app.modules.business.constants import (
|
from app.modules.business.constants import (
|
||||||
CLOSED_RISK_STATUSES,
|
CLOSED_RISK_STATUSES,
|
||||||
DONE_STATUSES,
|
DONE_STATUSES,
|
||||||
|
GENERATED_RISK_EVENT_TYPES,
|
||||||
PROJECT_CLOSED_STATUSES,
|
PROJECT_CLOSED_STATUSES,
|
||||||
SUPPLIER_RISK_LEVELS,
|
SUPPLIER_RISK_LEVELS,
|
||||||
BusinessDomain,
|
BusinessDomain,
|
||||||
@@ -90,13 +92,18 @@ class RiskService:
|
|||||||
fund_risks = self.fund_risks()
|
fund_risks = self.fund_risks()
|
||||||
supplier_risks = self.supplier_risks()
|
supplier_risks = self.supplier_risks()
|
||||||
open_events = self.list_events(status_filter=StatusValue.OPEN)
|
open_events = self.list_events(status_filter=StatusValue.OPEN)
|
||||||
|
external_open_events = [
|
||||||
|
item
|
||||||
|
for item in open_events
|
||||||
|
if item.get("risk_type") not in GENERATED_RISK_EVENT_TYPES
|
||||||
|
]
|
||||||
risk_score = (
|
risk_score = (
|
||||||
len(overdue_tasks) * 1
|
len(overdue_tasks) * 1
|
||||||
+ len(delayed_projects) * 3
|
+ len(delayed_projects) * 3
|
||||||
+ len(over_budget_projects) * 4
|
+ len(over_budget_projects) * 4
|
||||||
+ len(fund_risks) * 5
|
+ len(fund_risks) * 5
|
||||||
+ len(supplier_risks) * 3
|
+ len(supplier_risks) * 3
|
||||||
+ len(open_events) * 2
|
+ len(external_open_events) * 2
|
||||||
)
|
)
|
||||||
if risk_score >= 15:
|
if risk_score >= 15:
|
||||||
level = RiskLevel.HIGH
|
level = RiskLevel.HIGH
|
||||||
@@ -192,7 +199,7 @@ class RiskService:
|
|||||||
"project_code": task.project_code,
|
"project_code": task.project_code,
|
||||||
"owner": task.owner,
|
"owner": task.owner,
|
||||||
"due_date": task.due_date,
|
"due_date": task.due_date,
|
||||||
"detected_at": datetime.utcnow(),
|
"detected_at": utc_now(),
|
||||||
"description": "任务已超过截止日期且未完成。",
|
"description": "任务已超过截止日期且未完成。",
|
||||||
"mitigation": (
|
"mitigation": (
|
||||||
"请负责人更新进度、明确阻塞项并给出新的完成时间。"
|
"请负责人更新进度、明确阻塞项并给出新的完成时间。"
|
||||||
@@ -223,7 +230,7 @@ class RiskService:
|
|||||||
"project_code": project.code,
|
"project_code": project.code,
|
||||||
"owner": project.owner,
|
"owner": project.owner,
|
||||||
"due_date": project.due_date,
|
"due_date": project.due_date,
|
||||||
"detected_at": datetime.utcnow(),
|
"detected_at": utc_now(),
|
||||||
"description": "项目已超过计划截止日期且未进入完成状态。",
|
"description": "项目已超过计划截止日期且未进入完成状态。",
|
||||||
"mitigation": (
|
"mitigation": (
|
||||||
"请项目负责人提交延期原因、资源需求和纠偏计划。"
|
"请项目负责人提交延期原因、资源需求和纠偏计划。"
|
||||||
@@ -252,7 +259,7 @@ class RiskService:
|
|||||||
"project_code": project.code,
|
"project_code": project.code,
|
||||||
"owner": project.owner,
|
"owner": project.owner,
|
||||||
"due_date": project.due_date,
|
"due_date": project.due_date,
|
||||||
"detected_at": datetime.utcnow(),
|
"detected_at": utc_now(),
|
||||||
"description": "项目实际成本已超过预算。",
|
"description": "项目实际成本已超过预算。",
|
||||||
"mitigation": (
|
"mitigation": (
|
||||||
"请复核预算科目、冻结非必要采购并补充审批依据。"
|
"请复核预算科目、冻结非必要采购并补充审批依据。"
|
||||||
@@ -276,7 +283,7 @@ class RiskService:
|
|||||||
"source_domain": BusinessDomain.FUND_ACCOUNTS,
|
"source_domain": BusinessDomain.FUND_ACCOUNTS,
|
||||||
"source_record_id": str(account.id),
|
"source_record_id": str(account.id),
|
||||||
"owner": None,
|
"owner": None,
|
||||||
"detected_at": datetime.utcnow(),
|
"detected_at": utc_now(),
|
||||||
"description": "账户当前余额低于设置的安全线。",
|
"description": "账户当前余额低于设置的安全线。",
|
||||||
"mitigation": (
|
"mitigation": (
|
||||||
"请财务确认收付款计划,"
|
"请财务确认收付款计划,"
|
||||||
@@ -309,7 +316,7 @@ class RiskService:
|
|||||||
"source_domain": BusinessDomain.SUPPLIERS,
|
"source_domain": BusinessDomain.SUPPLIERS,
|
||||||
"source_record_id": str(supplier.id),
|
"source_record_id": str(supplier.id),
|
||||||
"owner": supplier.contact,
|
"owner": supplier.contact,
|
||||||
"detected_at": datetime.utcnow(),
|
"detected_at": utc_now(),
|
||||||
"description": "供应商风险等级或黑名单状态需要关注。",
|
"description": "供应商风险等级或黑名单状态需要关注。",
|
||||||
"mitigation": (
|
"mitigation": (
|
||||||
"请采购负责人复核供应商准入、履约和替代方案。"
|
"请采购负责人复核供应商准入、履约和替代方案。"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dependencies:
|
|||||||
- psycopg[binary]==3.2.3
|
- psycopg[binary]==3.2.3
|
||||||
- pydantic-settings==2.7.1
|
- pydantic-settings==2.7.1
|
||||||
- python-dotenv==1.0.1
|
- python-dotenv==1.0.1
|
||||||
|
- alembic==1.14.0
|
||||||
- httpx==0.28.1
|
- httpx==0.28.1
|
||||||
- lark-oapi==1.6.8
|
- lark-oapi==1.6.8
|
||||||
- apscheduler==3.10.4
|
- apscheduler==3.10.4
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.modules.ai_agent import adapters
|
from app.modules.ai_agent import adapters
|
||||||
from app.modules.ai_agent.constants import (
|
from app.modules.ai_agent.constants import (
|
||||||
@@ -128,6 +131,7 @@ def test_openclaw_adapter_uses_gateway_health_and_tool_invoke(monkeypatch) -> No
|
|||||||
settings = Settings(
|
settings = Settings(
|
||||||
openclaw_http_url="http://openclaw.local",
|
openclaw_http_url="http://openclaw.local",
|
||||||
openclaw_gateway_token="gateway-token",
|
openclaw_gateway_token="gateway-token",
|
||||||
|
openclaw_allowed_tools=["sessions_list"],
|
||||||
)
|
)
|
||||||
|
|
||||||
health = adapters.OpenClawAdapter(settings).health()
|
health = adapters.OpenClawAdapter(settings).health()
|
||||||
@@ -158,6 +162,7 @@ def test_openclaw_hermes_adapter_runs_recall_answer_and_remember(monkeypatch) ->
|
|||||||
model_provider="openclaw_hermes",
|
model_provider="openclaw_hermes",
|
||||||
openclaw_http_url="http://openclaw.local",
|
openclaw_http_url="http://openclaw.local",
|
||||||
openclaw_gateway_token="openclaw-key",
|
openclaw_gateway_token="openclaw-key",
|
||||||
|
openclaw_allowed_tools=["sessions_list"],
|
||||||
hermes_base_url="http://hermes.local/v1",
|
hermes_base_url="http://hermes.local/v1",
|
||||||
hermes_api_key="hermes-key",
|
hermes_api_key="hermes-key",
|
||||||
)
|
)
|
||||||
@@ -187,3 +192,19 @@ def test_openclaw_hermes_adapter_runs_recall_answer_and_remember(monkeypatch) ->
|
|||||||
assert AIContextKey.OPENCLAW in DummyClient.calls[4]["json"][AIHttpPayloadKey.MESSAGES][1][
|
assert AIContextKey.OPENCLAW in DummyClient.calls[4]["json"][AIHttpPayloadKey.MESSAGES][1][
|
||||||
AIHttpPayloadKey.CONTENT
|
AIHttpPayloadKey.CONTENT
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_openclaw_adapter_blocks_tools_not_in_allowlist(monkeypatch) -> None:
|
||||||
|
DummyClient.calls = []
|
||||||
|
monkeypatch.setattr(adapters.httpx, "Client", DummyClient)
|
||||||
|
settings = Settings(
|
||||||
|
openclaw_http_url="http://openclaw.local",
|
||||||
|
openclaw_gateway_token="gateway-token",
|
||||||
|
openclaw_allowed_tools=["sessions_list"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
adapters.OpenClawAdapter(settings).invoke_tool("filesystem_write")
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
assert DummyClient.calls == []
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import tempfile
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
|
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
|
||||||
from app.modules.business.constants import StatusValue
|
from app.modules.business.constants import StatusValue
|
||||||
|
|
||||||
@@ -14,13 +17,17 @@ os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
|
|||||||
os.environ["API_KEY"] = "test-key"
|
os.environ["API_KEY"] = "test-key"
|
||||||
os.environ["FEISHU_APP_ID"] = ""
|
os.environ["FEISHU_APP_ID"] = ""
|
||||||
os.environ["FEISHU_APP_SECRET"] = ""
|
os.environ["FEISHU_APP_SECRET"] = ""
|
||||||
|
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
|
||||||
os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP
|
os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP
|
||||||
os.environ["SCHEDULER_ENABLED"] = "false"
|
os.environ["SCHEDULER_ENABLED"] = "false"
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.core.database import Base, engine
|
from app.core.database import Base, engine
|
||||||
|
from app.core.security import require_api_key
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
from app.modules.legacy_mysql.service import LegacyMySQLService
|
||||||
from app.modules.reports.constants import (
|
from app.modules.reports.constants import (
|
||||||
LifecycleAttentionKey,
|
LifecycleAttentionKey,
|
||||||
LifecycleResponseKey,
|
LifecycleResponseKey,
|
||||||
@@ -78,7 +85,7 @@ def test_project_report_and_feishu_command_preview() -> None:
|
|||||||
def test_feishu_webhook_routes_message_event() -> None:
|
def test_feishu_webhook_routes_message_event() -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"schema": "2.0",
|
"schema": "2.0",
|
||||||
"header": {"event_type": "im.message.receive_v1"},
|
"header": {"event_type": "im.message.receive_v1", "token": "test-feishu-token"},
|
||||||
"event": {
|
"event": {
|
||||||
"sender": {"sender_id": {"open_id": "ou_test"}},
|
"sender": {"sender_id": {"open_id": "ou_test"}},
|
||||||
"message": {
|
"message": {
|
||||||
@@ -95,12 +102,70 @@ def test_feishu_webhook_routes_message_event() -> None:
|
|||||||
assert data["result"]["command"] == "risk_summary"
|
assert data["result"]["command"] == "risk_summary"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("API_KEY", "")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
try:
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
require_api_key("test-key")
|
||||||
|
assert exc_info.value.status_code == 503
|
||||||
|
|
||||||
|
monkeypatch.setenv("API_KEY", "test-key")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/feishu/webhook",
|
||||||
|
json={"schema": "2.0", "header": {"event_type": "im.message.receive_v1"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
finally:
|
||||||
|
monkeypatch.setenv("API_KEY", "test-key")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
def test_approval_gate_for_high_risk_update() -> None:
|
def test_approval_gate_for_high_risk_update() -> None:
|
||||||
|
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": {"code": "FUND-SMOKE-001"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert create_approval_response.status_code == 200
|
||||||
|
assert create_approval_response.json()["applicant"] == "api"
|
||||||
|
create_ticket_id = create_approval_response.json()["ticket_id"]
|
||||||
|
|
||||||
|
approve_create_response = client.post(
|
||||||
|
f"/api/v1/approvals/{create_ticket_id}/approve",
|
||||||
|
headers=headers,
|
||||||
|
json={"approver": "spoofed-manager", "comment": "ok"},
|
||||||
|
)
|
||||||
|
assert approve_create_response.status_code == 200
|
||||||
|
assert approve_create_response.json()["approver"] == "api"
|
||||||
|
|
||||||
create_response = client.post(
|
create_response = client.post(
|
||||||
"/api/v1/business/fund-accounts",
|
"/api/v1/business/fund-accounts",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={
|
json={
|
||||||
"actor": "pytest",
|
"actor": "spoofed-user",
|
||||||
|
"approval_ticket_id": create_ticket_id,
|
||||||
"data": {
|
"data": {
|
||||||
"code": "FUND-SMOKE-001",
|
"code": "FUND-SMOKE-001",
|
||||||
"name": "Main Account",
|
"name": "Main Account",
|
||||||
@@ -115,7 +180,7 @@ def test_approval_gate_for_high_risk_update() -> None:
|
|||||||
blocked_response = client.patch(
|
blocked_response = client.patch(
|
||||||
f"/api/v1/business/fund-accounts/{record_id}",
|
f"/api/v1/business/fund-accounts/{record_id}",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={"actor": "pytest", "data": {"current_balance": 100}},
|
json={"actor": "spoofed-user", "data": {"current_balance": 100}},
|
||||||
)
|
)
|
||||||
assert blocked_response.status_code == 409
|
assert blocked_response.status_code == 409
|
||||||
|
|
||||||
@@ -126,7 +191,7 @@ def test_approval_gate_for_high_risk_update() -> None:
|
|||||||
"domain": "fund-accounts",
|
"domain": "fund-accounts",
|
||||||
"record_id": str(record_id),
|
"record_id": str(record_id),
|
||||||
"action": "update:fund-accounts",
|
"action": "update:fund-accounts",
|
||||||
"applicant": "pytest",
|
"applicant": "spoofed-user",
|
||||||
"reason": "Smoke test balance adjustment",
|
"reason": "Smoke test balance adjustment",
|
||||||
"payload": {"current_balance": 100},
|
"payload": {"current_balance": 100},
|
||||||
},
|
},
|
||||||
@@ -138,7 +203,7 @@ def test_approval_gate_for_high_risk_update() -> None:
|
|||||||
f"/api/v1/business/fund-accounts/{record_id}",
|
f"/api/v1/business/fund-accounts/{record_id}",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={
|
json={
|
||||||
"actor": "pytest",
|
"actor": "spoofed-user",
|
||||||
"approval_ticket_id": ticket_id,
|
"approval_ticket_id": ticket_id,
|
||||||
"data": {"current_balance": 100},
|
"data": {"current_balance": 100},
|
||||||
},
|
},
|
||||||
@@ -148,16 +213,17 @@ def test_approval_gate_for_high_risk_update() -> None:
|
|||||||
approve_response = client.post(
|
approve_response = client.post(
|
||||||
f"/api/v1/approvals/{ticket_id}/approve",
|
f"/api/v1/approvals/{ticket_id}/approve",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={"approver": "manager", "comment": "ok"},
|
json={"approver": "spoofed-manager", "comment": "ok"},
|
||||||
)
|
)
|
||||||
assert approve_response.status_code == 200
|
assert approve_response.status_code == 200
|
||||||
assert approve_response.json()["status"] == "approved"
|
assert approve_response.json()["status"] == "approved"
|
||||||
|
assert approve_response.json()["approver"] == "api"
|
||||||
|
|
||||||
update_response = client.patch(
|
update_response = client.patch(
|
||||||
f"/api/v1/business/fund-accounts/{record_id}",
|
f"/api/v1/business/fund-accounts/{record_id}",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={
|
json={
|
||||||
"actor": "pytest",
|
"actor": "spoofed-user",
|
||||||
"approval_ticket_id": ticket_id,
|
"approval_ticket_id": ticket_id,
|
||||||
"data": {"current_balance": 100},
|
"data": {"current_balance": 100},
|
||||||
},
|
},
|
||||||
@@ -383,3 +449,10 @@ def test_ai_noop_provider() -> None:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()[AIResponseKey.PROVIDER] == AIProviderName.NOOP
|
assert response.json()[AIResponseKey.PROVIDER] == AIProviderName.NOOP
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_project_payload_does_not_create_legacy_none_code() -> None:
|
||||||
|
payload = LegacyMySQLService(None)._project_payload({"name": "Missing Id"}, {})
|
||||||
|
|
||||||
|
assert payload["code"] is None
|
||||||
|
assert payload["external_id"] is None
|
||||||
|
|||||||
Reference in New Issue
Block a user