mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +00:00
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:
parent
68475668ea
commit
6bfffeee13
76
actions/calibre
Executable file
76
actions/calibre
Executable 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()
|
||||
124
plinth/modules/calibre/__init__.py
Normal file
124
plinth/modules/calibre/__init__.py
Normal 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]])
|
||||
@ -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>
|
||||
@ -0,0 +1 @@
|
||||
plinth.modules.calibre
|
||||
@ -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
|
||||
29
plinth/modules/calibre/forms.py
Normal file
29
plinth/modules/calibre/forms.py
Normal 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
|
||||
21
plinth/modules/calibre/manifest.py
Normal file
21
plinth/modules/calibre/manifest.py
Normal 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']
|
||||
})
|
||||
30
plinth/modules/calibre/templates/calibre-delete-library.html
Normal file
30
plinth/modules/calibre/templates/calibre-delete-library.html
Normal 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 %}
|
||||
59
plinth/modules/calibre/templates/calibre.html
Normal file
59
plinth/modules/calibre/templates/calibre.html
Normal 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 %}
|
||||
0
plinth/modules/calibre/tests/__init__.py
Normal file
0
plinth/modules/calibre/tests/__init__.py
Normal file
1
plinth/modules/calibre/tests/data/sample.txt
Normal file
1
plinth/modules/calibre/tests/data/sample.txt
Normal file
@ -0,0 +1 @@
|
||||
Testing
|
||||
84
plinth/modules/calibre/tests/test_actions.py
Normal file
84
plinth/modules/calibre/tests/test_actions.py
Normal 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'])
|
||||
180
plinth/modules/calibre/tests/test_functional.py
Normal file
180
plinth/modules/calibre/tests/test_functional.py
Normal 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)
|
||||
146
plinth/modules/calibre/tests/test_views.py
Normal file
146
plinth/modules/calibre/tests/test_views.py
Normal 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')
|
||||
16
plinth/modules/calibre/urls.py
Normal file
16
plinth/modules/calibre/urls.py
Normal 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'),
|
||||
]
|
||||
75
plinth/modules/calibre/views.py
Normal file
75
plinth/modules/calibre/views.py
Normal 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
|
||||
})
|
||||
22
plinth/templates/form.html
Normal file
22
plinth/templates/form.html
Normal 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 %}
|
||||
@ -4,6 +4,7 @@ markers = functional
|
||||
apps
|
||||
backups
|
||||
bind
|
||||
calibre
|
||||
configuration
|
||||
date_and_time
|
||||
deluge
|
||||
|
||||
BIN
static/themes/default/icons/calibre.png
Normal file
BIN
static/themes/default/icons/calibre.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
1139
static/themes/default/icons/calibre.svg
Normal file
1139
static/themes/default/icons/calibre.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 52 KiB |
Loading…
x
Reference in New Issue
Block a user