mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-05-27 10:44:33 +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
|
apps
|
||||||
backups
|
backups
|
||||||
bind
|
bind
|
||||||
|
calibre
|
||||||
configuration
|
configuration
|
||||||
date_and_time
|
date_and_time
|
||||||
deluge
|
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