feat: 工单列表重设计+权限组实例过滤+通知修复

后端:
- 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时中间滚动)
This commit is contained in:
logan committed 2026-05-24 22:55:51 +08:00
1 parent 88d064ea95
commit 08f0f88cf1
20 files changed
+1672 -944

No files matched your search

+43
View File
@@ -53,6 +53,49 @@ async def _get_workflow_names(db: AsyncSession, instances: list) -> dict:
return {w.id: w.name for w in result.scalars().all()}
@router.get("/accessible", response_model=List[InstanceResponse])
async def list_accessible_instances(
access_type: str = "readonly",
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""返回当前用户有权访问的实例列表(按权限组的 access_type 过滤)。
所有用户(包括 admin)统一按权限组关联过滤:
user_groups → instance_group_members(access_type) → 返回对应实例
access_type: readonly=查询授权, write=变更授权
"""
from app.models.instance_group import instance_group_members, user_groups as ug_table
# 查用户关联的所有权限组
grp_result = await db.execute(
select(ug_table.c.group_id).where(ug_table.c.user_id == current_user.id)
)
group_ids = [row[0] for row in grp_result.all()]
if not group_ids:
return []
# 从 instance_group_members 中获取指定 access_type 的 instance_id
mem_result = await db.execute(
select(instance_group_members.c.instance_id)
.where(
instance_group_members.c.group_id.in_(group_ids),
instance_group_members.c.access_type == access_type,
)
.distinct()
)
instance_ids = [row[0] for row in mem_result.all()]
if not instance_ids:
return []
# 查实例详情
stmt = select(Instance).where(Instance.id.in_(instance_ids)).order_by(Instance.id)
result = await db.execute(stmt)
instances = result.scalars().all()
wnames = await _get_workflow_names(db, instances)
return [_serialize_json_fields(i, wnames.get(i.workflow_id)) for i in instances]
@router.get("", response_model=List[InstanceResponse])
async def list_instances(
db_type: Optional[str] = None,
@@ -117,7 +117,7 @@ async def _sync_members(
)
@router.get("", response_model=List[InstanceGroupResponse])
@router.get("", response_model=List[InstanceGroupDetailResponse])
async def list_instance_groups(
db_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
@@ -131,7 +131,7 @@ async def list_instance_groups(
return [await _serialize_group_detail(g, db) for g in groups]
@router.post("", response_model=InstanceGroupResponse, status_code=201)
@router.post("", response_model=InstanceGroupDetailResponse, status_code=201)
async def create_instance_group(
data: InstanceGroupCreate,
db: AsyncSession = Depends(get_db),
@@ -164,7 +164,7 @@ async def create_instance_group(
await _sync_members(db, group.id, data.readonly_instance_ids, data.write_instance_ids)
await db.commit()
return await _serialize_group(group, db)
return await _serialize_group_detail(group, db)
@router.get("/{group_id}", response_model=InstanceGroupDetailResponse)
@@ -182,7 +182,7 @@ async def get_instance_group(
return await _serialize_group_detail(group, db)
@router.put("/{group_id}", response_model=InstanceGroupResponse)
@router.put("/{group_id}", response_model=InstanceGroupDetailResponse)
async def update_instance_group(
group_id: int,
data: InstanceGroupUpdate,
@@ -242,7 +242,7 @@ async def update_instance_group(
)
await db.commit()
return await _serialize_group(group, db)
return await _serialize_group_detail(group, db)
@router.delete("/{group_id}")
@@ -0,0 +1,287 @@
"""定时任务 API — 基于 SQL 的自动化任务管理。"""
import time
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.models.user import User
from app.models.instance import Instance
from app.models.scheduled_task import ScheduledTask, ScheduledTaskLog
from app.schemas.scheduled_task import (
ScheduledTaskCreate, ScheduledTaskUpdate,
ScheduledTaskResponse, ScheduledTaskLogResponse, TaskRunResponse,
)
from app.services.db_connector import DBConnector
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/scheduled-tasks", tags=["自动化任务"])
def _serialize(task: ScheduledTask, instance_name: str = None, instance_db_type: str = None) -> dict:
return {
"id": task.id,
"name": task.name,
"description": task.description,
"task_sql_type": task.task_sql_type,
"instance_id": task.instance_id,
"instance_name": instance_name,
"instance_db_type": instance_db_type,
"target_db": task.target_db,
"target_table": task.target_table,
"sql_content": task.sql_content,
"cron_expression": task.cron_expression,
"is_enabled": task.is_enabled,
"last_run_at": task.last_run_at,
"last_run_status": task.last_run_status,
"last_run_message": task.last_run_message,
"next_run_at": task.next_run_at,
"run_count": task.run_count or 0,
"created_by": task.created_by,
"created_at": task.created_at,
}
async def _get_instance(db: AsyncSession, instance_id: int) -> Instance:
result = await db.execute(select(Instance).where(Instance.id == instance_id))
inst = result.scalar_one_or_none()
if not inst:
raise HTTPException(404, "数据源不存在")
return inst
# ── CRUD ──────────────────────────────────────────────────────
@router.get("", response_model=list[ScheduledTaskResponse])
async def list_tasks(
task_sql_type: Optional[str] = None,
is_enabled: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""列出所有定时任务。"""
q = select(ScheduledTask).order_by(ScheduledTask.created_at.desc())
if task_sql_type:
q = q.where(ScheduledTask.task_sql_type == task_sql_type)
if is_enabled is not None:
q = q.where(ScheduledTask.is_enabled == is_enabled)
result = await db.execute(q)
tasks = result.scalars().all()
# batch load instance info
inst_ids = {t.instance_id for t in tasks}
inst_map = {}
if inst_ids:
ir = await db.execute(select(Instance).where(Instance.id.in_(inst_ids)))
for inst in ir.scalars().all():
inst_map[inst.id] = inst
return [_serialize(t, inst_map.get(t.instance_id).name if inst_map.get(t.instance_id) else None,
inst_map.get(t.instance_id).db_type if inst_map.get(t.instance_id) else None)
for t in tasks]
@router.post("", response_model=ScheduledTaskResponse, status_code=201)
async def create_task(
body: ScheduledTaskCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("admin")),
):
"""创建定时任务。"""
# validate instance exists
inst = await _get_instance(db, body.instance_id)
task = ScheduledTask(
name=body.name,
description=body.description,
task_sql_type=body.task_sql_type.value,
instance_id=body.instance_id,
target_db=body.target_db,
target_table=body.target_table,
sql_content=body.sql_content,
cron_expression=body.cron_expression,
is_enabled=body.is_enabled,
created_by=user.id,
)
db.add(task)
await db.flush()
await db.refresh(task)
return _serialize(task, inst.name, inst.db_type)
@router.get("/{task_id}", response_model=ScheduledTaskResponse)
async def get_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""获取任务详情。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
inst = await _get_instance(db, task.instance_id)
return _serialize(task, inst.name, inst.db_type)
@router.put("/{task_id}", response_model=ScheduledTaskResponse)
async def update_task(
task_id: int,
body: ScheduledTaskUpdate,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""更新任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
update_data = body.model_dump(exclude_unset=True)
if "task_sql_type" in update_data and update_data["task_sql_type"] is not None:
update_data["task_sql_type"] = update_data["task_sql_type"].value
for k, v in update_data.items():
setattr(task, k, v)
await db.flush()
await db.refresh(task)
inst = await _get_instance(db, task.instance_id)
return _serialize(task, inst.name, inst.db_type)
@router.delete("/{task_id}")
async def delete_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""删除任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
await db.delete(task)
return {"detail": "已删除"}
@router.patch("/{task_id}/toggle")
async def toggle_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""启用/禁用任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
task.is_enabled = not task.is_enabled
await db.flush()
return {"is_enabled": task.is_enabled}
# ── 执行 ──────────────────────────────────────────────────────
@router.post("/{task_id}/run", response_model=TaskRunResponse)
async def run_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""手动执行一次任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
inst = await _get_instance(db, task.instance_id)
target_db = task.target_db or inst.database or ""
log = ScheduledTaskLog(
task_id=task.id,
status="running",
sql_content=task.sql_content,
)
db.add(log)
await db.flush()
start = time.time()
try:
msg = await DBConnector.execute_dml(inst, target_db, task.sql_content)
elapsed = int((time.time() - start) * 1000)
# parse affected rows from message like "执行成功,影响 3 行"
affected = 0
import re
m = re.search(r'(\d+)', msg)
if m:
affected = int(m.group(1))
log.status = "success"
log.affected_rows = affected
log.duration_ms = elapsed
log.finished_at = __import__("datetime").datetime.now()
task.last_run_at = __import__("datetime").datetime.now()
task.last_run_status = "success"
task.last_run_message = msg
task.run_count = (task.run_count or 0) + 1
await db.flush()
return TaskRunResponse(success=True, message=msg, affected_rows=affected, duration_ms=elapsed)
except Exception as e:
elapsed = int((time.time() - start) * 1000)
error_msg = str(e)
log.status = "failed"
log.error_message = error_msg
log.duration_ms = elapsed
log.finished_at = __import__("datetime").datetime.now()
task.last_run_at = __import__("datetime").datetime.now()
task.last_run_status = "failed"
task.last_run_message = error_msg
task.run_count = (task.run_count or 0) + 1
await db.flush()
return TaskRunResponse(success=False, message=error_msg, duration_ms=elapsed)
# ── 执行日志 ──────────────────────────────────────────────────
@router.get("/{task_id}/logs", response_model=list[ScheduledTaskLogResponse])
async def get_task_logs(
task_id: int,
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""获取任务执行日志。"""
q = (
select(ScheduledTaskLog)
.where(ScheduledTaskLog.task_id == task_id)
.order_by(ScheduledTaskLog.started_at.desc())
.limit(limit)
)
result = await db.execute(q)
logs = result.scalars().all()
return [
{
"id": l.id,
"task_id": l.task_id,
"status": l.status,
"sql_content": l.sql_content,
"affected_rows": l.affected_rows or 0,
"duration_ms": l.duration_ms,
"error_message": l.error_message,
"started_at": l.started_at,
"finished_at": l.finished_at,
}
for l in logs
]
+52 -10
View File
@@ -38,6 +38,24 @@ import logging
logger = logging.getLogger(__name__)
def _generate_ticket_no(ticket_id: int) -> str:
"""生成工单编号,格式: YYYYMMDDHHmmss + 微秒前3位 + 序号补齐。
示例: 20260522160543841
Args:
ticket_id: 工单自增ID,用作末尾序号
Returns:
工单编号字符串
"""
now = datetime.now()
base = now.strftime("%Y%m%d%H%M%S")
ms = f"{now.microsecond:06d}"[:3] # 取前3位毫秒
seq = str(ticket_id).zfill(3)[-3:] # ID 补齐到3位
return f"{base}{ms}{seq}"
if TYPE_CHECKING:
from app.services.ticket_executor import ExecutionReport
@@ -112,17 +130,10 @@ class TicketExecuteConfirm(BaseModel):
async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
"""丰富工单数据,添加申请人/审批人名称等信息。
Args:
ticket: 工单对象
db: 数据库会话
Returns:
丰富的工单字典
"""
"""丰富工单数据,添加申请人/审批人/实例名称/当前审核人等信息。"""
data = {
"id": ticket.id,
"ticket_no": ticket.ticket_no,
"title": ticket.title,
"db_type": ticket.db_type,
"instance_id": ticket.instance_id,
@@ -147,12 +158,39 @@ async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
applicant = r.scalar_one_or_none()
data["applicant_name"] = applicant.display_name if applicant else "未知"
# Resolve approver name
# Resolve approver name (最终审批人)
if ticket.approver_id:
r = await db.execute(select(User).where(User.id == ticket.approver_id))
approver = r.scalar_one_or_none()
data["approver_name"] = approver.display_name if approver else "未知"
# Resolve instance name
if ticket.instance_id:
r = await db.execute(select(Instance).where(Instance.id == ticket.instance_id))
inst = r.scalar_one_or_none()
data["instance_name"] = inst.name if inst else "未知"
# Resolve current approver (审批/执行阶段的当前待审核人)
data["current_approver_name"] = None
if ticket.status in ("reviewing", "approved", "executing"):
pending_approval = await db.execute(
select(TicketApproval)
.where(
TicketApproval.ticket_id == ticket.id,
TicketApproval.action == "pending",
)
.order_by(TicketApproval.step_number)
.limit(1)
)
pending = pending_approval.scalar_one_or_none()
if pending:
if pending.approver_id:
r = await db.execute(select(User).where(User.id == pending.approver_id))
approver_user = r.scalar_one_or_none()
data["current_approver_name"] = approver_user.display_name if approver_user else None
elif pending.approver_role:
data["current_approver_name"] = pending.approver_role
return data
@@ -370,6 +408,10 @@ async def create_ticket(
db.add(ticket)
await db.flush() # flush to get ticket.id
# 生成工单编号
ticket.ticket_no = _generate_ticket_no(ticket.id)
await db.flush()
# 7. 创建审批步骤(多级审批流)
if default_workflow:
wf_steps_result = await db.execute(
+34 -5
View File
@@ -95,11 +95,13 @@ async def create_workflow(
)
db.add(step)
await db.commit()
# Reload with steps
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 == workflow.id)
.where(WorkflowDefinition.id == wf_id)
.options(selectinload(WorkflowDefinition.steps))
)
workflow = result.scalar_one()
@@ -146,8 +148,35 @@ async def update_workflow(
if data.is_active is not None:
workflow.is_active = data.is_active
await db.commit()
await db.refresh(workflow)
# 更新步骤(先删后建)
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)
+2 -1
View File
@@ -7,7 +7,7 @@ from app.api.v1.endpoints import (
instance_group, masking_rule, rollback,
workflow, impact, schema_drift, pool,
mongo, query_history, sql_assistant, db_types,
settings,
settings, scheduled_task,
)
api_router = APIRouter(prefix="/api/v1")
@@ -40,3 +40,4 @@ api_router.include_router(sql_assistant.router)
api_router.include_router(db_types.router)
api_router.include_router(settings.router, prefix="/settings", tags=["系统设置"])
api_router.include_router(scheduled_task.router)
+29 -7
View File
@@ -1,22 +1,44 @@
"""定时任务模型。"""
"""定时任务模型 — 基于纯SQL的自动化任务。"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, func
from app.core.database import Base
class ScheduledTask(Base):
"""定时任务。"""
"""定时任务:insert / update / delete 三种类型,指定数据源+库表+纯SQL。"""
__tablename__ = "scheduled_tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
task_type = Column(String(30), nullable=False)
instance_id = Column(Integer, ForeignKey("instances.id"), nullable=True)
description = Column(Text, nullable=True)
task_sql_type = Column(String(20), nullable=False, default="insert") # insert / update / delete
instance_id = Column(Integer, ForeignKey("instances.id", ondelete="CASCADE"), nullable=False)
target_db = Column(String(100), nullable=True)
target_table = Column(String(100), nullable=True)
sql_content = Column(Text, nullable=False)
cron_expression = Column(String(50), nullable=False)
config = Column(Text, nullable=True) # JSON
is_enabled = Column(Boolean, default=True)
# 运行状态
last_run_at = Column(DateTime, nullable=True)
last_run_status = Column(String(20), nullable=True)
last_run_status = Column(String(20), nullable=True) # success / failed
last_run_message = Column(Text, nullable=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
next_run_at = Column(DateTime, nullable=True)
run_count = Column(Integer, default=0)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class ScheduledTaskLog(Base):
"""定时任务执行日志。"""
__tablename__ = "scheduled_task_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
task_id = Column(Integer, ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False)
status = Column(String(20), nullable=False) # success / failed / running
sql_content = Column(Text, nullable=True)
affected_rows = Column(Integer, default=0)
duration_ms = Column(Integer, nullable=True)
error_message = Column(Text, nullable=True)
started_at = Column(DateTime, server_default=func.now())
finished_at = Column(DateTime, nullable=True)
+1
View File
@@ -7,6 +7,7 @@ class Ticket(Base):
__tablename__ = "tickets"
id = Column(Integer, primary_key=True, autoincrement=True)
ticket_no = Column(String(20), unique=True, nullable=True, comment="工单编号,格式: 20260522160543841")
title = Column(String(200), nullable=False)
db_type = Column(String(20), nullable=False)
instance_id = Column(Integer, ForeignKey("instances.id"), nullable=False)
+82
View File
@@ -0,0 +1,82 @@
"""定时任务 Schema — 基于 SQL 的自动化任务。"""
from typing import Optional, List
from pydantic import BaseModel, Field
from datetime import datetime
from enum import Enum
class TaskSqlType(str, Enum):
insert = "insert"
update = "update"
delete = "delete"
class ScheduledTaskCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
task_sql_type: TaskSqlType
instance_id: int
target_db: Optional[str] = None
target_table: Optional[str] = None
sql_content: str = Field(..., min_length=1)
cron_expression: str = Field(..., min_length=1)
is_enabled: bool = True
class ScheduledTaskUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
task_sql_type: Optional[TaskSqlType] = None
instance_id: Optional[int] = None
target_db: Optional[str] = None
target_table: Optional[str] = None
sql_content: Optional[str] = Field(None, min_length=1)
cron_expression: Optional[str] = Field(None, min_length=1)
is_enabled: Optional[bool] = None
class ScheduledTaskResponse(BaseModel):
id: int
name: str
description: Optional[str]
task_sql_type: str
instance_id: int
instance_name: Optional[str] = None
instance_db_type: Optional[str] = None
target_db: Optional[str]
target_table: Optional[str]
sql_content: str
cron_expression: str
is_enabled: bool
last_run_at: Optional[datetime]
last_run_status: Optional[str]
last_run_message: Optional[str]
next_run_at: Optional[datetime]
run_count: int
created_by: Optional[int]
created_at: datetime
class Config:
from_attributes = True
class ScheduledTaskLogResponse(BaseModel):
id: int
task_id: int
status: str
sql_content: Optional[str]
affected_rows: int
duration_ms: Optional[int]
error_message: Optional[str]
started_at: datetime
finished_at: Optional[datetime]
class Config:
from_attributes = True
class TaskRunResponse(BaseModel):
success: bool
message: str
affected_rows: int = 0
duration_ms: int = 0
+3
View File
@@ -21,9 +21,11 @@ class TicketReview(BaseModel):
class TicketResponse(BaseModel):
id: int
ticket_no: Optional[str] = None
title: str
db_type: str
instance_id: int
instance_name: Optional[str] = None
target_database: str
sql_content: str
rollback_sql: Optional[str] = None
@@ -33,6 +35,7 @@ class TicketResponse(BaseModel):
applicant_name: Optional[str] = None
approver_id: Optional[int] = None
approver_name: Optional[str] = None
current_approver_name: Optional[str] = None
reason: Optional[str] = None
audit_result: Optional[str] = None
execute_result: Optional[str] = None
+1
View File
@@ -33,6 +33,7 @@ class WorkflowUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
steps: Optional[List[WorkflowStepCreate]] = None
class WorkflowResponse(BaseModel):