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:
Sunil Mohan Adapa 2025-07-30 19:14:21 -07:00 committed by Joseph Nuthalapati
parent 36c36dad8d
commit 1f98dfcad1
No known key found for this signature in database
GPG Key ID: 5398F00A2FA43C35
9 changed files with 158 additions and 48 deletions

View File

@ -140,6 +140,14 @@ class App:
"""
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):
"""Install and configure the app and its components."""
for component in self.components.values():
@ -330,6 +338,10 @@ class Component:
"""
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):
"""Run operations to install and configure the component."""

View File

@ -34,11 +34,11 @@ class DelugePackages(Packages):
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':
return True
return False
return super().has_unavailable_packages()
return super().is_available()
class DelugeApp(app_module.App):

View File

@ -249,27 +249,26 @@ class Packages(app_module.FollowerComponent):
return packages_installed(self.conflicts)
def has_unavailable_packages(self) -> bool | None:
"""Return whether any of the packages are not available.
def is_available(self) -> bool:
"""Return whether all of the packages are available.
Returns True if one or more of the packages is not available in the
user's Debian distribution or False otherwise. Returns None if it
cannot be reliably determined whether the packages are available or
not.
Returns True if all of the packages are available in the user's Debian
distribution or False otherwise. Returns True if it cannot be reliably
determined whether the packages are available or not.
"""
apt_lists_dir = pathlib.Path('/var/lib/apt/lists/')
num_files = len(
[child for child in apt_lists_dir.iterdir() if child.is_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
try:
self.get_actual_packages()
except MissingPackageError:
return True
return False
return False
return True
def _filter_packages_to_keep(self, packages: list[str]) -> list[str]:
"""Filter out the list of packages to keep from given list.

View File

@ -33,24 +33,36 @@
{% endif %}
</p>
<div class="app-unavailable d-none">
<div class="alert alert-warning d-flex align-items-center" role="alert">
<div class="me-2">
<span class="fa fa-exclamation-triangle" aria-hidden="true"></span>
<span class="visually-hidden">{% trans "Caution:" %}</span>
</div>
<div>
{% blocktrans trimmed %}
This application is currently not available in your distribution.
{% endblocktrans %}
</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 %}
<div class="alert alert-warning d-flex align-items-center" role="alert">
<div class="me-2">
<span class="fa fa-exclamation-triangle" aria-hidden="true"></span>
<span class="visually-hidden">{% trans "Caution:" %}</span>
</div>
<div>
{% blocktrans trimmed %}
This application is currently not available in your distribution.
{% 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>
{% elif package_conflicts and package_conflicts_action.value != 'ignore' %}
<div class="alert alert-warning d-flex align-items-center" role="alert">
<div class="me-2">
@ -70,10 +82,8 @@
</div>
{% endif %}
<input type="submit" class="btn btn-md btn-primary" name="install"
{% if has_unavailable_packages %}
disabled="disabled"
{% endif %}
<input type="submit" class="btn btn-md btn-primary install-button"
name="install"
{% if setup_state.value == 'needs-setup' %}
value="{% trans "Install" %}"
{% elif setup_state.value == 'needs-update' %}

View File

@ -132,6 +132,20 @@ def test_get_components_of_type(app_with_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):
"""Test that running setup on an app runs setup on components."""
for component in app_with_components.components.values():
@ -333,6 +347,12 @@ def test_component_app_property():
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():
"""Test running setup on component."""
component = Component('test-component')

View File

@ -340,21 +340,21 @@ def test_packages_find_conflicts(packages_installed_):
@patch('apt.Cache')
@patch('pathlib.Path')
def test_packages_has_unavailable_packages(path_class, cache):
"""Test checking for unavailable packages."""
def test_packages_is_available(path_class, cache):
"""Test checking for available packages."""
path = Mock()
path_class.return_value = path
path.iterdir.return_value = [Mock()]
component = Packages('test-component', ['package1', 'package2'])
assert component.has_unavailable_packages() is None
assert component.is_available()
path.iterdir.return_value = [Mock(), Mock()]
cache.return_value = ['package1', 'package2']
assert not component.has_unavailable_packages()
assert component.is_available()
cache.return_value = ['package1']
assert component.has_unavailable_packages()
assert not component.is_available()
def test_packages_installed():

View File

@ -26,6 +26,8 @@ urlpatterns = [
re_path(r'', include((system_urlpatterns, 'system'))),
re_path(r'^uninstall/(?P<app_id>[1-9a-z\-_]+)/$',
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,
name='rerun-setup'),

View File

@ -12,7 +12,7 @@ from django.contrib import messages
from django.core.exceptions import ImproperlyConfigured
from django.forms import Form
from django.http import (Http404, HttpRequest, HttpResponseBadRequest,
HttpResponseRedirect)
HttpResponseRedirect, JsonResponse)
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.urls import reverse
@ -513,12 +513,6 @@ class SetupView(TemplateView):
context['show_uninstall'] = (not app.info.is_essential and setup_state
!= 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
if context['setup_state'] == app_module.App.SetupState.UP_TO_DATE:
context['refresh_page_sec'] = 0
@ -565,12 +559,16 @@ class SetupView(TemplateView):
return conflicts, conflicts_action
@staticmethod
def _has_unavailable_packages(app_):
"""Return whether the app has unavailable packages."""
components = app_.get_components_of_type(Packages)
return any(component for component in components
if component.has_unavailable_packages())
def is_available_view(request, app_id):
"""Return whether an app is available.
This check may take quite some time, so don't perform this check when
loading the app's setup page.
"""
app = app_module.App.get(app_id)
data = {'is_available': app.is_available()}
return JsonResponse(data)
@require_POST

View File

@ -206,3 +206,72 @@ function setSelectAllValue(parent) {
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();
});