feat: add boards and tasks management endpoints
This commit is contained in:
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
@@ -1,32 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.db.session import get_session
|
||||
from app.models.activity import Activity
|
||||
|
||||
router = APIRouter(prefix="/activities", tags=["activities"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_activities(limit: int = 50, session: Session = Depends(get_session)):
|
||||
items = session.exec(
|
||||
select(Activity).order_by(Activity.id.desc()).limit(max(1, min(limit, 200)))
|
||||
).all()
|
||||
out = []
|
||||
for a in items:
|
||||
out.append(
|
||||
{
|
||||
"id": a.id,
|
||||
"actor_employee_id": a.actor_employee_id,
|
||||
"entity_type": a.entity_type,
|
||||
"entity_id": a.entity_id,
|
||||
"verb": a.verb,
|
||||
"payload": json.loads(a.payload_json) if a.payload_json else None,
|
||||
"created_at": a.created_at,
|
||||
}
|
||||
)
|
||||
return out
|
||||
26
backend/app/api/activity.py
Normal file
26
backend/app/api/activity.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.auth import get_auth_context
|
||||
from app.db.session import get_session
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.schemas.activity_events import ActivityEventRead
|
||||
from app.services.admin_access import require_admin
|
||||
|
||||
router = APIRouter(prefix="/activity", tags=["activity"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ActivityEventRead])
|
||||
def list_activity(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> list[ActivityEvent]:
|
||||
require_admin(auth)
|
||||
statement = (
|
||||
select(ActivityEvent).order_by(ActivityEvent.created_at.desc()).offset(offset).limit(limit)
|
||||
)
|
||||
return list(session.exec(statement))
|
||||
205
backend/app/api/agents.py
Normal file
205
backend/app/api/agents.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.auth import get_auth_context
|
||||
from app.db.session import get_session
|
||||
from app.integrations.openclaw_gateway import OpenClawGatewayError, openclaw_call
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.agents import Agent
|
||||
from app.schemas.agents import (
|
||||
AgentCreate,
|
||||
AgentHeartbeat,
|
||||
AgentHeartbeatCreate,
|
||||
AgentRead,
|
||||
AgentUpdate,
|
||||
)
|
||||
from app.services.admin_access import require_admin
|
||||
|
||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
||||
|
||||
OFFLINE_AFTER = timedelta(minutes=10)
|
||||
DEFAULT_GATEWAY_CHANNEL = "openclaw-agency"
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
return slug or uuid4().hex
|
||||
|
||||
|
||||
def _build_session_label(agent_name: str) -> str:
|
||||
return f"{DEFAULT_GATEWAY_CHANNEL}-{_slugify(agent_name)}"
|
||||
|
||||
|
||||
async def _create_gateway_session(agent_name: str) -> str:
|
||||
label = _build_session_label(agent_name)
|
||||
try:
|
||||
await openclaw_call("sessions.patch", {"key": label, "label": agent_name})
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
return label
|
||||
|
||||
|
||||
def _with_computed_status(agent: Agent) -> Agent:
|
||||
now = datetime.utcnow()
|
||||
if agent.last_seen_at and now - agent.last_seen_at > OFFLINE_AFTER:
|
||||
agent.status = "offline"
|
||||
return agent
|
||||
|
||||
|
||||
def _record_heartbeat(session: Session, agent: Agent) -> None:
|
||||
event = ActivityEvent(
|
||||
event_type="agent.heartbeat",
|
||||
message=f"Heartbeat received from {agent.name}.",
|
||||
agent_id=agent.id,
|
||||
)
|
||||
session.add(event)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AgentRead])
|
||||
def list_agents(
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> list[Agent]:
|
||||
require_admin(auth)
|
||||
agents = list(session.exec(select(Agent)))
|
||||
return [_with_computed_status(agent) for agent in agents]
|
||||
|
||||
|
||||
@router.post("", response_model=AgentRead)
|
||||
async def create_agent(
|
||||
payload: AgentCreate,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> Agent:
|
||||
require_admin(auth)
|
||||
agent = Agent.model_validate(payload)
|
||||
agent.openclaw_session_id = await _create_gateway_session(agent.name)
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
session.add(
|
||||
ActivityEvent(
|
||||
event_type="agent.session.created",
|
||||
message=f"Session created for {agent.name}.",
|
||||
agent_id=agent.id,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return agent
|
||||
|
||||
|
||||
@router.get("/{agent_id}", response_model=AgentRead)
|
||||
def get_agent(
|
||||
agent_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> Agent:
|
||||
require_admin(auth)
|
||||
agent = session.get(Agent, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return _with_computed_status(agent)
|
||||
|
||||
|
||||
@router.patch("/{agent_id}", response_model=AgentRead)
|
||||
def update_agent(
|
||||
agent_id: str,
|
||||
payload: AgentUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> Agent:
|
||||
require_admin(auth)
|
||||
agent = session.get(Agent, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
for key, value in updates.items():
|
||||
setattr(agent, key, value)
|
||||
agent.updated_at = datetime.utcnow()
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
return _with_computed_status(agent)
|
||||
|
||||
|
||||
@router.post("/{agent_id}/heartbeat", response_model=AgentRead)
|
||||
def heartbeat_agent(
|
||||
agent_id: str,
|
||||
payload: AgentHeartbeat,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> Agent:
|
||||
require_admin(auth)
|
||||
agent = session.get(Agent, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if payload.status:
|
||||
agent.status = payload.status
|
||||
agent.last_seen_at = datetime.utcnow()
|
||||
agent.updated_at = datetime.utcnow()
|
||||
_record_heartbeat(session, agent)
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
return _with_computed_status(agent)
|
||||
|
||||
|
||||
@router.post("/heartbeat", response_model=AgentRead)
|
||||
async def heartbeat_or_create_agent(
|
||||
payload: AgentHeartbeatCreate,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> Agent:
|
||||
require_admin(auth)
|
||||
agent = session.exec(select(Agent).where(Agent.name == payload.name)).first()
|
||||
if agent is None:
|
||||
agent = Agent(name=payload.name, status=payload.status or "online")
|
||||
agent.openclaw_session_id = await _create_gateway_session(agent.name)
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
session.add(
|
||||
ActivityEvent(
|
||||
event_type="agent.session.created",
|
||||
message=f"Session created for {agent.name}.",
|
||||
agent_id=agent.id,
|
||||
)
|
||||
)
|
||||
elif not agent.openclaw_session_id:
|
||||
agent.openclaw_session_id = await _create_gateway_session(agent.name)
|
||||
session.add(
|
||||
ActivityEvent(
|
||||
event_type="agent.session.created",
|
||||
message=f"Session created for {agent.name}.",
|
||||
agent_id=agent.id,
|
||||
)
|
||||
)
|
||||
if payload.status:
|
||||
agent.status = payload.status
|
||||
agent.last_seen_at = datetime.utcnow()
|
||||
agent.updated_at = datetime.utcnow()
|
||||
_record_heartbeat(session, agent)
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
return _with_computed_status(agent)
|
||||
|
||||
|
||||
@router.delete("/{agent_id}")
|
||||
def delete_agent(
|
||||
agent_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> dict[str, bool]:
|
||||
require_admin(auth)
|
||||
agent = session.get(Agent, agent_id)
|
||||
if agent:
|
||||
session.delete(agent)
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
15
backend/app/api/auth.py
Normal file
15
backend/app/api/auth.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.core.auth import get_auth_context
|
||||
from app.schemas.users import UserRead
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/bootstrap", response_model=UserRead)
|
||||
async def bootstrap_user(auth=Depends(get_auth_context)) -> UserRead:
|
||||
if auth.actor_type != "user" or auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return auth.user
|
||||
82
backend/app/api/boards.py
Normal file
82
backend/app/api/boards.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.auth import get_auth_context
|
||||
from app.db.session import get_session
|
||||
from app.models.boards import Board
|
||||
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
||||
from app.services.admin_access import require_admin
|
||||
|
||||
router = APIRouter(prefix="/boards", tags=["boards"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[BoardRead])
|
||||
def list_boards(
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> list[Board]:
|
||||
require_admin(auth)
|
||||
return list(session.exec(select(Board)))
|
||||
|
||||
|
||||
@router.post("", response_model=BoardRead)
|
||||
def create_board(
|
||||
payload: BoardCreate,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> Board:
|
||||
require_admin(auth)
|
||||
board = Board.model_validate(payload)
|
||||
session.add(board)
|
||||
session.commit()
|
||||
session.refresh(board)
|
||||
return board
|
||||
|
||||
|
||||
@router.get("/{board_id}", response_model=BoardRead)
|
||||
def get_board(
|
||||
board_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> Board:
|
||||
require_admin(auth)
|
||||
board = session.get(Board, board_id)
|
||||
if board is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return board
|
||||
|
||||
|
||||
@router.patch("/{board_id}", response_model=BoardRead)
|
||||
def update_board(
|
||||
board_id: str,
|
||||
payload: BoardUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> Board:
|
||||
require_admin(auth)
|
||||
board = session.get(Board, board_id)
|
||||
if board is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
for key, value in updates.items():
|
||||
setattr(board, key, value)
|
||||
session.add(board)
|
||||
session.commit()
|
||||
session.refresh(board)
|
||||
return board
|
||||
|
||||
|
||||
@router.delete("/{board_id}")
|
||||
def delete_board(
|
||||
board_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> dict[str, bool]:
|
||||
require_admin(auth)
|
||||
board = session.get(Board, board_id)
|
||||
if board:
|
||||
session.delete(board)
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
99
backend/app/api/gateway.py
Normal file
99
backend/app/api/gateway.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status
|
||||
|
||||
from app.core.auth import get_auth_context
|
||||
from app.core.config import settings
|
||||
from app.integrations.openclaw_gateway import (
|
||||
OpenClawGatewayError,
|
||||
get_chat_history,
|
||||
openclaw_call,
|
||||
send_message,
|
||||
)
|
||||
from app.services.admin_access import require_admin
|
||||
|
||||
router = APIRouter(prefix="/gateway", tags=["gateway"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def gateway_status(auth=Depends(get_auth_context)) -> dict[str, object]:
|
||||
require_admin(auth)
|
||||
gateway_url = settings.openclaw_gateway_url or "ws://127.0.0.1:18789"
|
||||
try:
|
||||
sessions = await openclaw_call("sessions.list")
|
||||
if isinstance(sessions, dict):
|
||||
sessions_list = list(sessions.get("sessions") or [])
|
||||
else:
|
||||
sessions_list = list(sessions or [])
|
||||
return {
|
||||
"connected": True,
|
||||
"gateway_url": gateway_url,
|
||||
"sessions_count": len(sessions_list),
|
||||
"sessions": sessions_list,
|
||||
}
|
||||
except OpenClawGatewayError as exc:
|
||||
return {
|
||||
"connected": False,
|
||||
"gateway_url": gateway_url,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def list_sessions(auth=Depends(get_auth_context)) -> dict[str, object]:
|
||||
require_admin(auth)
|
||||
try:
|
||||
sessions = await openclaw_call("sessions.list")
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if isinstance(sessions, dict):
|
||||
return {"sessions": list(sessions.get("sessions") or [])}
|
||||
return {"sessions": list(sessions or [])}
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}")
|
||||
async def get_session(session_id: str, auth=Depends(get_auth_context)) -> dict[str, object]:
|
||||
require_admin(auth)
|
||||
try:
|
||||
sessions = await openclaw_call("sessions.list")
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if isinstance(sessions, dict):
|
||||
sessions_list = list(sessions.get("sessions") or [])
|
||||
else:
|
||||
sessions_list = list(sessions or [])
|
||||
session = next((item for item in sessions_list if item.get("key") == session_id), None)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
return {"session": session}
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/history")
|
||||
async def get_session_history(session_id: str, auth=Depends(get_auth_context)) -> dict[str, object]:
|
||||
require_admin(auth)
|
||||
try:
|
||||
history = await get_chat_history(session_id)
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if isinstance(history, dict) and isinstance(history.get("messages"), list):
|
||||
return {"history": history["messages"]}
|
||||
return {"history": list(history or [])}
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/message")
|
||||
async def send_session_message(
|
||||
session_id: str,
|
||||
payload: dict = Body(...),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> dict[str, bool]:
|
||||
require_admin(auth)
|
||||
content = payload.get("content")
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="content is required"
|
||||
)
|
||||
try:
|
||||
await send_message(content, session_key=session_id)
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
return {"ok": True}
|
||||
@@ -1,474 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.utils import get_actor_employee_id, log_activity
|
||||
from app.core.urls import public_api_base_url
|
||||
from app.db.session import get_session
|
||||
from app.integrations.openclaw import OpenClawClient
|
||||
from app.models.org import Department, Employee, Team
|
||||
from app.schemas.org import (
|
||||
DepartmentCreate,
|
||||
DepartmentUpdate,
|
||||
EmployeeCreate,
|
||||
EmployeeUpdate,
|
||||
TeamCreate,
|
||||
TeamUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["org"])
|
||||
|
||||
|
||||
def _enforce_employee_create_policy(
|
||||
session: Session, *, actor_employee_id: int, target_employee_type: str
|
||||
) -> None:
|
||||
"""Enforce: agents can only create/provision agents; humans can create humans + agents."""
|
||||
|
||||
actor = session.get(Employee, actor_employee_id)
|
||||
if actor is None:
|
||||
# Actor header is required; if it points to nothing, treat as invalid.
|
||||
raise HTTPException(status_code=400, detail="Actor employee not found")
|
||||
|
||||
target = (target_employee_type or "").lower()
|
||||
actor_type = (actor.employee_type or "").lower()
|
||||
|
||||
if actor_type == "agent" and target != "agent":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Agent employees may only create/provision agent employees",
|
||||
)
|
||||
|
||||
|
||||
def _default_agent_prompt(emp: Employee) -> str:
|
||||
"""Generate a conservative default prompt for a newly-created agent employee.
|
||||
|
||||
We keep this short and deterministic; the human can refine later.
|
||||
"""
|
||||
|
||||
title = emp.title or "Agent"
|
||||
dept = str(emp.department_id) if emp.department_id is not None else "(unassigned)"
|
||||
|
||||
return (
|
||||
f"You are {emp.name}, an AI agent employee in Mission Control.\n"
|
||||
f"Your employee_id is {emp.id}.\n"
|
||||
f"Title: {title}. Department id: {dept}.\n\n"
|
||||
"Mission Control API access (no UI):\n"
|
||||
f"- Base URL: {public_api_base_url()}\n"
|
||||
"- Auth: none. REQUIRED header on ALL write operations: X-Actor-Employee-Id: <your_employee_id>\n"
|
||||
f" Example for you: X-Actor-Employee-Id: {emp.id}\n\n"
|
||||
"How to execute writes from an OpenClaw agent (IMPORTANT):\n"
|
||||
"- Use the exec tool to run curl against the Base URL above.\n"
|
||||
"- Example: start a task\n"
|
||||
" curl -sS -X PATCH $BASE/tasks/<TASK_ID> -H 'X-Actor-Employee-Id: <your_employee_id>' -H 'Content-Type: application/json' -d '{\"status\":\"in_progress\"}'\n"
|
||||
"- Example: add a progress comment\n"
|
||||
" curl -sS -X POST $BASE/task-comments -H 'X-Actor-Employee-Id: <your_employee_id>' -H 'Content-Type: application/json' -d '{\"task_id\":<TASK_ID>,\"body\":\"...\"}'\n\n"
|
||||
"Common endpoints (JSON):\n"
|
||||
"- GET /tasks, POST /tasks\n"
|
||||
"- GET /task-comments, POST /task-comments\n"
|
||||
"- GET /projects, GET /employees, GET /departments\n"
|
||||
"- OpenAPI schema: GET /openapi.json\n\n"
|
||||
"Rules:\n"
|
||||
"- Use the Mission Control API only (no UI).\n"
|
||||
"- You are responsible for driving assigned work to completion.\n"
|
||||
"- For every task you own: (1) read it, (2) plan next steps, (3) post progress comments, (4) update status as it moves (backlog/ready/in_progress/review/done/blocked).\n"
|
||||
"- Always leave an audit trail: add a comment whenever you start work, whenever you learn something important, and whenever you change status.\n"
|
||||
"- If blocked, set status=blocked and comment what you need (missing access, unclear requirements, etc.).\n"
|
||||
"- When notified about tasks/comments, respond with concise, actionable updates and immediately sync the task state in Mission Control.\n"
|
||||
"- Do not invent facts; ask for missing context.\n"
|
||||
)
|
||||
|
||||
|
||||
def _maybe_auto_provision_agent(session: Session, *, emp: Employee, actor_employee_id: int) -> None:
|
||||
"""Auto-provision an OpenClaw session for an agent employee.
|
||||
|
||||
This is intentionally best-effort. If OpenClaw is not configured or the call fails,
|
||||
we leave the employee as-is (openclaw_session_key stays null).
|
||||
"""
|
||||
|
||||
# Enforce: agent actors may only provision agents (humans can provision agents).
|
||||
_enforce_employee_create_policy(
|
||||
session, actor_employee_id=actor_employee_id, target_employee_type=emp.employee_type
|
||||
)
|
||||
|
||||
if emp.employee_type != "agent":
|
||||
return
|
||||
if emp.status != "active":
|
||||
return
|
||||
if emp.openclaw_session_key:
|
||||
return
|
||||
|
||||
client = OpenClawClient.from_env()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
# FULL IMPLEMENTATION: ensure a dedicated OpenClaw agent profile exists per employee.
|
||||
try:
|
||||
from app.integrations.openclaw_agents import ensure_full_agent_profile
|
||||
|
||||
info = ensure_full_agent_profile(
|
||||
client=client,
|
||||
employee_id=int(emp.id),
|
||||
employee_name=emp.name,
|
||||
)
|
||||
emp.openclaw_agent_id = info["agent_id"]
|
||||
session.add(emp)
|
||||
session.flush()
|
||||
except Exception as e:
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="agent_profile_failed",
|
||||
payload={"error": f"{type(e).__name__}: {e}"},
|
||||
)
|
||||
# Do not block employee creation on provisioning.
|
||||
return
|
||||
|
||||
label = f"employee:{emp.id}:{emp.name}"
|
||||
try:
|
||||
resp = client.tools_invoke(
|
||||
"sessions_spawn",
|
||||
{
|
||||
"task": _default_agent_prompt(emp),
|
||||
"label": label,
|
||||
"agentId": emp.openclaw_agent_id,
|
||||
"cleanup": "keep",
|
||||
"runTimeoutSeconds": 600,
|
||||
},
|
||||
timeout_s=20.0,
|
||||
)
|
||||
except Exception as e:
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="provision_failed",
|
||||
payload={"error": f"{type(e).__name__}: {e}"},
|
||||
)
|
||||
return
|
||||
|
||||
session_key = None
|
||||
if isinstance(resp, dict):
|
||||
session_key = resp.get("sessionKey")
|
||||
if not session_key:
|
||||
result = resp.get("result") or {}
|
||||
if isinstance(result, dict):
|
||||
session_key = result.get("sessionKey") or result.get("childSessionKey")
|
||||
details = (result.get("details") if isinstance(result, dict) else None) or {}
|
||||
if isinstance(details, dict):
|
||||
session_key = session_key or details.get("sessionKey") or details.get("childSessionKey")
|
||||
|
||||
if not session_key:
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="provision_incomplete",
|
||||
payload={"label": label},
|
||||
)
|
||||
return
|
||||
|
||||
emp.openclaw_session_key = session_key
|
||||
session.add(emp)
|
||||
session.flush()
|
||||
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="provisioned",
|
||||
payload={"session_key": session_key, "label": label},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/departments", response_model=list[Department])
|
||||
def list_departments(session: Session = Depends(get_session)):
|
||||
return session.exec(select(Department).order_by(Department.name.asc())).all()
|
||||
|
||||
|
||||
@router.get("/teams", response_model=list[Team])
|
||||
def list_teams(department_id: int | None = None, session: Session = Depends(get_session)):
|
||||
q = select(Team)
|
||||
if department_id is not None:
|
||||
q = q.where(Team.department_id == department_id)
|
||||
return session.exec(q.order_by(Team.name.asc())).all()
|
||||
|
||||
|
||||
@router.post("/teams", response_model=Team)
|
||||
def create_team(
|
||||
payload: TeamCreate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
team = Team(**payload.model_dump())
|
||||
session.add(team)
|
||||
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="team",
|
||||
entity_id=team.id,
|
||||
verb="created",
|
||||
payload={
|
||||
"name": team.name,
|
||||
"department_id": team.department_id,
|
||||
"lead_employee_id": team.lead_employee_id,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Team already exists or violates constraints")
|
||||
|
||||
session.refresh(team)
|
||||
return team
|
||||
|
||||
|
||||
@router.patch("/teams/{team_id}", response_model=Team)
|
||||
def update_team(
|
||||
team_id: int,
|
||||
payload: TeamUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
team = session.get(Team, team_id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=404, detail="Team not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(team, k, v)
|
||||
|
||||
session.add(team)
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="team",
|
||||
entity_id=team.id,
|
||||
verb="updated",
|
||||
payload=data,
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Team update violates constraints")
|
||||
|
||||
session.refresh(team)
|
||||
return team
|
||||
|
||||
|
||||
@router.post("/departments", response_model=Department)
|
||||
def create_department(
|
||||
payload: DepartmentCreate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
"""Create a department.
|
||||
|
||||
Important: keep the operation atomic. We flush to get dept.id, log the activity,
|
||||
then commit once. We also translate common DB integrity errors into 409s.
|
||||
"""
|
||||
|
||||
dept = Department(name=payload.name, head_employee_id=payload.head_employee_id)
|
||||
session.add(dept)
|
||||
|
||||
try:
|
||||
session.flush() # assigns dept.id without committing
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="department",
|
||||
entity_id=dept.id,
|
||||
verb="created",
|
||||
payload={"name": dept.name},
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Department already exists or violates constraints"
|
||||
)
|
||||
|
||||
session.refresh(dept)
|
||||
return dept
|
||||
|
||||
|
||||
@router.patch("/departments/{department_id}", response_model=Department)
|
||||
def update_department(
|
||||
department_id: int,
|
||||
payload: DepartmentUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
dept = session.get(Department, department_id)
|
||||
if not dept:
|
||||
raise HTTPException(status_code=404, detail="Department not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(dept, k, v)
|
||||
|
||||
session.add(dept)
|
||||
session.commit()
|
||||
session.refresh(dept)
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="department",
|
||||
entity_id=dept.id,
|
||||
verb="updated",
|
||||
payload=data,
|
||||
)
|
||||
session.commit()
|
||||
return dept
|
||||
|
||||
|
||||
@router.get("/employees", response_model=list[Employee])
|
||||
def list_employees(session: Session = Depends(get_session)):
|
||||
return session.exec(select(Employee).order_by(Employee.id.asc())).all()
|
||||
|
||||
|
||||
@router.post("/employees", response_model=Employee)
|
||||
def create_employee(
|
||||
payload: EmployeeCreate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
_enforce_employee_create_policy(
|
||||
session, actor_employee_id=actor_employee_id, target_employee_type=payload.employee_type
|
||||
)
|
||||
|
||||
emp = Employee(**payload.model_dump())
|
||||
session.add(emp)
|
||||
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="created",
|
||||
payload={"name": emp.name, "type": emp.employee_type},
|
||||
)
|
||||
|
||||
# AUTO-PROVISION: if this is an agent employee, try to create an OpenClaw session.
|
||||
_maybe_auto_provision_agent(session, emp=emp, actor_employee_id=actor_employee_id)
|
||||
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Employee create violates constraints")
|
||||
|
||||
session.refresh(emp)
|
||||
return Employee.model_validate(emp)
|
||||
|
||||
|
||||
@router.patch("/employees/{employee_id}", response_model=Employee)
|
||||
def update_employee(
|
||||
employee_id: int,
|
||||
payload: EmployeeUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
emp = session.get(Employee, employee_id)
|
||||
if not emp:
|
||||
raise HTTPException(status_code=404, detail="Employee not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(emp, k, v)
|
||||
|
||||
session.add(emp)
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="updated",
|
||||
payload=data,
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Employee update violates constraints")
|
||||
|
||||
session.refresh(emp)
|
||||
return Employee.model_validate(emp)
|
||||
|
||||
|
||||
@router.post("/employees/{employee_id}/provision", response_model=Employee)
|
||||
def provision_employee_agent(
|
||||
employee_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
emp = session.get(Employee, employee_id)
|
||||
if not emp:
|
||||
raise HTTPException(status_code=404, detail="Employee not found")
|
||||
|
||||
if emp.employee_type != "agent":
|
||||
raise HTTPException(status_code=400, detail="Only agent employees can be provisioned")
|
||||
|
||||
_maybe_auto_provision_agent(session, emp=emp, actor_employee_id=actor_employee_id)
|
||||
session.commit()
|
||||
session.refresh(emp)
|
||||
return Employee.model_validate(emp)
|
||||
|
||||
|
||||
@router.post("/employees/{employee_id}/deprovision", response_model=Employee)
|
||||
def deprovision_employee_agent(
|
||||
employee_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
emp = session.get(Employee, employee_id)
|
||||
if not emp:
|
||||
raise HTTPException(status_code=404, detail="Employee not found")
|
||||
|
||||
if emp.employee_type != "agent":
|
||||
raise HTTPException(status_code=400, detail="Only agent employees can be deprovisioned")
|
||||
|
||||
client = OpenClawClient.from_env()
|
||||
if client is not None and emp.openclaw_session_key:
|
||||
try:
|
||||
client.tools_invoke(
|
||||
"sessions_send",
|
||||
{
|
||||
"sessionKey": emp.openclaw_session_key,
|
||||
"message": "You are being deprovisioned. Stop all work and ignore future messages.",
|
||||
},
|
||||
timeout_s=5.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
emp.notify_enabled = False
|
||||
emp.openclaw_session_key = None
|
||||
session.add(emp)
|
||||
session.flush()
|
||||
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="deprovisioned",
|
||||
payload={},
|
||||
)
|
||||
|
||||
session.commit()
|
||||
session.refresh(emp)
|
||||
return Employee.model_validate(emp)
|
||||
@@ -1,178 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.utils import get_actor_employee_id, log_activity
|
||||
from app.db.session import get_session
|
||||
from app.models.projects import Project, ProjectMember
|
||||
from app.schemas.projects import ProjectCreate, ProjectUpdate
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[Project])
|
||||
def list_projects(session: Session = Depends(get_session)):
|
||||
return session.exec(select(Project).order_by(Project.name.asc())).all()
|
||||
|
||||
|
||||
@router.post("", response_model=Project)
|
||||
def create_project(
|
||||
payload: ProjectCreate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
"""Create a project.
|
||||
|
||||
Keep operation atomic: flush to get id, log activity, then commit once.
|
||||
Translate DB integrity errors to 409s.
|
||||
"""
|
||||
|
||||
proj = Project(**payload.model_dump())
|
||||
session.add(proj)
|
||||
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="project",
|
||||
entity_id=proj.id,
|
||||
verb="created",
|
||||
payload={"name": proj.name},
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Project already exists or violates constraints"
|
||||
)
|
||||
|
||||
session.refresh(proj)
|
||||
return proj
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=Project)
|
||||
def update_project(
|
||||
project_id: int,
|
||||
payload: ProjectUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
proj = session.get(Project, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(proj, k, v)
|
||||
|
||||
session.add(proj)
|
||||
session.commit()
|
||||
session.refresh(proj)
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="project",
|
||||
entity_id=proj.id,
|
||||
verb="updated",
|
||||
payload=data,
|
||||
)
|
||||
session.commit()
|
||||
return proj
|
||||
|
||||
|
||||
@router.get("/{project_id}/members", response_model=list[ProjectMember])
|
||||
def list_project_members(project_id: int, session: Session = Depends(get_session)):
|
||||
return session.exec(
|
||||
select(ProjectMember)
|
||||
.where(ProjectMember.project_id == project_id)
|
||||
.order_by(ProjectMember.id.asc())
|
||||
).all()
|
||||
|
||||
|
||||
@router.post("/{project_id}/members", response_model=ProjectMember)
|
||||
def add_project_member(
|
||||
project_id: int,
|
||||
payload: ProjectMember,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
existing = session.exec(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id, ProjectMember.employee_id == payload.employee_id
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Member already added")
|
||||
member = ProjectMember(
|
||||
project_id=project_id, employee_id=payload.employee_id, role=payload.role
|
||||
)
|
||||
session.add(member)
|
||||
session.commit()
|
||||
session.refresh(member)
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="project_member",
|
||||
entity_id=member.id,
|
||||
verb="added",
|
||||
payload={"project_id": project_id, "employee_id": member.employee_id, "role": member.role},
|
||||
)
|
||||
session.commit()
|
||||
return member
|
||||
|
||||
|
||||
@router.delete("/{project_id}/members/{member_id}")
|
||||
def remove_project_member(
|
||||
project_id: int,
|
||||
member_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
member = session.get(ProjectMember, member_id)
|
||||
if not member or member.project_id != project_id:
|
||||
raise HTTPException(status_code=404, detail="Project member not found")
|
||||
session.delete(member)
|
||||
session.commit()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="project_member",
|
||||
entity_id=member_id,
|
||||
verb="removed",
|
||||
payload={"project_id": project_id},
|
||||
)
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.patch("/{project_id}/members/{member_id}", response_model=ProjectMember)
|
||||
def update_project_member(
|
||||
project_id: int,
|
||||
member_id: int,
|
||||
payload: ProjectMember,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
member = session.get(ProjectMember, member_id)
|
||||
if not member or member.project_id != project_id:
|
||||
raise HTTPException(status_code=404, detail="Project member not found")
|
||||
|
||||
if payload.role is not None:
|
||||
member.role = payload.role
|
||||
|
||||
session.add(member)
|
||||
session.commit()
|
||||
session.refresh(member)
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="project_member",
|
||||
entity_id=member.id,
|
||||
verb="updated",
|
||||
payload={"project_id": project_id, "role": member.role},
|
||||
)
|
||||
session.commit()
|
||||
return member
|
||||
115
backend/app/api/tasks.py
Normal file
115
backend/app/api/tasks.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.auth import get_auth_context
|
||||
from app.db.session import get_session
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.boards import Board
|
||||
from app.models.tasks import Task
|
||||
from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate
|
||||
from app.services.admin_access import require_admin
|
||||
|
||||
router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskRead])
|
||||
def list_tasks(
|
||||
board_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> list[Task]:
|
||||
require_admin(auth)
|
||||
board = session.get(Board, board_id)
|
||||
if board is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return list(session.exec(select(Task).where(Task.board_id == board.id)))
|
||||
|
||||
|
||||
@router.post("", response_model=TaskRead)
|
||||
def create_task(
|
||||
board_id: str,
|
||||
payload: TaskCreate,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> Task:
|
||||
require_admin(auth)
|
||||
board = session.get(Board, board_id)
|
||||
if board is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
task = Task.model_validate(payload)
|
||||
task.board_id = board.id
|
||||
if task.created_by_user_id is None and auth.user is not None:
|
||||
task.created_by_user_id = auth.user.id
|
||||
session.add(task)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
|
||||
event = ActivityEvent(
|
||||
event_type="task.created",
|
||||
task_id=task.id,
|
||||
message=f"Task created: {task.title}.",
|
||||
)
|
||||
session.add(event)
|
||||
session.commit()
|
||||
return task
|
||||
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskRead)
|
||||
def update_task(
|
||||
board_id: str,
|
||||
task_id: str,
|
||||
payload: TaskUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> Task:
|
||||
require_admin(auth)
|
||||
board = session.get(Board, board_id)
|
||||
if board is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
task = session.get(Task, task_id)
|
||||
if task is None or task.board_id != board.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
previous_status = task.status
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
for key, value in updates.items():
|
||||
setattr(task, key, value)
|
||||
task.updated_at = datetime.utcnow()
|
||||
|
||||
session.add(task)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
|
||||
if "status" in updates and task.status != previous_status:
|
||||
event_type = "task.status_changed"
|
||||
message = f"Task moved to {task.status}: {task.title}."
|
||||
else:
|
||||
event_type = "task.updated"
|
||||
message = f"Task updated: {task.title}."
|
||||
event = ActivityEvent(event_type=event_type, task_id=task.id, message=message)
|
||||
session.add(event)
|
||||
session.commit()
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
def delete_task(
|
||||
board_id: str,
|
||||
task_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
auth=Depends(get_auth_context),
|
||||
) -> dict[str, bool]:
|
||||
require_admin(auth)
|
||||
board = session.get(Board, board_id)
|
||||
if board is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
task = session.get(Task, task_id)
|
||||
if task and task.board_id == board.id:
|
||||
session.delete(task)
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
@@ -1,37 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models.activity import Activity
|
||||
|
||||
|
||||
def log_activity(
|
||||
session: Session,
|
||||
*,
|
||||
actor_employee_id: int | None,
|
||||
entity_type: str,
|
||||
entity_id: int | None,
|
||||
verb: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
session.add(
|
||||
Activity(
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
verb=verb,
|
||||
payload_json=json.dumps(payload) if payload is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_actor_employee_id(
|
||||
x_actor_employee_id: int | None = Header(default=None, alias="X-Actor-Employee-Id"),
|
||||
) -> int:
|
||||
if x_actor_employee_id is None:
|
||||
raise HTTPException(status_code=400, detail="X-Actor-Employee-Id required")
|
||||
return x_actor_employee_id
|
||||
@@ -1,443 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.utils import get_actor_employee_id, log_activity
|
||||
from app.db.session import get_session
|
||||
from app.integrations.notify import NotifyContext, notify_openclaw
|
||||
from app.integrations.openclaw import OpenClawClient
|
||||
from app.models.org import Employee
|
||||
from app.models.work import Task, TaskComment
|
||||
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskReviewDecision, TaskUpdate
|
||||
|
||||
logger = logging.getLogger("app.work")
|
||||
|
||||
router = APIRouter(tags=["work"])
|
||||
|
||||
ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"}
|
||||
|
||||
|
||||
def _validate_task_assignee(session: Session, assignee_employee_id: int) -> None:
|
||||
"""Enforce that only provisioned agents can be assigned tasks.
|
||||
|
||||
Humans can be assigned regardless.
|
||||
Agents must be active, notify_enabled, and have openclaw_session_key.
|
||||
"""
|
||||
|
||||
emp = session.get(Employee, assignee_employee_id)
|
||||
if emp is None:
|
||||
raise HTTPException(status_code=400, detail="Assignee employee not found")
|
||||
|
||||
if emp.employee_type == "agent":
|
||||
if emp.status != "active":
|
||||
raise HTTPException(status_code=400, detail="Cannot assign task to inactive agent")
|
||||
if not emp.notify_enabled:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Cannot assign task to agent with notifications disabled"
|
||||
)
|
||||
if not emp.openclaw_session_key:
|
||||
raise HTTPException(status_code=400, detail="Cannot assign task to unprovisioned agent")
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[Task])
|
||||
def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)):
|
||||
stmt = select(Task).order_by(Task.id.asc())
|
||||
if project_id is not None:
|
||||
stmt = stmt.where(Task.project_id == project_id)
|
||||
return session.exec(stmt).all()
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=Task)
|
||||
def create_task(
|
||||
payload: TaskCreate,
|
||||
background: BackgroundTasks,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
# SECURITY / AUDIT: never allow spoofing task creator.
|
||||
# The creator is always the actor making the request.
|
||||
payload = TaskCreate(**{**payload.model_dump(), "created_by_employee_id": actor_employee_id})
|
||||
|
||||
if payload.assignee_employee_id is not None:
|
||||
_validate_task_assignee(session, payload.assignee_employee_id)
|
||||
|
||||
# Default reviewer to the manager of the assignee (if not explicitly provided).
|
||||
if payload.reviewer_employee_id is None and payload.assignee_employee_id is not None:
|
||||
assignee = session.get(Employee, payload.assignee_employee_id)
|
||||
if assignee is not None and assignee.manager_id is not None:
|
||||
payload = TaskCreate(
|
||||
**{**payload.model_dump(), "reviewer_employee_id": assignee.manager_id}
|
||||
)
|
||||
|
||||
task = Task(**payload.model_dump())
|
||||
if task.status not in ALLOWED_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
task.updated_at = datetime.utcnow()
|
||||
session.add(task)
|
||||
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="task",
|
||||
entity_id=task.id,
|
||||
verb="created",
|
||||
payload={"project_id": task.project_id, "title": task.title},
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Task create violates constraints")
|
||||
|
||||
session.refresh(task)
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(event="task.created", actor_employee_id=actor_employee_id, task_id=task.id),
|
||||
)
|
||||
# Explicitly return a serializable payload (guards against empty {} responses)
|
||||
return Task.model_validate(task)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/dispatch")
|
||||
def dispatch_task(
|
||||
task_id: int,
|
||||
background: BackgroundTasks,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
|
||||
task = session.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
logger.info(
|
||||
"dispatch_task: loaded",
|
||||
extra={
|
||||
"task_id": getattr(task, "id", None),
|
||||
"assignee_employee_id": getattr(task, "assignee_employee_id", None),
|
||||
},
|
||||
)
|
||||
|
||||
if task.assignee_employee_id is None:
|
||||
raise HTTPException(status_code=400, detail="Task has no assignee")
|
||||
|
||||
_validate_task_assignee(session, task.assignee_employee_id)
|
||||
|
||||
client = OpenClawClient.from_env()
|
||||
if client is None:
|
||||
logger.warning("dispatch_task: missing OpenClaw env")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="OpenClaw gateway is not configured (set OPENCLAW_GATEWAY_URL/TOKEN)",
|
||||
)
|
||||
|
||||
# Best-effort: enqueue a dispatch notification.
|
||||
# IMPORTANT: if a task is already in review, the reviewer (not the assignee) should be notified.
|
||||
status = (getattr(task, "status", None) or "").lower()
|
||||
if status in {"review", "ready_for_review"}:
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(
|
||||
event="status.changed",
|
||||
actor_employee_id=actor_employee_id,
|
||||
task_id=task.id,
|
||||
changed_fields={"status": {"to": task.status}},
|
||||
),
|
||||
)
|
||||
else:
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task_id=task.id),
|
||||
)
|
||||
|
||||
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,
|
||||
NotifyContext(
|
||||
event="comment.created",
|
||||
actor_employee_id=actor_employee_id,
|
||||
task_id=task.id,
|
||||
comment_id=c.id,
|
||||
),
|
||||
)
|
||||
|
||||
# Notify reviewer/PMs about status change
|
||||
if before_status != task.status:
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(
|
||||
event="status.changed",
|
||||
actor_employee_id=actor_employee_id,
|
||||
task_id=task.id,
|
||||
changed_fields={"status": {"from": before_status, "to": task.status}},
|
||||
),
|
||||
)
|
||||
|
||||
return Task.model_validate(task)
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}", response_model=Task)
|
||||
def update_task(
|
||||
task_id: int,
|
||||
payload: TaskUpdate,
|
||||
background: BackgroundTasks,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
|
||||
task = session.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
before = {
|
||||
"assignee_employee_id": task.assignee_employee_id,
|
||||
"reviewer_employee_id": task.reviewer_employee_id,
|
||||
"status": task.status,
|
||||
}
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "assignee_employee_id" in data and data["assignee_employee_id"] is not None:
|
||||
_validate_task_assignee(session, data["assignee_employee_id"])
|
||||
if "status" in data and data["status"] not in ALLOWED_STATUSES:
|
||||
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 (
|
||||
data.get("status") in {"review", "ready_for_review"}
|
||||
and data.get("reviewer_employee_id") is None
|
||||
):
|
||||
assignee_id = data.get("assignee_employee_id", task.assignee_employee_id)
|
||||
if assignee_id is not None:
|
||||
assignee = session.get(Employee, assignee_id)
|
||||
if assignee is not None and assignee.manager_id is not None:
|
||||
data["reviewer_employee_id"] = assignee.manager_id
|
||||
|
||||
for k, v in data.items():
|
||||
setattr(task, k, v)
|
||||
task.updated_at = datetime.utcnow()
|
||||
session.add(task)
|
||||
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="task",
|
||||
entity_id=task.id,
|
||||
verb="updated",
|
||||
payload=data,
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Task update violates constraints")
|
||||
|
||||
session.refresh(task)
|
||||
|
||||
# notify based on meaningful changes
|
||||
changed = {}
|
||||
if before.get("assignee_employee_id") != task.assignee_employee_id:
|
||||
changed["assignee_employee_id"] = {
|
||||
"from": before.get("assignee_employee_id"),
|
||||
"to": task.assignee_employee_id,
|
||||
}
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(
|
||||
event="task.assigned",
|
||||
actor_employee_id=actor_employee_id,
|
||||
task_id=task.id,
|
||||
changed_fields=changed,
|
||||
),
|
||||
)
|
||||
if before.get("status") != task.status:
|
||||
changed["status"] = {"from": before.get("status"), "to": task.status}
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(
|
||||
event="status.changed",
|
||||
actor_employee_id=actor_employee_id,
|
||||
task_id=task.id,
|
||||
changed_fields=changed,
|
||||
),
|
||||
)
|
||||
if not changed and data:
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(
|
||||
event="task.updated",
|
||||
actor_employee_id=actor_employee_id,
|
||||
task_id=task.id,
|
||||
changed_fields=data,
|
||||
),
|
||||
)
|
||||
|
||||
return Task.model_validate(task)
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}")
|
||||
def delete_task(
|
||||
task_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
|
||||
task = session.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
session.delete(task)
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="task",
|
||||
entity_id=task_id,
|
||||
verb="deleted",
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Task delete violates constraints")
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/task-comments", response_model=list[TaskComment])
|
||||
def list_task_comments(task_id: int, session: Session = Depends(get_session)):
|
||||
return session.exec(
|
||||
select(TaskComment).where(TaskComment.task_id == task_id).order_by(TaskComment.id.asc())
|
||||
).all()
|
||||
|
||||
|
||||
@router.post("/task-comments", response_model=TaskComment)
|
||||
def create_task_comment(
|
||||
payload: TaskCommentCreate,
|
||||
background: BackgroundTasks,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
# SECURITY / AUDIT: never allow spoofing comment authorship.
|
||||
# The author is always the actor making the request.
|
||||
payload = TaskCommentCreate(**{**payload.model_dump(), "author_employee_id": actor_employee_id})
|
||||
|
||||
c = TaskComment(**payload.model_dump())
|
||||
session.add(c)
|
||||
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="task",
|
||||
entity_id=c.task_id,
|
||||
verb="commented",
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Comment create violates constraints")
|
||||
|
||||
session.refresh(c)
|
||||
task = session.get(Task, c.task_id)
|
||||
if task is not None:
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(
|
||||
event="comment.created",
|
||||
actor_employee_id=actor_employee_id,
|
||||
task_id=task.id,
|
||||
comment_id=c.id,
|
||||
),
|
||||
)
|
||||
return TaskComment.model_validate(c)
|
||||
Reference in New Issue
Block a user