Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
webhooks,
workers,
workflows,
workspaces,
)

v1_router = APIRouter(prefix="/api/v1")
Expand All @@ -53,6 +54,7 @@
v1_router.include_router(skill_record.router)
v1_router.include_router(webhooks.router)
v1_router.include_router(workflows.router)
v1_router.include_router(workspaces.router)
v1_router.include_router(notifications.router)
v1_router.include_router(workers.router)
v1_router.include_router(dashboard.router)
Expand Down
282 changes: 282 additions & 0 deletions backend/api/v1/workspaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
"""Workspace / Project / WorkflowDraft / WorkflowVersion authoring endpoints."""

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from backend.database import get_db
from backend.schemas import workflow_authoring as schemas
from backend.schemas.common import ApiResponse
from backend.services import workflow_authoring_service as authoring_service
from backend.services import validation_run_service
from backend.services.workflow_authoring_service import DraftRevisionConflictError
from backend.services.validation_run_service import (
ValidationRunAlreadyConsumedError,
ValidationRunNotFoundError,
ValidationRunNotPassedError,
ValidationRunRequestError,
ValidationRunStaleError,
)

router = APIRouter(tags=["workflow-authoring"])


@router.post("/workspaces", response_model=ApiResponse[schemas.WorkspaceRead], status_code=201)
async def create_workspace(
body: schemas.WorkspaceCreate,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[schemas.WorkspaceRead]:
try:
workspace = await authoring_service.create_workspace(db, body)
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail=f"Workspace slug {body.slug!r} already exists") from exc
await db.refresh(workspace)
return ApiResponse.ok(schemas.WorkspaceRead.model_validate(workspace))


@router.get("/workspaces", response_model=ApiResponse[list[schemas.WorkspaceRead]])
async def list_workspaces(db: AsyncSession = Depends(get_db)) -> ApiResponse[list[schemas.WorkspaceRead]]:
workspaces = await authoring_service.list_workspaces(db)
return ApiResponse.ok([schemas.WorkspaceRead.model_validate(w) for w in workspaces])


@router.get("/workspaces/{workspace_id}", response_model=ApiResponse[schemas.WorkspaceRead])
async def get_workspace(
workspace_id: str, db: AsyncSession = Depends(get_db)
) -> ApiResponse[schemas.WorkspaceRead]:
workspace = await authoring_service.get_workspace(db, workspace_id)
if workspace is None:
raise HTTPException(status_code=404, detail="Workspace not found")
return ApiResponse.ok(schemas.WorkspaceRead.model_validate(workspace))


@router.get(
"/workspaces/{workspace_id}/settings",
response_model=ApiResponse[schemas.WorkspaceSettingsRead],
)
async def get_workspace_settings(
workspace_id: str, db: AsyncSession = Depends(get_db)
) -> ApiResponse[schemas.WorkspaceSettingsRead]:
settings = await authoring_service.get_workspace_settings(db, workspace_id)
if settings is None:
raise HTTPException(status_code=404, detail="Workspace not found")
return ApiResponse.ok(schemas.WorkspaceSettingsRead.model_validate(settings))


