From cfdf92cf0d33ed97a5a38b274f37dc8950e04ade Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Tue, 17 Oct 2023 13:21:04 -0700 Subject: [PATCH] kiwix: Fix various issues after review - Fix icon paths in copyright file. - Minor refactoring. - Add Kiwix library link to app page as well as users may want to see the content available before installing the app. - Consolidate terminology to 'content package' for UI and just 'package' internally. - Drop unused SYSTEM_USER constant. - Simplify the ExecStart= in systemd service file. - Fix incorrect i18n caused by non-lazy formatting of strings. - Confirm that xml parsing is not vulnerable as expat library of required version is used in Debian bookworm. - Don't start the kiwix daemon when managing library if app is disabled. - Ignore errors when removing files during uninstallation. - Handle failures more gracefully when library XML file does not have required attributes. - Update SVG/PNG icons to adhere to FreedomBox guidelines. - Trim block translations in templates. - Drop comments/deadcode inside translation strings. - Drop a comment inside add content page that only makes sense with multiple methods for adding content. - tests: Don't use pkg_resources library as it is deprecated. We can use importlib.resources library in future if we run tests on zip installations. - Fix potential security issues while writing file to tmp directory. Signed-off-by: Sunil Mohan Adapa --- debian/copyright | 4 +- plinth/modules/kiwix/__init__.py | 21 ++-- .../system/kiwix-server-freedombox.service | 2 +- plinth/modules/kiwix/forms.py | 13 +- plinth/modules/kiwix/manifest.py | 2 +- plinth/modules/kiwix/privileged.py | 68 ++++++----- plinth/modules/kiwix/static/icons/kiwix.png | Bin 10665 -> 18465 bytes plinth/modules/kiwix/static/icons/kiwix.svg | 60 +++++++--- .../kiwix/templates/add-content-package.html | 58 --------- .../kiwix/templates/kiwix-add-package.html | 39 ++++++ ...package.html => kiwix-delete-package.html} | 3 +- plinth/modules/kiwix/templates/kiwix.html | 15 ++- plinth/modules/kiwix/tests/data/invalid.zim | 2 +- plinth/modules/kiwix/tests/test_functional.py | 69 +++++------ plinth/modules/kiwix/tests/test_privileged.py | 50 ++++---- .../modules/kiwix/tests/test_validations.py | 21 ++-- plinth/modules/kiwix/tests/test_views.py | 111 ++++++++---------- plinth/modules/kiwix/urls.py | 8 +- plinth/modules/kiwix/views.py | 44 ++++--- 19 files changed, 301 insertions(+), 289 deletions(-) delete mode 100644 plinth/modules/kiwix/templates/add-content-package.html create mode 100644 plinth/modules/kiwix/templates/kiwix-add-package.html rename plinth/modules/kiwix/templates/{delete-content-package.html => kiwix-delete-package.html} (93%) diff --git a/debian/copyright b/debian/copyright index 6e8cf2899..c6cbdcb28 100644 --- a/debian/copyright +++ b/debian/copyright @@ -132,8 +132,8 @@ Files: plinth/modules/janus/static/icons/janus.png Copyright: 2014-2022 Meetecho License: GPL-3 with OpenSSL exception -Files: static/themes/default/icons/kiwix.png - static/themes/default/icons/kiwix.svg +Files: plinth/modules/kiwix/static/icons/kiwix.png + plinth/modules/kiwix/static/icons/kiwix.svg Copyright: 2020 The other Kiwix guy Comment: https://commons.wikimedia.org/wiki/File:Kiwix_logo_v3.svg License: CC-BY-SA-4.0 diff --git a/plinth/modules/kiwix/__init__.py b/plinth/modules/kiwix/__init__.py index 7243ddcb2..af21083f1 100644 --- a/plinth/modules/kiwix/__init__.py +++ b/plinth/modules/kiwix/__init__.py @@ -5,13 +5,14 @@ FreedomBox app for Kiwix content server. from django.utils.translation import gettext_lazy as _ -from plinth import app as app_module, frontpage, menu, package +from plinth import app as app_module +from plinth import frontpage, menu, package from plinth.config import DropinConfigs from plinth.daemon import Daemon from plinth.modules.apache.components import Webserver from plinth.modules.backups.components import BackupRestore -from plinth.modules.kiwix import manifest -from plinth.modules.firewall.components import Firewall, FirewallLocalProtection +from plinth.modules.firewall.components import (Firewall, + FirewallLocalProtection) from plinth.modules.users.components import UsersAndGroups from . import manifest, privileged @@ -28,11 +29,13 @@ _description = [
  • Educational materials: PHET, TED Ed, Vikidia
  • eBooks: Project Gutenberg
  • Magazines: Low-tech Magazine
  • - ''') + '''), + _('You can download content packages from the Kiwix ' + 'project or create your own.'), ] -SYSTEM_USER = 'kiwix' - class KiwixApp(app_module.App): """FreedomBox app for Kiwix.""" @@ -116,6 +119,6 @@ class KiwixApp(app_module.App): def validate_file_name(file_name: str): - """Check if the content archive file has a valid extension.""" - if not file_name.endswith(".zim"): - raise ValueError(f"Expected a ZIM file. Found {file_name}") + """Check if the content package file has a valid extension.""" + if not file_name.endswith('.zim'): + raise ValueError(f'Expected a ZIM file. Found {file_name}') diff --git a/plinth/modules/kiwix/data/usr/lib/systemd/system/kiwix-server-freedombox.service b/plinth/modules/kiwix/data/usr/lib/systemd/system/kiwix-server-freedombox.service index 7abe49c60..a1ceaa2e9 100644 --- a/plinth/modules/kiwix/data/usr/lib/systemd/system/kiwix-server-freedombox.service +++ b/plinth/modules/kiwix/data/usr/lib/systemd/system/kiwix-server-freedombox.service @@ -13,7 +13,7 @@ Environment=HOME="/var/lib/kiwix-server-freedombox" Environment=LIBRARY_PATH="/var/lib/kiwix-server-freedombox/library_zim.xml" Environment=ARGS="--library --port=4201 --urlRootLocation=kiwix" ExecStartPre=sh -e -c "mkdir -p $HOME/content; library=$$(ls ${LIBRARY_PATH} 2>/dev/null || true); [ \"x$${library}\" = \"x\" ] && (mkdir -p \"${HOME}\" && echo '\n\n' > \"${LIBRARY_PATH}\") || true" -ExecStart=sh -e -c "exec /usr/bin/kiwix-serve $ARGS $LIBRARY_PATH" +ExecStart=/usr/bin/kiwix-serve $ARGS $LIBRARY_PATH Restart=on-failure ExecReload=/bin/kill -HUP $MAINPID DynamicUser=yes diff --git a/plinth/modules/kiwix/forms.py b/plinth/modules/kiwix/forms.py index c926c36ca..d41629d24 100644 --- a/plinth/modules/kiwix/forms.py +++ b/plinth/modules/kiwix/forms.py @@ -8,18 +8,21 @@ from django.core import validators from django.utils.translation import gettext_lazy as _ from plinth import cfg +from plinth.utils import format_lazy from .privileged import KIWIX_HOME -class AddContentForm(forms.Form): - """Form to create an empty library.""" +class AddPackageForm(forms.Form): + """Form to upload a content package to a library.""" # Would be nice to have a progress bar when uploading large files. file = forms.FileField( label=_('Upload File'), required=True, validators=[ validators.FileExtensionValidator( ['zim'], _('Content packages have to be in .zim format')) - ], help_text=_(f'''Uploaded ZIM files will be stored under - {KIWIX_HOME}/content on your {cfg.box_name}. If Kiwix fails to add the file, - it will be deleted immediately to save disk space.''')) + ], help_text=format_lazy( + _('Uploaded ZIM files will be stored under {kiwix_home}/content ' + 'on your {box_name}. If Kiwix fails to add the file, it will be ' + 'deleted immediately to save disk space.'), + box_name=_(cfg.box_name), kiwix_home=KIWIX_HOME)) diff --git a/plinth/modules/kiwix/manifest.py b/plinth/modules/kiwix/manifest.py index e7e3bc3d8..bf45ebe8d 100644 --- a/plinth/modules/kiwix/manifest.py +++ b/plinth/modules/kiwix/manifest.py @@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _ from plinth.clients import validate clients = validate([{ - 'name': _('kiwix'), + 'name': _('Kiwix'), 'platforms': [{ 'type': 'web', 'url': '/kiwix' diff --git a/plinth/modules/kiwix/privileged.py b/plinth/modules/kiwix/privileged.py index 5a47db19b..43cabd5fd 100644 --- a/plinth/modules/kiwix/privileged.py +++ b/plinth/modules/kiwix/privileged.py @@ -3,10 +3,11 @@ Privileged actions for Kiwix content server. """ -import subprocess +import os import pathlib import shutil -import xml.etree.ElementTree as ET +import subprocess +from xml.etree import ElementTree from plinth import action_utils from plinth.actions import privileged @@ -19,19 +20,19 @@ CONTENT_DIR = KIWIX_HOME / 'content' @privileged -def add_content(file_name: str): +def add_package(file_name: str): """Adds a content package to Kiwix. Adding packages is idempotent. - Users can add content to Kiwix in multiple ways: + Users can add content to Kiwix in multiple ways: - Upload a ZIM file - Provide a link to the ZIM file - Provide a magnet link to the ZIM file - The commandline download manager aria2c is a dependency of kiwix-tools. - aria2c is used for both HTTP and Magnet downloads. - """ + The commandline download manager aria2c is a dependency of kiwix-tools. + aria2c is used for both HTTP and Magnet downloads. + """ kiwix.validate_file_name(file_name) # Moving files to the Kiwix library path ensures that @@ -39,6 +40,8 @@ def add_content(file_name: str): zim_file_name = pathlib.Path(file_name).name CONTENT_DIR.mkdir(exist_ok=True) zim_file_dest = str(CONTENT_DIR / zim_file_name) + shutil.chown(file_name, 'root', 'root') + os.chmod(file_name, 0o644) shutil.move(file_name, zim_file_dest) _kiwix_manage_add(zim_file_dest) @@ -48,40 +51,51 @@ def _kiwix_manage_add(zim_file: str): subprocess.check_call(['kiwix-manage', LIBRARY_FILE, 'add', zim_file]) # kiwix-serve doesn't read the library file unless it is restarted. - action_utils.service_restart('kiwix-server-freedombox') + action_utils.service_try_restart('kiwix-server-freedombox') @privileged -def uninstall(): +def uninstall() -> None: """Remove all content during uninstall.""" - shutil.rmtree(str(CONTENT_DIR)) - LIBRARY_FILE.unlink() + shutil.rmtree(str(CONTENT_DIR), ignore_errors=True) + LIBRARY_FILE.unlink(missing_ok=True) @privileged -def list_content_packages() -> dict[str, dict]: - library = ET.parse(LIBRARY_FILE).getroot() +def list_packages() -> dict[str, dict[str, str]]: + """Return the list of content packages configured in library file.""" + library = ElementTree.parse(LIBRARY_FILE).getroot() - # Relying on the fact that Python dictionaries maintain order of insertion. - return { - book.attrib['id']: { - 'title': book.attrib['title'], - 'description': book.attrib['description'], - # strip '.zim' from the path - 'path': book.attrib['path'].split('/')[-1][:-4].lower() - } - for book in library - } + books = {} + for book in library: + path = book.attrib['path'].split('/')[-1] + path = path.removesuffix('.zim').lower() # Strip '.zim' from the path + try: + books[book.attrib['id']] = { + 'title': book.attrib['title'], + 'description': book.attrib['description'], + 'path': path + } + except KeyError: + pass # Ignore entries that don't have expected properties + + return books @privileged -def delete_content_package(zim_id: str): - library = ET.parse(LIBRARY_FILE).getroot() +def delete_package(zim_id: str): + """Remove a content package from the library file.""" + library = ElementTree.parse(LIBRARY_FILE).getroot() for book in library: - if book.attrib['id'] == zim_id: + try: + if book.attrib['id'] != zim_id: + continue + subprocess.check_call( ['kiwix-manage', LIBRARY_FILE, 'remove', zim_id]) (KIWIX_HOME / book.attrib['path']).unlink() - action_utils.service_restart('kiwix-server-freedombox') + action_utils.service_try_restart('kiwix-server-freedombox') return + except KeyError: # Expected properties not found on elements + pass diff --git a/plinth/modules/kiwix/static/icons/kiwix.png b/plinth/modules/kiwix/static/icons/kiwix.png index 0217628241302b53b8fbb8a80f9c4f9298e18d12..9abe720c8c1cc87c7f27feb8ef8c2d9f054a08a0 100644 GIT binary patch literal 18465 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 literal 10665 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 diff --git a/plinth/modules/kiwix/static/icons/kiwix.svg b/plinth/modules/kiwix/static/icons/kiwix.svg index 566b1064b..f944fd9ff 100644 --- a/plinth/modules/kiwix/static/icons/kiwix.svg +++ b/plinth/modules/kiwix/static/icons/kiwix.svg @@ -1,20 +1,48 @@ - + - -

    {{ title }}

    - -

    - {% blocktrans %} - You can download - content packages from the Kiwix project or - create your own. - {% endblocktrans %} -

    - -

    - {% blocktrans %} - Content packages can be added in the following ways: -

      -
    • upload a ZIM file
    • - - - - -
    - - - - {% endblocktrans %} -

    - - {% if max_filesize %} - - {% endif %} - -
    - {% csrf_token %} - - {{ form|bootstrap }} - - -
    - -{% endblock %} diff --git a/plinth/modules/kiwix/templates/kiwix-add-package.html b/plinth/modules/kiwix/templates/kiwix-add-package.html new file mode 100644 index 000000000..1e64d958e --- /dev/null +++ b/plinth/modules/kiwix/templates/kiwix-add-package.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} + +

    {{ title }}

    + +

    + {% blocktrans trimmed %} + You can download content packages from the Kiwix + project or create your own. + {% endblocktrans %} +

    + + {% if max_filesize %} + + {% endif %} + +
    + {% csrf_token %} + + {{ form|bootstrap }} + + +
    + +{% endblock %} diff --git a/plinth/modules/kiwix/templates/delete-content-package.html b/plinth/modules/kiwix/templates/kiwix-delete-package.html similarity index 93% rename from plinth/modules/kiwix/templates/delete-content-package.html rename to plinth/modules/kiwix/templates/kiwix-delete-package.html index 70f209af7..44159849a 100644 --- a/plinth/modules/kiwix/templates/delete-content-package.html +++ b/plinth/modules/kiwix/templates/kiwix-delete-package.html @@ -15,7 +15,8 @@

    {% blocktrans trimmed %} - Delete this package permanently? You may add it back later if you have a copy of the ZIM file. + Delete this package permanently? You may add it back later if you have a + copy of the ZIM file. {% endblocktrans %}

    diff --git a/plinth/modules/kiwix/templates/kiwix.html b/plinth/modules/kiwix/templates/kiwix.html index 45fb03c7d..e3a30a822 100644 --- a/plinth/modules/kiwix/templates/kiwix.html +++ b/plinth/modules/kiwix/templates/kiwix.html @@ -8,32 +8,31 @@ {% block configuration %} {{ block.super }} -

    {% trans "Manage Content" %}

    +

    {% trans "Manage Content Packages" %}

    {% if not packages %} -

    {% trans 'No content available.' %}

    +

    {% trans 'No content packages available.' %}

    {% else %}
    {% for id, package in packages.items %}
    - + title="{{ package.description }}"> {{ package.title }} - diff --git a/plinth/modules/kiwix/tests/data/invalid.zim b/plinth/modules/kiwix/tests/data/invalid.zim index bd7fab739..944c593a9 100644 --- a/plinth/modules/kiwix/tests/data/invalid.zim +++ b/plinth/modules/kiwix/tests/data/invalid.zim @@ -1 +1 @@ -Nothing to see here. \ No newline at end of file +Nothing to see here. diff --git a/plinth/modules/kiwix/tests/test_functional.py b/plinth/modules/kiwix/tests/test_functional.py index 02d364939..91c8ea682 100644 --- a/plinth/modules/kiwix/tests/test_functional.py +++ b/plinth/modules/kiwix/tests/test_functional.py @@ -3,94 +3,97 @@ Functional, browser based tests for Kiwix app. """ -import pkg_resources +import pathlib +from time import sleep + import pytest -from time import sleep -from plinth.modules.kiwix.tests.test_privileged import ZIM_ID - from plinth.tests import functional +from .test_privileged import ZIM_ID + pytestmark = [pytest.mark.apps, pytest.mark.sso, pytest.mark.kiwix] _default_url = functional.config['DEFAULT']['url'] -ZIM_ID = 'bc4f8cdf-5626-2b13-3860-0033deddfbea' +_data_dir = pathlib.Path(__file__).parent / 'data' class TestKiwixApp(functional.BaseAppTests): + """Basic functional tests for Kiwix app.""" + app_name = 'kiwix' has_service = True has_web = True - def test_add_delete_content_package(self, session_browser): + def test_add_delete_package(self, session_browser): """Test adding/deleting content package to the library.""" functional.app_enable(session_browser, 'kiwix') - zim_file = pkg_resources.resource_filename( - 'plinth.modules.kiwix.tests', 'data/FreedomBox.zim') - _add_content_package(session_browser, zim_file) - assert _is_content_package_listed(session_browser, 'freedombox') - assert _is_content_package_available(session_browser, 'FreedomBox') + zim_file = _data_dir / 'FreedomBox.zim' + _add_package(session_browser, str(zim_file)) + assert _is_package_listed(session_browser, 'freedombox') + assert _is_package_available(session_browser, 'FreedomBox') - _delete_content_package(session_browser, ZIM_ID) - assert not _is_content_package_listed(session_browser, 'freedombox') - assert not _is_content_package_available(session_browser, 'FreedomBox') + _delete_package(session_browser, ZIM_ID) + assert not _is_package_listed(session_browser, 'freedombox') + assert not _is_package_available(session_browser, 'FreedomBox') @pytest.mark.backups def test_backup_restore(self, session_browser): """Test backing up and restoring.""" functional.app_enable(session_browser, 'kiwix') - zim_file = pkg_resources.resource_filename( - 'plinth.modules.kiwix.tests', 'data/FreedomBox.zim') - _add_content_package(session_browser, zim_file) + zim_file = _data_dir / 'FreedomBox.zim' + _add_package(session_browser, str(zim_file)) functional.backup_create(session_browser, 'kiwix', 'test_kiwix') - _delete_content_package(session_browser, ZIM_ID) + _delete_package(session_browser, ZIM_ID) functional.backup_restore(session_browser, 'kiwix', 'test_kiwix') - assert _is_content_package_listed(session_browser, 'freedombox') - assert _is_content_package_available(session_browser, 'FreedomBox') + assert _is_package_listed(session_browser, 'freedombox') + assert _is_package_available(session_browser, 'FreedomBox') def test_add_invalid_zim_file(self, session_browser): """Test handling of invalid zim files.""" functional.app_enable(session_browser, 'kiwix') - zim_file = pkg_resources.resource_filename( - 'plinth.modules.kiwix.tests', 'data/invalid.zim') - _add_content_package(session_browser, zim_file) + zim_file = _data_dir / 'invalid.zim' + _add_package(session_browser, str(zim_file)) - assert not _is_content_package_listed(session_browser, 'invalid') + assert not _is_package_listed(session_browser, 'invalid') -def _add_content_package(browser, file_name): - browser.links.find_by_href('/plinth/apps/kiwix/content/add/').first.click() +def _add_package(browser, file_name): + """Add a package by uploading the ZIM file in kiwix app page.""" + browser.links.find_by_href('/plinth/apps/kiwix/package/add/').first.click() browser.attach_file('kiwix-file', file_name) functional.submit(browser, form_class='form-kiwix') -def _is_content_package_available(browser, title) -> bool: +def _is_package_available(browser, title) -> bool: + """Check whether a ZIM file is available in Kiwix web interface.""" browser.visit(f'{_default_url}/kiwix') sleep(1) # Allow time for the books to appear titles = browser.find_by_id('book__title') - print(len(titles)) - print([title.value for title in titles]) - return any(map(lambda e: e.value == title, titles)) + return any(element.value == title for element in titles) -def _is_content_package_listed(browser, name) -> bool: +def _is_package_listed(browser, name) -> bool: + """Return whether a content package is list in kiwix app page.""" functional.nav_to_module(browser, 'kiwix') links_found = browser.links.find_by_partial_href(f'/kiwix/viewer#{name}') return len(links_found) == 1 -def _delete_content_package(browser, zim_id): +def _delete_package(browser, zim_id): + """Delete a content package from the kiwix app page.""" functional.nav_to_module(browser, 'kiwix') link = browser.links.find_by_href( - f'/plinth/apps/kiwix/content/{zim_id}/delete/') + f'/plinth/apps/kiwix/package/{zim_id}/delete/') if not link: raise ValueError('ZIM file missing!') + link.first.click() functional.submit(browser, form_class='form-delete') diff --git a/plinth/modules/kiwix/tests/test_privileged.py b/plinth/modules/kiwix/tests/test_privileged.py index 3050c70b3..34b98707d 100644 --- a/plinth/modules/kiwix/tests/test_privileged.py +++ b/plinth/modules/kiwix/tests/test_privileged.py @@ -4,7 +4,7 @@ Test module for Kiwix actions. """ import pathlib -import pkg_resources +import shutil from unittest.mock import patch import pytest @@ -22,65 +22,57 @@ ZIM_ID = 'bc4f8cdf-5626-2b13-3860-0033deddfbea' @pytest.fixture(autouse=True) -def fixture_kiwix_home(tmpdir): - """Set Kiwix home to a new temporary directory - initialized with an empty library file.""" - privileged.KIWIX_HOME = pathlib.Path(str(tmpdir / 'kiwix')) +def fixture_kiwix_home(tmp_path): + """Create a new Kiwix home in a new temporary directory. + + Initialize with a sample, valid library file. + """ + privileged.KIWIX_HOME = tmp_path / 'kiwix' privileged.KIWIX_HOME.mkdir() privileged.CONTENT_DIR = privileged.KIWIX_HOME / 'content' privileged.CONTENT_DIR.mkdir() privileged.LIBRARY_FILE = privileged.KIWIX_HOME / 'library_zim.xml' - with open(privileged.LIBRARY_FILE, 'w', encoding='utf_8') as library_file: - library_file.write(EMPTY_LIBRARY_CONTENTS) + source_file = pathlib.Path(__file__).parent / 'data/sample_library_zim.xml' + shutil.copy(source_file, privileged.LIBRARY_FILE) @pytest.fixture(autouse=True) def fixture_patch(): """Patch some underlying methods.""" - with patch('subprocess.check_call'), patch('subprocess.run'): + with patch('subprocess.check_call'), patch('subprocess.run'), patch( + 'os.chown'): yield -def test_add_content(tmpdir): +def test_add_package(tmp_path): """Test adding a content package to Kiwix.""" - some_dir = tmpdir / 'some' / 'dir' - pathlib.Path(some_dir).mkdir(parents=True, exist_ok=True) + some_dir = tmp_path / 'some' / 'dir' + some_dir.mkdir(parents=True, exist_ok=True) zim_file_name = 'wikipedia_en_all_maxi_2022-05.zim' orig_file = some_dir / zim_file_name - pathlib.Path(orig_file).touch() + orig_file.touch() - privileged.add_content(str(orig_file)) + privileged.add_package(str(orig_file)) assert (privileged.KIWIX_HOME / 'content' / zim_file_name).exists() assert not orig_file.exists() -def test_list_content_packages(): +def test_list_packages(): """Test listing the content packages from a library file.""" - privileged.LIBRARY_FILE = pkg_resources.resource_filename( - 'plinth.modules.kiwix.tests', 'data/sample_library_zim.xml') - content_packages = privileged.list_content_packages() - assert content_packages[ZIM_ID] == { + content = privileged.list_packages() + assert content[ZIM_ID] == { 'title': 'FreedomBox', 'description': 'A sample content archive', 'path': 'freedombox' } -def test_delete_content_package(): +def test_delete_package(): """Test deleting one content package.""" - sample_library_file = pkg_resources.resource_filename( - 'plinth.modules.kiwix.tests', 'data/sample_library_zim.xml') - - with open(sample_library_file, 'r', - encoding='utf_8') as sample_library_file: - with open(privileged.LIBRARY_FILE, 'w', - encoding='utf_8') as library_file: - library_file.write(sample_library_file.read()) - zim_file = privileged.CONTENT_DIR / 'FreedomBox.zim' zim_file.touch() - privileged.delete_content_package(ZIM_ID) + privileged.delete_package(ZIM_ID) assert not zim_file.exists() # Cannot check that the book is removed from library_zim.xml diff --git a/plinth/modules/kiwix/tests/test_validations.py b/plinth/modules/kiwix/tests/test_validations.py index f4113e131..5be411a25 100644 --- a/plinth/modules/kiwix/tests/test_validations.py +++ b/plinth/modules/kiwix/tests/test_validations.py @@ -3,19 +3,18 @@ Test module for Kiwix validations. """ -import unittest +import pytest + from plinth.modules import kiwix -class TestValidations(unittest.TestCase): +def test_add_file_with_invalid_extension(): + """Test that adding a file with invalid fails as expected.""" + with pytest.raises(ValueError): + kiwix.validate_file_name('wikipedia.zip') - def test_add_file_with_invalid_extension(self): - self.assertRaises(ValueError, - lambda: kiwix.validate_file_name('wikipedia.zip')) + # We don't support the legacy format of split zim files. + with pytest.raises(ValueError): + kiwix.validate_file_name('wikipedia_en_all_maxi_2022-05.zima') - # We don't support the legacy format of split zim files. - self.assertRaises( - ValueError, lambda: kiwix.validate_file_name( - 'wikipedia_en_all_maxi_2022-05.zima')) - - kiwix.validate_file_name('wikipedia_en_all_maxi_2022-05.zim') + kiwix.validate_file_name('wikipedia_en_all_maxi_2022-05.zim') diff --git a/plinth/modules/kiwix/tests/test_views.py b/plinth/modules/kiwix/tests/test_views.py index 099ccf290..fc77cd22d 100644 --- a/plinth/modules/kiwix/tests/test_views.py +++ b/plinth/modules/kiwix/tests/test_views.py @@ -3,15 +3,15 @@ Test module for Kiwix views. """ -from plinth import module_loader -from django import urls +import pathlib from unittest.mock import call, patch -from django.contrib.messages.storage.fallback import FallbackStorage -from django.http.response import Http404 -from django.test.client import encode_multipart, RequestFactory import pytest +from django import urls +from django.contrib.messages.storage.fallback import FallbackStorage +from django.http.response import Http404 +from plinth import module_loader from plinth.modules.kiwix import views # For all tests, use plinth.urls instead of urls configured for testing @@ -19,6 +19,8 @@ pytestmark = pytest.mark.urls('plinth.urls') ZIM_ID = 'bc4f8cdf-5626-2b13-3860-0033deddfbea' +_data_dir = pathlib.Path(__file__).parent / 'data' + @pytest.fixture(autouse=True, scope='module') def fixture_kiwix_urls(): @@ -41,107 +43,88 @@ def make_request(request, view, **kwargs): @pytest.fixture(autouse=True) -def kiwix_patch(): +def fiture_kiwix_patch(): """Patch kiwix methods.""" - with patch('plinth.modules.kiwix.privileged.list_content_packages' - ) as list_libraries: + with patch( + 'plinth.modules.kiwix.privileged.list_packages') as list_libraries: list_libraries.return_value = { ZIM_ID: { - 'title': 'TestExistingContentPackage', + 'title': 'TestExistingPackage', 'description': 'A sample content package', - 'path': 'test_existing_content_package' + 'path': 'test_existing_package' } } yield -@pytest.fixture() -def storage_info_patch(): - """Patch storage info method.""" - with patch('plinth.modules.storage.get_mount_info') as get_mount_info: - get_mount_info.return_value = {'free_bytes': 1000000000000} - yield - - -@patch('plinth.modules.kiwix.privileged.add_content') -def test_add_content_package(add_content, rf): +@patch('tempfile.TemporaryDirectory') +@patch('plinth.modules.kiwix.privileged.add_package') +def test_add_package(add_package, temp_dir_class, rf, tmp_path): """Test that adding content view works.""" - with open('plinth/modules/kiwix/tests/data/FreedomBox.zim', - 'rb') as zim_file: - post_data = { - 'kiwix-file': zim_file, - } - post_data = encode_multipart('BoUnDaRyStRiNg', post_data) - request = rf.post( - '', data=post_data, content_type='multipart/form-data; ' - 'boundary=BoUnDaRyStRiNg') + temp_dir_class.return_value.__enter__.return_value = str(tmp_path) + with open(_data_dir / 'FreedomBox.zim', 'rb') as zim_file: + post_data = {'kiwix-file': zim_file} + request = rf.post('', data=post_data) response, messages = make_request(request, - views.AddContentView.as_view()) + views.AddPackageView.as_view()) assert response.status_code == 302 assert response.url == urls.reverse('kiwix:index') assert list(messages)[0].message == 'Content package added.' - add_content.assert_has_calls([call('/tmp/FreedomBox.zim')]) + add_package.assert_has_calls([call(f'{tmp_path}/FreedomBox.zim')]) -@patch('plinth.modules.kiwix.privileged.add_content') -def test_add_content_package_failed(add_content, rf): +@patch('plinth.modules.kiwix.privileged.add_package') +def test_add_package_failed(add_package, rf): """Test that adding content package fails in case of an error.""" - add_content.side_effect = RuntimeError('TestError') - with open('plinth/modules/kiwix/tests/data/FreedomBox.zim', - 'rb') as zim_file: - post_data = { - 'kiwix-file': zim_file, - } - post_data = encode_multipart('BoUnDaRyStRiNg', post_data) - request = rf.post( - '', data=post_data, content_type='multipart/form-data; ' - 'boundary=BoUnDaRyStRiNg') + add_package.side_effect = RuntimeError('TestError') + with open(_data_dir / 'FreedomBox.zim', 'rb') as zim_file: + post_data = {'kiwix-file': zim_file} + request = rf.post('', data=post_data) response, messages = make_request(request, - views.AddContentView.as_view()) + views.AddPackageView.as_view()) assert response.status_code == 302 assert response.url == urls.reverse('kiwix:index') - assert list(messages)[0].message == \ - 'Failed to add content package.' + assert list(messages)[0].message == 'Failed to add content package.' @patch('plinth.app.App.get') def test_delete_package_confirmation_view(_app, rf): - """Test that deleting package confirmation shows correct title.""" - response, _ = make_request(rf.get(''), views.delete_content, zim_id=ZIM_ID) + """Test that deleting content confirmation shows correct title.""" + response, _ = make_request(rf.get(''), views.delete_package, zim_id=ZIM_ID) assert response.status_code == 200 - assert response.context_data['name'] == 'TestExistingContentPackage' + assert response.context_data['name'] == 'TestExistingPackage' -@patch('plinth.modules.kiwix.privileged.delete_content_package') +@patch('plinth.modules.kiwix.privileged.delete_package') @patch('plinth.app.App.get') -def test_delete_content_package(_app, delete_content_package, rf): +def test_delete_package(_app, delete_package, rf): """Test that deleting a content package works.""" - response, messages = make_request(rf.post(''), views.delete_content, + response, messages = make_request(rf.post(''), views.delete_package, zim_id=ZIM_ID) assert response.status_code == 302 assert response.url == urls.reverse('kiwix:index') - assert list(messages)[0].message == 'TestExistingContentPackage deleted.' - delete_content_package.assert_has_calls([call(ZIM_ID)]) + assert list(messages)[0].message == 'TestExistingPackage deleted.' + delete_package.assert_has_calls([call(ZIM_ID)]) -@patch('plinth.modules.kiwix.privileged.delete_content_package') -def test_delete_content_package_error(delete_content_package, rf): - """Test that deleting a content package shows an error when operation fails.""" - delete_content_package.side_effect = ValueError('TestError') - response, messages = make_request(rf.post(''), views.delete_content, +@patch('plinth.modules.kiwix.privileged.delete_package') +def test_delete_package_error(delete_package, rf): + """Test that deleting content shows an error when operation fails.""" + delete_package.side_effect = ValueError('TestError') + response, messages = make_request(rf.post(''), views.delete_package, zim_id=ZIM_ID) assert response.status_code == 302 assert response.url == urls.reverse('kiwix:index') assert list(messages)[0].message == \ - 'Could not delete TestExistingContentPackage: TestError' + 'Could not delete TestExistingPackage: TestError' -def test_delete_content_package_non_existing(rf): - """Test that deleting a content package shows error when operation fails.""" +def test_delete_package_non_existing(rf): + """Test that deleting content shows error when operation fails.""" with pytest.raises(Http404): - make_request(rf.post(''), views.delete_content, + make_request(rf.post(''), views.delete_package, zim_id='NonExistentZimId') with pytest.raises(Http404): - make_request(rf.get(''), views.delete_content, + make_request(rf.get(''), views.delete_package, zim_id='NonExistentZimId') diff --git a/plinth/modules/kiwix/urls.py b/plinth/modules/kiwix/urls.py index 1170d12b5..892a46d00 100644 --- a/plinth/modules/kiwix/urls.py +++ b/plinth/modules/kiwix/urls.py @@ -9,8 +9,8 @@ from . import views urlpatterns = [ re_path(r'^apps/kiwix/$', views.KiwixAppView.as_view(), name='index'), - re_path(r'^apps/kiwix/content/add/$', views.AddContentView.as_view(), - name='add-content'), - re_path(r'^apps/kiwix/content/(?P[a-zA-Z0-9-]+)/delete/$', - views.delete_content, name='delete-content'), + re_path(r'^apps/kiwix/package/add/$', views.AddPackageView.as_view(), + name='add-package'), + re_path(r'^apps/kiwix/package/(?P[a-zA-Z0-9-]+)/delete/$', + views.delete_package, name='delete-package'), ] diff --git a/plinth/modules/kiwix/views.py b/plinth/modules/kiwix/views.py index 470a6f6f5..0f7a2dc40 100644 --- a/plinth/modules/kiwix/views.py +++ b/plinth/modules/kiwix/views.py @@ -4,6 +4,7 @@ Views for the Kiwix module. """ import logging +import tempfile from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin @@ -27,21 +28,23 @@ logger = logging.getLogger(__name__) class KiwixAppView(views.AppView): """Serve configuration form.""" + app_id = 'kiwix' template_name = 'kiwix.html' def get_context_data(self, **kwargs): """Return additional context for rendering the template.""" context = super().get_context_data(**kwargs) - context['packages'] = privileged.list_content_packages() + context['packages'] = privileged.list_packages() return context -class AddContentView(SuccessMessageMixin, FormView): - """View to add content in the form of ZIM files.""" - form_class = forms.AddContentForm +class AddPackageView(SuccessMessageMixin, FormView): + """View to add content package in the form of ZIM files.""" + + form_class = forms.AddPackageForm prefix = 'kiwix' - template_name = 'add-content-package.html' + template_name = 'kiwix-add-package.html' success_url = reverse_lazy('kiwix:index') success_message = _('Content package added.') @@ -66,23 +69,26 @@ class AddContentView(SuccessMessageMixin, FormView): def form_valid(self, form): """Store the uploaded file.""" multipart_file = self.request.FILES['kiwix-file'] - zim_file_name = '/tmp/' + multipart_file.name - with open(zim_file_name, 'wb+') as zim_file: - for chunk in multipart_file.chunks(): - zim_file.write(chunk) - try: - privileged.add_content(zim_file_name) - except Exception: - messages.error(self.request, _('Failed to add content package.')) - return redirect(reverse_lazy('kiwix:index')) + with tempfile.TemporaryDirectory() as temp_dir: + zim_file_name = temp_dir + '/' + multipart_file.name + with open(zim_file_name, 'wb+') as zim_file: + for chunk in multipart_file.chunks(): + zim_file.write(chunk) + + try: + privileged.add_package(zim_file_name) + except Exception: + messages.error(self.request, + _('Failed to add content package.')) + return redirect(reverse_lazy('kiwix:index')) return super().form_valid(form) -def delete_content(request, zim_id): +def delete_package(request, zim_id): """View to delete a library.""" - packages = privileged.list_content_packages() + packages = privileged.list_packages() if zim_id not in packages: raise Http404 @@ -90,8 +96,8 @@ def delete_content(request, zim_id): if request.method == 'POST': try: - privileged.delete_content_package(zim_id) - messages.success(request, _(f'{name} deleted.')) + privileged.delete_package(zim_id) + messages.success(request, _('{name} deleted.').format(name=name)) except Exception as error: messages.error( request, @@ -99,7 +105,7 @@ def delete_content(request, zim_id): name=name, error=error)) return redirect(reverse_lazy('kiwix:index')) - return TemplateResponse(request, 'delete-content-package.html', { + return TemplateResponse(request, 'kiwix-delete-package.html', { 'title': app_module.App.get('kiwix').info.name, 'name': name })