diff --git a/INSTALL b/INSTALL
index 80fe31ab0..14b9dd327 100644
--- a/INSTALL
+++ b/INSTALL
@@ -7,7 +7,7 @@
$ sudo apt-get install libjs-jquery libjs-modernizr \
libjs-bootstrap make pandoc python3 python3-cherrypy3 \
python3-coverage python3-django python3-bootstrapform \
- python3-setuptools
+ python3-gi python3-setuptools gir1.2-packagekitglib-1.0
2. Install Plinth:
diff --git a/plinth/forms.py b/plinth/forms.py
new file mode 100644
index 000000000..aa3ee74b4
--- /dev/null
+++ b/plinth/forms.py
@@ -0,0 +1,33 @@
+#
+# This file is part of Plinth.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+"""
+Plinth framework forms
+"""
+
+from django import forms
+
+
+class PackageInstallForm(forms.Form):
+ """Prompt for installation of a package.
+
+ XXX: Don't store the package list in a hidden input as it can be
+ modified on the client side. Use session store to store and retrieve
+ the package list. It has to be form specific so that multiple
+ instances of forms don't clash with each other.
+ """
+ package_names = forms.CharField(widget=forms.HiddenInput)
diff --git a/plinth/package.py b/plinth/package.py
new file mode 100644
index 000000000..e7fd3a344
--- /dev/null
+++ b/plinth/package.py
@@ -0,0 +1,184 @@
+#
+# This file is part of Plinth.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+"""
+Framework for installing and updating distribution packages
+"""
+
+import functools
+from gi.repository import PackageKitGlib as packagekit
+import logging
+import threading
+
+import plinth
+
+
+logger = logging.getLogger(__name__)
+transactions = {}
+packages_resolved = {}
+
+
+class Transaction(object):
+ """Information about an ongoing transaction."""
+
+ def __init__(self, package_names):
+ """Initialize transaction object.
+
+ Set most values to None until they are sent as progress update.
+ """
+ self.package_names = package_names
+
+ # Progress
+ self.allow_cancel = None
+ self.percentage = None
+ self.status = None
+ self.status_string = None
+ self.flags = None
+ self.package = None
+ self.package_id = None
+ self.item_progress = None
+ self.role = None
+ self.caller_active = None
+ self.download_size_remaining = None
+
+ def get_id(self):
+ """Return a identifier to use as a key in a map of transactions."""
+ return frozenset(self.package_names)
+
+ def __str__(self):
+ """Return the string representation of the object"""
+ return ('Transaction(packages={0}, allow_cancel={1}, status={2}, '
+ ' percentage={3}, package={4}, item_progress={5})').format(
+ self.package_names, self.allow_cancel, self.status_string,
+ self.percentage, self.package, self.item_progress)
+
+ def start_install(self):
+ """Start a PackageKit transaction to install given list of packages.
+
+ This operation is non-blocking at it spawns a new thread.
+ """
+ thread = threading.Thread(target=self._install)
+ thread.start()
+
+ def _install(self):
+ """Run a PackageKit transaction to install given packages."""
+ package_ids = [packages_resolved[package_name].get_id()
+ for package_name in self.package_names]
+ client = packagekit.Client()
+ client.set_interactive(False)
+ client.install_packages(packagekit.TransactionFlagEnum.ONLY_TRUSTED,
+ package_ids + [None], None,
+ self.progress_callback, self)
+
+ def progress_callback(self, progress, progress_type, user_data):
+ """Process progress updates on package resolve operation"""
+ if progress_type == packagekit.ProgressType.PERCENTAGE:
+ self.percentage = progress.props.percentage
+ elif progress_type == packagekit.ProgressType.PACKAGE:
+ self.package = progress.props.package
+ elif progress_type == packagekit.ProgressType.ALLOW_CANCEL:
+ self.allow_cancel = progress.props.allow_cancel
+ elif progress_type == packagekit.ProgressType.PACKAGE_ID:
+ self.package_id = progress.props.package_id
+ elif progress_type == packagekit.ProgressType.ITEM_PROGRESS:
+ self.item_progress = progress.props.item_progress
+ elif progress_type == packagekit.ProgressType.STATUS:
+ self.status = progress.props.status
+ self.status_string = \
+ packagekit.StatusEnum.to_string(progress.props.status)
+ if self.status == packagekit.StatusEnum.FINISHED:
+ self.finish()
+ elif progress_type == packagekit.ProgressType.TRANSACTION_FLAGS:
+ self.flags = progress.props.transaction_flags
+ elif progress_type == packagekit.ProgressType.ROLE:
+ self.role = progress.props.role
+ elif progress_type == packagekit.ProgressType.CALLER_ACTIVE:
+ self.caller_active = progress.props.caller_active
+ elif progress_type == packagekit.ProgressType.DOWNLOAD_SIZE_REMAINING:
+ self.download_size_remaining = \
+ progress.props.download_size_remaining
+ else:
+ logger.info('Unhandle packagekit progress callback - %s, %s',
+ progress, progress_type)
+
+ def finish(self):
+ """Perform clean up operations on the transaction.
+
+ Remove self from global transactions list.
+ """
+ del transactions[self.get_id()]
+
+
+def required(*package_names):
+ """Decorate a view to check and install required packages."""
+
+ def wrapper2(func):
+ """Return a function to check and install packages."""
+
+ @functools.wraps(func)
+ def wrapper(request, *args, **kwargs):
+ """Check and install packages required by a view."""
+ if not is_installing(package_names) and \
+ check_installed(package_names):
+ return func(request, *args, **kwargs)
+
+ view = plinth.views.PackageInstallView.as_view()
+ return view(request, package_names=package_names, *args, **kwargs)
+
+ return wrapper
+
+ return wrapper2
+
+
+def check_installed(package_names):
+ """Return a boolean installed status of package.
+
+ This operation is blocking and waits until the check is finished.
+ """
+ def _callback(progress, progress_type, user_data):
+ """Process progress updates on package resolve operation."""
+ pass
+
+ client = packagekit.Client()
+ response = client.resolve(packagekit.FilterEnum.INSTALLED,
+ package_names + (None, ), None,
+ _callback, None)
+
+ installed_package_names = []
+ for package in response.get_package_array():
+ if package.get_info() == packagekit.InfoEnum.INSTALLED:
+ installed_package_names.append(package.get_name())
+
+ packages_resolved[package.get_name()] = package
+
+ return set(installed_package_names) == set(package_names)
+
+
+def is_installing(package_names):
+ """Return whether a set of packages are currently being installed."""
+ return frozenset(package_names) in transactions
+
+
+def start_install(package_names):
+ """Start a PackageKit transaction to install given list of packages.
+
+ This operation is non-blocking at it spawns a new thread.
+ """
+ transaction = Transaction(package_names)
+ transactions[frozenset(package_names)] = transaction
+
+ transaction.start_install()
diff --git a/plinth/templates/package_install.html b/plinth/templates/package_install.html
new file mode 100644
index 000000000..0624a8898
--- /dev/null
+++ b/plinth/templates/package_install.html
@@ -0,0 +1,82 @@
+{% extends "base.html" %}
+{% comment %}
+#
+# This file is part of Plinth.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+{% endcomment %}
+
+{% load bootstrap %}
+
+
+{% block page_head %}
+
+ {% if is_installing %}
+
+ {% endif %}
+
+{% endblock %}
+
+
+{% block content %}
+
+
Installation
+
+ {% if not is_installing %}
+
+ This feature requires addtional packages to be installed. Do you
+ wish to install them?
+
+
+
+ | Package | Summary |
+
+
+ {% for package in packages %}
+
+ | {{ package.get_name }} |
+ {{ package.get_summary }} |
+
+ {% endfor %}
+
+
+
+
+
+ {% else %}
+
+ {% for key, transaction in transactions.items %}
+ Installing {{ transaction.package_names|join:", " }}:
+ {{ transaction.status_string }}
+
+
+
+ {{ transaction.percentage }}% complete
+
+
+ {% endfor %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/plinth/views.py b/plinth/views.py
index 481911979..81a58c022 100644
--- a/plinth/views.py
+++ b/plinth/views.py
@@ -19,13 +19,61 @@
Main Plinth views
"""
+from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import reverse
from django.http.response import HttpResponseRedirect
+from django.views.generic.edit import FormView
+
+from plinth import package as package_module
+from plinth.forms import PackageInstallForm
def index(request):
- """Serve the main index page"""
+ """Serve the main index page."""
if request.user.is_authenticated():
return HttpResponseRedirect(reverse('apps:index'))
return HttpResponseRedirect(reverse('help:about'))
+
+
+class PackageInstallView(FormView):
+ """View to prompt and install packages."""
+ template_name = 'package_install.html'
+ form_class = PackageInstallForm
+
+ def get_context_data(self, **kwargs):
+ """Return the context data rendering the template."""
+ context = super(PackageInstallView, self).get_context_data(**kwargs)
+ if 'packages_names' not in context:
+ context['package_names'] = self.kwargs.get('package_names', [])
+
+ # Package details must have been resolved before building the form
+ context['packages'] = [package_module.packages_resolved[package_name]
+ for package_name in context['package_names']]
+ context['is_installing'] = \
+ package_module.is_installing(context['package_names'])
+ context['transactions'] = package_module.transactions
+
+ return context
+
+ def get_initial(self):
+ """Return the initial data to be filled in the form."""
+ initial = super(PackageInstallView, self).get_initial()
+ try:
+ initial['package_names'] = ','.join(self.kwargs['package_names'])
+ except KeyError:
+ raise ImproperlyConfigured('Argument package_names must be '
+ 'provided to PackageInstallView')
+
+ return initial
+
+ def form_valid(self, form):
+ """Handle successful validation of the form.
+
+ Start the package installation and show this view again.
+ """
+ package_names = form.cleaned_data['package_names'].split(',')
+ package_module.start_install(package_names)
+
+ return self.render_to_response(
+ self.get_context_data(package_names=package_names))
diff --git a/setup.py b/setup.py
index 970a0f8de..1e0d85247 100755
--- a/setup.py
+++ b/setup.py
@@ -114,7 +114,8 @@ setuptools.setup(
install_requires=[
'cherrypy >= 3.0',
'django >= 1.7.0',
- 'django-bootstrap-form'
+ 'django-bootstrap-form',
+ 'pygobject'
],
tests_require=['coverage >= 3.7'],
include_package_data=True,