diff --git a/app/src/components/Archive.js b/app/src/components/Archive.js index bd1eeee4..8867d5a0 100644 --- a/app/src/components/Archive.js +++ b/app/src/components/Archive.js @@ -1,6 +1,6 @@ import React, {useState} from "react"; import {Card, Confirm, Container, Icon, Image, Placeholder, Tab} from "semantic-ui-react"; -import Paginator, {ClearButton, ExternalLink, SearchInput, textEllipsis, truncateLong, uploadDate} from "./Common"; +import Paginator, {ClearButton, ExternalLink, SearchInput, textEllipsis, uploadDate} from "./Common"; import {deleteArchive, postDownload, refreshArchives} from "../api"; import Button from "semantic-ui-react/dist/commonjs/elements/Button"; import {Link, NavLink} from "react-router-dom"; @@ -8,6 +8,7 @@ import {ArchivePlaceholder} from "./Placeholder"; import Table from "semantic-ui-react/dist/commonjs/collections/Table"; import {useArchives} from "../hooks/useArchives"; import {useDomains} from "../hooks/useDomains"; +import Message from "semantic-ui-react/dist/commonjs/collections/Message"; function FailedArchiveCard({archive, syncArchive, deleteArchive}) { @@ -163,7 +164,10 @@ export function ArchivesList({archives, searchStr, syncArchive, localDeleteArchi return

No archives found! Is your search too restrictive?

; } else if (archives.length === 0) { // No archives fetched. - return

No archives found! Have you archived any webpages?

; + return + No archives + Have you archived any webpages? + ; } else { // Archives fetched successfully! return ; @@ -245,14 +249,14 @@ function Domains() { } - if (domains) { + if (domains && domains.length > 0) { return ( <> Domain - URLs + Archives @@ -264,13 +268,22 @@ function Domains() { ) } + if (domains === null) { + return (<> + + + + + + + ) + } + return (<> - - - - - - + + No domains yet. + Archive some webpages! + ) } diff --git a/modules/inventory/api.py b/modules/inventory/api.py index dd25d89d..cc605c09 100644 --- a/modules/inventory/api.py +++ b/modules/inventory/api.py @@ -117,13 +117,17 @@ def put_item(_: Request, item_id: int, data: dict): @bp.delete('/item/') +@bp.delete('/item/') @validate_doc( 'Delete items from an inventory.', ) @run_after(save_inventories_file) def item_delete(_: Request, item_ids: str): try: - item_ids = [int(i) for i in item_ids.split(',')] + if isinstance(item_ids, int): + item_ids = [item_ids, ] + else: + item_ids = [int(i) for i in item_ids.split(',')] except ValueError: raise ValidationError('Could not parse item_ids') diff --git a/modules/inventory/conftest.py b/modules/inventory/conftest.py new file mode 100644 index 00000000..d6cc0ab4 --- /dev/null +++ b/modules/inventory/conftest.py @@ -0,0 +1,26 @@ +import pytest + +from modules.inventory import Inventory, Item, DEFAULT_CATEGORIES, DEFAULT_INVENTORIES + + +@pytest.fixture +def test_inventory(test_session): + inventory = Inventory(name='Test Inventory') + test_session.add(inventory) + test_session.commit() + return inventory + + +@pytest.fixture +def init_test_inventory(test_session): + for subcategory, category in DEFAULT_CATEGORIES: + item = Item(subcategory=subcategory, category=category) + test_session.add(item) + + for name in DEFAULT_INVENTORIES: + inv = Inventory(name=name) + test_session.add(inv) + + inventory = test_session.query(Inventory).filter_by(name='Food Storage').one() + test_session.commit() + return inventory diff --git a/modules/inventory/inventory.py b/modules/inventory/inventory.py index ccf80f84..cbd8b05b 100644 --- a/modules/inventory/inventory.py +++ b/modules/inventory/inventory.py @@ -10,6 +10,7 @@ from sqlalchemy.orm.exc import NoResultFound from wrolpi.common import logger, Base from wrolpi.db import get_db_session, get_db_curs +from wrolpi.errors import APIError from .models import Inventory, Item, InventoriesVersion logger = logger.getChild(__name__) @@ -174,7 +175,10 @@ def update_item(item_id: int, item: dict): def delete_items(items_ids: List[int]): with get_db_curs(commit=True) as curs: - curs.execute('UPDATE item SET deleted_at=current_timestamp WHERE id = ANY(%s)', (items_ids,)) + curs.execute('UPDATE item SET deleted_at=current_timestamp WHERE id = ANY(%s) RETURNING id', (items_ids,)) + deleted_ids = {i['id'] for i in curs.fetchall()} + if set(items_ids) != deleted_ids: + raise APIError('Could not delete the items') def get_inventories_version(): diff --git a/modules/inventory/test/test_api.py b/modules/inventory/test/test_api.py new file mode 100644 index 00000000..e686f039 --- /dev/null +++ b/modules/inventory/test/test_api.py @@ -0,0 +1,51 @@ +from http import HTTPStatus + +from modules.inventory import Item +from modules.inventory.inventory import save_item + + +def test_delete_items(test_session, init_test_inventory, test_client): + """ + Multiple Items can be deleted in a single request. + """ + item = dict( + brand='Ocean', + name='Salt', + count='8.0', + unit='lbs', + item_size='25.0', + category='cooking ingredients', + subcategory='salt', + ) + save_item(init_test_inventory.id, item) + save_item(init_test_inventory.id, item) + items = test_session.query(Item).filter(Item.brand != None).all() + + request, response = test_client.delete(f'/api/inventory/item/{items[0].id},{items[1].id}') + assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code + # Refresh all items. Verify they were deleted. + [test_session.refresh(i) for i in items] + assert all([i.deleted_at for i in items]) + + +def test_delete_item(test_session, init_test_inventory, test_client): + """ + An item can be deleted by itself. + """ + item = dict( + brand='Ocean', + name='Salt', + count='8.0', + unit='lbs', + item_size='25.0', + category='cooking ingredients', + subcategory='salt', + ) + save_item(init_test_inventory.id, item) + item = test_session.query(Item).filter(Item.brand != None).one() + assert not item.deleted_at + + request, response = test_client.delete(f'/api/inventory/item/{item.id}') + assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code + test_session.refresh(item) + assert item.deleted_at diff --git a/modules/inventory/test/test_inventory.py b/modules/inventory/test/test_inventory.py index c5011e49..9da58ceb 100644 --- a/modules/inventory/test/test_inventory.py +++ b/modules/inventory/test/test_inventory.py @@ -277,7 +277,7 @@ def quantity_to_string(quantity: Quantity) -> str: ] ) def test_compact_unit(quantity, expected): - # Round the result so we don't have to specify all those zeros for the test definition. + # Round the result, so we don't have to specify all those zeros for the test definition. assert quantity_to_string(compact_unit(quantity)) == expected @@ -296,7 +296,7 @@ length = gallon.dimensionality {(('grains',), oz.dimensionality): Decimal('1') * oz}, ), ( - # The larger of the units is what is returned. + # The largest of the units is what is returned. [ {'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'}, {'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'lbs'}, diff --git a/modules/videos/__init__.py b/modules/videos/__init__.py index 8bfe14d6..5cd95bbb 100644 --- a/modules/videos/__init__.py +++ b/modules/videos/__init__.py @@ -1 +1,8 @@ from modules.videos import downloader +from wrolpi import before_startup + + +@before_startup +def startup_spread_channel_downloads(): + from .channel import lib as channel_lib + channel_lib.spread_channel_downloads() diff --git a/modules/videos/api.py b/modules/videos/api.py index 82af9f0b..719a30c1 100644 --- a/modules/videos/api.py +++ b/modules/videos/api.py @@ -5,7 +5,6 @@ from http import HTTPStatus from sanic import Blueprint, response from sanic.request import Request -from wrolpi import before_startup from wrolpi.common import create_websocket_feed, get_sanic_url, \ wrol_mode_check from wrolpi.common import logger @@ -128,8 +127,3 @@ async def favorite(_: Request, data: dict): async def statistics(_: Request): ret = await get_statistics() return json_response(ret, HTTPStatus.OK) - - -@before_startup -def startup_spread_channel_downloads(): - channel_lib.spread_channel_downloads()