add endpoint to fetch task result

This commit is contained in:
Georges-Antoine Assi
2025-08-02 15:25:44 -04:00
parent ff434b448f
commit 0acc9de6e6
12 changed files with 271 additions and 37 deletions

View File

@@ -437,5 +437,3 @@ async def delete_smart_collection(request: Request, id: int) -> None:
log.info(f"Deleting {hl(smart_collection.name, color=BLUE)} from database")
db_collection_handler.delete_smart_collection(id)
return

View File

@@ -1,13 +1,20 @@
from typing import TypedDict
from rq_scheduler.scheduler import JobStatus
class TaskExecutionResponse(TypedDict):
task_name: str
task_id: str
status: str
status: JobStatus | None
queued_at: str
class TaskStatusResponse(TaskExecutionResponse):
started_at: str | None
ended_at: str | None
class BulkOperationResponse(TypedDict):
total_items: int
successful_items: int

View File

@@ -508,7 +508,6 @@ async def scan_platforms(
await sm.emit("scan:done", scan_stats.__dict__)
except ScanStoppedException:
await stop_scan()
return
except Exception as e:
log.error(f"Error in scan_platform: {e}")
# Catch all exceptions and emit error to the client

View File

@@ -5,11 +5,12 @@ from config import (
RESCAN_ON_FILESYSTEM_CHANGE_DELAY,
)
from decorators.auth import protected_route
from endpoints.responses import TaskExecutionResponse
from endpoints.responses import TaskExecutionResponse, TaskStatusResponse
from endpoints.responses.tasks import GroupedTasksDict, TaskInfo
from fastapi import HTTPException, Request
from handler.auth.constants import Scope
from handler.redis_handler import low_prio_queue
from rq.job import Job
from tasks.manual.cleanup_orphaned_resources import cleanup_orphaned_resources_task
from tasks.scheduled.scan_library import scan_library_task
from tasks.scheduled.update_launchbox_metadata import update_launchbox_metadata_task
@@ -86,6 +87,46 @@ async def list_tasks(request: Request) -> GroupedTasksDict:
return grouped_tasks
@protected_route(router.get, "/{task_id}", [Scope.TASKS_RUN])
async def get_task_by_id(request: Request, task_id: str) -> TaskStatusResponse:
"""Get the status of a task by its job ID.
Args:
request (Request): FastAPI Request object
task_id (str): Job ID of the task to retrieve status for
Returns:
TaskStatusResponse: Task status information
"""
try:
job = Job.fetch(task_id, connection=low_prio_queue.connection)
except Exception as e:
raise HTTPException(
status_code=404,
detail=f"Task with ID '{task_id}' not found",
) from e
# Convert datetime objects to ISO format strings
queued_at = job.created_at.isoformat() if job.created_at else None
started_at = job.started_at.isoformat() if job.started_at else None
ended_at = job.ended_at.isoformat() if job.ended_at else None
# Get task name from job metadata or function name
task_name = (
job.meta.get("task_name") or job.func_name if job.meta else job.func_name
)
return TaskStatusResponse(
{
"task_name": str(task_name),
"task_id": task_id,
"status": job.get_status(),
"queued_at": queued_at or "",
"started_at": started_at,
"ended_at": ended_at,
}
)
@protected_route(router.post, "/run", [Scope.TASKS_RUN])
async def run_all_tasks(request: Request) -> list[TaskExecutionResponse]:
"""Run all runnable tasks endpoint
@@ -117,7 +158,7 @@ async def run_all_tasks(request: Request) -> list[TaskExecutionResponse]:
{
"task_name": task_name,
"task_id": job.get_id(),
"status": "queued",
"status": job.get_status(),
"queued_at": datetime.now(timezone.utc).isoformat(),
}
for (task_name, job) in jobs
@@ -155,6 +196,6 @@ async def run_single_task(request: Request, task_name: str) -> TaskExecutionResp
return {
"task_name": task_name,
"task_id": job.get_id(),
"status": "queued",
"status": job.get_status(),
"queued_at": datetime.now(timezone.utc).isoformat(),
}

View File

@@ -89,7 +89,7 @@ class TestListTasks:
"/api/tasks", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
data = response.json()
# Check structure
@@ -137,7 +137,7 @@ class TestListTasks:
"/api/tasks", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["scheduled"] == []
@@ -172,7 +172,7 @@ class TestListTasks:
response = client.get(
"/api/tasks", headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 403
assert response.status_code == status.HTTP_403_FORBIDDEN
class TestRunAllTasks:
@@ -180,7 +180,9 @@ class TestRunAllTasks:
@patch(
"endpoints.tasks.low_prio_queue.enqueue",
return_value=Mock(get_id=Mock(return_value="1")),
return_value=Mock(
get_id=Mock(return_value="1"), get_status=Mock(return_value="queued")
),
)
@patch(
"endpoints.tasks.manual_tasks",
@@ -204,7 +206,7 @@ class TestRunAllTasks:
"/api/tasks/run", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 3
assert data[0]["task_name"] == "task1"
@@ -264,7 +266,9 @@ class TestRunSingleTask:
@patch(
"endpoints.tasks.low_prio_queue.enqueue",
return_value=Mock(get_id=Mock(return_value="1")),
return_value=Mock(
get_id=Mock(return_value="1"), get_status=Mock(return_value="queued")
),
)
@patch(
"endpoints.tasks.manual_tasks",
@@ -278,14 +282,14 @@ class TestRunSingleTask:
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["task_name"] == "test_task"
assert data["task_id"] == "1"
assert data["status"] == "queued"
assert "queued_at" in data
assert "task_id" in data
# Verify that enqueue was called
mock_queue.assert_called_once()
@patch("endpoints.tasks.manual_tasks", {})
@@ -297,10 +301,9 @@ class TestRunSingleTask:
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 404
assert response.status_code == status.HTTP_404_NOT_FOUND
data = response.json()
assert "not found" in data["detail"].lower()
assert "available tasks are" in data["detail"]
@patch("endpoints.tasks.low_prio_queue")
@patch(
@@ -315,13 +318,10 @@ class TestRunSingleTask:
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 400
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = response.json()
assert "cannot be run" in data["detail"].lower()
# Verify that enqueue was not called
mock_queue.enqueue.assert_not_called()
@patch("endpoints.tasks.low_prio_queue")
@patch(
"endpoints.tasks.manual_tasks",
@@ -339,19 +339,140 @@ class TestRunSingleTask:
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 400
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = response.json()
assert "cannot be run" in data["detail"].lower()
# Verify that enqueue was not called
mock_queue.enqueue.assert_not_called()
def test_run_single_task_unauthorized(self, client):
"""Test that unauthorized requests are rejected"""
"""Test running a task without authentication"""
response = client.post("/api/tasks/run/test_task")
assert response.status_code == status.HTTP_403_FORBIDDEN
class TestGetTaskById:
"""Test suite for the get_task_by_id endpoint"""
@patch("endpoints.tasks.low_prio_queue")
@patch("endpoints.tasks.Job.fetch")
def test_get_task_by_id_success(
self, mock_job_fetch, mock_queue, client, access_token
):
"""Test successful retrieval of a task by job ID"""
# Mock job object with all necessary attributes
mock_job = Mock()
mock_job.created_at = Mock()
mock_job.created_at.isoformat.return_value = "2023-01-01T00:00:00"
mock_job.started_at = Mock()
mock_job.started_at.isoformat.return_value = "2023-01-01T00:01:00"
mock_job.ended_at = Mock()
mock_job.ended_at.isoformat.return_value = "2023-01-01T00:02:00"
mock_job.meta = {"task_name": "test_task"}
mock_job.func_name = "test_task"
mock_job.get_status.return_value = "finished"
mock_job_fetch.return_value = mock_job
response = client.get(
"/api/tasks/test-job-id-123",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["task_name"] == "test_task"
assert data["task_id"] == "test-job-id-123"
assert data["status"] == "finished"
assert data["queued_at"] == "2023-01-01T00:00:00"
assert data["started_at"] == "2023-01-01T00:01:00"
assert data["ended_at"] == "2023-01-01T00:02:00"
mock_job_fetch.assert_called_once_with(
"test-job-id-123", connection=mock_queue.connection
)
@patch("endpoints.tasks.low_prio_queue")
@patch("endpoints.tasks.Job.fetch")
def test_get_task_by_id_not_found(
self, mock_job_fetch, mock_queue, client, access_token
):
"""Test retrieval of a non-existent task by job ID"""
mock_job_fetch.side_effect = Exception("Job not found")
response = client.get(
"/api/tasks/nonexistent-job-id",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 404
data = response.json()
assert "not found" in data["detail"].lower()
@patch("endpoints.tasks.low_prio_queue")
@patch("endpoints.tasks.Job.fetch")
def test_get_task_by_id_with_exception_info(
self, mock_job_fetch, mock_queue, client, access_token
):
"""Test retrieval of a task that failed with exception"""
mock_job = Mock()
mock_job.created_at = Mock()
mock_job.created_at.isoformat.return_value = "2023-01-01T00:00:00"
mock_job.started_at = Mock()
mock_job.started_at.isoformat.return_value = "2023-01-01T00:01:00"
mock_job.ended_at = Mock()
mock_job.ended_at.isoformat.return_value = "2023-01-01T00:01:30"
mock_job.meta = {"task_name": "test_task"}
mock_job.func_name = "test_task"
mock_job.get_status.return_value = "failed"
mock_job_fetch.return_value = mock_job
response = client.get(
"/api/tasks/failed-job-id",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["status"] == "failed"
@patch("endpoints.tasks.low_prio_queue")
@patch("endpoints.tasks.Job.fetch")
def test_get_task_by_id_no_metadata(
self, mock_job_fetch, mock_queue, client, access_token
):
"""Test retrieval of a task with no metadata"""
mock_job = Mock()
mock_job.created_at = Mock()
mock_job.created_at.isoformat.return_value = "2023-01-01T00:00:00"
mock_job.started_at = None
mock_job.ended_at = None
mock_job.meta = None
mock_job.func_name = "test_task"
mock_job.get_status.return_value = "queued"
mock_job_fetch.return_value = mock_job
response = client.get(
"/api/tasks/queued-job-id",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["task_name"] == "test_task"
assert data["status"] == "queued"
assert data["started_at"] is None
assert data["ended_at"] is None
def test_get_task_by_id_unauthorized(self, client):
"""Test retrieval of a task without authentication"""
response = client.get("/api/tasks/test-job-id")
assert response.status_code == status.HTTP_403_FORBIDDEN
class TestTaskInfoBuilding:
"""Test suite for the _build_task_info helper function"""
@@ -388,7 +509,7 @@ class TestTaskInfoBuilding:
"/api/tasks", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
# The mock ensures the structure is correct
@@ -399,7 +520,9 @@ class TestIntegration:
@patch("endpoints.tasks.RESCAN_ON_FILESYSTEM_CHANGE_DELAY", 5)
@patch(
"endpoints.tasks.low_prio_queue.enqueue",
return_value=Mock(get_id=Mock(return_value="1")),
return_value=Mock(
get_id=Mock(return_value="1"), get_status=Mock(return_value="queued")
),
)
def test_full_workflow(self, mock_queue, client, access_token):
"""Test a complete workflow: list tasks, then run a specific task"""
@@ -407,7 +530,7 @@ class TestIntegration:
list_response = client.get(
"/api/tasks", headers={"Authorization": f"Bearer {access_token}"}
)
assert list_response.status_code == 200
assert list_response.status_code == status.HTTP_200_OK
# Then run a specific task (if any exist)
with patch(
@@ -423,7 +546,7 @@ class TestIntegration:
"/api/tasks/run/workflow_task",
headers={"Authorization": f"Bearer {access_token}"},
)
assert run_response.status_code == 200
assert run_response.status_code == status.HTTP_200_OK
assert mock_queue.called
def test_error_handling(self, client, access_token):

View File

@@ -93,7 +93,6 @@ class EventHandler(FileSystemEventHandler):
[db_platform.id],
scan_type=ScanType.QUICK,
)
return
if __name__ == "__main__":

View File

@@ -33,6 +33,7 @@ export type { IGDBAgeRating } from './models/IGDBAgeRating';
export type { IGDBMetadataPlatform } from './models/IGDBMetadataPlatform';
export type { IGDBRelatedGame } from './models/IGDBRelatedGame';
export type { InviteLinkSchema } from './models/InviteLinkSchema';
export type { JobStatus } from './models/JobStatus';
export type { LaunchboxImage } from './models/LaunchboxImage';
export type { MetadataSourcesDict } from './models/MetadataSourcesDict';
export type { MobyMetadataPlatform } from './models/MobyMetadataPlatform';
@@ -65,6 +66,7 @@ export type { StatsReturn } from './models/StatsReturn';
export type { SystemDict } from './models/SystemDict';
export type { TaskExecutionResponse } from './models/TaskExecutionResponse';
export type { TaskInfo } from './models/TaskInfo';
export type { TaskStatusResponse } from './models/TaskStatusResponse';
export type { TinfoilFeedFileSchema } from './models/TinfoilFeedFileSchema';
export type { TinfoilFeedSchema } from './models/TinfoilFeedSchema';
export type { TinfoilFeedTitleDBSchema } from './models/TinfoilFeedTitleDBSchema';

View File

@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* The Status of Job within its lifecycle at any given time.
*/
export type JobStatus = 'queued' | 'finished' | 'failed' | 'started' | 'deferred' | 'scheduled' | 'stopped' | 'canceled';

View File

@@ -2,10 +2,11 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { JobStatus } from './JobStatus';
export type TaskExecutionResponse = {
task_name: string;
task_id: string;
status: string;
status: (JobStatus | null);
queued_at: string;
};

View File

@@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { JobStatus } from './JobStatus';
export type TaskStatusResponse = {
task_name: string;
task_id: string;
status: (JobStatus | null);
queued_at: string;
started_at: (string | null);
ended_at: (string | null);
};

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { inject, computed } from "vue";
import type { Events } from "@/types/emitter";
import api from "@/services/api/index";
import taskApi from "@/services/api/task";
import type { Emitter } from "mitt";
import storeRunningTasks from "@/stores/runningTasks";
@@ -41,9 +41,11 @@ function run() {
// Add task to running tasks
runningTasksStore.addTask(props.name);
api
.post(`/tasks/run/${props.name}`)
.then(() => {
taskApi
.runTask(props.name)
.then(({ data }) => {
console.log(data);
emitter?.emit("snackbarShow", {
msg: `Task '${props.title}' ran successfully!`,
icon: "mdi-check-bold",

View File

@@ -0,0 +1,40 @@
import api from "@/services/api";
import {
type TaskExecutionResponse,
type TaskStatusResponse,
} from "@/__generated__";
export const taskApi = api;
async function getTasks(): Promise<{
data: {
scheduled: TaskExecutionResponse[];
manual: TaskExecutionResponse[];
watcher: TaskExecutionResponse[];
};
}> {
return api.get("/tasks");
}
async function getTaskById(
taskId: string,
): Promise<{ data: TaskStatusResponse }> {
return api.get(`/tasks/${taskId}`);
}
async function runAllTasks(): Promise<{ data: TaskExecutionResponse[] }> {
return api.post("/tasks/run");
}
async function runTask(
taskName: string,
): Promise<{ data: TaskExecutionResponse }> {
return api.post(`/tasks/run/${taskName}`);
}
export default {
getTasks,
getTaskById,
runAllTasks,
runTask,
};