diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 71262a8..3c8ab17 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,7 +1,9 @@ from fastapi import APIRouter -from app.api.v1.endpoints import states +from app.api.v1.endpoints import states, workflows api_router = APIRouter() # Include all endpoint routers api_router.include_router(states.router, prefix="/states", tags=["states"]) +api_router.include_router( + workflows.router, prefix="/workflows", tags=["workflows"]) diff --git a/backend/app/api/v1/endpoints/states.py b/backend/app/api/v1/endpoints/states.py index c4b8b80..ef95b5f 100644 --- a/backend/app/api/v1/endpoints/states.py +++ b/backend/app/api/v1/endpoints/states.py @@ -1,6 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session -from typing import List from app.database import get_db from app.schemas.state import StateCreate, StateUpdate, StateResponse from app.services.state_service import StateService @@ -8,15 +7,16 @@ router = APIRouter() -@router.get("/", response_model=List[StateResponse], summary="Get all states") +@router.get("/", response_model=list[StateResponse], summary="Get all states") async def get_states( skip: int = Query(0, ge=0, description="Number of records to skip"), - limit: int = Query(100, - ge=1, - le=1000, - description="Maximum number of records to return" - ), - db: Session = Depends(get_db) + limit: int = Query( + 100, + ge=1, + le=1000, + description="Maximum number of records to return", + ), + db: Session = Depends(get_db), ): """ Retrieve all states with pagination support, ordered by sort_order. @@ -27,10 +27,11 @@ async def get_states( return states -@router.get("/active", - response_model=List[StateResponse], - summary="Get active states" - ) +@router.get( + "/active", + response_model=list[StateResponse], + summary="Get active states", +) async def get_active_states(db: Session = Depends(get_db)): """ Retrieve all active states, ordered by sort_order. @@ -39,13 +40,14 @@ async def get_active_states(db: Session = Depends(get_db)): return states -@router.get("/{state_id}", - response_model=StateResponse, - summary="Get state by ID" - ) +@router.get( + "/{state_id}", + response_model=StateResponse, + summary="Get state by ID", +) async def get_state( state_id: int, - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """ Retrieve a specific state by its ID. @@ -57,14 +59,15 @@ async def get_state( return state -@router.post("/", - response_model=StateResponse, - status_code=201, - summary="Create new state" - ) +@router.post( + "/", + response_model=StateResponse, + status_code=201, + summary="Create new state", +) async def create_state( state: StateCreate, - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """ Create a new state. @@ -77,14 +80,15 @@ async def create_state( return StateService.create_state(db, state) -@router.put("/{state_id}", - response_model=StateResponse, - summary="Update state" - ) +@router.put( + "/{state_id}", + response_model=StateResponse, + summary="Update state", +) async def update_state( state_id: int, state_update: StateUpdate, - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """ Update an existing state. @@ -98,7 +102,7 @@ async def update_state( @router.delete("/{state_id}", summary="Delete state") async def delete_state( state_id: int, - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """ Delete a state. @@ -108,10 +112,11 @@ async def delete_state( return {"message": f"State {state_id} deleted successfully"} -@router.post("/initialize", - response_model=List[StateResponse], - summary="Initialize default states" - ) +@router.post( + "/initialize", + response_model=list[StateResponse], + summary="Initialize default states", +) async def initialize_default_states(db: Session = Depends(get_db)): """ Initialize default states (New, In Progress, Done) if they don't exist. diff --git a/backend/app/api/v1/endpoints/workflows.py b/backend/app/api/v1/endpoints/workflows.py new file mode 100644 index 0000000..99f2f60 --- /dev/null +++ b/backend/app/api/v1/endpoints/workflows.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter, HTTPException + +from app.services.workflow_service import WorkflowService +from app.schemas.workflow import StartWorkflowRequest, CompleteTaskRequest + + +router = APIRouter() + + + + + +@router.post("/start", summary="Start a new workflow instance") +async def start_workflow(payload: StartWorkflowRequest): + try: + return WorkflowService.start_workflow( + name=payload.name, + bpmn_filename=payload.bpmn_filename, + process_id=payload.process_id, + ) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="BPMN file not found") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.get("/", summary="List workflow instances") +async def list_workflows(): + return WorkflowService.list_workflows() + + +@router.post( + "/{instance_id}/tasks/{task_id}/complete", + summary="Complete a user task", +) +async def complete_task( + instance_id: str, + task_id: str, + payload: CompleteTaskRequest, +): + try: + return WorkflowService.complete_user_task( + instance_id, + task_id, + payload.data.model_dump(), + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 9f2f091..8d1c8b6 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1 +1 @@ -# Schemas Package + diff --git a/backend/app/schemas/state.py b/backend/app/schemas/state.py index 64a08cb..c3a1ded 100644 --- a/backend/app/schemas/state.py +++ b/backend/app/schemas/state.py @@ -1,5 +1,4 @@ from pydantic import BaseModel, Field, ConfigDict, field_serializer -from typing import Optional from datetime import datetime @@ -13,10 +12,10 @@ class StateBase(CustomBaseModel): max_length=50, description="State name" ) - description: Optional[str] = Field(None, - max_length=200, - description="State description" - ) + description: str | None = Field(None, + max_length=200, + description="State description" + ) is_active: bool = Field(True, description="Whether the state is active") sort_order: int = Field(0, ge=0, description="Sort order for display") @@ -26,17 +25,17 @@ class StateCreate(StateBase): class StateUpdate(CustomBaseModel): - name: Optional[str] = Field(None, min_length=1, max_length=50) - description: Optional[str] = Field(None, max_length=200) - is_active: Optional[bool] = None - sort_order: Optional[int] = Field(None, ge=0) + name: str | None = Field(None, min_length=1, max_length=50) + description: str | None = Field(None, max_length=200) + is_active: bool | None = None + sort_order: int | None = Field(None, ge=0) class StateResponse(StateBase): id: int created_at: datetime - updated_at: Optional[datetime] = None + updated_at: datetime | None = None @field_serializer("created_at", "updated_at", when_used="json") - def serialize_dt(self, dt: Optional[datetime]) -> Optional[str]: + def serialize_dt(self, dt: datetime | None) -> str | None: return dt.isoformat() if dt else None diff --git a/backend/app/schemas/workflow.py b/backend/app/schemas/workflow.py new file mode 100644 index 0000000..ecfdbfa --- /dev/null +++ b/backend/app/schemas/workflow.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, Field + + +class StartWorkflowRequest(BaseModel): + name: str = Field(default="Simple Approval") + bpmn_filename: str = Field(default="simple_approval.bpmn") + process_id: str = Field(default="SimpleApproval") + + +class CompleteTaskRequest(BaseModel): + class ApprovalData(BaseModel): + approved: bool + + data: ApprovalData + diff --git a/backend/app/services/state_service.py b/backend/app/services/state_service.py index 3e15078..918e931 100644 --- a/backend/app/services/state_service.py +++ b/backend/app/services/state_service.py @@ -1,6 +1,5 @@ from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError -from typing import List, Optional from app.models.state import State from app.schemas.state import StateCreate, StateUpdate from fastapi import HTTPException @@ -8,10 +7,11 @@ class StateService: @staticmethod - def get_states(db: Session, - skip: int = 0, - limit: int = 100 - ) -> List[State]: + def get_states( + db: Session, + skip: int = 0, + limit: int = 100, + ) -> list[State]: """Get all states with pagination, ordered by sort_order""" return ( db.query(State) @@ -22,17 +22,17 @@ def get_states(db: Session, ) @staticmethod - def get_state_by_id(db: Session, state_id: int) -> Optional[State]: + def get_state_by_id(db: Session, state_id: int) -> State | None: """Get state by ID""" return db.query(State).filter(State.id == state_id).first() @staticmethod - def get_state_by_name(db: Session, name: str) -> Optional[State]: + def get_state_by_name(db: Session, name: str) -> State | None: """Get state by name""" return db.query(State).filter(State.name == name).first() @staticmethod - def get_active_states(db: Session) -> List[State]: + def get_active_states(db: Session) -> list[State]: """Get all active states, ordered by sort_order""" return ( db.query(State) @@ -65,10 +65,11 @@ def create_state(db: Session, state: StateCreate) -> State: ) @staticmethod - def update_state(db: Session, - state_id: int, - state_update: StateUpdate - ) -> State: + def update_state( + db: Session, + state_id: int, + state_update: StateUpdate, + ) -> State: """Update an existing state""" db_state = StateService.get_state_by_id(db, state_id) if not db_state: @@ -117,7 +118,7 @@ def delete_state(db: Session, state_id: int) -> bool: ) @staticmethod - def initialize_default_states(db: Session) -> List[State]: + def initialize_default_states(db: Session) -> list[State]: """Initialize default states if they don't exist""" default_states = [ {"name": "New", diff --git a/backend/app/services/workflow_service.py b/backend/app/services/workflow_service.py new file mode 100644 index 0000000..50f8238 --- /dev/null +++ b/backend/app/services/workflow_service.py @@ -0,0 +1,157 @@ +import threading +import uuid +from pathlib import Path + +from SpiffWorkflow.bpmn.parser import BpmnParser +from SpiffWorkflow.bpmn.workflow import BpmnWorkflow +from SpiffWorkflow.task import Task, TaskState + + +class WorkflowInstance: + def __init__(self, instance_id: str, name: str, workflow: BpmnWorkflow): + self.instance_id = instance_id + self.name = name + self.workflow = workflow + + def to_dict(self) -> dict: + return { + "id": self.instance_id, + "name": self.name, + "status": self._status(), + "ready_user_tasks": [ + { + "id": str(task.id), + "name": task.task_spec.name, + "spec_name": task.task_spec.name, + } + for task in WorkflowService.get_ready_user_tasks(self.workflow) + ], + } + + def _status(self) -> str: + if self.workflow.is_completed(): + return "COMPLETED" + # SpiffWorkflow 3.x uses American spelling + is_canceled = getattr(self.workflow, "is_canceled", None) + if callable(is_canceled) and is_canceled(): + return "CANCELLED" + return "RUNNING" + + +class WorkflowService: + _lock = threading.Lock() + _instances: dict[str, WorkflowInstance] = {} + + @staticmethod + def _get_parser() -> BpmnParser: + # Always use a fresh parser so BPMN edits are picked up in dev + return BpmnParser() + + @staticmethod + def _get_bpmn_path(filename: str) -> Path: + base = Path(__file__).resolve().parent.parent / "workflows" + return base / filename + + @staticmethod + def load_spec(bpmn_filename: str, process_id: str): + parser = WorkflowService._get_parser() + path = WorkflowService._get_bpmn_path(bpmn_filename) + parser.add_bpmn_file(str(path)) + return parser.get_spec(process_id) + + @staticmethod + def start_workflow( + name: str, + bpmn_filename: str, + process_id: str, + ) -> dict: + spec = WorkflowService.load_spec(bpmn_filename, process_id) + workflow = BpmnWorkflow(spec) + # Initialize defaults so expressions have values + workflow.data["approved"] = False + workflow.do_engine_steps() + instance_id = str(uuid.uuid4()) + instance = WorkflowInstance(instance_id, name, workflow) + with WorkflowService._lock: + WorkflowService._instances[instance_id] = instance + return instance.to_dict() + + @staticmethod + def list_workflows() -> list[dict]: + with WorkflowService._lock: + return [ + instance.to_dict() + for instance in WorkflowService._instances.values() + ] + + @staticmethod + def get_instance(instance_id: str) -> WorkflowInstance: + with WorkflowService._lock: + instance = WorkflowService._instances.get(instance_id) + if instance is None: + raise KeyError("Workflow instance not found") + return instance + + @staticmethod + def get_ready_user_tasks(workflow: BpmnWorkflow) -> list[Task]: + # Ensure waiting tasks are refreshed before we query states + if hasattr(workflow, "refresh_waiting_tasks"): + workflow.refresh_waiting_tasks() + if hasattr(workflow, "update_waiting_tasks"): + workflow.update_waiting_tasks() + # Include tasks that are waiting or ready for user action + tasks_ready: list[Task] = workflow.get_tasks(state=TaskState.READY) + tasks_waiting: list[Task] = workflow.get_tasks(state=TaskState.WAITING) + tasks: list[Task] = tasks_ready + [ + t for t in tasks_waiting if t not in tasks_ready + ] + # Identify BPMN User Tasks by bpmn_name or class name heuristic + + def is_user_task(t: Task) -> bool: + name = getattr(t.task_spec, "bpmn_name", "") + if isinstance(name, str) and name.lower() == "usertask": + return True + return "usertask" in t.task_spec.__class__.__name__.lower() + return [t for t in tasks if is_user_task(t)] + + @staticmethod + def complete_user_task(instance_id: str, task_id: str, data: dict) -> dict: + instance = WorkflowService.get_instance(instance_id) + workflow = instance.workflow + # Locate task: prefer direct lookup by id, fallback to string match + task: Task | None = None + try: + task = workflow.get_task_from_id(int(task_id)) + except Exception: + task = next( + (t for t in workflow.get_tasks() if str(t.id) == str(task_id)), + None, + ) + if task is None: + raise KeyError("Task not found") + if task.state != TaskState.READY: + # Attempt to move the task to READY state, then re-check + try: + workflow.run_task_from_id(task.id) + # Some engines require refreshing waiting tasks + if hasattr(workflow, "refresh_waiting_tasks"): + workflow.refresh_waiting_tasks() + except Exception: + pass + # Re-fetch task and verify state + try: + task = workflow.get_task_from_id(task.id) + except Exception: + pass + if task.state != TaskState.READY: + raise ValueError("Task is not ready") + # Normalize approved flag to boolean for expression + normalized: dict = dict(data or {}) + if "approved" in normalized: + normalized["approved"] = bool(normalized["approved"]) + task.data.update(normalized) + # Ensure gateway conditions can see the variables + workflow.data.update(normalized) + task.complete() + workflow.do_engine_steps() + return instance.to_dict() diff --git a/backend/app/workflows/simple_approval.bpmn b/backend/app/workflows/simple_approval.bpmn new file mode 100644 index 0000000..ebae62a --- /dev/null +++ b/backend/app/workflows/simple_approval.bpmn @@ -0,0 +1,73 @@ + + + + + Flow_Start_Task + + + Flow_Start_Task + Flow_Task_GW + + + Flow_Task_GW + Flow_GW_Approved + Flow_GW_Rejected + + + Flow_GW_Approved + + + Flow_GW_Rejected + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/requirements.txt b/backend/requirements.txt index 57004d2..e7db10e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,4 +7,5 @@ sqlalchemy==2.0.23 psycopg2-binary==2.9.9 alembic==1.12.1 pytest==7.4.2 -flake8==7.0.0 \ No newline at end of file +flake8==7.0.0 +SpiffWorkflow==3.1.1 \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..261136a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.eslintcache \ No newline at end of file diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 8c82344..7e75c66 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -8,6 +8,7 @@ import prettier from 'eslint-plugin-prettier'; export default [ { files: ['**/*.ts', '**/*.vue', '**/*.js'], + ignores:['**/node_modules/**', '**/dist/**', '**/build/**'], languageOptions: { parser: vueParser, parserOptions: { diff --git a/frontend/package.json b/frontend/package.json index 1e79136..48d66b7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "vue-tsc -b && vite build", "preview": "vite preview --host", - "lint": "eslint . --ext .ts,.vue,.js", + "lint": "eslint . --ext .ts,.vue,.js --cache", "lint:fix": "eslint . --ext .ts,.vue,.js --fix", "format": "prettier --write .", "test": "vitest", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8c9655e..a7ee05a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,5 @@ diff --git a/frontend/tests/HelloWorld.spec.ts b/frontend/tests/HelloWorld.spec.ts index f4db323..048e74e 100644 --- a/frontend/tests/HelloWorld.spec.ts +++ b/frontend/tests/HelloWorld.spec.ts @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/vue'; import { describe, it, expect } from 'vitest'; import HelloWorld from '../src/components/HelloWorld.vue'; +import '@testing-library/jest-dom'; describe('HelloWorld.vue', () => { it('renders the correct message', () => {