feat(workflow): enforce review-only done for agent assignees + reviewer decision endpoint
This commit is contained in:
@@ -13,7 +13,7 @@ from app.integrations.notify import NotifyContext, notify_openclaw
|
|||||||
from app.integrations.openclaw import OpenClawClient
|
from app.integrations.openclaw import OpenClawClient
|
||||||
from app.models.org import Employee
|
from app.models.org import Employee
|
||||||
from app.models.work import Task, TaskComment
|
from app.models.work import Task, TaskComment
|
||||||
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate
|
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskReviewDecision, TaskUpdate
|
||||||
|
|
||||||
logger = logging.getLogger("app.work")
|
logger = logging.getLogger("app.work")
|
||||||
|
|
||||||
@@ -149,6 +149,97 @@ def dispatch_task(
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_reviewer_comment(body: str | None) -> str:
|
||||||
|
if body is None or not body.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="Reviewer must provide a comment for audit")
|
||||||
|
return body.strip()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/review", response_model=Task)
|
||||||
|
def review_task(
|
||||||
|
task_id: int,
|
||||||
|
payload: TaskReviewDecision,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||||
|
):
|
||||||
|
"""Reviewer approves or requests changes.
|
||||||
|
|
||||||
|
- Approve => status=done
|
||||||
|
- Changes => status=in_progress
|
||||||
|
|
||||||
|
Always writes a TaskComment by the reviewer for audit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
task = session.get(Task, task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
if task.reviewer_employee_id is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Task has no reviewer")
|
||||||
|
|
||||||
|
if actor_employee_id != task.reviewer_employee_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Only the reviewer can approve/request changes")
|
||||||
|
|
||||||
|
decision = (payload.decision or "").strip().lower()
|
||||||
|
if decision not in {"approve", "changes"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid decision")
|
||||||
|
|
||||||
|
comment_body = _require_reviewer_comment(payload.comment_body)
|
||||||
|
|
||||||
|
new_status = "done" if decision == "approve" else "in_progress"
|
||||||
|
|
||||||
|
before_status = task.status
|
||||||
|
task.status = new_status
|
||||||
|
task.updated_at = datetime.utcnow()
|
||||||
|
session.add(task)
|
||||||
|
|
||||||
|
c = TaskComment(task_id=task.id, author_employee_id=actor_employee_id, body=comment_body)
|
||||||
|
session.add(c)
|
||||||
|
|
||||||
|
try:
|
||||||
|
session.flush()
|
||||||
|
log_activity(
|
||||||
|
session,
|
||||||
|
actor_employee_id=actor_employee_id,
|
||||||
|
entity_type="task",
|
||||||
|
entity_id=task.id,
|
||||||
|
verb="reviewed",
|
||||||
|
payload={"decision": decision, "status": new_status},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="Review action violates constraints")
|
||||||
|
|
||||||
|
session.refresh(task)
|
||||||
|
session.refresh(c)
|
||||||
|
|
||||||
|
# Notify assignee (comment.created will exclude author)
|
||||||
|
background.add_task(
|
||||||
|
notify_openclaw,
|
||||||
|
session,
|
||||||
|
NotifyContext(
|
||||||
|
event="comment.created", actor_employee_id=actor_employee_id, task=task, comment=c
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify reviewer/PMs about status change
|
||||||
|
if before_status != task.status:
|
||||||
|
background.add_task(
|
||||||
|
notify_openclaw,
|
||||||
|
session,
|
||||||
|
NotifyContext(
|
||||||
|
event="status.changed",
|
||||||
|
actor_employee_id=actor_employee_id,
|
||||||
|
task=task,
|
||||||
|
changed_fields={"status": {"from": before_status, "to": task.status}},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Task.model_validate(task)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/tasks/{task_id}", response_model=Task)
|
@router.patch("/tasks/{task_id}", response_model=Task)
|
||||||
def update_task(
|
def update_task(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
@@ -174,6 +265,20 @@ def update_task(
|
|||||||
if "status" in data and data["status"] not in ALLOWED_STATUSES:
|
if "status" in data and data["status"] not in ALLOWED_STATUSES:
|
||||||
raise HTTPException(status_code=400, detail="Invalid status")
|
raise HTTPException(status_code=400, detail="Invalid status")
|
||||||
|
|
||||||
|
# Enforce review workflow: agent assignees cannot mark tasks done directly.
|
||||||
|
if data.get("status") == "done":
|
||||||
|
assignee = (
|
||||||
|
session.get(Employee, task.assignee_employee_id) if task.assignee_employee_id else None
|
||||||
|
)
|
||||||
|
if assignee is not None and assignee.employee_type == "agent":
|
||||||
|
if actor_employee_id == task.assignee_employee_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Assignee agents cannot mark tasks done; set status=review for manager approval",
|
||||||
|
)
|
||||||
|
if task.reviewer_employee_id is not None and actor_employee_id != task.reviewer_employee_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Only the reviewer can mark a task done")
|
||||||
|
|
||||||
# If a task is sent to review and no reviewer is set, default reviewer to assignee's manager.
|
# If a task is sent to review and no reviewer is set, default reviewer to assignee's manager.
|
||||||
if (
|
if (
|
||||||
data.get("status") in {"review", "ready_for_review"}
|
data.get("status") in {"review", "ready_for_review"}
|
||||||
|
|||||||
@@ -26,3 +26,8 @@ class TaskCommentCreate(SQLModel):
|
|||||||
author_employee_id: int | None = None
|
author_employee_id: int | None = None
|
||||||
reply_to_comment_id: int | None = None
|
reply_to_comment_id: int | None = None
|
||||||
body: str
|
body: str
|
||||||
|
|
||||||
|
|
||||||
|
class TaskReviewDecision(SQLModel):
|
||||||
|
decision: str # approve | changes
|
||||||
|
comment_body: str
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export * from "./task";
|
|||||||
export * from "./taskComment";
|
export * from "./taskComment";
|
||||||
export * from "./taskCommentCreate";
|
export * from "./taskCommentCreate";
|
||||||
export * from "./taskCreate";
|
export * from "./taskCreate";
|
||||||
|
export * from "./taskReviewDecision";
|
||||||
export * from "./taskUpdate";
|
export * from "./taskUpdate";
|
||||||
export * from "./team";
|
export * from "./team";
|
||||||
export * from "./teamCreate";
|
export * from "./teamCreate";
|
||||||
|
|||||||
11
frontend/src/api/generated/model/taskReviewDecision.ts
Normal file
11
frontend/src/api/generated/model/taskReviewDecision.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.2.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenClaw Agency API
|
||||||
|
* OpenAPI spec version: 0.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TaskReviewDecision {
|
||||||
|
decision: string;
|
||||||
|
comment_body: string;
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
TaskComment,
|
TaskComment,
|
||||||
TaskCommentCreate,
|
TaskCommentCreate,
|
||||||
TaskCreate,
|
TaskCreate,
|
||||||
|
TaskReviewDecision,
|
||||||
TaskUpdate,
|
TaskUpdate,
|
||||||
} from ".././model";
|
} from ".././model";
|
||||||
|
|
||||||
@@ -467,6 +468,130 @@ export const useDispatchTaskTasksTaskIdDispatchPost = <
|
|||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Reviewer approves or requests changes.
|
||||||
|
|
||||||
|
- Approve => status=done
|
||||||
|
- Changes => status=in_progress
|
||||||
|
|
||||||
|
Always writes a TaskComment by the reviewer for audit.
|
||||||
|
* @summary Review Task
|
||||||
|
*/
|
||||||
|
export type reviewTaskTasksTaskIdReviewPostResponse200 = {
|
||||||
|
data: Task;
|
||||||
|
status: 200;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type reviewTaskTasksTaskIdReviewPostResponse422 = {
|
||||||
|
data: HTTPValidationError;
|
||||||
|
status: 422;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type reviewTaskTasksTaskIdReviewPostResponseSuccess =
|
||||||
|
reviewTaskTasksTaskIdReviewPostResponse200 & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
export type reviewTaskTasksTaskIdReviewPostResponseError =
|
||||||
|
reviewTaskTasksTaskIdReviewPostResponse422 & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type reviewTaskTasksTaskIdReviewPostResponse =
|
||||||
|
| reviewTaskTasksTaskIdReviewPostResponseSuccess
|
||||||
|
| reviewTaskTasksTaskIdReviewPostResponseError;
|
||||||
|
|
||||||
|
export const getReviewTaskTasksTaskIdReviewPostUrl = (taskId: number) => {
|
||||||
|
return `/tasks/${taskId}/review`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reviewTaskTasksTaskIdReviewPost = async (
|
||||||
|
taskId: number,
|
||||||
|
taskReviewDecision: TaskReviewDecision,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<reviewTaskTasksTaskIdReviewPostResponse> => {
|
||||||
|
return customFetch<reviewTaskTasksTaskIdReviewPostResponse>(
|
||||||
|
getReviewTaskTasksTaskIdReviewPostUrl(taskId),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||||
|
body: JSON.stringify(taskReviewDecision),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getReviewTaskTasksTaskIdReviewPostMutationOptions = <
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
|
||||||
|
TError,
|
||||||
|
{ taskId: number; data: TaskReviewDecision },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
|
||||||
|
TError,
|
||||||
|
{ taskId: number; data: TaskReviewDecision },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationKey = ["reviewTaskTasksTaskIdReviewPost"];
|
||||||
|
const { mutation: mutationOptions, request: requestOptions } = options
|
||||||
|
? options.mutation &&
|
||||||
|
"mutationKey" in options.mutation &&
|
||||||
|
options.mutation.mutationKey
|
||||||
|
? options
|
||||||
|
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||||
|
: { mutation: { mutationKey }, request: undefined };
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<
|
||||||
|
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
|
||||||
|
{ taskId: number; data: TaskReviewDecision }
|
||||||
|
> = (props) => {
|
||||||
|
const { taskId, data } = props ?? {};
|
||||||
|
|
||||||
|
return reviewTaskTasksTaskIdReviewPost(taskId, data, requestOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReviewTaskTasksTaskIdReviewPostMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>
|
||||||
|
>;
|
||||||
|
export type ReviewTaskTasksTaskIdReviewPostMutationBody = TaskReviewDecision;
|
||||||
|
export type ReviewTaskTasksTaskIdReviewPostMutationError = HTTPValidationError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Review Task
|
||||||
|
*/
|
||||||
|
export const useReviewTaskTasksTaskIdReviewPost = <
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
TContext = unknown,
|
||||||
|
>(
|
||||||
|
options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
|
||||||
|
TError,
|
||||||
|
{ taskId: number; data: TaskReviewDecision },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
|
||||||
|
TError,
|
||||||
|
{ taskId: number; data: TaskReviewDecision },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(
|
||||||
|
getReviewTaskTasksTaskIdReviewPostMutationOptions(options),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* @summary Update Task
|
* @summary Update Task
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user