diff --git a/actions/calibre b/actions/calibre new file mode 100755 index 000000000..bc8d51cd7 --- /dev/null +++ b/actions/calibre @@ -0,0 +1,76 @@ +#!/usr/bin/python3 +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Configuration helper for calibre. +""" + +import argparse +import json +import pathlib +import shutil +import subprocess + +from plinth.modules import calibre + +LIBRARIES_PATH = pathlib.Path('/var/lib/calibre-server-freedombox/libraries') + + +def parse_arguments(): + """Return parsed command line arguments as dictionary.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') + + subparsers.add_parser('list-libraries', + help='Return the list of libraries setup') + subparser = subparsers.add_parser('create-library', + help='Create an empty library') + subparser.add_argument('name', help='Name of the new library') + subparser = subparsers.add_parser('delete-library', + help='Delete a library and its contents') + subparser.add_argument('name', help='Name of the library to delete') + + subparsers.required = True + return parser.parse_args() + + +def subcommand_list_libraries(_): + """Return the list of libraries setup.""" + libraries = [] + for library in LIBRARIES_PATH.glob('*/metadata.db'): + libraries.append(str(library.parent.name)) + + print(json.dumps({'libraries': libraries})) + + +def subcommand_create_library(arguments): + """Create an empty library.""" + calibre.validate_library_name(arguments.name) + library = LIBRARIES_PATH / arguments.name + library.mkdir(mode=0o755) # Raise exception if already exists + subprocess.call( + ['calibredb', '--with-library', library, 'list_categories'], + stdout=subprocess.DEVNULL) + + # Force systemd StateDirectory= logic to assign proper ownership to the + # DynamicUser= + shutil.chown(LIBRARIES_PATH.parent, 'root', 'root') + + +def subcommand_delete_library(arguments): + """Delete a library and its contents.""" + calibre.validate_library_name(arguments.name) + library = LIBRARIES_PATH / arguments.name + shutil.rmtree(library) + + +def main(): + """Parse arguments and perform all duties.""" + arguments = parse_arguments() + + subcommand = arguments.subcommand.replace('-', '_') + subcommand_method = globals()['subcommand_' + subcommand] + subcommand_method(arguments) + + +if __name__ == '__main__': + main() diff --git a/plinth/modules/calibre/__init__.py b/plinth/modules/calibre/__init__.py new file mode 100644 index 000000000..e3786812c --- /dev/null +++ b/plinth/modules/calibre/__init__.py @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +FreedomBox app for calibre e-book library. +""" + +import json +import re + +from django.utils.translation import ugettext_lazy as _ + +from plinth import actions +from plinth import app as app_module +from plinth import cfg, frontpage, menu +from plinth.daemon import Daemon +from plinth.modules.apache.components import Webserver +from plinth.modules.firewall.components import Firewall +from plinth.modules.users.components import UsersAndGroups +from plinth.utils import format_lazy + +from .manifest import backup, clients # noqa, pylint: disable=unused-import + +version = 1 + +managed_services = ['calibre-server-freedombox'] + +managed_packages = ['calibre'] + +_description = [ + format_lazy( + _('calibre server provides online access to your e-book collection. ' + 'You can store your e-books on your {box_name}, read them online or ' + 'from any of your devices.'), box_name=_(cfg.box_name)), + _('You can organize your e-books, extract and edit their metadata, and ' + 'perform advanced search. It can import, export, or convert across a ' + 'wide range of formats to make e-books ready for reading on any ' + 'device. You can read books on your browser with the builtin web ' + 'reader. It remembers your last read location, bookmarks, and ' + 'highlighted text.'), + _('Only users belonging to calibre group will be able to access ' + 'the app. Content distribution with OPDS is currently not supported.') +] + +app = None + +LIBRARY_NAME_PATTERN = r'[a-zA-Z0-9 _-]+' + + +class CalibreApp(app_module.App): + """FreedomBox app for calibre.""" + + app_id = 'calibre' + + def __init__(self): + """Create components for the app.""" + super().__init__() + + groups = {'calibre': _('Administer calibre application')} + + info = app_module.Info(app_id=self.app_id, version=version, + name=_('calibre'), icon_filename='calibre', + short_description=_('E-book Library'), + description=_description, manual_page='calibre', + clients=clients) + self.add(info) + + menu_item = menu.Menu('menu-calibre', info.name, + info.short_description, info.icon_filename, + 'calibre:index', parent_url_name='apps') + self.add(menu_item) + + shortcut = frontpage.Shortcut('shortcut-calibre', info.name, + short_description=info.short_description, + icon=info.icon_filename, url='/calibre', + clients=info.clients, + login_required=True, + allowed_groups=list(groups)) + self.add(shortcut) + + firewall = Firewall('firewall-calibre', info.name, + ports=['http', 'https'], is_external=True) + self.add(firewall) + + webserver = Webserver('webserver-calibre', 'calibre-freedombox', + urls=['https://{host}/calibre']) + self.add(webserver) + + daemon = Daemon('daemon-calibre', managed_services[0], + listen_ports=[(8844, 'tcp4')]) + self.add(daemon) + + users_and_groups = UsersAndGroups('users-and-groups-calibre', + reserved_usernames=['calibre'], + groups=groups) + self.add(users_and_groups) + + +def setup(helper, old_version=None): + """Install and configure the module.""" + helper.install(managed_packages) + helper.call('post', app.enable) + + +def validate_library_name(library_name): + """Raise exception if library name does not fit the accepted pattern.""" + if not re.fullmatch(r'[A-Za-z0-9_.-]+', library_name): + raise Exception('Invalid library name') + + +def list_libraries(): + """Return a list of libraries.""" + output = actions.superuser_run('calibre', ['list-libraries']) + return json.loads(output)['libraries'] + + +def create_library(name): + """Create an empty library.""" + actions.superuser_run('calibre', ['create-library', name]) + actions.superuser_run('service', ['try-restart', managed_services[0]]) + + +def delete_library(name): + """Delete a library and its contents.""" + actions.superuser_run('calibre', ['delete-library', name]) + actions.superuser_run('service', ['try-restart', managed_services[0]]) diff --git a/plinth/modules/calibre/data/etc/apache2/conf-available/calibre-freedombox.conf b/plinth/modules/calibre/data/etc/apache2/conf-available/calibre-freedombox.conf new file mode 100644 index 000000000..e423f2134 --- /dev/null +++ b/plinth/modules/calibre/data/etc/apache2/conf-available/calibre-freedombox.conf @@ -0,0 +1,10 @@ +## +## On all sites, provide calibre web interface on a path: /calibre +## + + ProxyPass http://localhost:8844/calibre + Include includes/freedombox-single-sign-on.conf + + TKTAuthToken "admin" "calibre" + + diff --git a/plinth/modules/calibre/data/etc/plinth/modules-enabled/calibre b/plinth/modules/calibre/data/etc/plinth/modules-enabled/calibre new file mode 100644 index 000000000..bef75a6fd --- /dev/null +++ b/plinth/modules/calibre/data/etc/plinth/modules-enabled/calibre @@ -0,0 +1 @@ +plinth.modules.calibre diff --git a/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service b/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service new file mode 100644 index 000000000..29941bc85 --- /dev/null +++ b/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +[Unit] +Description=calibre Content Server +Documentation=man:calibre-server(1) +After=network.target + +[Service] +CapabilityBoundingSet=~CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_SETUID CAP_SETGID CAP_SETPCAP CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER CAP_NET_ADMIN CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE CAP_KILL CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_LINUX_IMMUTABLE CAP_IPC_LOCK CAP_SYS_CHROOT CAP_BLOCK_SUSPEND CAP_LEASE CAP_SYS_PACCT CAP_SYS_TTY_CONFIG CAP_SYS_BOOT CAP_MAC_ADMIN CAP_MAC_OVERRIDE CAP_SYS_NICE CAP_SYS_RESOURCE +DevicePolicy=closed +Environment=HOME="/var/lib/calibre-server-freedombox" +Environment=DEFAULT_LIBRARY="/var/lib/calibre-server-freedombox/libraries/Library" +Environment=ARGS="--listen-on 127.0.0.1 --url-prefix /calibre --port 8844 --enable-local-write --disable-auth" +ExecStartPre=sh -e -c "files=$$(ls ${HOME}/libraries/*/metadata.db 2>/dev/null || true); [ \"x$${files}\" = \"x\" ] && (mkdir -p \"${DEFAULT_LIBRARY}\" && calibredb --with-library=\"${DEFAULT_LIBRARY}\" list_categories > /dev/null) || true" +ExecStart=sh -e -c "files=${HOME}/libraries/*/metadata.db; libraries=$$(dirname $${files}) ; exec /usr/bin/calibre-server $ARGS $${libraries}" +Restart=on-failure +ExecReload=/bin/kill -HUP $MAINPID +DynamicUser=yes +LockPersonality=yes +NoNewPrivileges=yes +PrivateDevices=yes +PrivateMounts=yes +PrivateTmp=yes +PrivateUsers=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +StateDirectory=calibre-server-freedombox +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@resources +SystemCallFilter=~@privileged +SystemCallErrorNumber=EPERM +Type=simple + + +[Install] +WantedBy=multi-user.target diff --git a/plinth/modules/calibre/forms.py b/plinth/modules/calibre/forms.py new file mode 100644 index 000000000..61fe67d61 --- /dev/null +++ b/plinth/modules/calibre/forms.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Django form for configuring calibre. +""" + +from django import forms +from django.core import validators +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +from plinth.modules import calibre + + +class CreateLibraryForm(forms.Form): + """Form to create an empty library.""" + + name = forms.CharField( + label=_('Name of the new library'), strip=True, + validators=[validators.RegexValidator(r'^[A-Za-z0-9_.-]+$')]) + + def clean_name(self): + """Check if the library name is valid.""" + name = self.cleaned_data['name'] + + if name in calibre.list_libraries(): + raise ValidationError( + _('A library with this name already exists.')) + + return name diff --git a/plinth/modules/calibre/manifest.py b/plinth/modules/calibre/manifest.py new file mode 100644 index 000000000..2b6ce7317 --- /dev/null +++ b/plinth/modules/calibre/manifest.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.utils.translation import ugettext_lazy as _ + +from plinth.clients import validate +from plinth.modules.backups.api import validate as validate_backup + +clients = validate([{ + 'name': _('calibre'), + 'platforms': [{ + 'type': 'web', + 'url': '/calibre/' + }] +}]) + +backup = validate_backup({ + 'data': { + 'directories': ['/var/lib/private/calibre-server-freedombox/'] + }, + 'services': ['calibre-server-freedombox'] +}) diff --git a/plinth/modules/calibre/templates/calibre-delete-library.html b/plinth/modules/calibre/templates/calibre-delete-library.html new file mode 100644 index 000000000..93946e251 --- /dev/null +++ b/plinth/modules/calibre/templates/calibre-delete-library.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load i18n %} + +{% block content %} + +

