mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-05-27 10:44:33 +00:00
setup: Perform a check for app availability after the page loads
- Using AJAX request instead of loading the initial page slowly. Tests: - Unit tests passes. - Deluge app is not available in bookworm and is available in Trixie. - When app is available, no message is shown. Install button is enabled. - When app is not available a proper warning alert message is shown. Install button is disabled. - During check for the availability, the progress message is shown. Install button is disabled. - When Javascript is disabled on the page, no availability check is performed. Install button is enabled. - When an exception is raised in the is-available view, error message is shown. Install button is enabled. - When is-available view return HTML response, error message is shown. Install button is enabled. - When is-available view invalid JSON response, error message is shown. Install button is enabled. Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org> Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
This commit is contained in:
parent
36c36dad8d
commit
1f98dfcad1
@ -140,6 +140,14 @@ class App:
|
|||||||
"""
|
"""
|
||||||
return self.get_component(self.app_id + '-info')
|
return self.get_component(self.app_id + '-info')
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Return whether the app is available to install."""
|
||||||
|
for component in self.components.values():
|
||||||
|
if not component.is_available():
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def setup(self, old_version):
|
def setup(self, old_version):
|
||||||
"""Install and configure the app and its components."""
|
"""Install and configure the app and its components."""
|
||||||
for component in self.components.values():
|
for component in self.components.values():
|
||||||
@ -330,6 +338,10 @@ class Component:
|
|||||||
"""
|
"""
|
||||||
return App.get(self.app_id)
|
return App.get(self.app_id)
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Return whether the app is available to install."""
|
||||||
|
return True
|
||||||
|
|
||||||
def setup(self, old_version):
|
def setup(self, old_version):
|
||||||
"""Run operations to install and configure the component."""
|
"""Run operations to install and configure the component."""
|
||||||
|
|
||||||
|
|||||||
@ -34,11 +34,11 @@ class DelugePackages(Packages):
|
|||||||
https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1031593
|
https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1031593
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def has_unavailable_packages(self) -> bool | None:
|
def is_available(self) -> bool:
|
||||||
if get_current_release()[1] == 'bookworm':
|
if get_current_release()[1] == 'bookworm':
|
||||||
return True
|
return False
|
||||||
|
|
||||||
return super().has_unavailable_packages()
|
return super().is_available()
|
||||||
|
|
||||||
|
|
||||||
class DelugeApp(app_module.App):
|
class DelugeApp(app_module.App):
|
||||||
|
|||||||
@ -249,28 +249,27 @@ class Packages(app_module.FollowerComponent):
|
|||||||
|
|
||||||
return packages_installed(self.conflicts)
|
return packages_installed(self.conflicts)
|
||||||
|
|
||||||
def has_unavailable_packages(self) -> bool | None:
|
def is_available(self) -> bool:
|
||||||
"""Return whether any of the packages are not available.
|
"""Return whether all of the packages are available.
|
||||||
|
|
||||||
Returns True if one or more of the packages is not available in the
|
Returns True if all of the packages are available in the user's Debian
|
||||||
user's Debian distribution or False otherwise. Returns None if it
|
distribution or False otherwise. Returns True if it cannot be reliably
|
||||||
cannot be reliably determined whether the packages are available or
|
determined whether the packages are available or not.
|
||||||
not.
|
|
||||||
"""
|
"""
|
||||||
apt_lists_dir = pathlib.Path('/var/lib/apt/lists/')
|
apt_lists_dir = pathlib.Path('/var/lib/apt/lists/')
|
||||||
num_files = len(
|
num_files = len(
|
||||||
[child for child in apt_lists_dir.iterdir() if child.is_file()])
|
[child for child in apt_lists_dir.iterdir() if child.is_file()])
|
||||||
if num_files < 2: # not counting the lock file
|
if num_files < 2: # not counting the lock file
|
||||||
return None
|
return True # Don't know, package cache is not available
|
||||||
|
|
||||||
# List of all packages from all Package components
|
# List of all packages from all Package components
|
||||||
try:
|
try:
|
||||||
self.get_actual_packages()
|
self.get_actual_packages()
|
||||||
except MissingPackageError:
|
except MissingPackageError:
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def _filter_packages_to_keep(self, packages: list[str]) -> list[str]:
|
def _filter_packages_to_keep(self, packages: list[str]) -> list[str]:
|
||||||
"""Filter out the list of packages to keep from given list.
|
"""Filter out the list of packages to keep from given list.
|
||||||
|
|
||||||
|
|||||||
@ -33,10 +33,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form class="form-install" action="" method="post">
|
<div class="app-unavailable d-none">
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
{% if has_unavailable_packages %}
|
|
||||||
<div class="alert alert-warning d-flex align-items-center" role="alert">
|
<div class="alert alert-warning d-flex align-items-center" role="alert">
|
||||||
<div class="me-2">
|
<div class="me-2">
|
||||||
<span class="fa fa-exclamation-triangle" aria-hidden="true"></span>
|
<span class="fa fa-exclamation-triangle" aria-hidden="true"></span>
|
||||||
@ -46,11 +43,26 @@
|
|||||||
{% blocktrans trimmed %}
|
{% blocktrans trimmed %}
|
||||||
This application is currently not available in your distribution.
|
This application is currently not available in your distribution.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
<button type="submit" class="btn btn-default btn-sm" name="refresh-packages">
|
|
||||||
<span class="fa fa-refresh"></span> {% trans "Check again" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="app-checking-availability d-none" data-app-id="{{ app_id }}"
|
||||||
|
data-setup-state="{{ setup_state.value }}">
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
Checking app availability...
|
||||||
|
{% endblocktrans %}
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||||
|
</p>
|
||||||
|
<p class="app-checking-availability-error d-none">
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
Error checking app availability. Please refresh page.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="form-install" action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% if has_unavailable_packages %}
|
||||||
{% elif package_conflicts and package_conflicts_action.value != 'ignore' %}
|
{% elif package_conflicts and package_conflicts_action.value != 'ignore' %}
|
||||||
<div class="alert alert-warning d-flex align-items-center" role="alert">
|
<div class="alert alert-warning d-flex align-items-center" role="alert">
|
||||||
<div class="me-2">
|
<div class="me-2">
|
||||||
@ -70,10 +82,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<input type="submit" class="btn btn-md btn-primary" name="install"
|
<input type="submit" class="btn btn-md btn-primary install-button"
|
||||||
{% if has_unavailable_packages %}
|
name="install"
|
||||||
disabled="disabled"
|
|
||||||
{% endif %}
|
|
||||||
{% if setup_state.value == 'needs-setup' %}
|
{% if setup_state.value == 'needs-setup' %}
|
||||||
value="{% trans "Install" %}"
|
value="{% trans "Install" %}"
|
||||||
{% elif setup_state.value == 'needs-update' %}
|
{% elif setup_state.value == 'needs-update' %}
|
||||||
|
|||||||
@ -132,6 +132,20 @@ def test_get_components_of_type(app_with_components):
|
|||||||
assert list(components) == leader_components
|
assert list(components) == leader_components
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_is_available(app_with_components):
|
||||||
|
"""Test checking if an app is available for setup."""
|
||||||
|
for component in app_with_components.components.values():
|
||||||
|
component.is_available = Mock(return_value=True)
|
||||||
|
|
||||||
|
assert app_with_components.is_available()
|
||||||
|
for component in app_with_components.components.values():
|
||||||
|
component.is_available.assert_has_calls([call()])
|
||||||
|
|
||||||
|
component = list(app_with_components.components.values())[0]
|
||||||
|
component.is_available.return_value = False
|
||||||
|
assert not app_with_components.is_available()
|
||||||
|
|
||||||
|
|
||||||
def test_app_setup(app_with_components):
|
def test_app_setup(app_with_components):
|
||||||
"""Test that running setup on an app runs setup on components."""
|
"""Test that running setup on an app runs setup on components."""
|
||||||
for component in app_with_components.components.values():
|
for component in app_with_components.components.values():
|
||||||
@ -333,6 +347,12 @@ def test_component_app_property():
|
|||||||
assert component.app == app
|
assert component.app == app
|
||||||
|
|
||||||
|
|
||||||
|
def test_component_is_available():
|
||||||
|
"""Test checking if a component is available for setup."""
|
||||||
|
component = Component('test-component')
|
||||||
|
assert component.is_available()
|
||||||
|
|
||||||
|
|
||||||
def test_component_setup():
|
def test_component_setup():
|
||||||
"""Test running setup on component."""
|
"""Test running setup on component."""
|
||||||
component = Component('test-component')
|
component = Component('test-component')
|
||||||
|
|||||||
@ -340,21 +340,21 @@ def test_packages_find_conflicts(packages_installed_):
|
|||||||
|
|
||||||
@patch('apt.Cache')
|
@patch('apt.Cache')
|
||||||
@patch('pathlib.Path')
|
@patch('pathlib.Path')
|
||||||
def test_packages_has_unavailable_packages(path_class, cache):
|
def test_packages_is_available(path_class, cache):
|
||||||
"""Test checking for unavailable packages."""
|
"""Test checking for available packages."""
|
||||||
path = Mock()
|
path = Mock()
|
||||||
path_class.return_value = path
|
path_class.return_value = path
|
||||||
path.iterdir.return_value = [Mock()]
|
path.iterdir.return_value = [Mock()]
|
||||||
|
|
||||||
component = Packages('test-component', ['package1', 'package2'])
|
component = Packages('test-component', ['package1', 'package2'])
|
||||||
assert component.has_unavailable_packages() is None
|
assert component.is_available()
|
||||||
|
|
||||||
path.iterdir.return_value = [Mock(), Mock()]
|
path.iterdir.return_value = [Mock(), Mock()]
|
||||||
cache.return_value = ['package1', 'package2']
|
cache.return_value = ['package1', 'package2']
|
||||||
assert not component.has_unavailable_packages()
|
assert component.is_available()
|
||||||
|
|
||||||
cache.return_value = ['package1']
|
cache.return_value = ['package1']
|
||||||
assert component.has_unavailable_packages()
|
assert not component.is_available()
|
||||||
|
|
||||||
|
|
||||||
def test_packages_installed():
|
def test_packages_installed():
|
||||||
|
|||||||
@ -26,6 +26,8 @@ urlpatterns = [
|
|||||||
re_path(r'', include((system_urlpatterns, 'system'))),
|
re_path(r'', include((system_urlpatterns, 'system'))),
|
||||||
re_path(r'^uninstall/(?P<app_id>[1-9a-z\-_]+)/$',
|
re_path(r'^uninstall/(?P<app_id>[1-9a-z\-_]+)/$',
|
||||||
views.UninstallView.as_view(), name='uninstall'),
|
views.UninstallView.as_view(), name='uninstall'),
|
||||||
|
re_path(r'^is-available/(?P<app_id>[1-9a-z\-_]+)/$',
|
||||||
|
views.is_available_view, name='is-available'),
|
||||||
re_path(r'^rerun-setup/(?P<app_id>[1-9a-z\-_]+)/$', views.rerun_setup_view,
|
re_path(r'^rerun-setup/(?P<app_id>[1-9a-z\-_]+)/$', views.rerun_setup_view,
|
||||||
name='rerun-setup'),
|
name='rerun-setup'),
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from django.contrib import messages
|
|||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.forms import Form
|
from django.forms import Form
|
||||||
from django.http import (Http404, HttpRequest, HttpResponseBadRequest,
|
from django.http import (Http404, HttpRequest, HttpResponseBadRequest,
|
||||||
HttpResponseRedirect)
|
HttpResponseRedirect, JsonResponse)
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -513,12 +513,6 @@ class SetupView(TemplateView):
|
|||||||
context['show_uninstall'] = (not app.info.is_essential and setup_state
|
context['show_uninstall'] = (not app.info.is_essential and setup_state
|
||||||
!= app_module.App.SetupState.NEEDS_SETUP)
|
!= app_module.App.SetupState.NEEDS_SETUP)
|
||||||
|
|
||||||
# Perform expensive operation only if needed.
|
|
||||||
if not context['operations']:
|
|
||||||
context[
|
|
||||||
'has_unavailable_packages'] = self._has_unavailable_packages(
|
|
||||||
app)
|
|
||||||
|
|
||||||
context['refresh_page_sec'] = None
|
context['refresh_page_sec'] = None
|
||||||
if context['setup_state'] == app_module.App.SetupState.UP_TO_DATE:
|
if context['setup_state'] == app_module.App.SetupState.UP_TO_DATE:
|
||||||
context['refresh_page_sec'] = 0
|
context['refresh_page_sec'] = 0
|
||||||
@ -565,12 +559,16 @@ class SetupView(TemplateView):
|
|||||||
|
|
||||||
return conflicts, conflicts_action
|
return conflicts, conflicts_action
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _has_unavailable_packages(app_):
|
def is_available_view(request, app_id):
|
||||||
"""Return whether the app has unavailable packages."""
|
"""Return whether an app is available.
|
||||||
components = app_.get_components_of_type(Packages)
|
|
||||||
return any(component for component in components
|
This check may take quite some time, so don't perform this check when
|
||||||
if component.has_unavailable_packages())
|
loading the app's setup page.
|
||||||
|
"""
|
||||||
|
app = app_module.App.get(app_id)
|
||||||
|
data = {'is_available': app.is_available()}
|
||||||
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
|
|||||||
@ -206,3 +206,72 @@ function setSelectAllValue(parent) {
|
|||||||
|
|
||||||
parent.querySelector('.select-all').checked = enableSelectAll;
|
parent.querySelector('.select-all').checked = enableSelectAll;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether an app is available on its setup page.
|
||||||
|
*/
|
||||||
|
document.addEventListener('DOMContentLoaded', function(event) {
|
||||||
|
const checkingElement = document.querySelector('.app-checking-availability');
|
||||||
|
if (!checkingElement)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// App does not need setup, it likely needs upgrade
|
||||||
|
const setupState = checkingElement.getAttribute('data-setup-state');
|
||||||
|
if (setupState !== 'needs-setup')
|
||||||
|
return;
|
||||||
|
|
||||||
|
const appId = checkingElement.getAttribute('data-app-id');
|
||||||
|
checkingElement.classList.remove('d-none');
|
||||||
|
|
||||||
|
function setInstallButtonState(enable) {
|
||||||
|
const installButton = document.querySelector('.install-button');
|
||||||
|
if (enable) {
|
||||||
|
installButton.removeAttribute('disabled');
|
||||||
|
} else {
|
||||||
|
installButton.setAttribute('disabled', 'disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailable() {
|
||||||
|
document.querySelector('.app-unavailable').classList.remove('d-none');
|
||||||
|
setInstallButtonState(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function error() {
|
||||||
|
const element = document.querySelector('.app-checking-availability-error');
|
||||||
|
element.classList.remove('d-none');
|
||||||
|
checkingElement.classList.add('d-none');
|
||||||
|
setInstallButtonState(true); // Allow trying installation
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = new XMLHttpRequest();
|
||||||
|
request.timeout = 2 * 60 * 1000; // 2 minutes
|
||||||
|
request.onload = function() {
|
||||||
|
// Remove the progress spinner
|
||||||
|
checkingElement.classList.add('d-none');
|
||||||
|
|
||||||
|
let available = false;
|
||||||
|
if (this.status === 200) {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(this.responseText);
|
||||||
|
if (response.is_available === true) {
|
||||||
|
setInstallButtonState(true);
|
||||||
|
} else if (response.is_available === false) {
|
||||||
|
unavailable();
|
||||||
|
} else {
|
||||||
|
error();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = error;
|
||||||
|
request.ontimeout = error;
|
||||||
|
|
||||||
|
request.open('GET', `/plinth/is-available/${appId}/`, true);
|
||||||
|
setInstallButtonState(false);
|
||||||
|
request.send();
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user