diff --git a/plinth/templates/uninstall.html b/plinth/templates/uninstall.html
new file mode 100644
index 000000000..a4589f74d
--- /dev/null
+++ b/plinth/templates/uninstall.html
@@ -0,0 +1,39 @@
+{% extends "base.html" %}
+{% comment %}
+# SPDX-License-Identifier: AGPL-3.0-or-later
+{% endcomment %}
+
+{% load bootstrap %}
+{% load i18n %}
+
+{% block content %}
+
+ {% blocktrans trimmed with app_name=app_info.name %}
+ Uninstall App {{ app_name }}?
+ {% endblocktrans %}
+
+
+
+ {% blocktrans trimmed %}
+ Uninstalling an app is an exprimental feature.
+ {% endblocktrans %}
+
+
+
+ {% blocktrans trimmed %}
+ All app data and configuration will be permanently lost. App may be
+ installed freshly again.
+ {% endblocktrans %}
+
+
+
+
+
+{% endblock %}
diff --git a/plinth/urls.py b/plinth/urls.py
index 912983869..5d550ffab 100644
--- a/plinth/urls.py
+++ b/plinth/urls.py
@@ -15,6 +15,8 @@ urlpatterns = [
name='language-selection'),
re_path(r'^apps/$', views.AppsIndexView.as_view(), name='apps'),
re_path(r'^sys/$', views.system_index, name='system'),
+ re_path(r'^uninstall/(?P[1-9a-z\-_]+)/$',
+ views.UninstallView.as_view(), name='uninstall'),
# captcha urls are public
re_path(r'^captcha/image/(?P\w+)/$', public(cviews.captcha_image),
diff --git a/plinth/views.py b/plinth/views.py
index e95990401..3464dc850 100644
--- a/plinth/views.py
+++ b/plinth/views.py
@@ -3,6 +3,7 @@
Main FreedomBox views.
"""
+import datetime
import time
import urllib.parse
@@ -349,6 +350,57 @@ class SetupView(TemplateView):
if component.has_unavailable_packages())
+class UninstallView(FormView):
+ """View to uninstall apps."""
+
+ form_class = forms.UninstallForm
+ template_name = 'uninstall.html'
+
+ def dispatch(self, request, *args, **kwargs):
+ """Don't allow the view to be used on essential apps."""
+ app_id = self.kwargs['app_id']
+ app = app_module.App.get(app_id)
+ if app.info.is_essential:
+ raise Http404
+
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, *args, **kwargs):
+ """Add app information to the context data."""
+ context = super().get_context_data(*args, **kwargs)
+ app_id = self.kwargs['app_id']
+ app = app_module.App.get(app_id)
+ context['app_info'] = app.info
+ return context
+
+ def get_success_url(self):
+ """Return the URL to redirect to after uninstall."""
+ return reverse(self.kwargs['app_id'] + ':index')
+
+ def form_valid(self, form):
+ """Uninstall the app."""
+ app_id = self.kwargs['app_id']
+
+ # Backup the app
+ if form.cleaned_data['should_backup']:
+ repository_id = form.cleaned_data['repository']
+
+ import plinth.modules.backups.repository as repository_module
+ repository = repository_module.get_instance(repository_id)
+ if repository.flags.get('mountable'):
+ repository.mount()
+
+ name = datetime.datetime.now().strftime(
+ '%Y-%m-%d:%H:%M:%S') + ' ' + str(
+ _('before uninstall of {app_id}')).format(app_id=app_id)
+ repository.create_archive(name, [app_id])
+
+ # Uninstall
+ setup.run_uninstall_on_app(app_id)
+
+ return super().form_valid(form)
+
+
def notification_dismiss(request, id):
"""Dismiss a notification."""
from .notification import Notification