miniflux: Add new app

[sunil's changes]

- Add copyright information the logo.

- Deluge: undo an unintended change.

- Drop wrapper calls over privileged methods. The new privileged method
decorators make is easy to avoid these.

- Styling updates: docstrings, single quotes for strings, casing for UI strings.

- Drop "DO NOT EDIT" comment for files located in /usr as they are not expected
to be editable by the user.

- Fix 'miniflux' to 'Miniflux' in web client name.

- Overwrite FreedomBox settings onto the existing configuration file when setup
is re-run. This is to ensure that FreedomBox settings take priority.

- Use return value of the miniflux command to raise errors.

- Use pathlib module where possible.

- Move message parsing into the privileged module from views module.

- Resize SVG and PNG logo files for consistency with icon styling.

- Use hypens instead of underscores in URLs and Django URL names.

- Rename miniflux_configure.html to miniflux.html.

- Use base method for minor simplification in backup functional test. Ensure
that the test can be run independently when other tests are not run.

- Update tests to reflect code changes.

- Avoid concatenating internationalized strings so that they can be translated
properly.

Signed-off-by: Joseph Nuthalapati <njoseph@riseup.net>
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
Joseph Nuthalapati 2024-07-04 13:19:34 +05:30 committed by Sunil Mohan Adapa
parent ccbd5d7d20
commit 0b58a39758
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
17 changed files with 861 additions and 0 deletions

2
debian/control vendored
View File

@ -41,6 +41,7 @@ Build-Depends:
python3-openssl,
python3-pampy,
python3-paramiko,
python3-pexpect,
python3-pip,
python3-psutil,
python3-pytest,
@ -126,6 +127,7 @@ Depends:
python3-markupsafe,
python3-pampy,
python3-paramiko,
python3-pexpect,
python3-psutil,
python3-requests,
python3-ruamel.yaml,

6
debian/copyright vendored
View File

@ -164,6 +164,12 @@ Copyright: 2015 Calinou, Nils Dagsson Moskopp
Comment: https://github.com/minetest/minetest/blob/master/misc/minetest.svg
License: CC-BY-SA-3.0
Files: plinth/modules/miniflux/static/icons/miniflux.png
plinth/modules/miniflux/static/icons/miniflux.svg
Copyright: 2018, 2019 Frédéric Guillot
Comment: https://github.com/miniflux/logo
License: CC-BY-SA-4.0
Files: plinth/modules/mumble/static/icons/mumble.png
Copyright: 2009 Martin Skilnand
Comment: https://commons.wikimedia.org/wiki/File:Icons_mumble.svg

View File

@ -0,0 +1,115 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""FreedomBox app for Miniflux."""
from django.utils.translation import gettext_lazy as _
from plinth import app as app_module
from plinth import frontpage, menu
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.firewall.components import Firewall
from plinth.package import Packages
from . import manifest, privileged
_description = [
_('Miniflux is a web-based tool that aggregates news and blog updates from'
' various websites into one centralized, easy-to-read format. It has a '
'simple interface and focuses on a distraction-free reading experience. '
'You can can subscribe to your favorite sites and access full article '
'contents within the reader itself.'),
_('Key features include keyboard shortcuts for quick navigation, full-text'
' search, filtering articles, categories and favorites. Miniflux '
'preserves user privacy by removing trackers. The primary interface is '
'web-based. There are several third-party '
'<a href="https://miniflux.app/docs/apps.html">clients</a> as well.'),
]
class MinifluxApp(app_module.App):
"""FreedomBox app for Miniflux."""
app_id = 'miniflux'
_version = 1
def __init__(self):
"""Create components for the app."""
super().__init__()
info = app_module.Info(self.app_id, self._version, name=_('Miniflux'),
icon_filename='miniflux',
short_description=_('News Feed Reader'),
description=_description,
manual_page='miniflux',
clients=manifest.clients,
donation_url='https://miniflux.app/#donations')
self.add(info)
menu_item = menu.Menu('menu-miniflux', info.name,
info.short_description, info.icon_filename,
'miniflux:index', parent_url_name='apps')
self.add(menu_item)
shortcut = frontpage.Shortcut('shortcut-miniflux', info.name,
info.short_description,
info.icon_filename, url='/miniflux',
clients=manifest.clients,
login_required=True)
self.add(shortcut)
packages = Packages('packages-miniflux', [
'miniflux',
'postgresql',
'postgresql-contrib',
])
self.add(packages)
drop_in_configs = DropinConfigs(
'dropin-configs-miniflux',
['/etc/apache2/conf-available/miniflux-freedombox.conf'])
self.add(drop_in_configs)
firewall = Firewall('firewall-miniflux', info.name,
ports=['http', 'https'], is_external=True)
self.add(firewall)
webserver = Webserver('webserver-miniflux', 'miniflux-freedombox',
urls=['https://{host}/miniflux/'])
self.add(webserver)
daemon = Daemon('daemon-miniflux', 'miniflux',
listen_ports=[(8788, 'tcp4'), (8788, 'tcp6')])
self.add(daemon)
backup_restore = MinifluxBackupRestore('backup-restore-miniflux',
**manifest.backup)
self.add(backup_restore)
def setup(self, old_version=None):
"""Install and configure the app."""
privileged.pre_setup()
super().setup(old_version)
if not old_version:
self.enable()
def uninstall(self):
"""De-configure and uninstall the app."""
privileged.uninstall()
super().uninstall()
class MinifluxBackupRestore(BackupRestore):
"""Component to backup/restore Miniflux."""
def backup_pre(self, packet):
"""Save database contents."""
super().backup_pre(packet)
privileged.dump_database()
def restore_post(self, packet):
"""Restore database contents."""
super().restore_post(packet)
privileged.restore_database()

View File

@ -0,0 +1,6 @@
# FreedomBox configuration file stores both static settings and user
# preferences. These settings are loaded as environment variables. Hence, they
# take precedence, overriding the settings in miniflux.conf.
[Service]
EnvironmentFile=/etc/miniflux/freedombox.conf

View File

@ -0,0 +1,20 @@
##
## On all sites, provide miniflux web interface on a path: /miniflux
##
# Redirect /miniflux to /miniflux/ as the miniflux server does not
# work without a slash at the end.
<Location ~ ^/miniflux$>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_URI} ^/miniflux$
RewriteRule .* /miniflux/ [R=301,L]
</IfModule>
</Location>
<Location /miniflux/>
ProxyPreserveHost On
ProxyPass http://localhost:8788/miniflux/
ProxyPassReverse http://localhost:8788/miniflux/
</Location>

View File

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

View File

@ -0,0 +1,33 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
class UserCredentialsForm(forms.Form):
"""Form to create admin user or change a user's password."""
username = forms.CharField(label=_('Username'),
help_text=_('Enter a username for the user.'))
password = forms.CharField(
label=_('Password'), widget=forms.PasswordInput, min_length=6,
strip=False,
help_text=_('Enter a strong password with a minimum of 6 characters.'))
password_confirmation = forms.CharField(
label=_('Password confirmation'), widget=forms.PasswordInput,
min_length=6, strip=False,
help_text=_('Enter the same password for confirmation.'))
def clean(self):
"""Raise error if passwords don't match."""
cleaned_data = super().clean()
password = self.cleaned_data.get('password')
password_confirmation = self.cleaned_data.get('password_confirmation')
if password and password_confirmation and (password
!= password_confirmation):
self.add_error('password_confirmation',
ValidationError(_('Passwords do not match.')))
return cleaned_data

View File

@ -0,0 +1,27 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Application manifest for miniflux."""
from django.utils.translation import gettext_lazy as _
clients = [{
'name': _('Miniflux'),
'platforms': [{
'type': 'web',
'url': '/miniflux/'
}]
}]
backup = {
'config': {
'files': [
'/etc/miniflux/freedombox.conf',
'/var/lib/plinth/backups-data/miniflux-database.sql',
],
},
'secrets': {
'files': [
'/etc/miniflux/database', '/etc/dbconfig-common/miniflux.conf'
]
},
'services': ['miniflux']
}

View File

@ -0,0 +1,160 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Configuration helper for Miniflux feed reader."""
import json
import os
import pathlib
import subprocess
from typing import Dict
from urllib.parse import urlparse
import pexpect
from plinth import action_utils
from plinth.actions import privileged
from plinth.utils import is_non_empty_file
STATIC_SETTINGS = {
'BASE_URL': 'http://localhost/miniflux/',
'RUN_MIGRATIONS': 1,
'PORT': 8788
}
ENV_VARS_FILE = '/etc/miniflux/freedombox.conf'
DATABASE_FILE = '/etc/miniflux/database'
DB_BACKUP_FILE = '/var/lib/plinth/backups-data/miniflux-database.sql'
def _dict_to_env_file(dictionary: Dict) -> str:
"""Write a dictionary into a systemd environment file format."""
return "\n".join((f"{k}={v}" for k, v in dictionary.items()))
def _env_file_to_dict(env_vars: str) -> Dict:
"""Return systemd environtment variables as a dictionary."""
return {
line.split('=')[0]: line.split('=')[1].strip()
for line in env_vars.splitlines()
if line.strip() and not line.strip().startswith('#')
}
@privileged
def pre_setup():
"""Perform post-install actions for Miniflux."""
vars_file = pathlib.Path(ENV_VARS_FILE)
vars_file.parent.mkdir(parents=True, exist_ok=True)
existing_settings = {}
if is_non_empty_file(ENV_VARS_FILE):
# Any comments in the file will be dropped.
existing_settings = _env_file_to_dict(vars_file.read_text())
new_settings = existing_settings | STATIC_SETTINGS
vars_file.write_text(_dict_to_env_file(new_settings))
def _run_miniflux_intreractively(command: str, username: str,
password: str) -> str:
"""Fill interactive terminal prompt for username and password."""
args = ['-c', '/etc/miniflux/miniflux.conf', command]
child = pexpect.spawn('miniflux', args, env={'LOG_FORMAT': 'json'})
# The CLI is in English only.
child.expect('Enter Username: ')
child.sendline(username)
child.expect('Enter Password: ')
child.sendline(password)
child.expect(pexpect.EOF)
status = child.before.decode()
child.close()
if not os.WIFEXITED(child.exitstatus):
try:
status = json.loads(status)['msg']
except (KeyError, json.JSONDecodeError):
pass
raise Exception(status)
@privileged
def create_admin_user(username: str, password: str):
"""Create a new admin user for Miniflux CLI.
Raise exception if a user with the name already exists or otherwise fails.
"""
_run_miniflux_intreractively('--create-admin', username, password)
@privileged
def reset_user_password(username: str, password: str):
"""Reset a user password using Miniflux CLI.
Raise exception if the user does not exist or otherwise fails.
"""
_run_miniflux_intreractively('--reset-password', username, password)
@privileged
def uninstall():
"""Ensure that the database is removed."""
action_utils.debconf_set_selections(
['miniflux miniflux/purge boolean true'])
def _get_database_config():
"""Retrieve database credentials."""
db_connection_string = pathlib.Path(DATABASE_FILE).read_text().strip()
parsed_url = urlparse(db_connection_string)
return {
'user': parsed_url.username,
'password': parsed_url.password,
'database': parsed_url.path.lstrip('/'),
'host': parsed_url.hostname,
}
# The following 3 methods are duplicated in tt-rss/privileged.py
def _run_as_postgres(command, stdin=None, stdout=None):
"""Run a command as postgres user."""
command = ['sudo', '--user', 'postgres'] + command
return subprocess.run(command, stdin=stdin, stdout=stdout, check=True)
@privileged
def dump_database():
"""Dump database to file."""
config = _get_database_config()
os.makedirs(os.path.dirname(DB_BACKUP_FILE), exist_ok=True)
with open(DB_BACKUP_FILE, 'w', encoding='utf-8') as db_backup_file:
process = _run_as_postgres(['pg_dumpall', '--roles-only'],
stdout=subprocess.PIPE)
db_backup_file.write(f'DROP ROLE IF EXISTS {config["user"]};\n')
for line in process.stdout.decode().splitlines():
if config['user'] in line:
db_backup_file.write(line + '\n')
with open(DB_BACKUP_FILE, 'a', encoding='utf-8') as db_backup_file:
_run_as_postgres([
'pg_dump', '--create', '--clean', '--if-exists', config['database']
], stdout=db_backup_file)
@privileged
def restore_database():
"""Restore database from file."""
config = _get_database_config()
# This is needed for old backups only. New backups include 'DROP DATABASE
# IF EXISTS' and 'CREATE DATABASE' statements.
_run_as_postgres(['dropdb', config['database']])
_run_as_postgres(['createdb', config['database']])
with open(DB_BACKUP_FILE, 'r', encoding='utf-8') as db_restore_file:
_run_as_postgres(['psql', '--dbname', config['database']],
stdin=db_restore_file)

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
id="Layer_1"
data-name="Layer 1"
viewBox="0 0 512.00001 511.99999"
version="1.1"
sodipodi:docname="miniflux.svg"
width="512"
height="512"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
inkscape:export-filename="miniflux.png"
inkscape:export-xdpi="48"
inkscape:export-ydpi="48"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs83" />
<sodipodi:namedview
id="namedview81"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="0.54434451"
inkscape:cx="279.23493"
inkscape:cy="868.01647"
inkscape:window-width="1504"
inkscape:window-height="1282"
inkscape:window-x="26"
inkscape:window-y="23"
inkscape:window-maximized="0"
inkscape:current-layer="Layer_1" />
<title
id="title76">icon</title>
<path
d="M 166.81225,96.150624 A 113.67357,113.67357 0 0 1 220.80571,83.09152 q 59.78696,0 76.1702,51.99898 a 165.13831,165.13831 0 0 1 44.60278,-36.886035 q 26.27254,-15.112945 58.57602,-15.112945 44.84021,0 64.10833,27.40038 19.26811,27.40037 19.24437,82.27235 v 206.57129 c 0,5.16429 0.72419,8.6665 2.17256,10.54226 1.44837,1.87576 4.58256,3.56158 9.40255,4.91497 L 512,420.42006 v 12.64359 H 411.71795 q -13.05911,0 -18.79324,-9.84182 -5.73413,-9.84181 -5.79349,-29.51357 V 180.12067 q 0,-31.62678 -6.99256,-44.97081 -6.99256,-13.34403 -23.3758,-13.3559 -26.0351,0 -55.44183,30.86697 a 249.31018,249.31018 0 0 1 2.92049,40.10332 v 206.57129 c 0,5.16429 0.72419,8.6665 2.17256,10.54226 1.44837,1.87576 4.58256,3.56158 9.40255,4.91497 l 16.86999,5.62729 v 12.64359 H 232.38083 q -13.05911,0 -18.80511,-9.84182 -5.74601,-9.84181 -5.7935,-29.51357 V 180.12067 q 0,-31.62678 -6.98068,-44.97081 -6.98069,-13.34403 -23.38767,-13.3559 -25.56023,0 -52.54509,28.11269 v 249.42889 c 0,5.16429 0.71232,8.78522 2.16069,10.88655 1.44837,2.10133 4.41635,3.87024 8.92768,5.27113 l 16.38324,4.92684 v 12.64359 H 0 v -12.64359 l 16.869989,-5.62729 q 7.229995,-2.10132 9.402555,-4.91497 c 1.448373,-1.87576 2.17256,-5.37797 2.17256,-10.54226 V 133.74897 q 0,-7.72861 -2.17256,-10.54226 -2.17256,-2.81364 -9.402555,-4.92684 L 0,112.66446 V 100.02087 L 115.70367,78.93635 h 8.19162 v 49.18534 a 169.21038,169.21038 0 0 1 42.91696,-31.971066 z"
id="path78"
style="stroke-width:1.18719" />
<metadata
id="metadata85">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>icon</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,32 @@
{% extends "app.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block configuration %}
{{ block.super }}
<h3>{% trans "Configuration" %}</h3>
<p>
{% blocktrans trimmed %}
Create an admin user to get started. Other users can be created from
within Miniflux.
{% endblocktrans %}
</p>
<div class="btn-toolbar">
<a href="{% url 'miniflux:create-admin-user' %}" class="btn btn-default"
role="button" title="{% trans 'Create admin user' %}">
<span class="fa fa-plus" aria-hidden="true"></span>
{% trans 'Create admin user' %}
</a>
<a href="{% url 'miniflux:reset-user-password' %}" class="btn btn-default"
role="button" title="{% trans 'Reset user password' %}">
<span class="fa fa-key" aria-hidden="true"></span>
{% trans 'Reset user password' %}
</a>
</div>
{% endblock %}

View File

@ -0,0 +1,134 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Functional, browser based tests for Miniflux app.
"""
import pytest
from plinth.tests import functional
pytestmark = [pytest.mark.apps, pytest.mark.miniflux]
ADMIN_USERNAME = 'admin'
ADMIN_PASSWORD = 'str0ngp@$$word'
ADMIN_PASSWORD_NEW = 'str0ngERp@$$word'
CREDENTIALS = {'username': 'admin', 'password': ADMIN_PASSWORD}
class TestMinifluxApp(functional.BaseAppTests):
"""Class to customize basic app tests for Miniflux."""
app_name = 'miniflux'
has_service = True
has_web = True
@pytest.fixture(name='create_admin_user')
def fixture_create_admin_user(self, session_browser):
"""Create an admin user for Miniflux."""
functional.app_enable(session_browser, self.app_name)
_create_admin_user(session_browser)
def test_create_miniflux_admin_user(self, session_browser,
create_admin_user):
"""Test creating an admin user."""
_miniflux_login(session_browser)
# Verify that this user can see admin settings
with functional.wait_for_page_update(session_browser):
session_browser.links.find_by_href(
'/miniflux/settings').first.click()
assert not session_browser.links.find_by_href(
'/miniflux/users').is_empty()
def test_reset_miniflux_user_password(self, session_browser,
create_admin_user):
"""Test Miniflux user password reset."""
CREDENTIALS['password'] = ADMIN_PASSWORD_NEW
_reset_user_password(session_browser)
_miniflux_login(session_browser)
assert not session_browser.links.find_by_href(
'/miniflux/unread').is_empty()
@pytest.mark.backups
def test_backup_restore(self, session_browser, create_admin_user):
"""Test backup and restore of app data."""
_subscribe(session_browser, 'https://planet.debian.org/atom.xml')
super().test_backup_restore(session_browser)
assert _is_subscribed(session_browser, 'Planet Debian')
def _fill_credentials_form(browser, href):
"""Fill the user credentials form in Miniflux app."""
functional.nav_to_module(browser, 'miniflux')
with functional.wait_for_page_update(browser):
browser.links.find_by_href(
f'/plinth/apps/miniflux/{href}/').first.click()
browser.fill('miniflux-username', CREDENTIALS['username'])
browser.fill('miniflux-password', CREDENTIALS['password'])
browser.fill('miniflux-password_confirmation', CREDENTIALS['password'])
functional.submit(browser, form_class='form-miniflux')
def _create_admin_user(browser):
"""Create Miniflux admin user."""
_fill_credentials_form(browser, 'create-admin-user')
def _open_miniflux_app(browser):
"""Load the web interface of Miniflux."""
functional.visit(browser, '/miniflux/')
main = browser.find_by_id('main')
functional.eventually(lambda: main.visible)
def _miniflux_logout(browser):
"""Attempt to log out of Miniflux app. Doesn't fail if not logged in."""
_open_miniflux_app(browser)
maybe_logout_button = browser.links.find_by_href('/miniflux/logout')
if not maybe_logout_button.is_empty():
with functional.wait_for_page_update(browser):
maybe_logout_button.first.click()
def _miniflux_submit(browser):
"""Perform the Submit action in Miniflux forms."""
functional.submit(browser,
element=browser.find_by_css('button[type="submit"]'))
def _miniflux_login(browser):
"""Login to miniflux with the given credentials."""
_open_miniflux_app(browser)
_miniflux_logout(browser)
browser.find_by_id('form-username').fill(CREDENTIALS['username'])
browser.find_by_id('form-password').fill(CREDENTIALS['password'])
_miniflux_submit(browser)
def _reset_user_password(browser):
"""Reset a Miniflux user's password from FreedomBox web interface."""
_fill_credentials_form(browser, 'reset-user-password')
def _subscribe(browser, feed_url):
"""Subscribe to a feed in Miniflux."""
_open_miniflux_app(browser)
_miniflux_login(browser)
with functional.wait_for_page_update(browser):
browser.links.find_by_href('/miniflux/subscribe').first.click()
with functional.wait_for_page_update(browser):
browser.find_by_id('form-url').fill(feed_url)
_miniflux_submit(browser)
def _is_subscribed(browser, feed_name):
"""Check if the user is subscribed to a feed."""
_open_miniflux_app(browser)
_miniflux_login(browser)
with functional.wait_for_page_update(browser):
browser.links.find_by_href('/miniflux/feeds').first.click()
return browser.is_text_present(feed_name)

View File

@ -0,0 +1,166 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Tests for Miniflux views."""
from unittest.mock import patch
import pytest
from django import urls
from django.contrib.messages.storage.fallback import FallbackStorage
from plinth import module_loader
from plinth.modules.miniflux 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_miniflux_urls():
"""Make sure Miniflux 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.miniflux')
module_loader.include_urls()
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
##########################
# Create Admin User view #
##########################
def test_create_admin_user_view(rf):
"""Test that the create admin user view loads successfully."""
request = rf.get(urls.reverse('miniflux:create-admin-user'))
view = views.CreateAdminUserView.as_view()
response, _ = make_request(request, view)
assert response.status_code == 200
@patch('plinth.modules.miniflux.privileged.create_admin_user')
def test_create_admin_user_form_valid(create_admin_user, rf):
"""Test that the create admin user form is valid and redirects."""
form_data = {
'miniflux-username': 'admin',
'miniflux-password': 'strongpassword',
'miniflux-password_confirmation': 'strongpassword'
}
request = rf.post(urls.reverse('miniflux:create-admin-user'),
data=form_data)
view = views.CreateAdminUserView.as_view()
response, messages = make_request(request, view)
assert response.status_code == 302
assert list(messages)[0].message == 'Created admin user: admin'
def test_passwords_do_not_match(rf):
"""Test that the form shows an error when passwords do not match."""
form_data = {
'miniflux-username': 'admin',
'miniflux-password': 'strongpassword',
'miniflux-password_confirmation': 'weakpassword'
}
request = rf.post(urls.reverse('miniflux:create-admin-user'),
data=form_data)
view = views.CreateAdminUserView.as_view()
response, messages = make_request(request, view)
assert response.status_code == 200
assert response.context_data['form'].errors['password_confirmation'][
0] == 'Passwords do not match.'
def test_password_too_short(rf):
"""Test that the form shows an error when the password is too short."""
form_data = {
'miniflux-username': 'demo',
'miniflux-password': 'demo',
'miniflux-password_confirmation': 'demo'
}
request = rf.post(urls.reverse('miniflux:create-admin-user'),
data=form_data)
view = views.CreateAdminUserView.as_view()
response, messages = make_request(request, view)
assert response.status_code == 200
assert response.context_data['form'].errors['password'][
0] == 'Ensure this value has at least 6 characters (it has 4).'
@patch('plinth.modules.miniflux.privileged.create_admin_user')
def test_recreate_existing_user(create_admin_user, rf):
"""Test that trying to recreate an existing user fails."""
create_admin_user.side_effect = Exception(
'Skipping admin user creation because it already exists')
form_data = {
'miniflux-username': 'admin',
'miniflux-password': 'strongpassword',
'miniflux-password_confirmation': 'strongpassword'
}
request = rf.post(urls.reverse('miniflux:create-admin-user'),
data=form_data)
view = views.CreateAdminUserView.as_view()
response, messages = make_request(request, view)
error_msg = ('An error occurred while creating the user: Skipping admin '
'user creation because it already exists.')
assert response.status_code == 302
assert list(messages)[0].message == error_msg
############################
# Reset User Password view #
############################
@patch('plinth.modules.miniflux.privileged.reset_user_password')
def test_reset_user_password_form_valid(reset_user_password, rf):
"""Test that the reset user password form is valid and redirects."""
reset_user_password.return_value = 'Password changed!'
form_data = {
'miniflux-username': 'admin',
'miniflux-password': 'strongpassword',
'miniflux-password_confirmation': 'strongpassword'
}
request = rf.post(urls.reverse('miniflux:reset-user-password'),
data=form_data)
view = views.ResetUserPasswordView.as_view()
response, messages = make_request(request, view)
assert response.status_code == 302
assert list(messages)[0].message == 'Password reset for user: admin'
@patch('plinth.modules.miniflux.privileged.reset_user_password')
def test_reset_user_password_for_invalid_user(reset_user_password, rf):
"""Test that the resetting user password for an invalid user fails."""
reset_user_password.side_effect = Exception('user not found')
form_data = {
'miniflux-username': 'admin',
'miniflux-password': 'strongpassword',
'miniflux-password_confirmation': 'strongpassword'
}
request = rf.post(urls.reverse('miniflux:reset-user-password'),
data=form_data)
view = views.ResetUserPasswordView.as_view()
response, messages = make_request(request, view)
assert response.status_code == 302
assert list(
messages
)[0].message == 'An error occurred during password reset: user not found.'

View File

@ -0,0 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""URLs for the Miniflux module."""
from django.urls import re_path
from .views import CreateAdminUserView, MinifluxAppView, ResetUserPasswordView
urlpatterns = [
re_path(r'^apps/miniflux/$', MinifluxAppView.as_view(), name='index'),
re_path(r'^apps/miniflux/create-admin-user/$',
CreateAdminUserView.as_view(), name='create-admin-user'),
re_path(r'^apps/miniflux/reset-user-password/$',
ResetUserPasswordView.as_view(), name='reset-user-password'),
]

View File

@ -0,0 +1,88 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Django views for Miniflux."""
import logging
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic.edit import FormView
from plinth import views
from . import privileged
from .forms import UserCredentialsForm
logger = logging.getLogger(__name__)
class MinifluxAppView(views.AppView):
"""Serve configuration page."""
app_id = 'miniflux'
template_name = 'miniflux.html'
class CreateAdminUserView(SuccessMessageMixin, FormView):
"""View to create a new admin user."""
form_class = UserCredentialsForm
prefix = 'miniflux'
template_name = 'form.html'
success_url = reverse_lazy('miniflux:index')
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['title'] = _('Create Admin User')
return context
def form_valid(self, form):
"""Create the admin user on valid form submission."""
username = form.cleaned_data['username']
password = form.cleaned_data['password']
try:
privileged.create_admin_user(username, password)
self.success_message = _('Created admin user: {username}').format(
username=username)
except Exception as error:
messages.error(
self.request,
_('An error occurred while creating the user: {error}.').
format(error=error))
return super().form_valid(form)
class ResetUserPasswordView(SuccessMessageMixin, FormView):
"""View to reset a user password."""
form_class = UserCredentialsForm
prefix = 'miniflux'
template_name = 'form.html'
success_url = reverse_lazy('miniflux:index')
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['title'] = _('Reset User Password')
return context
def form_valid(self, form):
"""Reset password on valid form submission."""
username = form.cleaned_data['username']
password = form.cleaned_data['password']
try:
privileged.reset_user_password(username, password).strip()
self.success_message = _('Password reset for user: {username}'
).format(username=username)
except Exception as error:
messages.error(
self.request,
_('An error occurred during password reset: {error}.').format(
error=error))
return super().form_valid(form)