mirror of
https://github.com/rommapp/romm.git
synced 2026-02-18 23:42:07 +01:00
add endpoint to fetch task result
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -93,7 +93,6 @@ class EventHandler(FileSystemEventHandler):
|
||||
[db_platform.id],
|
||||
scan_type=ScanType.QUICK,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
2
frontend/src/__generated__/index.ts
generated
2
frontend/src/__generated__/index.ts
generated
@@ -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';
|
||||
|
||||
8
frontend/src/__generated__/models/JobStatus.ts
generated
Normal file
8
frontend/src/__generated__/models/JobStatus.ts
generated
Normal 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';
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
14
frontend/src/__generated__/models/TaskStatusResponse.ts
generated
Normal file
14
frontend/src/__generated__/models/TaskStatusResponse.ts
generated
Normal 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);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
40
frontend/src/services/api/task.ts
Normal file
40
frontend/src/services/api/task.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user