calibre: Add new e-book library app

[joseph: initial code for the app]
Signed-off-by: Joseph Nuthalapati <njoseph@thoughtworks.com>
[sunil: use the modified framework API]
[sunil: simplify setup logic, move to service file]
[sunil: strict security for service file, dynamic users]
[sunil: interface for managing libraries]
[sunil: implement backup/restore]
[sunil: add functional, action, and view tests]
[sunil: use svg icon]
[sunil: update description]
[sunil: fix apache configuration]
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
This commit is contained in:
Joseph Nuthalapati 2018-12-09 21:34:18 +05:30 committed by Joseph Nuthalapati
parent 68475668ea
commit 6bfffeee13
No known key found for this signature in database
GPG Key ID: 5398F00A2FA43C35
20 changed files with 2061 additions and 0 deletions

76
actions/calibre Executable file
View File

@ -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()

View File

@ -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 <em>calibre</em> 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]])

View File

@ -0,0 +1,10 @@
##
## On all sites, provide calibre web interface on a path: /calibre
##
<Location /calibre>
ProxyPass http://localhost:8844/calibre
Include includes/freedombox-single-sign-on.conf
<IfModule mod_auth_pubtkt.c>
TKTAuthToken "admin" "calibre"
</IfModule>
</Location>

View File

@ -0,0 +1 @@
plinth.modules.calibre

View File

@ -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

View File

@ -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

View File

@ -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']
})

View File

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block content %}
<h3>
{% blocktrans trimmed %}
Delete calibre Library <em>{{ name }}</em>
{% endblocktrans %}
</h3>
<p>
{% blocktrans trimmed %}
Delete this library permanently? All stored e-books and saved data will be
lost.
{% endblocktrans %}
</p>
<form class="form" method="post">
{% csrf_token %}
<input type="submit" class="btn btn-md btn-danger"
value="{% blocktrans %}Delete {{ name }}{% endblocktrans %}"/>
</form>
{% endblock %}

View File

@ -0,0 +1,59 @@
{% extends "app.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block page_head %}
<style type="text/css">
.library-label {
display: inline-block;
width: 40%;
}
.list-group-item .btn {
margin: -5px 2px;
}
</style>
{% endblock %}
{% block configuration %}
{{ block.super }}
<h3>{% trans "Manage Libraries" %}</h3>
<div class="btn-toolbar">
<a href="{% url 'calibre:create-library' %}" class="btn btn-default"
role="button" title="{% trans 'Create Library' %}">
<span class="fa fa-plus" aria-hidden="true"></span>
{% trans 'Create Library' %}
</a>
</div>
<div class="row">
<div class="col-sm-6">
{% if not libraries %}
<p>{% trans 'No libraries available.' %}</p>
{% else %}
<div id="calibre-libraries" class="list-group">
{% for library in libraries %}
<div class="list-group-item clearfix">
<a href="{% url 'calibre:delete-library' library %}"
class="btn btn-default btn-sm pull-right" role="button"
title="{% blocktrans %}Delete library {{ library }}{% endblocktrans %}">
<span class="fa fa-trash-o" aria-hidden="true"></span>
</a>
<a class="library-label"
href="/calibre/#library_id={{ library }}&panel=book_list"
title="{% blocktrans %}Go to library {{ library }}{% endblocktrans %}">
{{ library }}
</a>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

View File

@ -0,0 +1 @@
Testing

View File

@ -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'])

View File

@ -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)

View File

@ -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')

View File

@ -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<name>[a-zA-Z0-9_.-]+)/delete/$',
views.delete_library, name='delete-library'),
]

View File

@ -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
})

View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block content %}
<h3>{{ title }}</h3>
<form class="form" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary"
value="{% trans "Submit" %}"/>
</form>
{% endblock %}

View File

@ -4,6 +4,7 @@ markers = functional
apps
backups
bind
calibre
configuration
date_and_time
deluge

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 52 KiB