+ {% blocktrans trimmed %} + Delete calibre Library {{ name }} + {% endblocktrans %} +

+ +

+ {% blocktrans trimmed %} + Delete this library permanently? All stored e-books and saved data will be + lost. + {% endblocktrans %} +

+ +
+ {% csrf_token %} + + +
+ +{% endblock %} diff --git a/plinth/modules/calibre/templates/calibre.html b/plinth/modules/calibre/templates/calibre.html new file mode 100644 index 000000000..9e4e854a8 --- /dev/null +++ b/plinth/modules/calibre/templates/calibre.html @@ -0,0 +1,59 @@ +{% extends "app.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load i18n %} + +{% block page_head %} + +{% endblock %} + +{% block configuration %} + {{ block.super }} + +

{% trans "Manage Libraries" %}

+ +
+ + + {% trans 'Create Library' %} + +
+ +
+
+ {% if not libraries %} +

{% trans 'No libraries available.' %}

+ {% else %} +
+ {% for library in libraries %} + + {% endfor %} +
+ {% endif %} +
+
+ +{% endblock %} diff --git a/plinth/modules/calibre/tests/__init__.py b/plinth/modules/calibre/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/calibre/tests/data/sample.txt b/plinth/modules/calibre/tests/data/sample.txt new file mode 100644 index 000000000..73709ba68 --- /dev/null +++ b/plinth/modules/calibre/tests/data/sample.txt @@ -0,0 +1 @@ +Testing diff --git a/plinth/modules/calibre/tests/test_actions.py b/plinth/modules/calibre/tests/test_actions.py new file mode 100644 index 000000000..ff61ece9c --- /dev/null +++ b/plinth/modules/calibre/tests/test_actions.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Test module for calibre actions. +""" + +import imp +import json +import pathlib +from unittest.mock import call, patch + +import pytest + + +def _action_file(): + """Return the path to the 'calibre' actions file.""" + current_directory = pathlib.Path(__file__).parent + return str(current_directory / '..' / '..' / '..' / '..' / 'actions' / + 'calibre') + + +actions = imp.load_source('calibre', _action_file()) + + +@pytest.fixture(name='call_action') +def fixture_call_action(tmpdir, capsys): + """Run actions with custom root path.""" + + def _call_action(args, **kwargs): + actions.LIBRARIES_PATH = pathlib.Path(str(tmpdir)) + with patch('argparse._sys.argv', ['calibre'] + args): + actions.main() + captured = capsys.readouterr() + return captured.out + + return _call_action + + +@pytest.fixture(autouse=True) +def fixture_patch(): + """Patch some underlying methods.""" + + def side_effect(*args, **_kwargs): + if args[0][0] != 'calibredb': + return + + path = pathlib.Path(args[0][2]) / 'metadata.db' + path.touch() + + with patch('subprocess.call') as subprocess_call, \ + patch('shutil.chown'): + subprocess_call.side_effect = side_effect + yield + + +def test_list_libraries(call_action): + """Test listing libraries.""" + assert json.loads(call_action(['list-libraries'])) == {'libraries': []} + call_action(['create-library', 'TestLibrary']) + expected_output = {'libraries': ['TestLibrary']} + assert json.loads(call_action(['list-libraries'])) == expected_output + + +@patch('shutil.chown') +def test_create_library(chown, call_action): + """Test creating a library.""" + call_action(['create-library', 'TestLibrary']) + library = actions.LIBRARIES_PATH / 'TestLibrary' + assert (library / 'metadata.db').exists() + assert library.stat().st_mode == 0o40755 + expected_output = {'libraries': ['TestLibrary']} + assert json.loads(call_action(['list-libraries'])) == expected_output + + chown.assert_has_calls([call(library.parent.parent, 'root', 'root')]) + + +def test_delete_library(call_action): + """Test deleting a library.""" + call_action(['create-library', 'TestLibrary']) + + call_action(['delete-library', 'TestLibrary']) + assert json.loads(call_action(['list-libraries'])) == {'libraries': []} + + with pytest.raises(FileNotFoundError): + call_action(['delete-library', 'TestLibrary']) diff --git a/plinth/modules/calibre/tests/test_functional.py b/plinth/modules/calibre/tests/test_functional.py new file mode 100644 index 000000000..9f7ca97e6 --- /dev/null +++ b/plinth/modules/calibre/tests/test_functional.py @@ -0,0 +1,180 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Functional, browser based tests for calibre app. +""" + +import pathlib +import time + +import pytest + +from plinth.tests import functional + +pytestmark = [pytest.mark.apps, pytest.mark.sso, pytest.mark.calibre] + + +@pytest.fixture(scope='module', autouse=True) +def fixture_background(session_browser): + """Login and install the app.""" + functional.login(session_browser) + functional.install(session_browser, 'calibre') + yield + functional.app_disable(session_browser, 'calibre') + + +def test_enable_disable(session_browser): + """Test enabling the app.""" + functional.app_disable(session_browser, 'calibre') + + functional.app_enable(session_browser, 'calibre') + assert functional.service_is_running(session_browser, 'calibre') + assert functional.is_available(session_browser, 'calibre') + + functional.app_disable(session_browser, 'calibre') + assert not functional.service_is_running(session_browser, 'calibre') + assert not functional.is_available(session_browser, 'calibre') + + +def test_add_delete_library(session_browser): + """Test adding/deleting a new library.""" + functional.app_enable(session_browser, 'calibre') + _delete_library(session_browser, 'FunctionalTest', True) + + _add_library(session_browser, 'FunctionalTest') + assert _is_library_available(session_browser, 'FunctionalTest') + + _delete_library(session_browser, 'FunctionalTest') + assert not _is_library_available(session_browser, 'FunctionalTest') + + +def test_add_delete_book(session_browser): + """Test adding/delete book in the library.""" + functional.app_enable(session_browser, 'calibre') + _add_library(session_browser, 'FunctionalTest') + _delete_book(session_browser, 'FunctionalTest', 'sample.txt', True) + + _add_book(session_browser, 'FunctionalTest', 'sample.txt') + assert _is_book_available(session_browser, 'FunctionalTest', 'sample.txt') + + _delete_book(session_browser, 'FunctionalTest', 'sample.txt') + assert not _is_book_available(session_browser, 'FunctionalTest', + 'sample.txt') + + +@pytest.mark.backups +def test_backup(session_browser): + """Test backing up and restoring.""" + functional.app_enable(session_browser, 'calibre') + _add_library(session_browser, 'FunctionalTest') + functional.backup_create(session_browser, 'calibre', 'test_calibre') + _delete_library(session_browser, 'FunctionalTest') + functional.backup_restore(session_browser, 'calibre', 'test_calibre') + assert _is_library_available(session_browser, 'FunctionalTest') + + +def _add_library(browser, name): + """Add a new library.""" + if _is_library_available(browser, name): + return + + browser.find_link_by_href( + '/plinth/apps/calibre/library/create/').first.click() + browser.find_by_id('id_calibre-name').fill(name) + functional.submit(browser) + + +def _delete_library(browser, name, ignore_missing=False): + """Delete a library.""" + functional.nav_to_module(browser, 'calibre') + link = browser.find_link_by_href( + f'/plinth/apps/calibre/library/{name}/delete/') + if not link: + if ignore_missing: + return + + raise ValueError('Library not found') + + link.first.click() + functional.submit(browser) + + +def _is_library_available(browser, name): + """Return whether a library is present in the list of libraries.""" + functional.nav_to_module(browser, 'calibre') + link = browser.find_link_by_href( + f'/plinth/apps/calibre/library/{name}/delete/') + return bool(link) + + +def _visit_library(browser, name): + """Open the page for the library.""" + functional.visit(browser, '/calibre/') + + # Calibre interface will be available a short time after restarting the + # service. + def _service_available(): + unavailable_xpath = '//h1[contains(text(), "Service Unavailable")]' + available = not browser.find_by_xpath(unavailable_xpath) + if not available: + time.sleep(0.5) + functional.visit(browser, '/calibre/') + + return available + + functional.eventually(_service_available) + + functional.eventually(browser.find_by_css, + args=[f'.calibre-push-button[data-lid="{name}"]']) + link = browser.find_by_css(f'.calibre-push-button[data-lid="{name}"]') + if not link: + raise ValueError('Library not found') + + link.first.click() + functional.eventually(browser.find_by_css, [f'.book-list-cover-grid']) + + +def _add_book(browser, library_name, book_name): + """Add a book to the library through Calibre interface.""" + _visit_library(browser, library_name) + add_button = browser.find_by_css(f'a[data-button-icon="plus"]') + add_button.first.click() + + functional.eventually(browser.find_by_xpath, + ['//span[contains(text(), "Add books")]']) + browser.execute_script( + '''document.querySelector('input[type="file"]').setAttribute( + 'name', 'test-book-upload');''') + + file_path = pathlib.Path(__file__).parent / f'data/{book_name}' + browser.attach_file('test-book-upload', [str(file_path)]) + functional.eventually(browser.find_by_xpath, + ['//span[contains(text(), "Added successfully")]']) + + +def _delete_book(browser, library_name, book_name, ignore_missing=False): + """Delete a book from the library through Calibre interface.""" + _visit_library(browser, library_name) + book_name = book_name.partition('.')[0] + book = browser.find_by_xpath(f'//a[contains(@title, "{book_name}")]') + if not book: + if ignore_missing: + return + + raise Exception('Book not found') + + book.first.click() + delete_button = browser.find_by_css(f'a[data-button-icon="trash"]') + delete_button.first.click() + + dialog = browser.find_by_id('modal-container').first + functional.eventually(lambda: dialog.visible) + ok_button = browser.find_by_xpath('//span[contains(text(), "OK")]') + ok_button.first.click() + + +def _is_book_available(browser, library_name, book_name): + """Return whether a book is present in Calibre interface.""" + _visit_library(browser, library_name) + book_name = book_name.partition('.')[0] + book = browser.find_by_xpath(f'//a[contains(@title, "{book_name}")]') + return bool(book) diff --git a/plinth/modules/calibre/tests/test_views.py b/plinth/modules/calibre/tests/test_views.py new file mode 100644 index 000000000..24ede010d --- /dev/null +++ b/plinth/modules/calibre/tests/test_views.py @@ -0,0 +1,146 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Tests for calibre views. +""" + +from unittest.mock import call, patch + +import pytest +from django import urls +from django.contrib.messages.storage.fallback import FallbackStorage +from django.http.response import Http404 + +from plinth import actions, module_loader +from plinth.modules.calibre import views + +# For all tests, use plinth.urls instead of urls configured for testing +pytestmark = pytest.mark.urls('plinth.urls') + + +@pytest.fixture(autouse=True, scope='module') +def fixture_calibre_urls(): + """Make sure calibre app's URLs are part of plinth.urls.""" + with patch('plinth.module_loader._modules_to_load', new=[]) as modules, \ + patch('plinth.urls.urlpatterns', new=[]): + modules.append('plinth.modules.calibre') + module_loader.include_urls() + yield + + +@pytest.fixture(autouse=True) +def calibre_patch(): + """Patch calibre methods.""" + with patch('plinth.modules.calibre.list_libraries') as list_libraries: + list_libraries.return_value = ['TestExistingLibrary'] + + yield + + +def make_request(request, view, **kwargs): + """Make request with a message storage.""" + setattr(request, 'session', 'session') + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + response = view(request, **kwargs) + + return response, messages + + +@patch('plinth.modules.calibre.create_library') +def test_create_library(create_library, rf): + """Test that create library view works.""" + form_data = {'calibre-name': 'TestLibrary'} + request = rf.post(urls.reverse('calibre:create-library'), data=form_data) + view = views.CreateLibraryView.as_view() + response, messages = make_request(request, view) + + assert response.status_code == 302 + assert response.url == urls.reverse('calibre:index') + assert list(messages)[0].message == 'Library created.' + create_library.assert_has_calls([call('TestLibrary')]) + + +@patch('plinth.modules.calibre.create_library') +def test_create_library_failed(create_library, rf): + """Test that create library fails as expected.""" + create_library.side_effect = actions.ActionError('calibre', 'TestOutput', + 'TestError') + form_data = {'calibre-name': 'TestLibrary'} + request = rf.post(urls.reverse('calibre:create-library'), data=form_data) + view = views.CreateLibraryView.as_view() + response, messages = make_request(request, view) + + assert response.status_code == 302 + assert response.url == urls.reverse('calibre:index') + assert list(messages)[0].message == \ + 'An error occurred while creating the library. TestError' + + +def test_create_library_existing_library(rf): + """Test that create library errors out for an existing library name.""" + form_data = {'calibre-name': 'TestExistingLibrary'} + request = rf.post(urls.reverse('calibre:create-library'), data=form_data) + view = views.CreateLibraryView.as_view() + response, _ = make_request(request, view) + + assert response.context_data['form'].errors['name'][ + 0] == 'A library with this name already exists.' + assert response.status_code == 200 + + +def test_create_library_invalid_name(rf): + """Test that create library errors out for invalid name.""" + form_data = {'calibre-name': 'Invalid Library'} + request = rf.post(urls.reverse('calibre:create-library'), data=form_data) + view = views.CreateLibraryView.as_view() + response, _ = make_request(request, view) + + assert response.context_data['form'].errors['name'][ + 0] == 'Enter a valid value.' + assert response.status_code == 200 + + +@patch('plinth.modules.calibre.app') +def test_delete_library_confirmation_view(_app, rf): + """Test that deleting library confirmation shows correct name.""" + response, _ = make_request(rf.get(''), views.delete_library, + name='TestExistingLibrary') + assert response.status_code == 200 + assert response.context_data['name'] == 'TestExistingLibrary' + + +@patch('plinth.modules.calibre.delete_library') +@patch('plinth.modules.calibre.app') +def test_delete_library(_app, delete_library, rf): + """Test that deleting a library works.""" + response, messages = make_request(rf.post(''), views.delete_library, + name='TestExistingLibrary') + assert response.status_code == 302 + assert response.url == urls.reverse('calibre:index') + assert list(messages)[0].message == 'TestExistingLibrary deleted.' + delete_library.assert_has_calls([call('TestExistingLibrary')]) + + +@patch('plinth.modules.calibre.delete_library') +def test_delete_library_error(delete_library, rf): + """Test that deleting a library shows error when operation fails.""" + delete_library.side_effect = actions.ActionError('calibre', 'TestInput', + 'TestError') + response, messages = make_request(rf.post(''), views.delete_library, + name='TestExistingLibrary') + assert response.status_code == 302 + assert response.url == urls.reverse('calibre:index') + assert list(messages)[0].message == \ + 'Could not delete TestExistingLibrary: '\ + "('calibre', 'TestInput', 'TestError')" + + +def test_delete_library_non_existing(rf): + """Test that deleting a library shows error when operation fails.""" + with pytest.raises(Http404): + make_request(rf.post(''), views.delete_library, + name='TestNonExistingLibrary') + + with pytest.raises(Http404): + make_request(rf.get(''), views.delete_library, + name='TestNonExistingLibrary') diff --git a/plinth/modules/calibre/urls.py b/plinth/modules/calibre/urls.py new file mode 100644 index 000000000..a4cb694e3 --- /dev/null +++ b/plinth/modules/calibre/urls.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +URLs for the calibre module. +""" + +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^apps/calibre/$', views.CalibreAppView.as_view(), name='index'), + url(r'^apps/calibre/library/create/$', views.CreateLibraryView.as_view(), + name='create-library'), + url(r'^apps/calibre/library/(?P[a-zA-Z0-9_.-]+)/delete/$', + views.delete_library, name='delete-library'), +] diff --git a/plinth/modules/calibre/views.py b/plinth/modules/calibre/views.py new file mode 100644 index 000000000..f969de9d5 --- /dev/null +++ b/plinth/modules/calibre/views.py @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Views for the calibre module. +""" + +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.http import Http404 +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.urls import reverse_lazy +from django.utils.translation import ugettext as _ +from django.views.generic.edit import FormView + +from plinth import actions, views +from plinth.modules import calibre + +from . import forms + + +class CalibreAppView(views.AppView): + """Serve configuration form.""" + app_id = 'calibre' + template_name = 'calibre.html' + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['libraries'] = calibre.list_libraries() + return context + + +class CreateLibraryView(SuccessMessageMixin, FormView): + """View to create an empty library.""" + form_class = forms.CreateLibraryForm + prefix = 'calibre' + template_name = 'form.html' + success_url = reverse_lazy('calibre:index') + success_message = _('Library created.') + + def form_valid(self, form): + """Create the library on valid form submission.""" + try: + calibre.create_library(form.cleaned_data['name']) + except actions.ActionError as error: + self.success_message = '' + error_text = error.args[2].split('\n')[0] + messages.error( + self.request, "{0} {1}".format( + _('An error occurred while creating the library.'), + error_text)) + + return super().form_valid(form) + + +def delete_library(request, name): + """View to delete a library.""" + if name not in calibre.list_libraries(): + raise Http404 + + if request.method == 'POST': + try: + calibre.delete_library(name) + messages.success(request, _('{name} deleted.').format(name=name)) + except actions.ActionError as error: + messages.error( + request, + _('Could not delete {name}: {error}').format( + name=name, error=error)) + return redirect(reverse_lazy('calibre:index')) + + return TemplateResponse(request, 'calibre-delete-library.html', { + 'title': calibre.app.info.name, + 'name': name + }) diff --git a/plinth/templates/form.html b/plinth/templates/form.html new file mode 100644 index 000000000..ae21f928a --- /dev/null +++ b/plinth/templates/form.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} + +

{{ title }}

+ +
+ {% csrf_token %} + + {{ form|bootstrap }} + + +
+ +{% endblock %} diff --git a/pytest.ini b/pytest.ini index e95adc2ab..cad6c2306 100644 --- a/pytest.ini +++ b/pytest.ini @@ -4,6 +4,7 @@ markers = functional apps backups bind + calibre configuration date_and_time deluge diff --git a/static/themes/default/icons/calibre.png b/static/themes/default/icons/calibre.png new file mode 100644 index 000000000..12e7f1067 Binary files /dev/null and b/static/themes/default/icons/calibre.png differ diff --git a/static/themes/default/icons/calibre.svg b/static/themes/default/icons/calibre.svg new file mode 100644 index 000000000..a433020da --- /dev/null +++ b/static/themes/default/icons/calibre.svg @@ -0,0 +1,1139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + Kovid Goyal + + + + + CC BY-SA + + + + + Kovid Goyal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +   + + + + + + + + + + + + + + +