diff --git a/app/src/api.js b/app/src/api.js index 1fd82867..219ef240 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -486,6 +486,10 @@ export async function killDownload(download_id) { return response; } +export async function restartDownload(download_id) { + return await apiPost(`${API_URI}/download/${download_id}/restart`); +} + export async function killDownloads() { let response = await apiPost(`${API_URI}/download/kill`); return response; diff --git a/app/src/components/admin/Downloads.js b/app/src/components/admin/Downloads.js index 4d1f67e0..ca29f16b 100644 --- a/app/src/components/admin/Downloads.js +++ b/app/src/components/admin/Downloads.js @@ -1,5 +1,5 @@ import React from "react"; -import {clearCompletedDownloads, clearFailedDownloads, deleteDownload, killDownload, postDownload} from "../../api"; +import {clearCompletedDownloads, clearFailedDownloads, deleteDownload, killDownload, restartDownload} from "../../api"; import {Link} from "react-router-dom"; import { APIButton, @@ -24,6 +24,7 @@ import { } from "semantic-ui-react"; import {Button, Header, Modal, ModalActions, ModalContent, ModalHeader, Placeholder, Segment, Table} from "../Theme"; import {useDownloads} from "../../hooks/customHooks"; +import {toast} from "react-semantic-toasts-2"; function ClearCompleteDownloads({callback}) { async function localClearDownloads() { @@ -67,7 +68,7 @@ function ClearFailedDownloads({callback}) { } -class DownloadRow extends React.Component { +class RecurringDownloadRow extends React.Component { constructor(props) { super(props); this.state = { @@ -86,6 +87,28 @@ class DownloadRow extends React.Component { } } + handleRetry = async () => { + const {id} = this.props; + try { + const response = await restartDownload(id); + if (response.status !== 204) { + throw Error('Unable to restart download'); + } + } catch (e) { + toast({ + type: 'error', + title: 'Error', + description: 'Unable to start download', + time: 5000, + }) + throw e; + } finally { + if (this.props.fetchDownloads) { + this.props.fetchDownloads(); + } + } + } + render() { let {url, frequency, last_successful_download, status, location, next_download, error} = this.props; const {errorModalOpen} = this.state; @@ -121,6 +144,17 @@ class DownloadRow extends React.Component { /> ; + const retryButton = <> + + ; + // Show "now" if we have passed the next_download. let next = 'now'; if (next_download) { @@ -142,12 +176,13 @@ class DownloadRow extends React.Component { {error && errorModal} {deleteButton} + {retryButton} } } -class StoppableRow extends React.Component { +class OnceDownloadRow extends React.Component { constructor(props) { super(props); } @@ -163,8 +198,17 @@ class StoppableRow extends React.Component { }; handleStart = async () => { - let downloader = this.props.downloader; - await postDownload(`${this.props.url}`, downloader); + try { + await restartDownload(this.props.id); + } catch (e) { + toast({ + type: 'error', + title: 'Error', + description: 'Unable to start download', + time: 5000, + }) + throw e; + } await this.props.fetchDownloads(); }; @@ -203,11 +247,12 @@ class StoppableRow extends React.Component { /> Retry + /> ); } else if (status === 'complete' && location) { @@ -256,7 +301,7 @@ export function OnceDownloadsTable({downloads, fetchDownloads}) { - {downloads.map(i => )} + {downloads.map(i => )} @@ -292,7 +337,7 @@ export function RecurringDownloadsTable({downloads, fetchDownloads}) { - {downloads.map(i => )} + {downloads.map(i => )} } else if (downloads) { diff --git a/wrolpi/downloader.py b/wrolpi/downloader.py index 6e8bfa8b..b14b5f57 100644 --- a/wrolpi/downloader.py +++ b/wrolpi/downloader.py @@ -766,6 +766,17 @@ class DownloadManager: return True return False + @optional_session + def restart_download(self, download_id: int, session: Session = None) -> Download: + """Renews a download and resets its download attempts.""" + download = self.get_download(session, id_=download_id) + if not download: + raise InvalidDownload(f'No download with id {download_id}') + download.renew(reset_attempts=True) + session.commit() + + return download + def kill_download(self, download_id: int): """Fail a Download. If it is pending, kill the Downloader so the download stops.""" with get_db_session(commit=True) as session: diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 015c1d13..3567cb74 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -251,6 +251,13 @@ async def post_download(_: Request, body: schema.DownloadRequest): return response.empty() +@api_bp.post('/download//restart') +@openapi.description('Restart a download.') +async def restart_download(_: Request, download_id: int): + download_manager.restart_download(download_id) + return response.empty() + + @api_bp.get('/download') @openapi.description('Get all Downloads that need to be processed.') async def get_downloads(_: Request): diff --git a/wrolpi/test/test_root_api.py b/wrolpi/test/test_root_api.py index ec669e8b..492b07e0 100644 --- a/wrolpi/test/test_root_api.py +++ b/wrolpi/test/test_root_api.py @@ -1,3 +1,4 @@ +import asyncio import json from http import HTTPStatus from itertools import zip_longest @@ -374,6 +375,25 @@ def test_get_downloaders(test_client): 'No downloaders returned' +@pytest.mark.asyncio +async def test_restart_download(test_session, test_async_client, test_download_manager, test_downloader): + """A Download can be restarted.""" + # Create a download, fail it, it should be restarted. + download = test_download_manager.create_download('https://example.com', test_downloader.name) + download.fail() + test_session.commit() + assert test_session.query(Download).one().status == 'failed' + + # Download is now "new" again. + request, response = await test_async_client.post(f'/api/download/{download.id}/restart') + assert response.status_code == HTTPStatus.NO_CONTENT + assert test_session.query(Download).one().status == 'new' + + # Wait for the background download to fail. It should be deferred. + await asyncio.sleep(0.5) + assert test_session.query(Download).one().status == 'deferred' + + def test_get_global_statistics(test_session, test_client): request, response = test_client.get('/api/statistics') assert response.json['global_statistics']['db_size'] > 1