后端: - Ticket model 新增 ticket_no 字段(格式: YYYYMMDDHHmmss+ms+seq, 如20260522160543841) - create_ticket 自动生成工单编号(flush后生成) - _enrich_ticket 新增 instance_name, current_approver_name 字段 - current_approver: 从 ticket_approvals 查询第一个 pending 状态的审批步骤获取审核人 - TicketResponse schema 新增 ticket_no, instance_name, current_approver_name - 新增 /instances/accessible endpoint(按用户权限组过滤实例, 无admin bypass) - instance_group response_model 改为 InstanceGroupDetailResponse(含readonly/write实例) - instance_group_members 脏数据清理, 重建 group_id=2 正确数据 - notification 模板修复: \n→\n 换行, 钉钉 \n→\n\n 双换行 - test_notification endpoint 改为真正调用 NotificationSender.send() - auditor 角色添加到 audit_rule/ticket 只读端点 - seed_data() 创建4个默认用户(admin/zhangsan/laoliu/lurenjia) - SQLite ALTER TABLE 添加 ticket_no 列, 迁移现有6条工单数据 前端: - TicketsPage 完全重设计: 工单编号/实例名称/提交人/当前审核人/状态/操作 - 状态映射简化: pending/reviewing/approved→审核中, success→完成, failed→失败 - 执行详情弹框: 解析 execute_result JSON 展示状态/影响行数/耗时/验证/原始输出/错误信息 - 状态流程可视化(提交→审批→通过→执行→完成) - 快速筛选: 全部/待审批/执行中/完成/失败 (合并 pending+reviewing+approved 统计) - TicketCreatePage: 去掉数据库类型选择器, 用 accessible(write) 过滤变更实例 - QueriesPage: 用 accessible(readonly) 过滤查询实例 - instance.ts 新增 accessible 方法 - 分页优化: 动态计算页码范围(总页>5时中间滚动)
274 lines
8.8 KiB
Python
274 lines
8.8 KiB
Python
"""审批流程管理 API。"""
|
|
from typing import List, Optional
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import require_role
|
|
from app.models.user import User
|
|
from app.models.workflow import WorkflowDefinition, WorkflowStep
|
|
from app.schemas.workflow import (
|
|
WorkflowCreate, WorkflowUpdate, WorkflowResponse,
|
|
WorkflowStepCreate,
|
|
)
|
|
|
|
router = APIRouter(prefix="/workflows", tags=["审批流程"])
|
|
|
|
|
|
def _serialize_step(step: WorkflowStep) -> dict:
|
|
return {
|
|
"id": step.id,
|
|
"workflow_id": step.workflow_id,
|
|
"step_number": step.step_number,
|
|
"name": step.name,
|
|
"step_type": step.step_type,
|
|
"approver_role": step.approver_role,
|
|
"approver_ids": step.approver_ids,
|
|
"is_required": step.is_required,
|
|
"timeout_hours": step.timeout_hours,
|
|
"auto_approve_on_timeout": step.auto_approve_on_timeout,
|
|
}
|
|
|
|
|
|
def _serialize_workflow(wf: WorkflowDefinition) -> dict:
|
|
return {
|
|
"id": wf.id,
|
|
"name": wf.name,
|
|
"description": wf.description,
|
|
"is_default": wf.is_default,
|
|
"is_active": wf.is_active,
|
|
"created_at": wf.created_at,
|
|
"steps": [_serialize_step(s) for s in wf.steps],
|
|
}
|
|
|
|
|
|
@router.get("", response_model=List[WorkflowResponse])
|
|
async def list_workflows(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin", "dba")),
|
|
):
|
|
result = await db.execute(
|
|
select(WorkflowDefinition)
|
|
.where(WorkflowDefinition.is_active == True)
|
|
.options(selectinload(WorkflowDefinition.steps))
|
|
)
|
|
workflows = result.scalars().unique().all()
|
|
return [_serialize_workflow(wf) for wf in workflows]
|
|
|
|
|
|
@router.post("", response_model=WorkflowResponse, status_code=201)
|
|
async def create_workflow(
|
|
data: WorkflowCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin")),
|
|
):
|
|
# 如果设为默认,取消其他默认
|
|
if data.is_default:
|
|
from sqlalchemy import update as sa_update
|
|
await db.execute(
|
|
sa_update(WorkflowDefinition)
|
|
.where(WorkflowDefinition.is_default == True)
|
|
.values(is_default=False)
|
|
)
|
|
|
|
workflow = WorkflowDefinition(
|
|
name=data.name,
|
|
description=data.description,
|
|
is_default=data.is_default,
|
|
)
|
|
db.add(workflow)
|
|
await db.flush()
|
|
|
|
for step_data in data.steps:
|
|
step = WorkflowStep(
|
|
workflow_id=workflow.id,
|
|
step_number=step_data.step_number,
|
|
name=step_data.name,
|
|
step_type=step_data.step_type,
|
|
approver_role=step_data.approver_role,
|
|
approver_ids=step_data.approver_ids,
|
|
is_required=step_data.is_required,
|
|
timeout_hours=step_data.timeout_hours,
|
|
auto_approve_on_timeout=step_data.auto_approve_on_timeout,
|
|
)
|
|
db.add(step)
|
|
|
|
await db.flush()
|
|
wf_id = workflow.id
|
|
# Reload with steps — use fresh query by id to avoid identity map issues
|
|
db.expire(workflow)
|
|
result = await db.execute(
|
|
select(WorkflowDefinition)
|
|
.where(WorkflowDefinition.id == wf_id)
|
|
.options(selectinload(WorkflowDefinition.steps))
|
|
)
|
|
workflow = result.scalar_one()
|
|
return _serialize_workflow(workflow)
|
|
|
|
|
|
@router.get("/{workflow_id}", response_model=WorkflowResponse)
|
|
async def get_workflow(
|
|
workflow_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin", "dba")),
|
|
):
|
|
result = await db.execute(
|
|
select(WorkflowDefinition)
|
|
.where(WorkflowDefinition.id == workflow_id)
|
|
.options(selectinload(WorkflowDefinition.steps))
|
|
)
|
|
workflow = result.scalar_one_or_none()
|
|
if not workflow:
|
|
raise HTTPException(status_code=404, detail="审批流程不存在")
|
|
return _serialize_workflow(workflow)
|
|
|
|
|
|
@router.put("/{workflow_id}", response_model=WorkflowResponse)
|
|
async def update_workflow(
|
|
workflow_id: int,
|
|
data: WorkflowUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin")),
|
|
):
|
|
result = await db.execute(
|
|
select(WorkflowDefinition)
|
|
.where(WorkflowDefinition.id == workflow_id)
|
|
.options(selectinload(WorkflowDefinition.steps))
|
|
)
|
|
workflow = result.scalar_one_or_none()
|
|
if not workflow:
|
|
raise HTTPException(status_code=404, detail="审批流程不存在")
|
|
|
|
if data.name is not None:
|
|
workflow.name = data.name
|
|
if data.description is not None:
|
|
workflow.description = data.description
|
|
if data.is_active is not None:
|
|
workflow.is_active = data.is_active
|
|
|
|
# 更新步骤(先删后建)
|
|
if data.steps is not None:
|
|
from sqlalchemy import delete as sa_delete
|
|
await db.execute(
|
|
sa_delete(WorkflowStep).where(WorkflowStep.workflow_id == workflow_id)
|
|
)
|
|
await db.flush()
|
|
for step_data in data.steps:
|
|
step = WorkflowStep(
|
|
workflow_id=workflow.id,
|
|
step_number=step_data.step_number,
|
|
name=step_data.name,
|
|
step_type=step_data.step_type,
|
|
approver_role=step_data.approver_role,
|
|
approver_ids=step_data.approver_ids,
|
|
is_required=step_data.is_required,
|
|
timeout_hours=step_data.timeout_hours,
|
|
auto_approve_on_timeout=step_data.auto_approve_on_timeout,
|
|
)
|
|
db.add(step)
|
|
await db.flush()
|
|
# Reload with steps
|
|
db.expire(workflow)
|
|
result = await db.execute(
|
|
select(WorkflowDefinition)
|
|
.where(WorkflowDefinition.id == workflow_id)
|
|
.options(selectinload(WorkflowDefinition.steps))
|
|
)
|
|
workflow = result.scalar_one()
|
|
return _serialize_workflow(workflow)
|
|
|
|
|
|
@router.delete("/{workflow_id}")
|
|
async def delete_workflow(
|
|
workflow_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin")),
|
|
):
|
|
result = await db.execute(
|
|
select(WorkflowDefinition).where(WorkflowDefinition.id == workflow_id)
|
|
)
|
|
workflow = result.scalar_one_or_none()
|
|
if not workflow:
|
|
raise HTTPException(status_code=404, detail="审批流程不存在")
|
|
|
|
workflow.is_active = False
|
|
await db.commit()
|
|
return {"message": "审批流程已删除"}
|
|
|
|
|
|
@router.post("/{workflow_id}/set-default")
|
|
async def set_default_workflow(
|
|
workflow_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin")),
|
|
):
|
|
"""设为默认审批流程。"""
|
|
# 取消所有默认
|
|
result = await db.execute(select(WorkflowDefinition))
|
|
all_workflows = result.scalars().all()
|
|
for wf in all_workflows:
|
|
wf.is_default = False
|
|
|
|
# 设置指定流程为默认
|
|
result = await db.execute(
|
|
select(WorkflowDefinition).where(WorkflowDefinition.id == workflow_id)
|
|
)
|
|
workflow = result.scalar_one_or_none()
|
|
if not workflow:
|
|
raise HTTPException(status_code=404, detail="审批流程不存在")
|
|
|
|
workflow.is_default = True
|
|
await db.commit()
|
|
return {"message": "审批流程 '{}' 已设为默认".format(workflow.name)}
|
|
|
|
|
|
@router.post("/{workflow_id}/clone", response_model=WorkflowResponse)
|
|
async def clone_workflow(
|
|
workflow_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin")),
|
|
):
|
|
"""克隆审批流程。"""
|
|
result = await db.execute(
|
|
select(WorkflowDefinition)
|
|
.where(WorkflowDefinition.id == workflow_id)
|
|
.options(selectinload(WorkflowDefinition.steps))
|
|
)
|
|
source = result.scalar_one_or_none()
|
|
if not source:
|
|
raise HTTPException(status_code=404, detail="审批流程不存在")
|
|
|
|
new_workflow = WorkflowDefinition(
|
|
name="{} (副本)".format(source.name),
|
|
description=source.description,
|
|
is_default=False,
|
|
)
|
|
db.add(new_workflow)
|
|
await db.flush()
|
|
|
|
for step in source.steps:
|
|
new_step = WorkflowStep(
|
|
workflow_id=new_workflow.id,
|
|
step_number=step.step_number,
|
|
name=step.name,
|
|
step_type=step.step_type,
|
|
approver_role=step.approver_role,
|
|
approver_ids=step.approver_ids,
|
|
is_required=step.is_required,
|
|
timeout_hours=step.timeout_hours,
|
|
auto_approve_on_timeout=step.auto_approve_on_timeout,
|
|
)
|
|
db.add(new_step)
|
|
|
|
await db.commit()
|
|
# Reload with steps
|
|
result = await db.execute(
|
|
select(WorkflowDefinition)
|
|
.where(WorkflowDefinition.id == new_workflow.id)
|
|
.options(selectinload(WorkflowDefinition.steps))
|
|
)
|
|
new_workflow = result.scalar_one()
|
|
return _serialize_workflow(new_workflow)
|