diff --git a/plinth/modules/udiskie/templates/udiskie.html b/plinth/modules/udiskie/templates/udiskie.html
index ba2f6efdb..abccae023 100644
--- a/plinth/modules/udiskie/templates/udiskie.html
+++ b/plinth/modules/udiskie/templates/udiskie.html
@@ -36,6 +36,7 @@
{% trans "Size" %} |
{% trans "Filesystem" %} |
{% trans "Mount Point" %} |
+ {% trans "Actions" %} |
@@ -46,6 +47,18 @@
{{ device.size }} |
{{ device.filesystem_type }} |
{{ device.mount_points|join:', ' }} |
+
+ {% if device.mount_points %}
+
+ {% endif %}
+ |
{% endfor %}
diff --git a/plinth/modules/udiskie/udisks2.py b/plinth/modules/udiskie/udisks2.py
index 4c30c188a..377a631f1 100644
--- a/plinth/modules/udiskie/udisks2.py
+++ b/plinth/modules/udiskie/udisks2.py
@@ -18,14 +18,24 @@
Library for interacting with udisks2 D-Bus service.
"""
+from django.utils.translation import ugettext as _
+
from plinth import utils
from plinth.modules.storage import format_bytes
+glib = utils.import_from_gi('GLib', '2.0')
+udisks = utils.import_from_gi('UDisks', '2.0')
+
+
+def _get_options():
+ """Return the common options used for udisks2 operations."""
+ options = glib.Variant(
+ 'a{sv}', {'auth.no_user_interaction': glib.Variant('b', True)})
+ return options
+
def list_devices():
"""List devices that can be ejected."""
- udisks = utils.import_from_gi('UDisks', '2.0')
-
client = udisks.Client.new_sync()
object_manager = client.get_object_manager()
@@ -60,3 +70,105 @@ def list_devices():
devices.append(device)
return devices
+
+
+def eject_drive_of_device(device_path):
+ """Eject a device after unmounting all of its partitions.
+
+ Return the details (model, vendor) of drives ejected.
+ """
+ client = udisks.Client.new_sync()
+ object_manager = client.get_object_manager()
+
+ found_objects = [
+ obj for obj in object_manager.get_objects()
+ if obj.get_block() and obj.get_block().props.device == device_path
+ ]
+
+ if not found_objects:
+ raise ValueError(
+ _('No such device - {device_path}').format(
+ device_path=device_path))
+
+ obj = found_objects[0]
+
+ # Unmount filesystems
+ block_device = obj.get_block()
+ drive_object_path = block_device.props.drive
+ if drive_object_path != '/':
+ umount_all_filesystems_of_drive(drive_object_path)
+ else:
+ # Block device has not associated drive
+ umount_filesystem(obj.get_filesystem())
+
+ # Eject the drive
+ drive = client.get_drive_for_block(block_device)
+ if drive:
+ drive.call_eject_sync(_get_options(), None)
+ return {
+ 'vendor': drive.props.vendor,
+ 'model': drive.props.model,
+ }
+
+ return None
+
+
+def umount_filesystem(filesystem):
+ """Unmount a filesystem """
+ if filesystem and filesystem.props.mount_points:
+ filesystem.call_unmount_sync(_get_options())
+
+
+def umount_all_filesystems_of_drive(drive_object_path):
+ """Unmount all filesystems on block devices of a drive."""
+ client = udisks.Client.new_sync()
+ object_manager = client.get_object_manager()
+
+ for obj in object_manager.get_objects():
+ block_device = obj.get_block()
+ if not block_device or block_device.props.drive != drive_object_path:
+ continue
+
+ umount_filesystem(obj.get_filesystem())
+
+
+def get_error_message(error):
+ """Return an error message given an exception."""
+ if error.matches(udisks.Error.quark(), udisks.Error.FAILED):
+ message = _('The operation failed.')
+ elif error.matches(udisks.Error.quark(), udisks.Error.CANCELLED):
+ message = _('The operation was cancelled.')
+ elif error.matches(udisks.Error.quark(), udisks.Error.ALREADY_UNMOUNTING):
+ message = _('The device is already unmounting.')
+ elif error.matches(udisks.Error.quark(), udisks.Error.NOT_SUPPORTED):
+ message = _('The operation is not supported due to '
+ 'missing driver/tool support.')
+ elif error.matches(udisks.Error.quark(), udisks.Error.TIMED_OUT):
+ message = _('The operation timed out.')
+ elif error.matches(udisks.Error.quark(), udisks.Error.WOULD_WAKEUP):
+ message = _('The operation would wake up a disk that is '
+ 'in a deep-sleep state.')
+ elif error.matches(udisks.Error.quark(), udisks.Error.DEVICE_BUSY):
+ message = _('Attempting to unmount a device that is busy.')
+ elif error.matches(udisks.Error.quark(), udisks.Error.ALREADY_CANCELLED):
+ message = _('The operation has already been cancelled.')
+ elif error.matches(udisks.Error.quark(), udisks.Error.NOT_AUTHORIZED) or \
+ error.matches(udisks.Error.quark(),
+ udisks.Error.NOT_AUTHORIZED_CAN_OBTAIN) or \
+ error.matches(udisks.Error.quark(),
+ udisks.Error.NOT_AUTHORIZED_DISMISSED):
+ message = _('Not authorized to perform the requested operation.')
+ elif error.matches(udisks.Error.quark(), udisks.Error.ALREADY_MOUNTED):
+ message = _('The device is already mounted.')
+ elif error.matches(udisks.Error.quark(), udisks.Error.NOT_MOUNTED):
+ message = _('The device is not mounted.')
+ elif error.matches(udisks.Error.quark(),
+ udisks.Error.OPTION_NOT_PERMITTED):
+ message = _('Not permitted to use the requested option.')
+ elif error.matches(udisks.Error.quark(),
+ udisks.Error.MOUNTED_BY_OTHER_USER):
+ message = _('The device is mounted by another user.')
+ else:
+ message = error.message
+
+ return message
diff --git a/plinth/modules/udiskie/urls.py b/plinth/modules/udiskie/urls.py
index c1570fbe4..519acb26b 100644
--- a/plinth/modules/udiskie/urls.py
+++ b/plinth/modules/udiskie/urls.py
@@ -20,12 +20,15 @@ URLs for the udiskie module.
from django.conf.urls import url
-from .views import UdiskieView
from plinth.modules import udiskie
+from .views import Index, eject
+
urlpatterns = [
- url(r'^sys/udiskie/$',
- UdiskieView.as_view(service_id=udiskie.managed_services[0],
- description=udiskie.description,
- show_status_block=True), name='index'),
+ url(
+ r'^sys/udiskie/$',
+ Index.as_view(service_id=udiskie.managed_services[0],
+ description=udiskie.description, show_status_block=True),
+ name='index'),
+ url(r'^sys/udiskie/eject/(?P[\w%]+)/$', eject, name='eject'),
]
diff --git a/plinth/modules/udiskie/views.py b/plinth/modules/udiskie/views.py
index 42bf11474..c00cbb689 100644
--- a/plinth/modules/udiskie/views.py
+++ b/plinth/modules/udiskie/views.py
@@ -18,17 +18,62 @@
Views for udiskie module.
"""
+import urllib.parse
+from logging import Logger
+
+from django.contrib import messages
+from django.shortcuts import redirect
+from django.urls import reverse
+from django.utils.translation import ugettext as _
+from django.views.decorators.http import require_POST
+
from plinth.views import ServiceView
from . import udisks2
+logger = Logger(__name__)
-class UdiskieView(ServiceView):
+
+class Index(ServiceView):
"""View to show devices."""
template_name = 'udiskie.html'
- def get_context_data(self, **kwargs):
+ def get_context_data(self, *args, **kwargs):
"""Return the context data rendering the template."""
context = super().get_context_data(**kwargs)
context['devices'] = udisks2.list_devices()
return context
+
+
+@require_POST
+def eject(request, device_path):
+ """Eject a device, given its path.
+
+ Device path is quoted with slashes written as %2F.
+
+ """
+ device_path = urllib.parse.unquote(device_path)
+
+ try:
+ drive = udisks2.eject_drive_of_device(device_path)
+ if drive:
+ messages.success(
+ request,
+ _('{drive_vendor} {drive_model} can be safely unplugged.')
+ .format(drive_vendor=drive['vendor'],
+ drive_model=drive['model']))
+ else:
+ messages.success(request, _('Device can be safely unplugged.'))
+ except Exception as exception:
+ try:
+ message = udisks2.get_error_message(exception)
+ except AttributeError:
+ message = str(exception)
+
+ logger.exception('Error ejecting device - %s', message)
+ messages.error(
+ request,
+ _('Error ejecting device: {error_message}').format(
+ error_message=message))
+
+ return redirect(reverse('udiskie:index'))