diff --git a/actions/disks b/actions/disks
new file mode 100755
index 000000000..de0d3b2f0
--- /dev/null
+++ b/actions/disks
@@ -0,0 +1,222 @@
+#!/usr/bin/python3
+#
+# 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 .
+#
+
+"""
+Configuration helper for disks manager.
+"""
+
+import argparse
+import re
+import subprocess
+import sys
+
+
+def parse_arguments():
+ """Return parsed command line arguments as dictionary."""
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
+
+ subparser = subparsers.add_parser(
+ 'is-partition-expandable',
+ help='Return whether a given partition can be expanded')
+ subparser.add_argument(
+ 'device', help='Partition for which check needs to be performed')
+
+ subparser = subparsers.add_parser(
+ 'expand-partition',
+ help='Expand a partition to take adjacent free space')
+ subparser.add_argument(
+ 'device', help='Partition which needs to be resized')
+
+ return parser.parse_args()
+
+
+def subcommand_is_partition_expandable(arguments):
+ """Return a list of partitions that can be expanded."""
+ _, _, free_space = _get_free_space(arguments.device)
+ print(free_space['size'])
+
+
+def subcommand_expand_partition(arguments):
+ """Expand a partition to take adjacent free space."""
+ device = arguments.device
+ device, requested_partition, free_space = _get_free_space(device)
+
+ if requested_partition['table_type'] == 'msdos' and \
+ int(requested_partition['number']) >= 5:
+ print('Expanding logical partitions currently unsupported',
+ file=sys.stderr)
+ sys.exit(4)
+
+ _resize_partition(device, requested_partition, free_space)
+ _resize_file_system(device, requested_partition, free_space)
+
+
+def _resize_partition(device, requested_partition, free_space):
+ """Resize the partition table entry."""
+ command = ['parted', '--align=optimal', '--script', device, 'unit', 'B',
+ 'resizepart', requested_partition['number'],
+ str(free_space['end'])]
+ try:
+ subprocess.run(command, check=True)
+ except subprocess.CalledProcessError as exception:
+ print('Error expanding partition:', exception, file=sys.stderr)
+ sys.exit(5)
+
+
+def _resize_file_system(device, requested_partition, free_space):
+ """Resize a file system inside a partition."""
+ if requested_partition['type'] == 'btrfs':
+ _resize_btrfs(device, requested_partition, free_space)
+ elif requested_partition['type'] == 'ext4':
+ _resize_ext4(device, requested_partition, free_space)
+
+
+def _resize_ext4(device, requested_partition, free_space):
+ """Resize an ext4 file system inside a partition."""
+ partition_device = _get_partition_device(
+ device, requested_partition['number'])
+ try:
+ command = ['resize2fs', partition_device]
+ subprocess.run(command, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, check=True)
+ except subprocess.CalledProcessError as exception:
+ print('Error expanding filesystem:', exception, file=sys.stderr)
+ sys.exit(6)
+
+
+def _resize_btrfs(device, requested_partition, free_space):
+ """Resize a btrfs file system inside a partition."""
+ try:
+ command = ['btrfs', 'filesystem', 'resize', 'max', '/']
+ subprocess.run(command, stdout=subprocess.DEVNULL, check=True)
+ except subprocess.CalledProcessError as exception:
+ print('Error expanding filesystem:', exception, file=sys.stderr)
+ sys.exit(6)
+
+
+def _get_free_space(device):
+ """Return the amount of free space after a partition."""
+ device, partition_number = \
+ _get_root_device_and_partition_number(device)
+
+ try:
+ requested_partition, free_spaces = \
+ _get_partitions_and_free_spaces(device, partition_number)
+ except Exception as exception:
+ print('Error getting partition details: ', exception, file=sys.stderr)
+ sys.exit(2)
+
+ # Don't accept extended partitions for now
+ if requested_partition['table_type'] == 'msdos' and \
+ int(requested_partition['number']) >= 5:
+ print('Expanding logical partitions currently unsupported',
+ file=sys.stderr)
+ sys.exit(3)
+
+ # Don't accept anything but btrfs and ext4 filesystems
+ if requested_partition['type'] not in ('btrfs', 'ext4'):
+ print('Unsupported file system type: ', requested_partition['type'],
+ file=sys.stderr)
+ sys.exit(4)
+
+ found_free_space = None
+ for free_space in free_spaces:
+ if free_space['start'] != requested_partition['end'] + 1:
+ continue
+
+ if free_space['size'] < 10 * 1024 * 1024: # Minimum 10MiB
+ continue
+
+ found_free_space = free_space
+
+ if not found_free_space:
+ sys.exit(5)
+
+ return device, requested_partition, found_free_space
+
+
+def _get_partition_device(device, partition_number):
+ """Return the device corresponding to a parition in a given device."""
+ if re.match('[0-9]', device[-1]):
+ return device + 'p' + str(partition_number)
+
+ return device + str(partition_number)
+
+
+def _get_root_device_and_partition_number(device):
+ """Return the parent device and number of partition separately."""
+ match = re.match('(.+[a-zA-Z]\d+)p(\d+)$', device)
+ if not match:
+ match = re.match('(.+[a-zA-Z])(\d+)$', device)
+ if not match:
+ print('Invalid device, must be a partition', file=sys.stderr)
+ sys.exit(1)
+
+ return match.group(1), match.group(2)
+
+
+def _get_partitions_and_free_spaces(device, partition_number):
+ """Run parted and return list of partitions and free spaces."""
+ command = ['parted', '--machine', '--script', device, 'unit', 'B',
+ 'print', 'free']
+ process = subprocess.run(command, stdout=subprocess.PIPE, check=True)
+
+ requested_partition = None
+ free_spaces = []
+
+ lines = process.stdout.decode().splitlines()
+ partition_table_type = lines[1].split(':')[5]
+ for line in lines[2:]:
+ line = line.rstrip(';')
+ keys = ('number', 'start', 'end', 'size', 'type')
+ parts = line.split(':')
+ segment = dict(zip(keys, parts[:5]))
+
+ segment['table_type'] = partition_table_type
+ segment['start'] = _interpret_unit(segment['start'])
+ segment['end'] = _interpret_unit(segment['end'])
+ segment['size'] = _interpret_unit(segment['size'])
+
+ if segment['type'] == 'free':
+ segment['number'] = None
+ free_spaces.append(segment)
+ else:
+ if segment['number'] == partition_number:
+ requested_partition = segment
+
+ return requested_partition, free_spaces
+
+
+def _interpret_unit(value):
+ """Return value in bytes after understanding parted unit."""
+ value = value.rstrip('B') # For now, we only need to understand bytes
+ return int(value)
+
+
+def main():
+ """Parse arguments and perform all duties."""
+ arguments = parse_arguments()
+
+ subcommand = arguments.subcommand.replace('-', '_')
+ subcommand_method = globals()['subcommand_' + subcommand]
+ subcommand_method(arguments)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/data/etc/plinth/modules-enabled/disks b/data/etc/plinth/modules-enabled/disks
new file mode 100644
index 000000000..6ffa3d8f9
--- /dev/null
+++ b/data/etc/plinth/modules-enabled/disks
@@ -0,0 +1 @@
+plinth.modules.disks
diff --git a/plinth/modules/disks/__init__.py b/plinth/modules/disks/__init__.py
new file mode 100644
index 000000000..02337c6fc
--- /dev/null
+++ b/plinth/modules/disks/__init__.py
@@ -0,0 +1,100 @@
+#
+# 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 module to manage disks.
+"""
+
+from django.utils.translation import ugettext_lazy as _
+import json
+import logging
+import subprocess
+
+from plinth import actions
+from plinth import cfg
+
+
+version = 1
+
+depends = ['system']
+
+title = _('Disks')
+
+description = []
+
+service = None
+
+logger = logging.getLogger(__name__)
+
+
+def init():
+ """Intialize the module."""
+ menu = cfg.main_menu.get('system:index')
+ menu.add_urlname(title, 'glyphicon-hdd', 'disks:index')
+
+
+def get_disks():
+ """Return the list of disks and free space available."""
+ command = ['df', '--exclude-type=tmpfs', '--exclude-type=devtmpfs',
+ '--output=source,target,fstype,size,used,pcent',
+ '--human-readable']
+ try:
+ process = subprocess.run(command, stdout=subprocess.PIPE, check=True)
+ except subprocess.CalledProcessError as exception:
+ logger.exception('Error getting disk information: %s', exception)
+ return []
+
+ output = process.stdout.decode()
+
+ disks = []
+ for line in output.splitlines()[1:]:
+ parts = line.split()
+ keys = ('device', 'mount_point', 'file_system_type', 'size', 'used',
+ 'percentage_used')
+ disk = dict(zip(keys, parts))
+ disk['percentage_used'] = int(disk['percentage_used'].rstrip('%'))
+ disks.append(disk)
+
+ return disks
+
+
+def get_root_device(disks):
+ """Return the root partition's device from list of partitions."""
+ devices = [disk['device'] for disk in disks if disk['mount_point'] == '/']
+ try:
+ return devices[0]
+ except IndexError:
+ return None
+
+
+def is_expandable(device):
+ """Return the list of partitions that can be expanded."""
+ if not device:
+ return False
+
+ try:
+ output = actions.superuser_run(
+ 'disks', ['is-partition-expandable', device])
+ except actions.ActionError:
+ return False
+
+ return int(output.strip())
+
+
+def expand_partition(device):
+ """Expand a partition."""
+ actions.superuser_run('disks', ['expand-partition', device])
diff --git a/plinth/modules/disks/templates/disks.html b/plinth/modules/disks/templates/disks.html
new file mode 100644
index 000000000..067825a6d
--- /dev/null
+++ b/plinth/modules/disks/templates/disks.html
@@ -0,0 +1,93 @@
+{% 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 %}
+{% load i18n %}
+
+{% block page_head %}
+
+{% endblock %}
+
+{% block content %}
+
{{ title }}
+
+ {% trans "The following disks are in use:" %}
+
+
+
+
+
+ | {% trans "Device" %} |
+ {% trans "Mount Point" %} |
+ {% trans "Type" %} |
+ {% trans "Used" %} |
+
+
+
+ {% for disk in disks %}
+
+ | {{ disk.device }} |
+ {{ disk.mount_point }} |
+ {{ disk.file_system_type }} |
+
+
+ {% if disk.percentage_used < 75 %}
+
+ {{ disk.percentage_used }}%
+
+
+ {{ disk.used }} / {{ disk.size }}
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% if expandable_root_size %}
+ Expandable Partition
+
+ {% blocktrans trimmed %}
+ There is {{ expandable_root_size }} of unallocated space
+ available after your root partition. Root partition can be
+ expanded to use this space. This will provide you additional
+ free space to store your files.
+ {% endblocktrans %}
+
+
+
+ {% trans "Expand Root Partition" %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/plinth/modules/disks/templates/disks_expand.html b/plinth/modules/disks/templates/disks_expand.html
new file mode 100644
index 000000000..61fc00a0d
--- /dev/null
+++ b/plinth/modules/disks/templates/disks_expand.html
@@ -0,0 +1,51 @@
+{% 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 %}
+{% load i18n %}
+
+{% block content %}
+ {{ title }}
+
+ {% if expandable_root_size %}
+
+
+ This feature is still in beta. Use at your own risk.
+
+
+
+ {% blocktrans trimmed %}
+ Please backup your data before proceeding. After this
+ operation, {{ expandable_root_size }} of additional free space
+ will be available in your root partition.
+ {% endblocktrans %}
+
+
+ {% else %}
+ There are no partitions available to expand.
+ {% endif %}
+
+{% endblock %}
diff --git a/plinth/modules/disks/tests/__init__.py b/plinth/modules/disks/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/plinth/modules/disks/tests/test_disks.py b/plinth/modules/disks/tests/test_disks.py
new file mode 100644
index 000000000..fe78571cd
--- /dev/null
+++ b/plinth/modules/disks/tests/test_disks.py
@@ -0,0 +1,240 @@
+#
+# 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 .
+#
+
+"""
+Test module for disks module operations.
+"""
+
+import os
+import re
+import subprocess
+import unittest
+
+
+euid = os.geteuid()
+
+
+def _get_partition_device(device, partition_number):
+ """Return the device corresponding to a parition in a given device."""
+ if re.match('[0-9]', device[-1]):
+ return device + 'p' + str(partition_number)
+
+ return device + str(partition_number)
+
+
+class Disk():
+ """Context manager to create/destroy a disk."""
+ def __init__(self, test_case, size, disk_info, file_system_info=None):
+ """Initialize the context manager object."""
+ self.size = size
+ self.test_case = test_case
+ self.disk_info = disk_info
+ self.file_system_info = file_system_info
+
+ self.disk_file = None
+ self.device = None
+
+ def _create_disk_file(self):
+ """Create a temporary file to act as a disk."""
+ directory = os.path.dirname(os.path.realpath(__file__))
+ disk_file = os.path.join(directory, 'temp_disk.img')
+
+ command = 'dd if=/dev/zero of={file} bs=1M count={size}' \
+ .format(size=self.size, file=disk_file)
+ subprocess.run(command.split(), stderr=subprocess.DEVNULL, check=True)
+
+ self.disk_file = disk_file
+
+ def _setup_loopback(self):
+ """Setup loop back on the create disk file."""
+ command = 'losetup --show --find {file}'.format(file=self.disk_file)
+ process = subprocess.run(command.split(), stdout=subprocess.PIPE,
+ check=True)
+ device = process.stdout.decode().strip()
+
+ subprocess.run(['partprobe', device], check=True)
+
+ self.device = device
+ self.test_case.device = device
+
+ def _create_partitions(self):
+ """Create partitions as specified in disk_info."""
+ steps = [step.split() for step in self.disk_info]
+ command = ['parted', '--align=optimal', '--script', self.disk_file]
+ for step in steps:
+ command += step
+
+ subprocess.run(command, check=True)
+
+ def _create_file_systems(self):
+ """Create file systems inside partitions."""
+ if not self.file_system_info:
+ return
+
+ for partition, file_system_type in self.file_system_info:
+ device = _get_partition_device(self.device, partition)
+ if file_system_type == 'btrfs':
+ command = ['mkfs.btrfs', '-K', device]
+ else:
+ command = ['mkfs.ext4', device]
+
+ subprocess.run(command, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, check=True)
+
+ def _cleanup_loopback(self):
+ """Undo the loopback device setup."""
+ subprocess.run(['losetup', '--detach', self.device])
+
+ def _remove_disk_file(self):
+ """Delete the disk_file."""
+ os.remove(self.disk_file)
+ self.disk_file = None
+
+ def __enter__(self):
+ """Enter the context, create the test disk."""
+ self._create_disk_file()
+ self._create_partitions()
+ self._setup_loopback()
+ self._create_file_systems()
+
+ def __exit__(self, *exc):
+ """Exit the context, destroy the test disk."""
+ self._cleanup_loopback()
+ self._remove_disk_file()
+
+
+class TestActions(unittest.TestCase):
+ """Test all actions related to disks."""
+ @unittest.skipUnless(euid == 0, 'Needs to be root')
+ def test_simple_case(self):
+ """Test a simple with no complications"""
+ disk_info = ['mktable msdos',
+ 'mkpart primary btrfs 1 8',
+ 'mkpart primary btrfs 9 16',
+ 'mkpart primary btrfs 20 200']
+ with Disk(self, 256, disk_info, [(3, 'btrfs')]):
+ # No free space
+ self.assert_free_space(1, space=False)
+ # < 10 MiB of free space
+ self.assert_free_space(2, space=False)
+ self.assert_free_space(3, space=True)
+
+ self.expand_partition(1, success=False)
+ self.expand_partition(2, success=False)
+ self.expand_partition(3, success=True)
+ self.expand_partition(3, success=False)
+
+ @unittest.skipUnless(euid == 0, 'Needs to be root')
+ def test_extended_partition_free_space(self):
+ """Test that free space does not show up when outside extended."""
+ disk_info = ['mktable msdos',
+ 'mkpart primary 1 8',
+ 'mkpart extended 8 32',
+ 'mkpart logical 9 16']
+ with Disk(self, 64, disk_info):
+ self.assert_free_space(5, space=False)
+ self.expand_partition(5, success=False)
+
+ @unittest.skipUnless(euid == 0, 'Needs to be root')
+ def test_gpt_partition_free_space(self):
+ """Test that GPT partitions can be expanded."""
+ # Specifically check for partition number > 4
+ disk_info = ['mktable gpt',
+ 'mkpart primary 1 4',
+ 'mkpart extended 4 8',
+ 'mkpart extended 8 12',
+ 'mkpart extended 12 16',
+ 'mkpart extended 16 160']
+ with Disk(self, 256, disk_info, [(5, 'btrfs')]):
+ self.assert_free_space(5, space=True)
+ self.expand_partition(5, success=True)
+ self.expand_partition(5, success=False)
+
+ @unittest.skipUnless(euid == 0, 'Needs to be root')
+ def test_unsupported_file_system(self):
+ """Test that free space after unknown file system does not count."""
+ disk_info = ['mktable msdos',
+ 'mkpart primary 1 8']
+ with Disk(self, 32, disk_info):
+ self.assert_free_space(1, space=False)
+ self.expand_partition(1, success=False)
+
+ @unittest.skipUnless(euid == 0, 'Needs to be root')
+ def test_btrfs_expansion(self):
+ """Test that btrfs file system can be expanded."""
+ disk_info = ['mktable msdos',
+ 'mkpart primary btrfs 1 200']
+ with Disk(self, 256, disk_info, [(1, 'btrfs')]):
+ self.expand_partition(1, success=True)
+ self.expand_partition(1, success=False)
+ self.assert_btrfs_file_system_healthy(1)
+
+ @unittest.skipUnless(euid == 0, 'Needs to be root')
+ def test_ext4_expansion(self):
+ """Test that ext4 file system can be expanded."""
+ disk_info = ['mktable msdos',
+ 'mkpart primary ext4 1 64']
+ with Disk(self, 128, disk_info, [(1, 'ext4')]):
+ self.expand_partition(1, success=True)
+ self.expand_partition(1, success=False)
+ self.assert_ext4_file_system_healthy(1)
+
+ def assert_free_space(self, partition_number, space=True):
+ """Verify that free is available/not available after a parition."""
+ device = _get_partition_device(self.device, partition_number)
+ result = self.run_action(['disks', 'is-partition-expandable', device])
+ self.assertEqual(result, space)
+
+ def expand_partition(self, partition_number, success=True):
+ """Expand a partition."""
+ self.assert_aligned(partition_number)
+ device = _get_partition_device(self.device, partition_number)
+ result = self.run_action(['disks', 'expand-partition', device])
+ self.assertEqual(result, success)
+ self.assert_aligned(partition_number)
+
+ def run_action(self, action_command):
+ """Run an action and return success/failure result."""
+ current_directory = os.path.dirname(os.path.realpath(__file__))
+ action = os.path.join(current_directory, '..', '..', '..', '..',
+ 'actions', action_command[0])
+ action_command[0] = action
+ try:
+ subprocess.run(action_command, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, check=True)
+ return True
+ except subprocess.CalledProcessError:
+ return False
+
+ def assert_aligned(self, partition_number):
+ """Test that partition is optimally aligned."""
+ subprocess.run(['parted', '--script', self.device, 'align-check',
+ 'opti', str(partition_number)])
+
+ def assert_btrfs_file_system_healthy(self, partition_number):
+ """Perform a successful ext4 file system check."""
+ device = _get_partition_device(self.device, partition_number)
+ command = ['btrfs', 'check', device]
+ subprocess.run(command, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, check=True)
+
+ def assert_ext4_file_system_healthy(self, partition_number):
+ """Perform a successful ext4 file system check."""
+ device = _get_partition_device(self.device, partition_number)
+ command = ['e2fsck', '-n', device]
+ subprocess.run(command, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, check=True)
diff --git a/plinth/modules/disks/urls.py b/plinth/modules/disks/urls.py
new file mode 100644
index 000000000..cbe3dc982
--- /dev/null
+++ b/plinth/modules/disks/urls.py
@@ -0,0 +1,30 @@
+#
+# 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 .
+#
+
+"""
+URLs for the disks module.
+"""
+
+from django.conf.urls import url
+
+from . import views
+
+
+urlpatterns = [
+ url(r'^sys/disks/$', views.index, name='index'),
+ url(r'^sys/disks/expand$', views.expand, name='expand'),
+]
diff --git a/plinth/modules/disks/views.py b/plinth/modules/disks/views.py
new file mode 100644
index 000000000..a5186a17f
--- /dev/null
+++ b/plinth/modules/disks/views.py
@@ -0,0 +1,92 @@
+#
+# 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 .
+#
+
+"""
+Views for disks module.
+"""
+
+from django.contrib import messages
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from django.template.response import TemplateResponse
+from django.utils.translation import ugettext as _
+
+from plinth.modules import disks as disks_module
+
+
+def index(request):
+ """Show connection list."""
+ disks = disks_module.get_disks()
+ root_device = disks_module.get_root_device(disks)
+ expandable_root_size = disks_module.is_expandable(root_device)
+ expandable_root_size = _format_bytes(expandable_root_size)
+
+ return TemplateResponse(request, 'disks.html',
+ {'title': _('Disks'),
+ 'disks': disks,
+ 'expandable_root_size': expandable_root_size})
+
+
+def expand(request):
+ """Warn and expand the root partition."""
+ disks = disks_module.get_disks()
+ root_device = disks_module.get_root_device(disks)
+
+ if request.method == 'POST':
+ expand_partition(request, root_device)
+ return redirect(reverse('disks:index'))
+
+ expandable_root_size = disks_module.is_expandable(root_device)
+ expandable_root_size = _format_bytes(expandable_root_size)
+ return TemplateResponse(request, 'disks_expand.html',
+ {'title': _('Expand Root Partition'),
+ 'expandable_root_size': expandable_root_size})
+
+
+def expand_partition(request, device):
+ """Expand the partition."""
+ try:
+ disks_module.expand_partition(device)
+ except Exception as exception:
+ messages.error(request, _('Error expanding partition: {exception}')
+ .format(exception=exception))
+ else:
+ messages.success(request, _('Partition expanded successfully.'))
+
+
+def _format_bytes(size):
+ """Return human readable disk size from bytes."""
+ if not size:
+ return size
+
+ if size < 1024:
+ return _('{disk_size} bytes').format(disk_size=size)
+
+ if size < 1024 ** 2:
+ size /= 1024
+ return _('{disk_size} KiB').format(disk_size=size)
+
+ if size < 1024 ** 3:
+ size /= 1024 ** 2
+ return _('{disk_size} MiB').format(disk_size=size)
+
+ if size < 1024 ** 4:
+ size /= 1024 ** 3
+ return _('{disk_size} GiB').format(disk_size=size)
+
+ size /= 1024 ** 4
+ return _('{disk_size} TiB').format(disk_size=size)