feat(task-021): add admin workflow template api
This commit is contained in:
@@ -0,0 +1,372 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
Role,
|
||||||
|
WorkflowStageCreateRequest,
|
||||||
|
WorkflowStageResponse,
|
||||||
|
WorkflowStageUpdateRequest,
|
||||||
|
WorkflowTemplateAuditEventListResponse,
|
||||||
|
WorkflowTemplateCreateRequest,
|
||||||
|
WorkflowTemplateListResponse,
|
||||||
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
|
WorkflowTemplateSummary,
|
||||||
|
WorkflowTemplateUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_TEMPLATE_CREATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_CREATED"
|
||||||
|
_TEMPLATE_UPDATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_UPDATED"
|
||||||
|
_STAGE_CREATED_EVENT_TYPE = "WORKFLOW_STAGE_CREATED"
|
||||||
|
_STAGE_UPDATED_EVENT_TYPE = "WORKFLOW_STAGE_UPDATED"
|
||||||
|
_STAGE_DELETED_EVENT_TYPE = "WORKFLOW_STAGE_DELETED"
|
||||||
|
_STAGES_REORDERED_EVENT_TYPE = "WORKFLOW_STAGES_REORDERED"
|
||||||
|
_TEMPLATE_ACTIVATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_ACTIVATED"
|
||||||
|
_TEMPLATE_ARCHIVED_EVENT_TYPE = "WORKFLOW_TEMPLATE_ARCHIVED"
|
||||||
|
|
||||||
|
|
||||||
|
def create_workflow_template(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
request: WorkflowTemplateCreateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
now = _now()
|
||||||
|
workflow = repository.workflow_templates.create(
|
||||||
|
name=request.name,
|
||||||
|
slug=request.slug,
|
||||||
|
description=request.description,
|
||||||
|
status=WorkflowTemplateStatus.DRAFT,
|
||||||
|
version=1,
|
||||||
|
created_by=current_user.id,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow.id,
|
||||||
|
event_type=_TEMPLATE_CREATED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"slug": workflow.slug, "version": workflow.version},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowTemplateResponse(workflow=workflow)
|
||||||
|
|
||||||
|
|
||||||
|
def list_workflow_templates(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateListResponse:
|
||||||
|
if current_user.role == Role.ADMIN:
|
||||||
|
workflows = repository.workflow_templates.list()
|
||||||
|
else:
|
||||||
|
workflows = repository.workflow_templates.list_active()
|
||||||
|
return WorkflowTemplateListResponse(workflows=workflows)
|
||||||
|
|
||||||
|
|
||||||
|
def get_workflow_template(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
workflow = repository.workflow_templates.get(workflow_id)
|
||||||
|
if current_user.role != Role.ADMIN and workflow.status != WorkflowTemplateStatus.ACTIVE:
|
||||||
|
raise LookupError(f"Workflow template not found: {workflow_id}")
|
||||||
|
return WorkflowTemplateResponse(workflow=workflow)
|
||||||
|
|
||||||
|
|
||||||
|
def update_workflow_template(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
request: WorkflowTemplateUpdateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
existing = repository.workflow_templates.get(workflow_id)
|
||||||
|
_require_draft(existing)
|
||||||
|
|
||||||
|
changes = request.model_dump(exclude_unset=True)
|
||||||
|
now = _now()
|
||||||
|
workflow = repository.workflow_templates.update(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
name=changes.get("name", existing.name),
|
||||||
|
slug=changes.get("slug", existing.slug),
|
||||||
|
description=changes.get("description", existing.description),
|
||||||
|
updated_by=current_user.id,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow.id,
|
||||||
|
event_type=_TEMPLATE_UPDATED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"changed_fields": sorted(changes.keys())},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowTemplateResponse(workflow=workflow)
|
||||||
|
|
||||||
|
|
||||||
|
def add_workflow_stage(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
request: WorkflowStageCreateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowStageResponse:
|
||||||
|
workflow = repository.workflow_templates.get(workflow_id)
|
||||||
|
_require_draft(workflow)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
stage = repository.workflow_template_stages.create(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stable_key=request.stable_key,
|
||||||
|
display_name=request.display_name,
|
||||||
|
description=request.description,
|
||||||
|
position=request.position,
|
||||||
|
owner_role=request.owner_role,
|
||||||
|
runner_profile_key=request.runner_profile_key,
|
||||||
|
required_inputs=request.required_inputs,
|
||||||
|
expected_outputs=request.expected_outputs,
|
||||||
|
acceptance_criteria=request.acceptance_criteria,
|
||||||
|
requires_human_approval=request.requires_human_approval,
|
||||||
|
retry_policy=request.retry_policy,
|
||||||
|
parts=_parts_json(request.parts),
|
||||||
|
updated_by=current_user.id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=_STAGE_CREATED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={
|
||||||
|
"stage_id": str(stage.id),
|
||||||
|
"stable_key": stage.stable_key,
|
||||||
|
"position": stage.position,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowStageResponse(stage=stage)
|
||||||
|
|
||||||
|
|
||||||
|
def update_workflow_stage(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_id: UUID,
|
||||||
|
request: WorkflowStageUpdateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowStageResponse:
|
||||||
|
workflow = repository.workflow_templates.get(workflow_id)
|
||||||
|
_require_draft(workflow)
|
||||||
|
existing = repository.workflow_template_stages.get(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_id=stage_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
changes = request.model_dump(exclude_unset=True)
|
||||||
|
now = _now()
|
||||||
|
stage = repository.workflow_template_stages.update(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_id=stage_id,
|
||||||
|
stable_key=changes.get("stable_key", existing.stable_key),
|
||||||
|
display_name=changes.get("display_name", existing.display_name),
|
||||||
|
description=changes.get("description", existing.description),
|
||||||
|
position=changes.get("position", existing.position),
|
||||||
|
owner_role=request.owner_role or existing.owner_role,
|
||||||
|
runner_profile_key=changes.get(
|
||||||
|
"runner_profile_key",
|
||||||
|
existing.runner_profile_key,
|
||||||
|
),
|
||||||
|
required_inputs=changes.get("required_inputs", existing.required_inputs),
|
||||||
|
expected_outputs=changes.get("expected_outputs", existing.expected_outputs),
|
||||||
|
acceptance_criteria=changes.get(
|
||||||
|
"acceptance_criteria",
|
||||||
|
existing.acceptance_criteria,
|
||||||
|
),
|
||||||
|
requires_human_approval=changes.get(
|
||||||
|
"requires_human_approval",
|
||||||
|
existing.requires_human_approval,
|
||||||
|
),
|
||||||
|
retry_policy=changes.get("retry_policy", existing.retry_policy),
|
||||||
|
parts=(
|
||||||
|
_parts_json(request.parts)
|
||||||
|
if request.parts is not None
|
||||||
|
else _parts_json(existing.parts)
|
||||||
|
),
|
||||||
|
updated_by=current_user.id,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=_STAGE_UPDATED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"stage_id": str(stage_id), "changed_fields": sorted(changes.keys())},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowStageResponse(stage=stage)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_workflow_stage(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
workflow = repository.workflow_templates.get(workflow_id)
|
||||||
|
_require_draft(workflow)
|
||||||
|
existing = repository.workflow_template_stages.get(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_id=stage_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
repository.workflow_template_stages.delete(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_id=stage_id,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=_STAGE_DELETED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"stage_id": str(stage_id), "stable_key": existing.stable_key},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowTemplateResponse(workflow=repository.workflow_templates.get(workflow_id))
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_workflow_stages(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_ids: list[UUID],
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
workflow = repository.workflow_templates.get(workflow_id)
|
||||||
|
_require_draft(workflow)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
repository.workflow_template_stages.reorder(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_ids=stage_ids,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=_STAGES_REORDERED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"stage_ids": [str(stage_id) for stage_id in stage_ids]},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowTemplateResponse(workflow=repository.workflow_templates.get(workflow_id))
|
||||||
|
|
||||||
|
|
||||||
|
def activate_workflow_template(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
workflow = repository.workflow_templates.get(workflow_id)
|
||||||
|
_require_draft(workflow)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
activated = repository.workflow_templates.activate(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
activated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=_TEMPLATE_ACTIVATED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"version": activated.version},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowTemplateResponse(workflow=activated)
|
||||||
|
|
||||||
|
|
||||||
|
def archive_workflow_template(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
repository.workflow_templates.get(workflow_id)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
archived = repository.workflow_templates.archive(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
archived_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=_TEMPLATE_ARCHIVED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"version": archived.version},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowTemplateResponse(workflow=archived)
|
||||||
|
|
||||||
|
|
||||||
|
def list_workflow_template_audit_events(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
) -> WorkflowTemplateAuditEventListResponse:
|
||||||
|
repository.workflow_templates.get(workflow_id)
|
||||||
|
return WorkflowTemplateAuditEventListResponse(
|
||||||
|
events=repository.workflow_template_events.list_for_workflow(workflow_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_event(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
event_type: str,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: dict[str, object],
|
||||||
|
created_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
repository.workflow_template_events.create(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=event_type,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload=payload,
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_draft(workflow: WorkflowTemplateSummary) -> None:
|
||||||
|
if workflow.status != WorkflowTemplateStatus.DRAFT:
|
||||||
|
raise ValueError("Only draft workflow templates can be mutated.")
|
||||||
|
|
||||||
|
|
||||||
|
def _parts_json(parts: list[object]) -> list[dict[str, object]]:
|
||||||
|
return [
|
||||||
|
part.model_dump(mode="json") if hasattr(part, "model_dump") else dict(part)
|
||||||
|
for part in parts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -15,6 +15,7 @@ from .enums import (
|
|||||||
ReviewType,
|
ReviewType,
|
||||||
Role,
|
Role,
|
||||||
ScriptConfigVersionStatus,
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
AgentJobOutput,
|
AgentJobOutput,
|
||||||
@@ -33,6 +34,19 @@ from .models import (
|
|||||||
BoundaryQuestionUpdateRequest,
|
BoundaryQuestionUpdateRequest,
|
||||||
WorkflowEventSummary,
|
WorkflowEventSummary,
|
||||||
ObservabilityTimelineEventSummary,
|
ObservabilityTimelineEventSummary,
|
||||||
|
WorkflowStageCreateRequest,
|
||||||
|
WorkflowStagePart,
|
||||||
|
WorkflowStageReorderRequest,
|
||||||
|
WorkflowStageResponse,
|
||||||
|
WorkflowStageSummary,
|
||||||
|
WorkflowStageUpdateRequest,
|
||||||
|
WorkflowTemplateAuditEventListResponse,
|
||||||
|
WorkflowTemplateAuditEventSummary,
|
||||||
|
WorkflowTemplateCreateRequest,
|
||||||
|
WorkflowTemplateListResponse,
|
||||||
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateSummary,
|
||||||
|
WorkflowTemplateUpdateRequest,
|
||||||
AssetGenerateSpecsResponse,
|
AssetGenerateSpecsResponse,
|
||||||
AssetListResponse,
|
AssetListResponse,
|
||||||
AssetResponse,
|
AssetResponse,
|
||||||
@@ -133,6 +147,20 @@ __all__ = [
|
|||||||
"BoundaryQuestionUpdateRequest",
|
"BoundaryQuestionUpdateRequest",
|
||||||
"WorkflowEventSummary",
|
"WorkflowEventSummary",
|
||||||
"ObservabilityTimelineEventSummary",
|
"ObservabilityTimelineEventSummary",
|
||||||
|
"WorkflowStageCreateRequest",
|
||||||
|
"WorkflowStagePart",
|
||||||
|
"WorkflowStageReorderRequest",
|
||||||
|
"WorkflowStageResponse",
|
||||||
|
"WorkflowStageSummary",
|
||||||
|
"WorkflowStageUpdateRequest",
|
||||||
|
"WorkflowTemplateAuditEventListResponse",
|
||||||
|
"WorkflowTemplateAuditEventSummary",
|
||||||
|
"WorkflowTemplateCreateRequest",
|
||||||
|
"WorkflowTemplateListResponse",
|
||||||
|
"WorkflowTemplateResponse",
|
||||||
|
"WorkflowTemplateStatus",
|
||||||
|
"WorkflowTemplateSummary",
|
||||||
|
"WorkflowTemplateUpdateRequest",
|
||||||
"AssetGenerateSpecsResponse",
|
"AssetGenerateSpecsResponse",
|
||||||
"AssetListResponse",
|
"AssetListResponse",
|
||||||
"AssetResponse",
|
"AssetResponse",
|
||||||
|
|||||||
@@ -139,3 +139,9 @@ class ScriptConfigVersionStatus(str, Enum):
|
|||||||
DRAFT = "DRAFT"
|
DRAFT = "DRAFT"
|
||||||
ACTIVE = "ACTIVE"
|
ACTIVE = "ACTIVE"
|
||||||
DEPRECATED = "DEPRECATED"
|
DEPRECATED = "DEPRECATED"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateStatus(str, Enum):
|
||||||
|
DRAFT = "DRAFT"
|
||||||
|
ACTIVE = "ACTIVE"
|
||||||
|
ARCHIVED = "ARCHIVED"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from .enums import (
|
|||||||
ReviewType,
|
ReviewType,
|
||||||
Role,
|
Role,
|
||||||
ScriptConfigVersionStatus,
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -158,6 +159,120 @@ class ScriptConfigVersionAuditEventListResponse(ContractModel):
|
|||||||
events: list[ScriptConfigVersionAuditEventSummary] = Field(default_factory=list)
|
events: list[ScriptConfigVersionAuditEventSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStagePart(ContractModel):
|
||||||
|
key: str = Field(min_length=1)
|
||||||
|
type: str = Field(min_length=1)
|
||||||
|
title: str = Field(min_length=1)
|
||||||
|
payload: JsonObject = Field(default_factory=dict)
|
||||||
|
acceptance_criteria: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStageSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
workflow_id: UUID
|
||||||
|
stable_key: str = Field(min_length=1)
|
||||||
|
display_name: str = Field(min_length=1)
|
||||||
|
description: str = Field(min_length=1)
|
||||||
|
position: int = Field(ge=1)
|
||||||
|
owner_role: Role
|
||||||
|
runner_profile_key: str = Field(min_length=1)
|
||||||
|
required_inputs: list[str] = Field(default_factory=list)
|
||||||
|
expected_outputs: list[str] = Field(default_factory=list)
|
||||||
|
acceptance_criteria: list[str] = Field(default_factory=list)
|
||||||
|
requires_human_approval: bool = False
|
||||||
|
retry_policy: JsonObject = Field(default_factory=dict)
|
||||||
|
parts: list[WorkflowStagePart] = Field(default_factory=list)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
slug: str = Field(min_length=1)
|
||||||
|
description: str = Field(min_length=1)
|
||||||
|
status: WorkflowTemplateStatus
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
created_by: UUID
|
||||||
|
updated_by: UUID | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
activated_at: datetime | None = None
|
||||||
|
archived_at: datetime | None = None
|
||||||
|
stages: list[WorkflowStageSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateCreateRequest(ContractModel):
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
slug: str = Field(min_length=1)
|
||||||
|
description: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateUpdateRequest(ContractModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1)
|
||||||
|
slug: str | None = Field(default=None, min_length=1)
|
||||||
|
description: str | None = Field(default=None, min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStageCreateRequest(ContractModel):
|
||||||
|
stable_key: str = Field(min_length=1)
|
||||||
|
display_name: str = Field(min_length=1)
|
||||||
|
description: str = Field(min_length=1)
|
||||||
|
position: int | None = Field(default=None, ge=1)
|
||||||
|
owner_role: Role
|
||||||
|
runner_profile_key: str = Field(min_length=1)
|
||||||
|
required_inputs: list[str] = Field(default_factory=list)
|
||||||
|
expected_outputs: list[str] = Field(default_factory=list)
|
||||||
|
acceptance_criteria: list[str] = Field(default_factory=list)
|
||||||
|
requires_human_approval: bool = False
|
||||||
|
retry_policy: JsonObject = Field(default_factory=dict)
|
||||||
|
parts: list[WorkflowStagePart] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStageUpdateRequest(ContractModel):
|
||||||
|
stable_key: str | None = Field(default=None, min_length=1)
|
||||||
|
display_name: str | None = Field(default=None, min_length=1)
|
||||||
|
description: str | None = Field(default=None, min_length=1)
|
||||||
|
position: int | None = Field(default=None, ge=1)
|
||||||
|
owner_role: Role | None = None
|
||||||
|
runner_profile_key: str | None = Field(default=None, min_length=1)
|
||||||
|
required_inputs: list[str] | None = None
|
||||||
|
expected_outputs: list[str] | None = None
|
||||||
|
acceptance_criteria: list[str] | None = None
|
||||||
|
requires_human_approval: bool | None = None
|
||||||
|
retry_policy: JsonObject | None = None
|
||||||
|
parts: list[WorkflowStagePart] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStageReorderRequest(ContractModel):
|
||||||
|
stage_ids: list[UUID] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateResponse(ContractModel):
|
||||||
|
workflow: WorkflowTemplateSummary
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateListResponse(ContractModel):
|
||||||
|
workflows: list[WorkflowTemplateSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStageResponse(ContractModel):
|
||||||
|
stage: WorkflowStageSummary
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateAuditEventSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
workflow_id: UUID
|
||||||
|
event_type: str = Field(min_length=1)
|
||||||
|
actor_user_id: UUID | None = None
|
||||||
|
payload: JsonObject = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateAuditEventListResponse(ContractModel):
|
||||||
|
events: list[WorkflowTemplateAuditEventSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ArticleCreateRequest(ContractModel):
|
class ArticleCreateRequest(ContractModel):
|
||||||
target_site_id: UUID
|
target_site_id: UUID
|
||||||
brief_description: str = Field(min_length=1)
|
brief_description: str = Field(min_length=1)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .enums import (
|
|||||||
ReviewType,
|
ReviewType,
|
||||||
Role,
|
Role,
|
||||||
ScriptConfigVersionStatus,
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
AgentJobOutput,
|
AgentJobOutput,
|
||||||
@@ -109,6 +110,19 @@ from .models import (
|
|||||||
TargetSiteConfigCreateRequest,
|
TargetSiteConfigCreateRequest,
|
||||||
TargetSiteConfigResponse,
|
TargetSiteConfigResponse,
|
||||||
WorkflowEventSummary,
|
WorkflowEventSummary,
|
||||||
|
WorkflowStageCreateRequest,
|
||||||
|
WorkflowStagePart,
|
||||||
|
WorkflowStageReorderRequest,
|
||||||
|
WorkflowStageResponse,
|
||||||
|
WorkflowStageSummary,
|
||||||
|
WorkflowStageUpdateRequest,
|
||||||
|
WorkflowTemplateAuditEventListResponse,
|
||||||
|
WorkflowTemplateAuditEventSummary,
|
||||||
|
WorkflowTemplateCreateRequest,
|
||||||
|
WorkflowTemplateListResponse,
|
||||||
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateSummary,
|
||||||
|
WorkflowTemplateUpdateRequest,
|
||||||
TargetSiteConfigUpdateRequest,
|
TargetSiteConfigUpdateRequest,
|
||||||
UserSummary,
|
UserSummary,
|
||||||
)
|
)
|
||||||
@@ -131,6 +145,7 @@ CONTRACT_ENUMS: tuple[type[Enum], ...] = (
|
|||||||
ReviewType,
|
ReviewType,
|
||||||
ContentReviewKind,
|
ContentReviewKind,
|
||||||
ScriptConfigVersionStatus,
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||||
@@ -148,6 +163,19 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
|||||||
ScriptConfigVersionListResponse,
|
ScriptConfigVersionListResponse,
|
||||||
ScriptConfigVersionAuditEventSummary,
|
ScriptConfigVersionAuditEventSummary,
|
||||||
ScriptConfigVersionAuditEventListResponse,
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
|
WorkflowStagePart,
|
||||||
|
WorkflowStageSummary,
|
||||||
|
WorkflowTemplateSummary,
|
||||||
|
WorkflowTemplateCreateRequest,
|
||||||
|
WorkflowTemplateUpdateRequest,
|
||||||
|
WorkflowStageCreateRequest,
|
||||||
|
WorkflowStageUpdateRequest,
|
||||||
|
WorkflowStageReorderRequest,
|
||||||
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateListResponse,
|
||||||
|
WorkflowStageResponse,
|
||||||
|
WorkflowTemplateAuditEventSummary,
|
||||||
|
WorkflowTemplateAuditEventListResponse,
|
||||||
ArticleCreateRequest,
|
ArticleCreateRequest,
|
||||||
ArticleSummary,
|
ArticleSummary,
|
||||||
ArticleCreateResponse,
|
ArticleCreateResponse,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ from __future__ import annotations
|
|||||||
USERS_TABLE = "users"
|
USERS_TABLE = "users"
|
||||||
TARGET_SITES_TABLE = "target_sites"
|
TARGET_SITES_TABLE = "target_sites"
|
||||||
SCRIPT_CONFIG_VERSIONS_TABLE = "script_config_versions"
|
SCRIPT_CONFIG_VERSIONS_TABLE = "script_config_versions"
|
||||||
|
WORKFLOW_TEMPLATES_TABLE = "workflow_templates"
|
||||||
|
WORKFLOW_TEMPLATE_STAGES_TABLE = "workflow_template_stages"
|
||||||
|
WORKFLOW_TEMPLATE_EVENTS_TABLE = "workflow_template_events"
|
||||||
ARTICLES_TABLE = "articles"
|
ARTICLES_TABLE = "articles"
|
||||||
BOUNDARY_QUESTIONS_TABLE = "boundary_questions"
|
BOUNDARY_QUESTIONS_TABLE = "boundary_questions"
|
||||||
ARTICLE_PLANS_TABLE = "article_plans"
|
ARTICLE_PLANS_TABLE = "article_plans"
|
||||||
@@ -26,6 +29,9 @@ CORE_TABLES: tuple[str, ...] = (
|
|||||||
USERS_TABLE,
|
USERS_TABLE,
|
||||||
TARGET_SITES_TABLE,
|
TARGET_SITES_TABLE,
|
||||||
SCRIPT_CONFIG_VERSIONS_TABLE,
|
SCRIPT_CONFIG_VERSIONS_TABLE,
|
||||||
|
WORKFLOW_TEMPLATES_TABLE,
|
||||||
|
WORKFLOW_TEMPLATE_STAGES_TABLE,
|
||||||
|
WORKFLOW_TEMPLATE_EVENTS_TABLE,
|
||||||
ARTICLES_TABLE,
|
ARTICLES_TABLE,
|
||||||
BOUNDARY_QUESTIONS_TABLE,
|
BOUNDARY_QUESTIONS_TABLE,
|
||||||
ARTICLE_PLANS_TABLE,
|
ARTICLE_PLANS_TABLE,
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ from src.domain.contracts import (
|
|||||||
ScriptConfigVersionStatus,
|
ScriptConfigVersionStatus,
|
||||||
TargetSiteConfig,
|
TargetSiteConfig,
|
||||||
UserSummary,
|
UserSummary,
|
||||||
|
WorkflowStagePart,
|
||||||
|
WorkflowStageSummary,
|
||||||
|
WorkflowTemplateAuditEventSummary,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
|
WorkflowTemplateSummary,
|
||||||
)
|
)
|
||||||
from src.infrastructure.schema import setup_database
|
from src.infrastructure.schema import setup_database
|
||||||
|
|
||||||
@@ -74,6 +79,9 @@ class BackendRepository:
|
|||||||
self.agent_jobs = AgentJobsRepository(self)
|
self.agent_jobs = AgentJobsRepository(self)
|
||||||
self.script_config_versions = ScriptConfigVersionsRepository(self)
|
self.script_config_versions = ScriptConfigVersionsRepository(self)
|
||||||
self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self)
|
self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self)
|
||||||
|
self.workflow_templates = WorkflowTemplatesRepository(self)
|
||||||
|
self.workflow_template_stages = WorkflowTemplateStagesRepository(self)
|
||||||
|
self.workflow_template_events = WorkflowTemplateAuditEventsRepository(self)
|
||||||
self.schema = SchemaRepository(self)
|
self.schema = SchemaRepository(self)
|
||||||
|
|
||||||
def setup(self) -> None:
|
def setup(self) -> None:
|
||||||
@@ -2735,6 +2743,722 @@ class AgentJobsRepository:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplatesRepository:
|
||||||
|
def __init__(self, repository: BackendRepository) -> None:
|
||||||
|
self._repository = repository
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
slug: str,
|
||||||
|
description: str,
|
||||||
|
status: WorkflowTemplateStatus,
|
||||||
|
version: int,
|
||||||
|
created_by: UUID,
|
||||||
|
updated_by: UUID,
|
||||||
|
created_at: datetime,
|
||||||
|
updated_at: datetime,
|
||||||
|
) -> WorkflowTemplateSummary:
|
||||||
|
workflow_id = uuid4()
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO workflow_templates (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
version,
|
||||||
|
created_by,
|
||||||
|
updated_by,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder}
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(workflow_id),
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
status.value,
|
||||||
|
version,
|
||||||
|
str(created_by),
|
||||||
|
str(updated_by),
|
||||||
|
_datetime_value(created_at),
|
||||||
|
_datetime_value(updated_at),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get(workflow_id)
|
||||||
|
|
||||||
|
def list(self) -> list[WorkflowTemplateSummary]:
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {self._select_columns()}
|
||||||
|
FROM workflow_templates
|
||||||
|
ORDER BY updated_at DESC, slug
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [self._with_stages(row) for row in rows]
|
||||||
|
|
||||||
|
def list_active(self) -> list[WorkflowTemplateSummary]:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {self._select_columns()}
|
||||||
|
FROM workflow_templates
|
||||||
|
WHERE status = {placeholder}
|
||||||
|
ORDER BY updated_at DESC, slug
|
||||||
|
""",
|
||||||
|
(WorkflowTemplateStatus.ACTIVE.value,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [self._with_stages(row) for row in rows]
|
||||||
|
|
||||||
|
def get(self, workflow_id: UUID) -> WorkflowTemplateSummary:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {self._select_columns()}
|
||||||
|
FROM workflow_templates
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(str(workflow_id),),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise LookupError(f"Workflow template not found: {workflow_id}")
|
||||||
|
return self._with_stages(row)
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
name: str,
|
||||||
|
slug: str,
|
||||||
|
description: str,
|
||||||
|
updated_by: UUID,
|
||||||
|
updated_at: datetime,
|
||||||
|
) -> WorkflowTemplateSummary:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE workflow_templates
|
||||||
|
SET
|
||||||
|
name = {placeholder},
|
||||||
|
slug = {placeholder},
|
||||||
|
description = {placeholder},
|
||||||
|
updated_by = {placeholder},
|
||||||
|
updated_at = {placeholder}
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
str(updated_by),
|
||||||
|
_datetime_value(updated_at),
|
||||||
|
str(workflow_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get(workflow_id)
|
||||||
|
|
||||||
|
def activate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
updated_by: UUID,
|
||||||
|
activated_at: datetime,
|
||||||
|
) -> WorkflowTemplateSummary:
|
||||||
|
existing = self.get(workflow_id)
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE workflow_templates
|
||||||
|
SET
|
||||||
|
status = {placeholder},
|
||||||
|
version = {placeholder},
|
||||||
|
updated_by = {placeholder},
|
||||||
|
updated_at = {placeholder},
|
||||||
|
activated_at = {placeholder}
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
WorkflowTemplateStatus.ACTIVE.value,
|
||||||
|
existing.version + 1,
|
||||||
|
str(updated_by),
|
||||||
|
_datetime_value(activated_at),
|
||||||
|
_datetime_value(activated_at),
|
||||||
|
str(workflow_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get(workflow_id)
|
||||||
|
|
||||||
|
def archive(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
updated_by: UUID,
|
||||||
|
archived_at: datetime,
|
||||||
|
) -> WorkflowTemplateSummary:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE workflow_templates
|
||||||
|
SET
|
||||||
|
status = {placeholder},
|
||||||
|
updated_by = {placeholder},
|
||||||
|
updated_at = {placeholder},
|
||||||
|
archived_at = {placeholder}
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
WorkflowTemplateStatus.ARCHIVED.value,
|
||||||
|
str(updated_by),
|
||||||
|
_datetime_value(archived_at),
|
||||||
|
_datetime_value(archived_at),
|
||||||
|
str(workflow_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get(workflow_id)
|
||||||
|
|
||||||
|
def touch(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
updated_by: UUID,
|
||||||
|
updated_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE workflow_templates
|
||||||
|
SET updated_by = {placeholder}, updated_at = {placeholder}
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(str(updated_by), _datetime_value(updated_at), str(workflow_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _with_stages(self, row: Any) -> WorkflowTemplateSummary:
|
||||||
|
workflow_id = _row_value(row, "id")
|
||||||
|
return _workflow_template_from_row(
|
||||||
|
row,
|
||||||
|
stages=self._stages_for_workflow(UUID(str(workflow_id))),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _stages_for_workflow(self, workflow_id: UUID) -> list[WorkflowStageSummary]:
|
||||||
|
return self._repository.workflow_template_stages.list_for_workflow(workflow_id)
|
||||||
|
|
||||||
|
def _select_columns(self) -> str:
|
||||||
|
return """
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
version,
|
||||||
|
created_by,
|
||||||
|
updated_by,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
activated_at,
|
||||||
|
archived_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateStagesRepository:
|
||||||
|
def __init__(self, repository: BackendRepository) -> None:
|
||||||
|
self._repository = repository
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
stable_key: str,
|
||||||
|
display_name: str,
|
||||||
|
description: str,
|
||||||
|
position: int | None,
|
||||||
|
owner_role: Role,
|
||||||
|
runner_profile_key: str,
|
||||||
|
required_inputs: list[str],
|
||||||
|
expected_outputs: list[str],
|
||||||
|
acceptance_criteria: list[str],
|
||||||
|
requires_human_approval: bool,
|
||||||
|
retry_policy: JsonObject,
|
||||||
|
parts: list[dict[str, Any]],
|
||||||
|
updated_by: UUID,
|
||||||
|
created_at: datetime,
|
||||||
|
updated_at: datetime,
|
||||||
|
) -> WorkflowStageSummary:
|
||||||
|
stage_id = uuid4()
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
json_cast = self._repository.json_cast()
|
||||||
|
insert_position = self._clamped_insert_position(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
requested_position=position,
|
||||||
|
)
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE workflow_template_stages
|
||||||
|
SET position = position + 1
|
||||||
|
WHERE workflow_template_id = {placeholder}
|
||||||
|
AND position >= {placeholder}
|
||||||
|
""",
|
||||||
|
(str(workflow_id), insert_position),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO workflow_template_stages (
|
||||||
|
id,
|
||||||
|
workflow_template_id,
|
||||||
|
stable_key,
|
||||||
|
display_name,
|
||||||
|
description,
|
||||||
|
position,
|
||||||
|
owner_role,
|
||||||
|
runner_profile_key,
|
||||||
|
required_inputs,
|
||||||
|
expected_outputs,
|
||||||
|
acceptance_criteria,
|
||||||
|
requires_human_approval,
|
||||||
|
retry_policy,
|
||||||
|
parts,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder}
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(stage_id),
|
||||||
|
str(workflow_id),
|
||||||
|
stable_key,
|
||||||
|
display_name,
|
||||||
|
description,
|
||||||
|
insert_position,
|
||||||
|
owner_role.value,
|
||||||
|
runner_profile_key,
|
||||||
|
_json_value(required_inputs),
|
||||||
|
_json_value(expected_outputs),
|
||||||
|
_json_value(acceptance_criteria),
|
||||||
|
_bool_value(requires_human_approval),
|
||||||
|
_json_value(retry_policy),
|
||||||
|
_json_value(parts),
|
||||||
|
_datetime_value(created_at),
|
||||||
|
_datetime_value(updated_at),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._touch_parent(
|
||||||
|
connection,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
updated_by=updated_by,
|
||||||
|
updated_at=updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get(workflow_id=workflow_id, stage_id=stage_id)
|
||||||
|
|
||||||
|
def list_for_workflow(self, workflow_id: UUID) -> list[WorkflowStageSummary]:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {self._select_columns()}
|
||||||
|
FROM workflow_template_stages
|
||||||
|
WHERE workflow_template_id = {placeholder}
|
||||||
|
ORDER BY position, created_at, id
|
||||||
|
""",
|
||||||
|
(str(workflow_id),),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [_workflow_stage_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
def get(self, *, workflow_id: UUID, stage_id: UUID) -> WorkflowStageSummary:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {self._select_columns()}
|
||||||
|
FROM workflow_template_stages
|
||||||
|
WHERE workflow_template_id = {placeholder}
|
||||||
|
AND id = {placeholder}
|
||||||
|
""",
|
||||||
|
(str(workflow_id), str(stage_id)),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise LookupError(f"Workflow stage not found: {stage_id}")
|
||||||
|
return _workflow_stage_from_row(row)
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_id: UUID,
|
||||||
|
stable_key: str,
|
||||||
|
display_name: str,
|
||||||
|
description: str,
|
||||||
|
position: int,
|
||||||
|
owner_role: Role,
|
||||||
|
runner_profile_key: str,
|
||||||
|
required_inputs: list[str],
|
||||||
|
expected_outputs: list[str],
|
||||||
|
acceptance_criteria: list[str],
|
||||||
|
requires_human_approval: bool,
|
||||||
|
retry_policy: JsonObject,
|
||||||
|
parts: list[dict[str, Any]],
|
||||||
|
updated_by: UUID,
|
||||||
|
updated_at: datetime,
|
||||||
|
) -> WorkflowStageSummary:
|
||||||
|
existing = self.get(workflow_id=workflow_id, stage_id=stage_id)
|
||||||
|
update_position = self._clamped_update_position(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
requested_position=position,
|
||||||
|
)
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
json_cast = self._repository.json_cast()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
if update_position < existing.position:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE workflow_template_stages
|
||||||
|
SET position = position + 1
|
||||||
|
WHERE workflow_template_id = {placeholder}
|
||||||
|
AND position >= {placeholder}
|
||||||
|
AND position < {placeholder}
|
||||||
|
""",
|
||||||
|
(str(workflow_id), update_position, existing.position),
|
||||||
|
)
|
||||||
|
elif update_position > existing.position:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE workflow_template_stages
|
||||||
|
SET position = position - 1
|
||||||
|
WHERE workflow_template_id = {placeholder}
|
||||||
|
AND position <= {placeholder}
|
||||||
|
AND position > {placeholder}
|
||||||
|
""",
|
||||||
|
(str(workflow_id), update_position, existing.position),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE workflow_template_stages
|
||||||
|
SET
|
||||||
|
stable_key = {placeholder},
|
||||||
|
display_name = {placeholder},
|
||||||
|
description = {placeholder},
|
||||||
|
position = {placeholder},
|
||||||
|
owner_role = {placeholder},
|
||||||
|
runner_profile_key = {placeholder},
|
||||||
|
required_inputs = {placeholder}{json_cast},
|
||||||
|
expected_outputs = {placeholder}{json_cast},
|
||||||
|
acceptance_criteria = {placeholder}{json_cast},
|
||||||
|
requires_human_approval = {placeholder},
|
||||||
|
retry_policy = {placeholder}{json_cast},
|
||||||
|
parts = {placeholder}{json_cast},
|
||||||
|
updated_at = {placeholder}
|
||||||
|
WHERE workflow_template_id = {placeholder}
|
||||||
|
AND id = {placeholder}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
stable_key,
|
||||||
|
display_name,
|
||||||
|
description,
|
||||||
|
update_position,
|
||||||
|
owner_role.value,
|
||||||
|
runner_profile_key,
|
||||||
|
_json_value(required_inputs),
|
||||||
|
_json_value(expected_outputs),
|
||||||
|
_json_value(acceptance_criteria),
|
||||||
|
_bool_value(requires_human_approval),
|
||||||
|
_json_value(retry_policy),
|
||||||
|
_json_value(parts),
|
||||||
|
_datetime_value(updated_at),
|
||||||
|
str(workflow_id),
|
||||||
|
str(stage_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._touch_parent(
|
||||||
|
connection,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
updated_by=updated_by,
|
||||||
|
updated_at=updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get(workflow_id=workflow_id, stage_id=stage_id)
|
||||||
|
|
||||||
|
def delete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_id: UUID,
|
||||||
|
updated_by: UUID,
|
||||||
|
updated_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
existing = self.get(workflow_id=workflow_id, stage_id=stage_id)
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
DELETE FROM workflow_template_stages
|
||||||
|
WHERE workflow_template_id = {placeholder}
|
||||||
|
AND id = {placeholder}
|
||||||
|
""",
|
||||||
|
(str(workflow_id), str(stage_id)),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE workflow_template_stages
|
||||||
|
SET position = position - 1
|
||||||
|
WHERE workflow_template_id = {placeholder}
|
||||||
|
AND position > {placeholder}
|
||||||
|
""",
|
||||||
|
(str(workflow_id), existing.position),
|
||||||
|
)
|
||||||
|
self._touch_parent(
|
||||||
|
connection,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
updated_by=updated_by,
|
||||||
|
updated_at=updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def reorder(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_ids: list[UUID],
|
||||||
|
updated_by: UUID,
|
||||||
|
updated_at: datetime,
|
||||||
|
) -> list[WorkflowStageSummary]:
|
||||||
|
existing = self.list_for_workflow(workflow_id)
|
||||||
|
existing_ids = {stage.id for stage in existing}
|
||||||
|
requested_ids = set(stage_ids)
|
||||||
|
if requested_ids != existing_ids or len(stage_ids) != len(existing):
|
||||||
|
raise ValueError("Stage reorder must include each workflow stage exactly once.")
|
||||||
|
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
for position, stage_id in enumerate(stage_ids, start=1):
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE workflow_template_stages
|
||||||
|
SET position = {placeholder}, updated_at = {placeholder}
|
||||||
|
WHERE workflow_template_id = {placeholder}
|
||||||
|
AND id = {placeholder}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
position,
|
||||||
|
_datetime_value(updated_at),
|
||||||
|
str(workflow_id),
|
||||||
|
str(stage_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._touch_parent(
|
||||||
|
connection,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
updated_by=updated_by,
|
||||||
|
updated_at=updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.list_for_workflow(workflow_id)
|
||||||
|
|
||||||
|
def _clamped_insert_position(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
requested_position: int | None,
|
||||||
|
) -> int:
|
||||||
|
count = len(self.list_for_workflow(workflow_id))
|
||||||
|
if requested_position is None:
|
||||||
|
return count + 1
|
||||||
|
return max(1, min(requested_position, count + 1))
|
||||||
|
|
||||||
|
def _clamped_update_position(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
requested_position: int,
|
||||||
|
) -> int:
|
||||||
|
count = len(self.list_for_workflow(workflow_id))
|
||||||
|
return max(1, min(requested_position, count))
|
||||||
|
|
||||||
|
def _touch_parent(
|
||||||
|
self,
|
||||||
|
connection: Any,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
updated_by: UUID,
|
||||||
|
updated_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE workflow_templates
|
||||||
|
SET updated_by = {placeholder}, updated_at = {placeholder}
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(str(updated_by), _datetime_value(updated_at), str(workflow_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _select_columns(self) -> str:
|
||||||
|
return """
|
||||||
|
id,
|
||||||
|
workflow_template_id,
|
||||||
|
stable_key,
|
||||||
|
display_name,
|
||||||
|
description,
|
||||||
|
position,
|
||||||
|
owner_role,
|
||||||
|
runner_profile_key,
|
||||||
|
required_inputs,
|
||||||
|
expected_outputs,
|
||||||
|
acceptance_criteria,
|
||||||
|
requires_human_approval,
|
||||||
|
retry_policy,
|
||||||
|
parts,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateAuditEventsRepository:
|
||||||
|
def __init__(self, repository: BackendRepository) -> None:
|
||||||
|
self._repository = repository
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
event_type: str,
|
||||||
|
actor_user_id: UUID | None,
|
||||||
|
payload: JsonObject,
|
||||||
|
created_at: datetime,
|
||||||
|
) -> WorkflowTemplateAuditEventSummary:
|
||||||
|
event_id = uuid4()
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
json_cast = self._repository.json_cast()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO workflow_template_events (
|
||||||
|
id,
|
||||||
|
workflow_template_id,
|
||||||
|
event_type,
|
||||||
|
actor_user_id,
|
||||||
|
payload,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder}
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(event_id),
|
||||||
|
str(workflow_id),
|
||||||
|
event_type,
|
||||||
|
_uuid_value(actor_user_id),
|
||||||
|
_json_value(payload),
|
||||||
|
_datetime_value(created_at),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get(event_id)
|
||||||
|
|
||||||
|
def get(self, event_id: UUID) -> WorkflowTemplateAuditEventSummary:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
workflow_template_id,
|
||||||
|
event_type,
|
||||||
|
actor_user_id,
|
||||||
|
payload,
|
||||||
|
created_at
|
||||||
|
FROM workflow_template_events
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(str(event_id),),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise LookupError(f"Workflow template event not found: {event_id}")
|
||||||
|
return _workflow_template_event_from_row(row)
|
||||||
|
|
||||||
|
def list_for_workflow(
|
||||||
|
self,
|
||||||
|
workflow_id: UUID,
|
||||||
|
) -> list[WorkflowTemplateAuditEventSummary]:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
workflow_template_id,
|
||||||
|
event_type,
|
||||||
|
actor_user_id,
|
||||||
|
payload,
|
||||||
|
created_at
|
||||||
|
FROM workflow_template_events
|
||||||
|
WHERE workflow_template_id = {placeholder}
|
||||||
|
ORDER BY created_at, id
|
||||||
|
""",
|
||||||
|
(str(workflow_id),),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [_workflow_template_event_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
class ScriptConfigVersionsRepository:
|
class ScriptConfigVersionsRepository:
|
||||||
def __init__(self, repository: BackendRepository) -> None:
|
def __init__(self, repository: BackendRepository) -> None:
|
||||||
self._repository = repository
|
self._repository = repository
|
||||||
@@ -3407,6 +4131,63 @@ def _agent_job_summary_from_row(row: Any) -> AgentJobSummary:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_template_from_row(
|
||||||
|
row: Any,
|
||||||
|
*,
|
||||||
|
stages: list[WorkflowStageSummary],
|
||||||
|
) -> WorkflowTemplateSummary:
|
||||||
|
return WorkflowTemplateSummary(
|
||||||
|
id=_row_value(row, "id"),
|
||||||
|
name=_row_value(row, "name"),
|
||||||
|
slug=_row_value(row, "slug"),
|
||||||
|
description=_row_value(row, "description"),
|
||||||
|
status=_row_value(row, "status"),
|
||||||
|
version=_row_value(row, "version"),
|
||||||
|
created_by=_row_value(row, "created_by"),
|
||||||
|
updated_by=_row_value(row, "updated_by"),
|
||||||
|
created_at=_row_value(row, "created_at"),
|
||||||
|
updated_at=_row_value(row, "updated_at"),
|
||||||
|
activated_at=_row_value(row, "activated_at"),
|
||||||
|
archived_at=_row_value(row, "archived_at"),
|
||||||
|
stages=stages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_stage_from_row(row: Any) -> WorkflowStageSummary:
|
||||||
|
return WorkflowStageSummary(
|
||||||
|
id=_row_value(row, "id"),
|
||||||
|
workflow_id=_row_value(row, "workflow_template_id"),
|
||||||
|
stable_key=_row_value(row, "stable_key"),
|
||||||
|
display_name=_row_value(row, "display_name"),
|
||||||
|
description=_row_value(row, "description"),
|
||||||
|
position=_row_value(row, "position"),
|
||||||
|
owner_role=_row_value(row, "owner_role"),
|
||||||
|
runner_profile_key=_row_value(row, "runner_profile_key"),
|
||||||
|
required_inputs=_json_from_row(row, "required_inputs"),
|
||||||
|
expected_outputs=_json_from_row(row, "expected_outputs"),
|
||||||
|
acceptance_criteria=_json_from_row(row, "acceptance_criteria"),
|
||||||
|
requires_human_approval=bool(_row_value(row, "requires_human_approval")),
|
||||||
|
retry_policy=_json_from_row(row, "retry_policy"),
|
||||||
|
parts=[
|
||||||
|
WorkflowStagePart.model_validate(item)
|
||||||
|
for item in _json_from_row(row, "parts")
|
||||||
|
],
|
||||||
|
created_at=_row_value(row, "created_at"),
|
||||||
|
updated_at=_row_value(row, "updated_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_template_event_from_row(row: Any) -> WorkflowTemplateAuditEventSummary:
|
||||||
|
return WorkflowTemplateAuditEventSummary(
|
||||||
|
id=_row_value(row, "id"),
|
||||||
|
workflow_id=_row_value(row, "workflow_template_id"),
|
||||||
|
event_type=_row_value(row, "event_type"),
|
||||||
|
actor_user_id=_row_value(row, "actor_user_id"),
|
||||||
|
payload=_json_from_row(row, "payload"),
|
||||||
|
created_at=_row_value(row, "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _script_config_version_event_from_row(row: Any) -> dict[str, Any]:
|
def _script_config_version_event_from_row(row: Any) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": _row_value(row, "id"),
|
"id": _row_value(row, "id"),
|
||||||
@@ -3431,6 +4212,12 @@ def _plain_row(row: Any) -> dict[str, Any]:
|
|||||||
"visual_rules",
|
"visual_rules",
|
||||||
"source_rules",
|
"source_rules",
|
||||||
"publishing_rules",
|
"publishing_rules",
|
||||||
|
"required_inputs",
|
||||||
|
"expected_outputs",
|
||||||
|
"acceptance_criteria",
|
||||||
|
"retry_policy",
|
||||||
|
"parts",
|
||||||
|
"payload",
|
||||||
):
|
):
|
||||||
if key in result:
|
if key in result:
|
||||||
result[key] = _json_decode(result[key])
|
result[key] = _json_decode(result[key])
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from src.domain.contracts import (
|
|||||||
PublishingStatus,
|
PublishingStatus,
|
||||||
Role,
|
Role,
|
||||||
ScriptConfigVersionStatus,
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +31,9 @@ PUBLISHING_STATUS_VALUES = _quoted_values(status.value for status in PublishingS
|
|||||||
SCRIPT_CONFIG_STATUS_VALUES = _quoted_values(
|
SCRIPT_CONFIG_STATUS_VALUES = _quoted_values(
|
||||||
status.value for status in ScriptConfigVersionStatus
|
status.value for status in ScriptConfigVersionStatus
|
||||||
)
|
)
|
||||||
|
WORKFLOW_TEMPLATE_STATUS_VALUES = _quoted_values(
|
||||||
|
status.value for status in WorkflowTemplateStatus
|
||||||
|
)
|
||||||
PLAN_REVIEW_STATUS_VALUES = _quoted_values(status.value for status in PlanReviewStatus)
|
PLAN_REVIEW_STATUS_VALUES = _quoted_values(status.value for status in PlanReviewStatus)
|
||||||
CLAIM_SUPPORT_STATUS_VALUES = _quoted_values(
|
CLAIM_SUPPORT_STATUS_VALUES = _quoted_values(
|
||||||
status.value for status in ClaimSupportStatus
|
status.value for status in ClaimSupportStatus
|
||||||
@@ -94,6 +98,53 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
f"""
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ({WORKFLOW_TEMPLATE_STATUS_VALUES})),
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
updated_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
activated_at TIMESTAMPTZ,
|
||||||
|
archived_at TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_template_stages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workflow_template_id UUID NOT NULL REFERENCES workflow_templates(id) ON DELETE CASCADE,
|
||||||
|
stable_key TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
owner_role TEXT NOT NULL CHECK (owner_role IN ('ADMIN', 'EDITOR')),
|
||||||
|
runner_profile_key TEXT NOT NULL,
|
||||||
|
required_inputs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
expected_outputs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
acceptance_criteria JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
requires_human_approval BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
retry_policy JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
parts JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (workflow_template_id, stable_key)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_template_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workflow_template_id UUID NOT NULL REFERENCES workflow_templates(id) ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
actor_user_id UUID REFERENCES users(id),
|
||||||
|
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
CREATE TABLE IF NOT EXISTS articles (
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
target_site_id UUID NOT NULL REFERENCES target_sites(id),
|
target_site_id UUID NOT NULL REFERENCES target_sites(id),
|
||||||
@@ -400,6 +451,19 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
|
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_workflow_templates_slug ON workflow_templates (slug)",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_templates_status_updated
|
||||||
|
ON workflow_templates (status, updated_at DESC)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_template_stages_order
|
||||||
|
ON workflow_template_stages (workflow_template_id, position)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_template_events_history
|
||||||
|
ON workflow_template_events (workflow_template_id, created_at)
|
||||||
|
""",
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_dashboard
|
CREATE INDEX IF NOT EXISTS idx_articles_dashboard
|
||||||
ON articles (target_site_id, status, publishing_status, updated_at DESC)
|
ON articles (target_site_id, status, publishing_status, updated_at DESC)
|
||||||
@@ -496,6 +560,53 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_templates (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
updated_by TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
activated_at TEXT,
|
||||||
|
archived_at TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_template_stages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workflow_template_id TEXT NOT NULL,
|
||||||
|
stable_key TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
owner_role TEXT NOT NULL,
|
||||||
|
runner_profile_key TEXT NOT NULL,
|
||||||
|
required_inputs TEXT NOT NULL DEFAULT '[]',
|
||||||
|
expected_outputs TEXT NOT NULL DEFAULT '[]',
|
||||||
|
acceptance_criteria TEXT NOT NULL DEFAULT '[]',
|
||||||
|
requires_human_approval INTEGER NOT NULL DEFAULT 0,
|
||||||
|
retry_policy TEXT NOT NULL DEFAULT '{}',
|
||||||
|
parts TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (workflow_template_id, stable_key)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_template_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workflow_template_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
actor_user_id TEXT,
|
||||||
|
payload TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS articles (
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
target_site_id TEXT NOT NULL,
|
target_site_id TEXT NOT NULL,
|
||||||
@@ -767,6 +878,19 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
|
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_workflow_templates_slug ON workflow_templates (slug)",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_templates_status_updated
|
||||||
|
ON workflow_templates (status, updated_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_template_stages_order
|
||||||
|
ON workflow_template_stages (workflow_template_id, position)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_template_events_history
|
||||||
|
ON workflow_template_events (workflow_template_id, created_at)
|
||||||
|
""",
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_dashboard
|
CREATE INDEX IF NOT EXISTS idx_articles_dashboard
|
||||||
ON articles (target_site_id, status, publishing_status, updated_at)
|
ON articles (target_site_id, status, publishing_status, updated_at)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from src.presentation.routes.plans import router as plans_router
|
|||||||
from src.presentation.routes.publishing import router as publishing_router
|
from src.presentation.routes.publishing import router as publishing_router
|
||||||
from src.presentation.routes.reviews import router as reviews_router
|
from src.presentation.routes.reviews import router as reviews_router
|
||||||
from src.presentation.routes.sites import router as sites_router
|
from src.presentation.routes.sites import router as sites_router
|
||||||
|
from src.presentation.routes.workflow_templates import router as workflow_templates_router
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="AI Content Pipeline Backend")
|
app = FastAPI(title="AI Content Pipeline Backend")
|
||||||
@@ -36,6 +37,7 @@ app.include_router(publishing_router)
|
|||||||
app.include_router(agent_jobs_router)
|
app.include_router(agent_jobs_router)
|
||||||
app.include_router(internal_agent_jobs_router)
|
app.include_router(internal_agent_jobs_router)
|
||||||
app.include_router(sites_router)
|
app.include_router(sites_router)
|
||||||
|
app.include_router(workflow_templates_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.workflow_templates import (
|
||||||
|
activate_workflow_template,
|
||||||
|
add_workflow_stage,
|
||||||
|
archive_workflow_template,
|
||||||
|
create_workflow_template,
|
||||||
|
delete_workflow_stage,
|
||||||
|
get_workflow_template,
|
||||||
|
list_workflow_template_audit_events,
|
||||||
|
list_workflow_templates,
|
||||||
|
reorder_workflow_stages,
|
||||||
|
update_workflow_stage,
|
||||||
|
update_workflow_template,
|
||||||
|
)
|
||||||
|
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
WorkflowStageCreateRequest,
|
||||||
|
WorkflowStageReorderRequest,
|
||||||
|
WorkflowStageResponse,
|
||||||
|
WorkflowStageUpdateRequest,
|
||||||
|
WorkflowTemplateAuditEventListResponse,
|
||||||
|
WorkflowTemplateCreateRequest,
|
||||||
|
WorkflowTemplateListResponse,
|
||||||
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateUpdateRequest,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["workflow_templates"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/workflows",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_workflow_template(
|
||||||
|
request: WorkflowTemplateCreateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
return create_workflow_template(
|
||||||
|
repository,
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/workflows", response_model=WorkflowTemplateListResponse)
|
||||||
|
def get_workflow_templates(
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateListResponse:
|
||||||
|
return list_workflow_templates(repository, current_user=current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/admin/workflows/{workflow_id}",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
)
|
||||||
|
def get_workflow_template_detail(
|
||||||
|
workflow_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
try:
|
||||||
|
return get_workflow_template(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/admin/workflows/{workflow_id}",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
)
|
||||||
|
def patch_workflow_template(
|
||||||
|
workflow_id: UUID,
|
||||||
|
request: WorkflowTemplateUpdateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
try:
|
||||||
|
return update_workflow_template(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/workflows/{workflow_id}/stages",
|
||||||
|
response_model=WorkflowStageResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_workflow_stage(
|
||||||
|
workflow_id: UUID,
|
||||||
|
request: WorkflowStageCreateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowStageResponse:
|
||||||
|
try:
|
||||||
|
return add_workflow_stage(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/admin/workflows/{workflow_id}/stages/{stage_id}",
|
||||||
|
response_model=WorkflowStageResponse,
|
||||||
|
)
|
||||||
|
def patch_workflow_stage(
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_id: UUID,
|
||||||
|
request: WorkflowStageUpdateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowStageResponse:
|
||||||
|
try:
|
||||||
|
return update_workflow_stage(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_id=stage_id,
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/admin/workflows/{workflow_id}/stages/{stage_id}",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
)
|
||||||
|
def delete_workflow_template_stage(
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
try:
|
||||||
|
return delete_workflow_stage(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_id=stage_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/workflows/{workflow_id}/stages/reorder",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
)
|
||||||
|
def post_reorder_workflow_stages(
|
||||||
|
workflow_id: UUID,
|
||||||
|
request: WorkflowStageReorderRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
try:
|
||||||
|
return reorder_workflow_stages(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_ids=request.stage_ids,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/workflows/{workflow_id}/activate",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
)
|
||||||
|
def post_activate_workflow_template(
|
||||||
|
workflow_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
try:
|
||||||
|
return activate_workflow_template(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/workflows/{workflow_id}/archive",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
)
|
||||||
|
def post_archive_workflow_template(
|
||||||
|
workflow_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
try:
|
||||||
|
return archive_workflow_template(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/admin/workflows/{workflow_id}/audit",
|
||||||
|
response_model=WorkflowTemplateAuditEventListResponse,
|
||||||
|
)
|
||||||
|
def get_workflow_template_audit_events(
|
||||||
|
workflow_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateAuditEventListResponse:
|
||||||
|
try:
|
||||||
|
return list_workflow_template_audit_events(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminWorkflowTemplatesPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'workflow-templates.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_admin_creates_workflow_template_with_two_editable_ordered_stages_and_structured_parts(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
admin_headers = {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
|
||||||
|
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers=admin_headers,
|
||||||
|
json=self._workflow_payload("article-production-v1"),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
workflow = create_response.json()["workflow"]
|
||||||
|
self.assertEqual("article-production-v1", workflow["slug"])
|
||||||
|
self.assertEqual("DRAFT", workflow["status"])
|
||||||
|
self.assertEqual(1, workflow["version"])
|
||||||
|
self.assertEqual([], workflow["stages"])
|
||||||
|
self.assertTrue(workflow["created_at"])
|
||||||
|
self.assertTrue(workflow["updated_at"])
|
||||||
|
|
||||||
|
intake_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow['id']}/stages",
|
||||||
|
headers=admin_headers,
|
||||||
|
json=self._intake_stage_payload(),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, intake_response.status_code, intake_response.text)
|
||||||
|
intake_stage = intake_response.json()["stage"]
|
||||||
|
|
||||||
|
draft_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow['id']}/stages",
|
||||||
|
headers=admin_headers,
|
||||||
|
json=self._draft_stage_payload(),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, draft_response.status_code, draft_response.text)
|
||||||
|
draft_stage = draft_response.json()["stage"]
|
||||||
|
|
||||||
|
edited_draft_parts = [
|
||||||
|
{
|
||||||
|
"key": "draft-outline",
|
||||||
|
"type": "outline",
|
||||||
|
"title": "Edited draft outline",
|
||||||
|
"payload": {
|
||||||
|
"prompt": "Build a sourced outline before drafting.",
|
||||||
|
"config": {
|
||||||
|
"minimum_sections": 5,
|
||||||
|
"require_source_placeholders": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Every section has a purpose.",
|
||||||
|
"Claims that need evidence are marked.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "draft-body",
|
||||||
|
"type": "generation",
|
||||||
|
"title": "Draft body",
|
||||||
|
"payload": {
|
||||||
|
"prompt": "Generate a complete longform draft.",
|
||||||
|
"config": {
|
||||||
|
"tone": "practical",
|
||||||
|
"include_evidence_markers": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Draft includes all approved outline sections.",
|
||||||
|
"Unsupported claims remain marked for review.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/admin/workflows/{workflow['id']}/stages/{draft_stage['id']}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"display_name": "Draft and evidence assembly",
|
||||||
|
"description": "Create an evidence-aware article draft.",
|
||||||
|
"owner_role": "EDITOR",
|
||||||
|
"runner_profile_key": "draft-writer-v2",
|
||||||
|
"required_inputs": [
|
||||||
|
"approved_plan",
|
||||||
|
"evidence_matrix",
|
||||||
|
],
|
||||||
|
"expected_outputs": [
|
||||||
|
"article_draft",
|
||||||
|
"claim_evidence_map",
|
||||||
|
],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Draft follows the approved plan.",
|
||||||
|
"Evidence markers are preserved.",
|
||||||
|
],
|
||||||
|
"requires_human_approval": True,
|
||||||
|
"retry_policy": {
|
||||||
|
"max_attempts": 2,
|
||||||
|
"backoff_seconds": 120,
|
||||||
|
},
|
||||||
|
"parts": edited_draft_parts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
detail_response = self.client.get(
|
||||||
|
f"/api/admin/workflows/{workflow['id']}",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, detail_response.status_code, detail_response.text)
|
||||||
|
detail = detail_response.json()["workflow"]
|
||||||
|
stages = detail["stages"]
|
||||||
|
|
||||||
|
self.assertEqual([1, 2], [stage["position"] for stage in stages])
|
||||||
|
self.assertEqual(
|
||||||
|
["intake-boundary-questions", "draft-assembly"],
|
||||||
|
[stage["stable_key"] for stage in stages],
|
||||||
|
)
|
||||||
|
self.assertEqual(intake_stage["id"], stages[0]["id"])
|
||||||
|
self.assertEqual(draft_stage["id"], stages[1]["id"])
|
||||||
|
self.assertEqual(self._intake_stage_payload()["parts"], stages[0]["parts"])
|
||||||
|
self.assertEqual("Draft and evidence assembly", stages[1]["display_name"])
|
||||||
|
self.assertEqual("draft-writer-v2", stages[1]["runner_profile_key"])
|
||||||
|
self.assertTrue(stages[1]["requires_human_approval"])
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"max_attempts": 2,
|
||||||
|
"backoff_seconds": 120,
|
||||||
|
},
|
||||||
|
stages[1]["retry_policy"],
|
||||||
|
)
|
||||||
|
self.assertEqual(edited_draft_parts, stages[1]["parts"])
|
||||||
|
|
||||||
|
def test_editor_mutation_is_denied_with_existing_authorization_error_shape(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=self._workflow_payload("editor-denied-workflow"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(403, response.status_code, response.text)
|
||||||
|
self.assertEqual({"detail": "Forbidden"}, response.json())
|
||||||
|
|
||||||
|
def test_admin_reorders_activates_archives_and_audits_editor_visible_workflow(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
admin_headers = {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
|
||||||
|
editor_headers = {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
|
||||||
|
workflow = self.client.post(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers=admin_headers,
|
||||||
|
json=self._workflow_payload("article-production-ops"),
|
||||||
|
).json()["workflow"]
|
||||||
|
workflow_id = workflow["id"]
|
||||||
|
intake_stage = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/stages",
|
||||||
|
headers=admin_headers,
|
||||||
|
json=self._intake_stage_payload(),
|
||||||
|
).json()["stage"]
|
||||||
|
draft_stage = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/stages",
|
||||||
|
headers=admin_headers,
|
||||||
|
json=self._draft_stage_payload(),
|
||||||
|
).json()["stage"]
|
||||||
|
|
||||||
|
reorder_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/stages/reorder",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"stage_ids": [draft_stage["id"], intake_stage["id"]]},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, reorder_response.status_code, reorder_response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
["draft-assembly", "intake-boundary-questions"],
|
||||||
|
[
|
||||||
|
stage["stable_key"]
|
||||||
|
for stage in reorder_response.json()["workflow"]["stages"]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
activate_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/activate",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, activate_response.status_code, activate_response.text)
|
||||||
|
activated = activate_response.json()["workflow"]
|
||||||
|
self.assertEqual("ACTIVE", activated["status"])
|
||||||
|
self.assertEqual(2, activated["version"])
|
||||||
|
|
||||||
|
editor_list_response = self.client.get(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers=editor_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, editor_list_response.status_code, editor_list_response.text)
|
||||||
|
self.assertIn(
|
||||||
|
workflow_id,
|
||||||
|
[item["id"] for item in editor_list_response.json()["workflows"]],
|
||||||
|
)
|
||||||
|
editor_detail_response = self.client.get(
|
||||||
|
f"/api/admin/workflows/{workflow_id}",
|
||||||
|
headers=editor_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, editor_detail_response.status_code, editor_detail_response.text)
|
||||||
|
|
||||||
|
immutable_patch_response = self.client.patch(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/stages/{draft_stage['id']}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"display_name": "Should not mutate active template"},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, immutable_patch_response.status_code, immutable_patch_response.text)
|
||||||
|
|
||||||
|
archive_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/archive",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, archive_response.status_code, archive_response.text)
|
||||||
|
self.assertEqual("ARCHIVED", archive_response.json()["workflow"]["status"])
|
||||||
|
|
||||||
|
archived_editor_list_response = self.client.get(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers=editor_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
200,
|
||||||
|
archived_editor_list_response.status_code,
|
||||||
|
archived_editor_list_response.text,
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
workflow_id,
|
||||||
|
[item["id"] for item in archived_editor_list_response.json()["workflows"]],
|
||||||
|
)
|
||||||
|
archived_editor_detail_response = self.client.get(
|
||||||
|
f"/api/admin/workflows/{workflow_id}",
|
||||||
|
headers=editor_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
404,
|
||||||
|
archived_editor_detail_response.status_code,
|
||||||
|
archived_editor_detail_response.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
audit_response = self.client.get(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/audit",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, audit_response.status_code, audit_response.text)
|
||||||
|
event_types = {
|
||||||
|
event["event_type"] for event in audit_response.json()["events"]
|
||||||
|
}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"WORKFLOW_TEMPLATE_CREATED",
|
||||||
|
"WORKFLOW_STAGE_CREATED",
|
||||||
|
"WORKFLOW_STAGES_REORDERED",
|
||||||
|
"WORKFLOW_TEMPLATE_ACTIVATED",
|
||||||
|
"WORKFLOW_TEMPLATE_ARCHIVED",
|
||||||
|
}.issubset(event_types)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _workflow_payload(self, slug: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"name": "Article production workflow",
|
||||||
|
"slug": slug,
|
||||||
|
"description": "Reusable editorial workflow for longform article production.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _intake_stage_payload(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"stable_key": "intake-boundary-questions",
|
||||||
|
"display_name": "Boundary question intake",
|
||||||
|
"description": "Collect required editorial context before planning.",
|
||||||
|
"position": 1,
|
||||||
|
"owner_role": "EDITOR",
|
||||||
|
"runner_profile_key": "boundary-question-agent-v1",
|
||||||
|
"required_inputs": [
|
||||||
|
"article_brief",
|
||||||
|
"target_site",
|
||||||
|
],
|
||||||
|
"expected_outputs": [
|
||||||
|
"answered_boundary_questions",
|
||||||
|
],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"All required boundary questions are answered.",
|
||||||
|
"Answers are tied to the source brief.",
|
||||||
|
],
|
||||||
|
"requires_human_approval": True,
|
||||||
|
"retry_policy": {
|
||||||
|
"max_attempts": 1,
|
||||||
|
"backoff_seconds": 0,
|
||||||
|
},
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"key": "required-context-checklist",
|
||||||
|
"type": "checklist",
|
||||||
|
"title": "Required context checklist",
|
||||||
|
"payload": {
|
||||||
|
"prompt": "Identify missing audience, keyword, and source constraints.",
|
||||||
|
"config": {
|
||||||
|
"required_fields": [
|
||||||
|
"audience",
|
||||||
|
"primary_keyword",
|
||||||
|
"source_rules",
|
||||||
|
],
|
||||||
|
"block_on_missing": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Missing required context is listed explicitly.",
|
||||||
|
"No free-text parsing is needed to read the checklist.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _draft_stage_payload(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"stable_key": "draft-assembly",
|
||||||
|
"display_name": "Draft assembly",
|
||||||
|
"description": "Generate a first draft from the approved plan.",
|
||||||
|
"position": 2,
|
||||||
|
"owner_role": "EDITOR",
|
||||||
|
"runner_profile_key": "draft-writer-v1",
|
||||||
|
"required_inputs": [
|
||||||
|
"approved_plan",
|
||||||
|
],
|
||||||
|
"expected_outputs": [
|
||||||
|
"article_draft",
|
||||||
|
],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Draft follows the approved plan.",
|
||||||
|
],
|
||||||
|
"requires_human_approval": False,
|
||||||
|
"retry_policy": {
|
||||||
|
"max_attempts": 2,
|
||||||
|
"backoff_seconds": 60,
|
||||||
|
},
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"key": "draft-outline",
|
||||||
|
"type": "outline",
|
||||||
|
"title": "Draft outline",
|
||||||
|
"payload": {
|
||||||
|
"prompt": "Build a concise outline before drafting.",
|
||||||
|
"config": {
|
||||||
|
"minimum_sections": 4,
|
||||||
|
"require_source_placeholders": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Every section has a purpose.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -53,15 +53,15 @@ Development description: Build the backend domain, persistence, and public API f
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] TDD pre-requirement: before implementation, write one failing Admin API test for creating a workflow template with two editable stages and one failing Editor mutation denial test; proceed one behavior at a time and record evidence in `Result`.
|
- [x] TDD pre-requirement: before implementation, write one failing Admin API test for creating a workflow template with two editable stages and one failing Editor mutation denial test; proceed one behavior at a time and record evidence in `Result`.
|
||||||
- [ ] Workflow templates are persisted with status, version, audit metadata, and ordered stages.
|
- [x] Workflow templates are persisted with status, version, audit metadata, and ordered stages.
|
||||||
- [ ] Stage parts are persisted as structured JSON and returned unchanged through the public API.
|
- [x] Stage parts are persisted as structured JSON and returned unchanged through the public API.
|
||||||
- [ ] Admin can update stage owner role, runner profile, inputs, outputs, acceptance criteria, human approval flag, retry policy, and stage parts.
|
- [x] Admin can update stage owner role, runner profile, inputs, outputs, acceptance criteria, human approval flag, retry policy, and stage parts.
|
||||||
- [ ] Admin can reorder stages and the returned workflow reflects the new order.
|
- [x] Admin can reorder stages and the returned workflow reflects the new order.
|
||||||
- [ ] Activation creates an immutable version increment and archives no data.
|
- [x] Activation creates an immutable version increment and archives no data.
|
||||||
- [ ] Archive hides workflow from Editor list but keeps Admin history visible.
|
- [x] Archive hides workflow from Editor list but keeps Admin history visible.
|
||||||
- [ ] Editor mutation attempts return the same authorization error shape used by existing Admin APIs.
|
- [x] Editor mutation attempts return the same authorization error shape used by existing Admin APIs.
|
||||||
- [ ] OpenAPI/shared contracts include workflow template request/response schemas.
|
- [x] OpenAPI/shared contracts include workflow template request/response schemas.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -71,9 +71,9 @@ Development description: Build the backend domain, persistence, and public API f
|
|||||||
|
|
||||||
## Result
|
## Result
|
||||||
|
|
||||||
- Status: Not started.
|
- Status: Implemented.
|
||||||
- TDD plan:
|
- TDD plan: Use the existing pre-requirement API tests in `apps/backend/tests/integration/test_admin_workflow_templates_public_api.py`, then cover the remaining acceptance criteria with a focused API flow for reorder, activate/archive, editor visibility, admin history, and audit persistence.
|
||||||
- Red evidence:
|
- Red evidence: Pre-implementation `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_admin_workflow_templates_public_api` failed with two public API assertions: expected `201`/`403`, actual `404 {"detail":"Not Found"}` for missing `/api/admin/workflows`.
|
||||||
- Green evidence:
|
- Green evidence: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_admin_workflow_templates_public_api` -> `Ran 3 tests ... OK`.
|
||||||
- Refactor notes:
|
- Refactor notes: Added workflow template status/contracts, admin/editor router, application service, SQLite/Postgres tables, repositories, ordered stage persistence, structured stage-part JSON, activation/archive, and audit events. Active workflow templates are readable by editors; draft/archived templates remain admin history only. Draft-only mutation preserves activated version immutability.
|
||||||
- Verification output:
|
- Verification output: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.contracts.test_public_openapi_contract` -> `Ran 1 test ... OK`; `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_schema_storage_contracts` -> `Ran 3 tests ... OK`; `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m py_compile ...` for changed backend modules -> OK. Existing broader auth suite was also attempted and still fails in `test_editor_can_create_articles_and_approve_plan_review` with `404 {"detail":"Not Found"}` while approving nonexistent plan id `00000000-0000-0000-0000-000000000123`; left unchanged.
|
||||||
|
|||||||
Reference in New Issue
Block a user