@router.put(
"/workspaces/{workspace_id}/settings",
response_model=ApiResponse[schemas.WorkspaceSettingsRead],
)
async def update_workspace_settings(
workspace_id: str,
body: schemas.WorkspaceSettingsUpdate,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[schemas.WorkspaceSettingsRead]:
settings = await authoring_service.get_workspace_settings(db, workspace_id)
if settings is None:
raise HTTPException(status_code=404, detail="Workspace not found")
settings = await authoring_service.update_workspace_settings(db, settings, body)
await db.commit()
await db.refresh(settings)
return ApiResponse.ok(schemas.WorkspaceSettingsRead.model_validate(settings))
Comment on lines +55 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
cat backend/migrations/versions/r5s6t7u8v9w0_add_workflow_authoring_tables.py 2>/dev/null | rg -n "workspace_settings|insert" -A3

Repository: 2233admin/opencli-admin

Length of output: 880


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== workspaces api ==\n'
sed -n '1,180p' backend/api/v1/workspaces.py

printf '\n== workflow authoring service ==\n'
sed -n '1,260p' backend/services/workflow_authoring_service.py

printf '\n== migrations mentioning workspace_settings or backfill ==\n'
rg -n "workspace_settings|backfill|timezone|create_workspace" backend/migrations backend/services backend/api -S

Repository: 2233admin/opencli-admin

Length of output: 27936


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== schemas/workflow_authoring.py ==\n'
sed -n '1,260p' backend/schemas/workflow_authoring.py

printf '\n== models/workflow_authoring.py ==\n'
sed -n '1,260p' backend/models/workflow_authoring.py

printf '\n== initial schema around workspace tables ==\n'
sed -n '1,170p' backend/migrations/versions/5a9a94795d00_initial_schema.py

printf '\n== workflow authoring migration around workspace settings ==\n'
sed -n '1,120p' backend/migrations/versions/r5s6t7u8v9w0_add_workflow_authoring_tables.py

printf '\n== search for global IntegrityError handling ==\n'
rg -n "IntegrityError|HTTPException\\(status_code=422|status_code=409" backend -S

Repository: 2233admin/opencli-admin

Length of output: 25616


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== workspaces api around later handlers ==\n'
sed -n '180,280p' backend/api/v1/workspaces.py

printf '\n== search migrations for any earlier workspaces/workspace_settings definitions ==\n'
rg -n "op\.create_table\(\s*['\"]workspaces['\"]|op\.create_table\(\s*['\"]workspace_settings['\"]|workspace_settings" backend/migrations/versions -S

printf '\n== inspect migration order / filenames ==\n'
ls backend/migrations/versions | sort

Repository: 2233admin/opencli-admin

Length of output: 6515


Handle IntegrityError in workspace settings updates
WorkspaceSettingsUpdate.timezone allows null, but workspace_settings.timezone is non-nullable, so this endpoint can raise an unhandled 500 on bad input. Wrap the commit/rollback and return 422 like the other create/update paths.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 60-60: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


[warning] 75-75: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/v1/workspaces.py` around lines 55 - 83, Update
update_workspace_settings to catch IntegrityError around db.commit(), roll back
the transaction on failure, and raise HTTPException with status 422 consistent
with other create/update paths; keep the successful refresh and response flow
unchanged.



@router.post(
"/workspaces/{workspace_id}/projects",
response_model=ApiResponse[schemas.ProjectRead],
status_code=201,
)
async def create_project(
workspace_id: str,
body: schemas.ProjectCreate,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[schemas.ProjectRead]:
workspace = await authoring_service.get_workspace(db, workspace_id)
if workspace is None:
raise HTTPException(status_code=404, detail="Workspace not found")
try:
project = await authoring_service.create_project(db, workspace, body)
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(
status_code=409, detail=f"Project slug {body.slug!r} already exists in this workspace"
) from exc
await db.refresh(project)
return ApiResponse.ok(schemas.ProjectRead.model_validate(project))


@router.get(
"/workspaces/{workspace_id}/projects",
response_model=ApiResponse[list[schemas.ProjectRead]],
)
async def list_projects(
workspace_id: str, db: AsyncSession = Depends(get_db)
) -> ApiResponse[list[schemas.ProjectRead]]:
workspace = await authoring_service.get_workspace(db, workspace_id)
if workspace is None:
raise HTTPException(status_code=404, detail="Workspace not found")
projects = await authoring_service.list_projects(db, workspace_id)
return ApiResponse.ok([schemas.ProjectRead.model_validate(p) for p in projects])


@router.get(
"/workspaces/{workspace_id}/projects/{project_id}",
response_model=ApiResponse[schemas.ProjectRead],
)
async def get_project_in_workspace(
workspace_id: str, project_id: str, db: AsyncSession = Depends(get_db)
) -> ApiResponse[schemas.ProjectRead]:
project = await authoring_service.get_project(db, project_id)
if project is None or project.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail="Project not found")
return ApiResponse.ok(schemas.ProjectRead.model_validate(project))


@router.post(
"/projects/{project_id}/drafts",
response_model=ApiResponse[schemas.WorkflowDraftRead],
status_code=201,
)
async def create_draft(
project_id: str,
body: schemas.WorkflowDraftCreate,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[schemas.WorkflowDraftRead]:
project = await authoring_service.get_project(db, project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
draft = await authoring_service.create_draft(db, project, body)
await db.commit()
await db.refresh(draft)
return ApiResponse.ok(schemas.WorkflowDraftRead.model_validate(draft))


@router.get("/drafts/{draft_id}", response_model=ApiResponse[schemas.WorkflowDraftRead])
async def get_draft(draft_id: str, db: AsyncSession = Depends(get_db)) -> ApiResponse[schemas.WorkflowDraftRead]:
draft = await authoring_service.get_draft(db, draft_id)
if draft is None:
raise HTTPException(status_code=404, detail="Draft not found")
return ApiResponse.ok(schemas.WorkflowDraftRead.model_validate(draft))


@router.put("/drafts/{draft_id}", response_model=ApiResponse[schemas.WorkflowDraftRead])
async def update_draft(
draft_id: str,
body: schemas.WorkflowDraftUpdate,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[schemas.WorkflowDraftRead]:
draft = await authoring_service.get_draft(db, draft_id)
if draft is None:
raise HTTPException(status_code=404, detail="Draft not found")
try:
draft = await authoring_service.update_draft(db, draft, body)
except DraftRevisionConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
await db.commit()
await db.refresh(draft)
return ApiResponse.ok(schemas.WorkflowDraftRead.model_validate(draft))


@router.post(
"/drafts/{draft_id}/validation-runs",
response_model=ApiResponse[schemas.ValidationRunRead],
status_code=201,
)
async def create_validation_run(
draft_id: str,
body: schemas.ValidationRunCreate,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[schemas.ValidationRunRead]:
draft = await authoring_service.get_draft(db, draft_id)
if draft is None:
raise HTTPException(status_code=404, detail="Draft not found")
try:
validation_run = await validation_run_service.run_validation(
db, draft, mode=body.mode, expected_events=body.expected_events
)
except ValidationRunRequestError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await db.commit()
await db.refresh(validation_run)
return ApiResponse.ok(schemas.ValidationRunRead.model_validate(validation_run))


@router.get(
"/drafts/{draft_id}/validation-runs/{validation_run_id}",
response_model=ApiResponse[schemas.ValidationRunRead],
)
async def get_validation_run(
draft_id: str, validation_run_id: str, db: AsyncSession = Depends(get_db)
) -> ApiResponse[schemas.ValidationRunRead]:
validation_run = await validation_run_service.get_validation_run(db, validation_run_id)
if validation_run is None or validation_run.draft_id != draft_id:
raise HTTPException(status_code=404, detail="Validation run not found")
return ApiResponse.ok(schemas.ValidationRunRead.model_validate(validation_run))


@router.post("/drafts/{draft_id}/publish", response_model=ApiResponse[schemas.WorkflowVersionRead])
async def publish_draft(
draft_id: str,
body: schemas.WorkflowDraftPublishRequest,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[schemas.WorkflowVersionRead]:
draft = await authoring_service.get_draft(db, draft_id)
if draft is None:
raise HTTPException(status_code=404, detail="Draft not found")
try:
version = await validation_run_service.publish_draft(
db,
draft,
validation_run_id=body.validation_run_id,
expected_revision=body.expected_revision,
)
await db.commit()
except DraftRevisionConflictError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValidationRunNotFoundError as exc:
await db.rollback()
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationRunStaleError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValidationRunNotPassedError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValidationRunAlreadyConsumedError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail=str(exc)) from exc
except IntegrityError as exc:
await db.rollback()
raise HTTPException(
status_code=409, detail="Validation run has already been published (concurrent publish)"
) from exc
await db.refresh(version)
return ApiResponse.ok(schemas.WorkflowVersionRead.model_validate(version))


@router.get(
"/projects/{project_id}/versions",
response_model=ApiResponse[list[schemas.WorkflowVersionRead]],
)
async def list_versions(
project_id: str, db: AsyncSession = Depends(get_db)
) -> ApiResponse[list[schemas.WorkflowVersionRead]]:
project = await authoring_service.get_project(db, project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
versions = await authoring_service.list_versions(db, project_id)
return ApiResponse.ok([schemas.WorkflowVersionRead.model_validate(v) for v in versions])


@router.get("/versions/{version_id}", response_model=ApiResponse[schemas.WorkflowVersionRead])
async def get_version(
version_id: str, db: AsyncSession = Depends(get_db)
) -> ApiResponse[schemas.WorkflowVersionRead]:
version = await authoring_service.get_version(db, version_id)
if version is None:
raise HTTPException(status_code=404, detail="Workflow version not found")
return ApiResponse.ok(schemas.WorkflowVersionRead.model_validate(version))
Loading