diff --git a/backend/endpoints/collections.py b/backend/endpoints/collections.py index 430653e8a..476685a4f 100644 --- a/backend/endpoints/collections.py +++ b/backend/endpoints/collections.py @@ -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 diff --git a/backend/endpoints/responses/__init__.py b/backend/endpoints/responses/__init__.py index 7b07a4f35..10234609a 100644 --- a/backend/endpoints/responses/__init__.py +++ b/backend/endpoints/responses/__init__.py @@ -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 diff --git a/backend/endpoints/sockets/scan.py b/backend/endpoints/sockets/scan.py index 43773d2e4..9c067b783 100644 --- a/backend/endpoints/sockets/scan.py +++ b/backend/endpoints/sockets/scan.py @@ -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 diff --git a/backend/endpoints/tasks.py b/backend/endpoints/tasks.py index 66aa2f81b..dd9368e59 100644 --- a/backend/endpoints/tasks.py +++ b/backend/endpoints/tasks.py @@ -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(), } diff --git a/backend/endpoints/tests/test_tasks.py b/backend/endpoints/tests/test_tasks.py index 042a9f7d7..16872fed0 100644 --- a/backend/endpoints/tests/test_tasks.py +++ b/backend/endpoints/tests/test_tasks.py @@ -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): diff --git a/backend/watcher.py b/backend/watcher.py index 0e9617d84..9c0be4015 100644 --- a/backend/watcher.py +++ b/backend/watcher.py @@ -93,7 +93,6 @@ class EventHandler(FileSystemEventHandler): [db_platform.id], scan_type=ScanType.QUICK, ) - return if __name__ == "__main__": diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 5f9b82926..5f23760fd 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -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'; diff --git a/frontend/src/__generated__/models/JobStatus.ts b/frontend/src/__generated__/models/JobStatus.ts new file mode 100644 index 000000000..7a4998322 --- /dev/null +++ b/frontend/src/__generated__/models/JobStatus.ts @@ -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'; diff --git a/frontend/src/__generated__/models/TaskExecutionResponse.ts b/frontend/src/__generated__/models/TaskExecutionResponse.ts index 2dfb84f6a..66cc3d692 100644 --- a/frontend/src/__generated__/models/TaskExecutionResponse.ts +++ b/frontend/src/__generated__/models/TaskExecutionResponse.ts @@ -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; }; diff --git a/frontend/src/__generated__/models/TaskStatusResponse.ts b/frontend/src/__generated__/models/TaskStatusResponse.ts new file mode 100644 index 000000000..65b214761 --- /dev/null +++ b/frontend/src/__generated__/models/TaskStatusResponse.ts @@ -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); +}; + diff --git a/frontend/src/components/Settings/Administration/TaskOption.vue b/frontend/src/components/Settings/Administration/TaskOption.vue index 5ab7115e5..8b60e0cd3 100644 --- a/frontend/src/components/Settings/Administration/TaskOption.vue +++ b/frontend/src/components/Settings/Administration/TaskOption.vue @@ -1,7 +1,7 @@