Skip to content

Commit 3738494

Browse files
committed
add status filtering on get, add count endpoint
1 parent 6bda262 commit 3738494

6 files changed

Lines changed: 372 additions & 17 deletions

File tree

docs/openapi.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

tests/routes/v1/test_task_router.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,3 +1326,150 @@ async def test_upload_invalid_workflow(
13261326
# (should be cleaned up after validation failure)
13271327
expected_path = storage_service.root_dir / client_id / "invalid.waldiez"
13281328
assert not expected_path.exists()
1329+
1330+
1331+
@pytest.mark.anyio
1332+
async def test_count_tasks_basic(
1333+
client: AsyncClient,
1334+
async_session: AsyncSession,
1335+
client_id: str,
1336+
create_task: CreateTaskCallable,
1337+
) -> None:
1338+
"""Count tasks without filters."""
1339+
task1, _ = await create_task(
1340+
async_session,
1341+
client_id=client_id,
1342+
flow_id="flow_a",
1343+
status=TaskStatus.PENDING,
1344+
filename="count_basic_1",
1345+
)
1346+
task2, _ = await create_task(
1347+
async_session,
1348+
client_id=client_id,
1349+
flow_id="flow_b",
1350+
status=TaskStatus.COMPLETED,
1351+
filename="count_basic_2",
1352+
)
1353+
1354+
response = await client.get("/tasks/count")
1355+
1356+
assert response.status_code == HTTP_200_OK
1357+
data = response.json()
1358+
assert data["count"] == 2
1359+
await async_session.delete(task1)
1360+
await async_session.delete(task2)
1361+
await async_session.commit()
1362+
1363+
1364+
@pytest.mark.anyio
1365+
async def test_count_tasks_status_filter(
1366+
client: AsyncClient,
1367+
async_session: AsyncSession,
1368+
client_id: str,
1369+
create_task: CreateTaskCallable,
1370+
) -> None:
1371+
"""Count tasks filtered by status."""
1372+
task1, _ = await create_task(
1373+
async_session,
1374+
client_id=client_id,
1375+
flow_id="flow_a",
1376+
status=TaskStatus.PENDING,
1377+
filename="count_status_pending_1",
1378+
)
1379+
task2, _ = await create_task(
1380+
async_session,
1381+
client_id=client_id,
1382+
flow_id="flow_b",
1383+
status=TaskStatus.COMPLETED,
1384+
filename="count_status_completed_1",
1385+
)
1386+
1387+
response = await client.get("/tasks/count", params={"status": "PENDING"})
1388+
1389+
assert response.status_code == HTTP_200_OK
1390+
data = response.json()
1391+
assert data["count"] == 1
1392+
await async_session.delete(task1)
1393+
await async_session.delete(task2)
1394+
await async_session.commit()
1395+
1396+
1397+
@pytest.mark.anyio
1398+
async def test_count_tasks_active_inactive_filters(
1399+
client: AsyncClient,
1400+
async_session: AsyncSession,
1401+
client_id: str,
1402+
create_task: CreateTaskCallable,
1403+
) -> None:
1404+
"""Count tasks using active_only/inactive_only flags."""
1405+
# active
1406+
task1, _ = await create_task(
1407+
async_session,
1408+
client_id=client_id,
1409+
flow_id="flow_a",
1410+
status=TaskStatus.PENDING,
1411+
filename="count_active_1",
1412+
)
1413+
task2, _ = await create_task(
1414+
async_session,
1415+
client_id=client_id,
1416+
flow_id="flow_b",
1417+
status=TaskStatus.RUNNING,
1418+
filename="count_active_2",
1419+
)
1420+
# inactive
1421+
task3, _ = await create_task(
1422+
async_session,
1423+
client_id=client_id,
1424+
flow_id="flow_c",
1425+
status=TaskStatus.COMPLETED,
1426+
filename="count_inactive_1",
1427+
)
1428+
1429+
resp_active = await client.get("/tasks/count", params={"active_only": True})
1430+
assert resp_active.status_code == HTTP_200_OK
1431+
assert resp_active.json()["count"] == 2
1432+
1433+
resp_inactive = await client.get(
1434+
"/tasks/count", params={"inactive_only": True}
1435+
)
1436+
assert resp_inactive.status_code == HTTP_200_OK
1437+
assert resp_inactive.json()["count"] == 1
1438+
await async_session.delete(task1)
1439+
await async_session.delete(task2)
1440+
await async_session.delete(task3)
1441+
await async_session.commit()
1442+
1443+
1444+
@pytest.mark.anyio
1445+
async def test_get_tasks_status_filter(
1446+
client: AsyncClient,
1447+
async_session: AsyncSession,
1448+
client_id: str,
1449+
create_task: CreateTaskCallable,
1450+
) -> None:
1451+
"""Test getting tasks filtered by status."""
1452+
task1, _ = await create_task(
1453+
async_session,
1454+
client_id=client_id,
1455+
flow_id="flow_status_1",
1456+
status=TaskStatus.PENDING,
1457+
filename="status_pending",
1458+
)
1459+
task2, _ = await create_task(
1460+
async_session,
1461+
client_id=client_id,
1462+
flow_id="flow_status_2",
1463+
status=TaskStatus.COMPLETED,
1464+
filename="status_completed",
1465+
)
1466+
1467+
response = await client.get("/tasks", params={"status": "PENDING"})
1468+
1469+
assert response.status_code == HTTP_200_OK
1470+
items = response.json()["items"]
1471+
assert len(items) == 1
1472+
assert items[0]["status"] == "PENDING"
1473+
await async_session.delete(task1)
1474+
await async_session.delete(task2)
1475+
await async_session.commit()

waldiez_runner/routes/v1/task_router.py

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from fastapi.responses import FileResponse, StreamingResponse
2525
from fastapi_pagination import Page
2626
from pydantic import ValidationError
27-
from starlette import status
27+
from starlette import status as http_status
2828
from typing_extensions import Literal
2929

3030
from waldiez_runner.config import Settings
@@ -47,6 +47,7 @@
4747
from waldiez_runner.models import TaskStatus
4848
from waldiez_runner.schemas.task import (
4949
InputResponse,
50+
TaskCountResponse,
5051
TaskCreate,
5152
TaskResponse,
5253
TaskUpdate,
@@ -90,6 +91,10 @@ async def get_client_tasks(
9091
client_id: Annotated[str, Depends(validate_tasks_audience)],
9192
db: Annotated[DatabaseManager, Depends(get_db_manager)],
9293
search: Annotated[str | None, Query(description="A term to search")] = None,
94+
status: Annotated[
95+
TaskStatus | None,
96+
Query(description="Filter by task status"),
97+
] = None,
9398
order_by: Annotated[
9499
TaskSort | None,
95100
Query(description="The field to sort the results"),
@@ -107,6 +112,8 @@ async def get_client_tasks(
107112
The client ID.
108113
db : DatabaseManager
109114
The database session manager.
115+
status : TaskStatus | None
116+
The task status to filter the tasks.
110117
search : str | None
111118
A search term to filter the tasks.
112119
order_by : str | None
@@ -125,6 +132,7 @@ async def get_client_tasks(
125132
session,
126133
client_id,
127134
params=params,
135+
status=status,
128136
search=search,
129137
order_by=order_by,
130138
descending=order_type == "desc",
@@ -140,6 +148,10 @@ async def get_client_tasks(
140148
async def get_all_tasks(
141149
_: Annotated[str, Depends(validate_admin_audience)],
142150
db: Annotated[DatabaseManager, Depends(get_db_manager)],
151+
status: Annotated[
152+
TaskStatus | None,
153+
Query(description="Filter by task status"),
154+
] = None,
143155
search: Annotated[str | None, Query(description="A term to search")] = None,
144156
order_by: Annotated[
145157
TaskSort | None,
@@ -156,6 +168,8 @@ async def get_all_tasks(
156168
----------
157169
db : DatabaseManager
158170
The database session manager.
171+
status : TaskStatus | None
172+
The task status to filter the tasks.
159173
search : str | None
160174
A search term to filter the tasks.
161175
order_by : str | None
@@ -173,6 +187,7 @@ async def get_all_tasks(
173187
return await TaskService.get_all_tasks(
174188
session,
175189
params=params,
190+
status=status,
176191
search=search,
177192
order_by=order_by,
178193
descending=order_type == "desc",
@@ -267,13 +282,13 @@ async def create_task(
267282

268283
if not file and not file_url and not filename:
269284
raise HTTPException(
270-
status_code=status.HTTP_400_BAD_REQUEST,
285+
status_code=http_status.HTTP_400_BAD_REQUEST,
271286
detail="Either file, file_url or filename must be provided",
272287
)
273288
provided = sum(bool(item) for item in [file, file_url, filename])
274289
if provided > 1:
275290
raise HTTPException(
276-
status_code=status.HTTP_400_BAD_REQUEST,
291+
status_code=http_status.HTTP_400_BAD_REQUEST,
277292
detail=(
278293
"Only one of `file`, `file_url` or `filename` can be provided"
279294
),
@@ -405,7 +420,88 @@ async def upload_task_workflow(
405420
except HTTPException:
406421
await storage.delete_file(save_path)
407422
raise
408-
return Response(status_code=status.HTTP_204_NO_CONTENT)
423+
return Response(status_code=http_status.HTTP_204_NO_CONTENT)
424+
425+
426+
@task_router.get("/tasks/count/", include_in_schema=False)
427+
@task_router.get(
428+
"/tasks/count",
429+
summary="Count tasks.",
430+
description="Get the number of tasks, optionally filtered by status.",
431+
response_model=TaskCountResponse,
432+
)
433+
async def count_tasks(
434+
client_id: Annotated[str, Depends(validate_tasks_audience)],
435+
db: Annotated[DatabaseManager, Depends(get_db_manager)],
436+
status: Annotated[
437+
TaskStatus | None, Query(description="Filter by task status")
438+
] = None,
439+
active_only: Annotated[
440+
bool, Query(description="Only get active tasks")
441+
] = False,
442+
inactive_only: Annotated[
443+
bool, Query(description="Only get inactive tasks")
444+
] = False,
445+
search: Annotated[str | None, Query(min_length=1)] = None,
446+
) -> TaskCountResponse:
447+
"""Get the number of tasks, optionally filtered by status.
448+
449+
Parameters
450+
----------
451+
client_id : str
452+
The client ID.
453+
db : DatabaseManager
454+
The database session manager.
455+
status : TaskStatus | None
456+
Optional task status to filter by.
457+
active_only : bool
458+
If True, count only active tasks.
459+
inactive_only : bool
460+
If True, count only inactive tasks.
461+
search : str | None
462+
Optional search term to filter tasks.
463+
464+
Returns
465+
-------
466+
TaskCountResponse
467+
The count of tasks.
468+
469+
Raises
470+
------
471+
HTTPException
472+
If the request is invalid or cannot be served.
473+
"""
474+
if active_only and inactive_only:
475+
raise HTTPException(
476+
status_code=http_status.HTTP_400_BAD_REQUEST,
477+
detail="active_only and inactive_only cannot both be true",
478+
)
479+
try:
480+
async with db.session() as session:
481+
count = await TaskService.count_client_tasks(
482+
session,
483+
client_id=client_id,
484+
status=status,
485+
active_only=active_only,
486+
inactive_only=inactive_only,
487+
search=search,
488+
)
489+
return TaskCountResponse(count=count)
490+
except ValueError as error:
491+
# Service-level validation errors => 400
492+
raise HTTPException(
493+
status_code=http_status.HTTP_400_BAD_REQUEST,
494+
detail=str(error),
495+
) from error
496+
except HTTPException:
497+
# If something below raises an HTTPException, preserve it
498+
raise
499+
except BaseException as error:
500+
LOG.error("Error counting tasks: %s", error)
501+
raise HTTPException(
502+
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
503+
detail="Internal server error",
504+
) from error
409505

410506

411507
@task_router.get("/tasks/{task_id}/", include_in_schema=False)
@@ -451,7 +547,7 @@ async def get_task(
451547
task = await TaskService.get_task(session, task_id=task_id)
452548
if task is None or (not is_admin and task.client_id != client_id):
453549
raise HTTPException(
454-
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
550+
status_code=http_status.HTTP_404_NOT_FOUND, detail="Task not found"
455551
)
456552
return TaskResponse.model_validate(task)
457553

@@ -559,17 +655,17 @@ async def on_input_request(
559655
task = await TaskService.get_task(session, task_id=task_id)
560656
except BaseException as e:
561657
raise HTTPException(
562-
status_code=status.HTTP_404_NOT_FOUND,
658+
status_code=http_status.HTTP_404_NOT_FOUND,
563659
detail=f"Task {task_id} not found",
564660
) from e
565661
if task is None or task.client_id != client_id:
566662
raise HTTPException(
567-
status_code=status.HTTP_404_NOT_FOUND,
663+
status_code=http_status.HTTP_404_NOT_FOUND,
568664
detail=f"Task {task_id} not found",
569665
)
570666
if task.status != TaskStatus.WAITING_FOR_INPUT:
571667
raise HTTPException(
572-
status_code=status.HTTP_400_BAD_REQUEST,
668+
status_code=http_status.HTTP_400_BAD_REQUEST,
573669
detail="Invalid input request",
574670
)
575671
if message.request_id != task.input_request_id:
@@ -579,15 +675,15 @@ async def on_input_request(
579675
task.input_request_id,
580676
)
581677
raise HTTPException(
582-
status_code=status.HTTP_400_BAD_REQUEST,
678+
status_code=http_status.HTTP_400_BAD_REQUEST,
583679
detail="Invalid input request",
584680
)
585681
background_tasks.add_task(
586682
publish_task_input_response,
587683
task_id=task_id,
588684
message=message,
589685
)
590-
return Response(status_code=status.HTTP_204_NO_CONTENT)
686+
return Response(status_code=http_status.HTTP_204_NO_CONTENT)
591687

592688

593689
@task_router.get(
@@ -860,7 +956,7 @@ async def delete_tasks(
860956
if not ids:
861957
# Require specific task IDs to prevent accidental deletion of all tasks
862958
raise HTTPException(
863-
status_code=status.HTTP_400_BAD_REQUEST,
959+
status_code=http_status.HTTP_400_BAD_REQUEST,
864960
detail="Task IDs must be specified for deletion",
865961
)
866962

waldiez_runner/schemas/task.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,9 @@ class InputResponse(BaseModel):
136136

137137
request_id: str
138138
data: str
139+
140+
141+
class TaskCountResponse(BaseModel):
142+
"""Task count response model."""
143+
144+
count: int

0 commit comments

Comments
 (0)