From 0f32696aafd030e69f9692fe9eaff7c5648b4e03 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Sat, 8 Jan 2022 16:07:08 +0100
Subject: [PATCH 1/3] add section to create/deploy applications (#60699)

---
 README                                        |  38 +++
 hobo/applications/__init__.py                 |   0
 hobo/applications/forms.py                    |  22 ++
 hobo/applications/migrations/0001_initial.py  |  95 ++++++
 hobo/applications/migrations/__init__.py      |   0
 hobo/applications/models.py                   | 102 ++++++
 .../hobo/applications/add-element.html        |  45 +++
 .../templates/hobo/applications/create.html   |  14 +
 .../applications/element_confirm_delete.html  |  20 ++
 .../templates/hobo/applications/home.html     |  42 +++
 .../templates/hobo/applications/install.html  |  17 +
 .../templates/hobo/applications/manifest.html |  62 ++++
 hobo/applications/urls.py                     |  40 +++
 hobo/applications/utils.py                    |  66 ++++
 hobo/applications/views.py                    | 297 ++++++++++++++++++
 hobo/environment/utils.py                     |   4 +-
 hobo/settings.py                              |   1 +
 hobo/static/css/applications.svg              |   1 +
 hobo/static/css/build-application.svg         |   1 +
 hobo/static/css/style.scss                    |  46 +++
 hobo/urls.py                                  |   2 +
 tests/test_application.py                     | 222 +++++++++++++
 22 files changed, 1136 insertions(+), 1 deletion(-)
 create mode 100644 hobo/applications/__init__.py
 create mode 100644 hobo/applications/forms.py
 create mode 100644 hobo/applications/migrations/0001_initial.py
 create mode 100644 hobo/applications/migrations/__init__.py
 create mode 100644 hobo/applications/models.py
 create mode 100644 hobo/applications/templates/hobo/applications/add-element.html
 create mode 100644 hobo/applications/templates/hobo/applications/create.html
 create mode 100644 hobo/applications/templates/hobo/applications/element_confirm_delete.html
 create mode 100644 hobo/applications/templates/hobo/applications/home.html
 create mode 100644 hobo/applications/templates/hobo/applications/install.html
 create mode 100644 hobo/applications/templates/hobo/applications/manifest.html
 create mode 100644 hobo/applications/urls.py
 create mode 100644 hobo/applications/utils.py
 create mode 100644 hobo/applications/views.py
 create mode 100644 hobo/static/css/applications.svg
 create mode 100644 hobo/static/css/build-application.svg
 create mode 100644 tests/test_application.py

diff --git a/README b/README
index 86432c26..2410fcfb 100644
--- a/README
+++ b/README
@@ -186,3 +186,41 @@ isort is used to format the imports, using those parameters:
 
 There is .pre-commit-config.yaml to use pre-commit to automatically run black and isort
 before commits. (execute `pre-commit install` to install the git hook.)
+
+
+License
+-------
+
+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 <http://www.gnu.org/licenses/>.
+
+
+Combo embeds some other pieces of code or art, with their own authors and
+copyright notices:
+
+Application images (hobo/static/css/*.svg) from the unDraw project:
+ # https://undraw.co/
+ #
+ # All images, assets and vectors published on unDraw can be used for free. You
+ # can use them for noncommercial and commercial purposes. You do not need to ask
+ # permission from or provide credit to the creator or unDraw.
+ #
+ # More precisely, unDraw grants you an nonexclusive, worldwide copyright
+ # license to download, copy, modify, distribute, perform, and use the assets
+ # provided from unDraw for free, including for commercial purposes, without
+ # permission from or attributing the creator or unDraw. This license does not
+ # include the right to compile assets, vectors or images from unDraw to
+ # replicate a similar or competing service, in any form or distribute the assets
+ # in packs. This extends to automated and non-automated ways to link, embed,
+ # scrape, search or download the assets included on the website without our
+ # consent.
diff --git a/hobo/applications/__init__.py b/hobo/applications/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/hobo/applications/forms.py b/hobo/applications/forms.py
new file mode 100644
index 00000000..1401aaa2
--- /dev/null
+++ b/hobo/applications/forms.py
@@ -0,0 +1,22 @@
+# hobo - portal to configure and deploy applications
+# Copyright (C) 2015-2022 Entr'ouvert
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+
+
+class InstallForm(forms.Form):
+    bundle = forms.FileField(label=_('Application'))
diff --git a/hobo/applications/migrations/0001_initial.py b/hobo/applications/migrations/0001_initial.py
new file mode 100644
index 00000000..b279a9a1
--- /dev/null
+++ b/hobo/applications/migrations/0001_initial.py
@@ -0,0 +1,95 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.29 on 2022-01-09 13:16
+from __future__ import unicode_literals
+
+import django.contrib.postgres.fields.jsonb
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = []
+
+    operations = [
+        migrations.CreateModel(
+            name='Application',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+                ),
+                ('name', models.CharField(max_length=100, verbose_name='Name')),
+                ('slug', models.SlugField(max_length=100)),
+                ('description', models.TextField(blank=True, verbose_name='Description')),
+                ('editable', models.BooleanField(default=True)),
+                ('creation_timestamp', models.DateTimeField(default=django.utils.timezone.now)),
+                ('last_update_timestamp', models.DateTimeField(auto_now=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Element',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+                ),
+                ('type', models.CharField(max_length=25, verbose_name='Type')),
+                ('slug', models.SlugField(max_length=500, verbose_name='Slug')),
+                ('name', models.CharField(max_length=500, verbose_name='Name')),
+                ('cache', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Relation',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+                ),
+                ('auto_dependency', models.BooleanField(default=False)),
+                (
+                    'application',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE, to='applications.Application'
+                    ),
+                ),
+                (
+                    'element',
+                    models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='applications.Element'),
+                ),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Version',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+                ),
+                ('bundle', models.FileField(blank=True, null=True, upload_to='applications')),
+                ('creation_timestamp', models.DateTimeField(default=django.utils.timezone.now)),
+                ('last_update_timestamp', models.DateTimeField(auto_now=True)),
+                (
+                    'deployment_status',
+                    django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict),
+                ),
+                (
+                    'application',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE, to='applications.Application'
+                    ),
+                ),
+            ],
+        ),
+        migrations.AddField(
+            model_name='application',
+            name='elements',
+            field=models.ManyToManyField(
+                blank=True, through='applications.Relation', to='applications.Element'
+            ),
+        ),
+    ]
diff --git a/hobo/applications/migrations/__init__.py b/hobo/applications/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/hobo/applications/models.py b/hobo/applications/models.py
new file mode 100644
index 00000000..68b3e84c
--- /dev/null
+++ b/hobo/applications/models.py
@@ -0,0 +1,102 @@
+# hobo - portal to configure and deploy applications
+# Copyright (C) 2015-2022 Entr'ouvert
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+import urllib.parse
+
+from django.conf import settings
+from django.contrib.postgres.fields import JSONField
+from django.db import models
+from django.utils.text import slugify
+from django.utils.timezone import now
+from django.utils.translation import ugettext_lazy as _
+
+from hobo.environment.utils import get_installed_services
+from hobo.signature import sign_url
+
+from .utils import Requests
+
+requests = Requests()
+
+
+class Application(models.Model):
+    name = models.CharField(max_length=100, verbose_name=_('Name'))
+    slug = models.SlugField(max_length=100)
+    description = models.TextField(verbose_name=_('Description'), blank=True)
+    editable = models.BooleanField(default=True)
+    elements = models.ManyToManyField('Element', blank=True, through='Relation')
+    creation_timestamp = models.DateTimeField(default=now)
+    last_update_timestamp = models.DateTimeField(auto_now=True)
+
+    def __repr__(self):
+        return '<Application %s>' % self.slug
+
+    def save(self, *args, **kwargs):
+        if not self.slug:
+            base_slug = slugify(self.name)[:95]
+            slug = base_slug
+            i = 1
+            while Application.objects.filter(slug=slug).exists():
+                slug = '%s-%s' % (base_slug, i)
+                i += 1
+            self.slug = slug
+        super().save(*args, **kwargs)
+
+
+class Element(models.Model):
+    type = models.CharField(max_length=25, verbose_name=_('Type'))
+    slug = models.SlugField(max_length=500, verbose_name=_('Slug'))
+    name = models.CharField(max_length=500, verbose_name=_('Name'))
+    cache = JSONField(blank=True, default=dict)
+
+    def __repr__(self):
+        return '<Element %s/%s>' % (self.type, self.slug)
+
+
+class Relation(models.Model):
+    application = models.ForeignKey(Application, on_delete=models.CASCADE)
+    element = models.ForeignKey(Element, on_delete=models.CASCADE)
+    auto_dependency = models.BooleanField(default=False)
+
+    def __repr__(self):
+        return '<Relation %s - %s/%s>' % (self.application.slug, self.element.type, self.element.slug)
+
+
+class Version(models.Model):
+    application = models.ForeignKey(Application, on_delete=models.CASCADE)
+    bundle = models.FileField(upload_to='applications', blank=True, null=True)
+    creation_timestamp = models.DateTimeField(default=now)
+    last_update_timestamp = models.DateTimeField(auto_now=True)
+    deployment_status = JSONField(blank=True, default=dict)
+
+    def __repr__(self):
+        return '<Version %s>' % self.application.slug
+
+    def deploy(self):
+        bundle_content = self.bundle.read()
+        for service_id, services in getattr(settings, 'KNOWN_SERVICES', {}).items():
+            if service_id != 'wcs':
+                continue
+            service_objects = {x.get_base_url_path(): x for x in get_installed_services(types=[service_id])}
+            for service in services.values():
+                if service_objects[service['url']].secondary:
+                    continue
+                url = urllib.parse.urljoin(service['url'], 'api/export-import/bundle-import/')
+                response = requests.put(sign_url(url, service['secret']), data=bundle_content)
+                if not response.ok:
+                    # TODO: report failures
+                    continue
+                # TODO: look at response content for afterjob URLs to display a progress bar
+                pass
diff --git a/hobo/applications/templates/hobo/applications/add-element.html b/hobo/applications/templates/hobo/applications/add-element.html
new file mode 100644
index 00000000..616334e8
--- /dev/null
+++ b/hobo/applications/templates/hobo/applications/add-element.html
@@ -0,0 +1,45 @@
+{% extends "hobo/applications/home.html" %}
+{% load i18n %}
+
+{% block appbar %}
+<h2>{{ type.text }}</h2>
+{% endblock %}
+
+{% block content %}
+<form method="post">
+  {% csrf_token %}
+  <div class="application-elements">
+    {% for element in elements %}
+    <label data-slugged-text="{{ element.text|slugify }}"><input type="checkbox" name="elements" value="{{ element.id }}">{{ element.text }}</label>
+    {% endfor %}
+  </div>
+  <div style="text-align: right">
+  <label>{% trans "Filter:" %} <input type="search" id="element-filter"></label>
+  </div>
+<div class="buttons">
+<button class="submit-button">{% trans "Add" %}</button>
+<a class="cancel" href="{% url 'application-manifest' app_slug=app.slug %}">{% trans "Cancel" %}</a>
+</div>
+<script>
+$('#element-filter').on('change blur keyup', function() {
+  const val = $(this).val().toLowerCase();
+  // force dimensions so a reduced number of elements do not affect size
+  $('.application-elements').css('height', $('.application-elements').height());
+  $('.application-elements').css('width', $('.application-elements').width());
+  if (!val) {
+    $('.application-elements label').show();
+  } else {
+    $('.application-elements label').each(function(idx, elem) {
+      var slugged_text = $(elem).attr('data-slugged-text');
+      if (slugged_text.indexOf(val) > -1) {
+        $(elem).show();
+      } else {
+        $(elem).hide();
+      }
+    });
+  }
+});
+</script>
+
+</form>
+{% endblock %}
diff --git a/hobo/applications/templates/hobo/applications/create.html b/hobo/applications/templates/hobo/applications/create.html
new file mode 100644
index 00000000..65ddc607
--- /dev/null
+++ b/hobo/applications/templates/hobo/applications/create.html
@@ -0,0 +1,14 @@
+{% extends "hobo/base.html" %}
+{% load i18n %}
+
+{% block appbar %}<h2>{% trans "Create Application" %}</h2>{% endblock %}
+
+{% block content %}
+<form method="post">
+{% csrf_token %}
+{{ form.as_p }}
+<div class="buttons">
+<button class="submit-button">{% trans "Create" %}</button>
+<a class="cancel" href=".">{% trans "Cancel" %}</a>
+</div>
+{% endblock %}
diff --git a/hobo/applications/templates/hobo/applications/element_confirm_delete.html b/hobo/applications/templates/hobo/applications/element_confirm_delete.html
new file mode 100644
index 00000000..f12f02ff
--- /dev/null
+++ b/hobo/applications/templates/hobo/applications/element_confirm_delete.html
@@ -0,0 +1,20 @@
+{% extends "hobo/base.html" %}
+{% load i18n %}
+
+{% block appbar %}
+<h2>{% blocktrans with title=object.element.name %}Removal of "{{ title }}"{% endblocktrans %}</h2>
+{% endblock %}
+
+{% block content %}
+<form method="post">
+  {% csrf_token %}
+  <p>
+  {% trans 'Are you sure you want to remove this element ?' %}
+  </p>
+  <div class="buttons">
+    <button class="delete-button">{% trans 'Delete' %}</button>
+    <a class="cancel" href="{% url 'application-manifest' app_slug=view.kwargs.app_slug %}">{% trans 'Cancel' %}</a>
+  </div>
+</form>
+{% endblock %}
+
diff --git a/hobo/applications/templates/hobo/applications/home.html b/hobo/applications/templates/hobo/applications/home.html
new file mode 100644
index 00000000..3f571db2
--- /dev/null
+++ b/hobo/applications/templates/hobo/applications/home.html
@@ -0,0 +1,42 @@
+{% extends "hobo/base.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="{% url 'applications-home' %}">{% trans 'Applications' %}</a>
+{% endblock %}
+
+{% block appbar %}
+  <h2>{% trans 'Applications' %}</h2>
+  <span class="actions">
+    <a rel="popup" href="{% url 'application-install' %}">{% trans 'Install' %}</a>
+    <a rel="popup" href="{% url 'application-init' %}">{% trans 'Create' %}</a>
+  </span>
+{% endblock %}
+
+{% block content %}
+
+{% if object_list %}
+<div id="applications">
+{% for application in object_list %}
+ <div class="application application--{{ application.slug }}">
+   <h3>{{ application.name }}</h3>
+   <p>{{ application.description|default:"" }}</p>
+   {% if application.editable %}
+   <div class="buttons">
+    <a class="button" href="{% url 'application-manifest' app_slug=application.slug %}"
+                      >{% trans "Edit" %}</a>
+   </div>
+   {% endif %}
+ </div>
+{% endfor %}
+</div>
+{% else %}
+  <div id="no-applications">
+    <div class="infonotice">
+    <p>{% trans "You should find, install or build some applications." %}</p>
+    </div>
+  </div>
+{% endif %}
+
+{% endblock %}
diff --git a/hobo/applications/templates/hobo/applications/install.html b/hobo/applications/templates/hobo/applications/install.html
new file mode 100644
index 00000000..549e85ba
--- /dev/null
+++ b/hobo/applications/templates/hobo/applications/install.html
@@ -0,0 +1,17 @@
+{% extends "hobo/base.html" %}
+{% load i18n %}
+
+{% block appbar %}
+<h2>{% trans "Install" %}</h2>
+{% endblock %}
+
+{% block content %}
+<form method="post" enctype="multipart/form-data">
+  {% csrf_token %}
+  {{ form.as_p }}
+  <div class="buttons">
+    <button class="submit-button">{% trans 'Install' %}</button>
+    <a class="cancel" href="{% url 'applications-home' %}">{% trans 'Cancel' %}</a>
+  </div>
+</form>
+{% endblock %}
diff --git a/hobo/applications/templates/hobo/applications/manifest.html b/hobo/applications/templates/hobo/applications/manifest.html
new file mode 100644
index 00000000..9cb1d786
--- /dev/null
+++ b/hobo/applications/templates/hobo/applications/manifest.html
@@ -0,0 +1,62 @@
+{% extends "hobo/applications/home.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="{% url 'application-manifest' app_slug=app.slug %}">{{ app.name }}</a>
+{% endblock %}
+
+{% block appbar %}
+<h2>{{ app.name }}</h2>
+  <span class="actions">
+    <a rel="popup" href="{% url 'application-metadata' app_slug=app.slug %}">{% trans 'Metadata' %}</a>
+  </span>
+{% endblock %}
+
+{% block content %}
+
+  <ul class="objects-list single-links">
+    {% for relation in relations %}
+    {% if not relation.auto_dependency %}
+    <li><a>{{ relation.element.name }} <span class="extra-info">- {{ relation.element.type_label }}</span></a>
+            <a rel="popup" class="delete" href="{% url 'application-delete-element' app_slug=app.slug pk=relation.id %}">{% trans "remove" %}</a></li>
+    {% endif %}
+    {% endfor %}
+    {% for relation in relations %}
+    {% if relation.auto_dependency %}
+    <li class="auto-dependency"><a>{{ relation.element.name }} <span class="extra-info">- {{ relation.element.type_label }}</span></a></li>
+    {% endif %}
+    {% endfor %}
+  </ul>
+
+  {% if relations %}
+  <div class="buttons">
+    <a class="pk-button" href="{% url 'application-scandeps' app_slug=app.slug %}">{% trans "Scan dependencies" %}</a>
+    &nbsp; &nbsp;
+    <a class="pk-button" href="{% url 'application-generate' app_slug=app.slug %}">{% trans "Generate application bundle" %}</a>
+    {% if versions %}
+    &nbsp; &nbsp;
+    <a class="pk-button" download href="{% url 'application-download' app_slug=app.slug %}">{% trans "Download" %}</a>
+    {% endif %}
+  </div>
+  {% else %}
+  <div id="application-empty">
+    <div class="infonotice">
+    <p>{% trans "You should now assemble the different parts of your application." %}</p>
+    </div>
+  </div>
+  {% endif %}
+
+{% endblock %}
+
+{% block sidebar %}
+<aside id="sidebar">
+<h3>{% trans "Add" %}</h3>
+{% for service, types in types_by_service.items %}
+<h4>{{ service }}</h4>
+{% for type in types %}
+<a class="button button-paragraph" rel="popup" href="{% url 'application-add-element' app_slug=app.slug type=type.id %}">{{ type.text }}</a>
+{% endfor %}
+{% endfor %}
+</aside>
+{% endblock %}
diff --git a/hobo/applications/urls.py b/hobo/applications/urls.py
new file mode 100644
index 00000000..ff620918
--- /dev/null
+++ b/hobo/applications/urls.py
@@ -0,0 +1,40 @@
+# hobo - portal to configure and deploy applications
+# Copyright (C) 2015-2022 Entr'ouvert
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+from django.conf.urls import url
+
+from . import views
+
+urlpatterns = [
+    url(r'^$', views.home, name='applications-home'),
+    url(r'^create/$', views.init, name='application-init'),
+    url(r'^install/$', views.install, name='application-install'),
+    url(r'^manifest/(?P<app_slug>[\w-]+)/$', views.manifest, name='application-manifest'),
+    url(r'^manifest/(?P<app_slug>[\w-]+)/metadata/$', views.metadata, name='application-metadata'),
+    url(r'^manifest/(?P<app_slug>[\w-]+)/scandeps/$', views.scandeps, name='application-scandeps'),
+    url(r'^manifest/(?P<app_slug>[\w-]+)/generate/$', views.generate, name='application-generate'),
+    url(r'^manifest/(?P<app_slug>[\w-]+)/download/$', views.download, name='application-download'),
+    url(
+        r'^manifest/(?P<app_slug>[\w-]+)/add/(?P<type>[\w-]+)/$',
+        views.add_element,
+        name='application-add-element',
+    ),
+    url(
+        r'^manifest/(?P<app_slug>[\w-]+)/delete/(?P<pk>\d+)/$',
+        views.delete_element,
+        name='application-delete-element',
+    ),
+]
diff --git a/hobo/applications/utils.py b/hobo/applications/utils.py
new file mode 100644
index 00000000..f6698f75
--- /dev/null
+++ b/hobo/applications/utils.py
@@ -0,0 +1,66 @@
+# hobo - portal to configure and deploy applications
+# Copyright (C) 2015-2022 Entr'ouvert
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+import urllib
+
+import requests
+from django.conf import settings
+from django.utils.http import urlencode
+from requests import Response
+from requests import Session as RequestsSession
+from requests.auth import AuthBase
+
+from hobo.signature import sign_url
+
+
+class PublikSignature(AuthBase):
+    def __init__(self, secret):
+        self.secret = secret
+
+    def __call__(self, request):
+        request.url = sign_url(request.url, self.secret)
+        return request
+
+
+def get_known_service_for_url(url):
+    netloc = urllib.parse.urlparse(url).netloc
+    for services in settings.KNOWN_SERVICES.values():
+        for service in services.values():
+            remote_url = service.get('url')
+            if urllib.parse.urlparse(remote_url).netloc == netloc:
+                return service
+    return None
+
+
+class Requests(RequestsSession):
+    def request(self, method, url, **kwargs):
+        remote_service = get_known_service_for_url(url)
+        kwargs['auth'] = PublikSignature(remote_service.get('secret'))
+
+        # only keeps the path (URI) in url parameter, scheme and netloc are
+        # in remote_service
+        scheme, netloc, path, params, query, fragment = urllib.parse.urlparse(url)
+        url = urllib.parse.urlunparse(('', '', path, params, query, fragment))
+
+        query_params = {'orig': remote_service.get('orig')}
+
+        remote_service_base_url = remote_service.get('url')
+        scheme, netloc, dummy, params, old_query, fragment = urllib.parse.urlparse(remote_service_base_url)
+
+        query = urlencode(query_params)
+        url = urllib.parse.urlunparse((scheme, netloc, path, params, query, fragment))
+
+        return super().request(method, url, **kwargs)
diff --git a/hobo/applications/views.py b/hobo/applications/views.py
new file mode 100644
index 00000000..2b0b37e1
--- /dev/null
+++ b/hobo/applications/views.py
@@ -0,0 +1,297 @@
+# hobo - portal to configure and deploy applications
+# Copyright (C) 2015-2022 Entr'ouvert
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+import io
+import json
+import tarfile
+import urllib.parse
+
+from django.conf import settings
+from django.core.files.base import ContentFile
+from django.http import HttpResponse, HttpResponseRedirect
+from django.urls import reverse, reverse_lazy
+from django.views.generic import FormView, ListView, TemplateView
+from django.views.generic.edit import CreateView, DeleteView, UpdateView
+
+from hobo.environment.utils import get_installed_services
+
+from .forms import InstallForm
+from .models import Application, Element, Relation, Version
+from .utils import Requests
+
+requests = Requests()
+
+
+class HomeView(ListView):
+    template_name = 'hobo/applications/home.html'
+    model = Application
+
+    def get_queryset(self):
+        return super().get_queryset().order_by('name')
+
+
+home = HomeView.as_view()
+
+
+class InitView(CreateView):
+    template_name = 'hobo/applications/create.html'
+    model = Application
+    fields = ['name']
+
+    def get_success_url(self):
+        return reverse('application-manifest', kwargs={'app_slug': self.object.slug})
+
+
+init = InitView.as_view()
+
+
+def get_object_types():
+    object_types = []
+    for service_id, services in getattr(settings, 'KNOWN_SERVICES', {}).items():
+        if service_id not in ('combo', 'wcs'):
+            continue
+        service_objects = {x.get_base_url_path(): x for x in get_installed_services(types=[service_id])}
+        for service in services.values():
+            if service_objects[service['url']].secondary:
+                continue
+            url = urllib.parse.urljoin(service['url'], 'api/export-import/')
+            response = requests.get(url)
+            if not response.ok:
+                continue
+            for object_type in response.json()['data']:
+                object_type['service'] = service
+                object_types.append(object_type)
+    return object_types
+
+
+class ManifestView(TemplateView):
+    template_name = 'hobo/applications/manifest.html'
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+
+        context['app'] = Application.objects.get(slug=self.kwargs['app_slug'])
+
+        context['relations'] = context['app'].relation_set.all().select_related('element')
+        context['versions'] = context['app'].version_set.exists()
+        context['types_by_service'] = {}
+
+        type_labels = {}
+        for object_type in get_object_types():
+            type_labels[object_type['id']] = object_type['singular']
+            if object_type.get('minor'):
+                continue
+            service = object_type['service']['title']
+            if service not in context['types_by_service']:
+                context['types_by_service'][service] = []
+            context['types_by_service'][service].append(object_type)
+
+        for relation in context['relations']:
+            relation.element.type_label = type_labels.get(relation.element.type)
+
+        return context
+
+
+manifest = ManifestView.as_view()
+
+
+class MetadataView(UpdateView):
+    template_name = 'hobo/applications/edit-metadata.html'
+    model = Application
+    slug_url_kwarg = 'app_slug'
+    fields = ['name', 'slug', 'description']
+
+    def get_success_url(self):
+        return reverse('application-manifest', kwargs={'app_slug': self.object.slug})
+
+
+metadata = MetadataView.as_view()
+
+
+class AppAddElementView(TemplateView):
+    template_name = 'hobo/applications/add-element.html'
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context['app'] = Application.objects.get(slug=self.kwargs['app_slug'])
+        for object_type in get_object_types():
+            if object_type.get('id') == self.kwargs['type']:
+                context['type'] = object_type
+                url = object_type['urls']['list']
+                response = requests.get(url)
+                context['elements'] = response.json()['data']
+                break
+        return context
+
+    def post(self, request, app_slug, type):
+        context = self.get_context_data()
+        app = context['app']
+        element_infos = {x['id']: x for x in context['elements']}
+        for element_slug in request.POST.getlist('elements'):
+            element, created = Element.objects.get_or_create(
+                type=type, slug=element_slug, defaults={'name': element_infos[element_slug]['text']}
+            )
+            element.name = element_infos[element_slug]['text']
+            element.cache = element_infos[element_slug]
+            element.save()
+            relation, created = Relation.objects.get_or_create(application=app, element=element)
+            relation.auto_dependency = False
+            relation.save()
+        return HttpResponseRedirect(reverse('application-manifest', kwargs={'app_slug': app_slug}))
+
+
+add_element = AppAddElementView.as_view()
+
+
+class AppDeleteElementView(DeleteView):
+    model = Relation
+    template_name = 'hobo/applications/element_confirm_delete.html'
+
+    def get_success_url(self):
+        return reverse('application-manifest', kwargs={'app_slug': self.kwargs['app_slug']})
+
+
+delete_element = AppDeleteElementView.as_view()
+
+
+def scandeps(request, app_slug):
+    app = Application.objects.get(slug=app_slug)
+    app.relation_set.filter(auto_dependency=True).delete()
+    relations = app.relation_set.select_related('element')
+    elements = {(x.element.type, x.element.slug): x.element for x in relations}
+    finished = False
+    while not finished:
+        finished = True
+        for (type, slug), element in list(elements.items()):
+            dependencies_url = element.cache['urls'].get('dependencies')
+            element.done = True
+            if not dependencies_url:
+                continue
+            response = requests.get(dependencies_url)
+            for dependency in response.json()['data']:
+                if (dependency['type'], dependency['id']) in elements:
+                    continue
+                finished = False
+                element, created = Element.objects.get_or_create(
+                    type=dependency['type'], slug=dependency['id'], defaults={'name': dependency['text']}
+                )
+                element.name = dependency['text']
+                element.cache = dependency
+                element.save()
+                relation, created = Relation.objects.get_or_create(application=app, element=element)
+                if created:
+                    relation.auto_dependency = True
+                    relation.save()
+                elements[(element.type, element.slug)] = element
+
+    return HttpResponseRedirect(reverse('application-manifest', kwargs={'app_slug': app_slug}))
+
+
+def generate(request, app_slug):
+    app = Application.objects.get(slug=app_slug)
+
+    version = Version(application=app)
+    version.save()
+
+    tar_io = io.BytesIO()
+    with tarfile.open(mode='w', fileobj=tar_io) as tar:
+        manifest_json = {
+            'application': app.name,
+            'slug': app.slug,
+            'description': app.description,
+            'elements': [],
+        }
+
+        for relation in app.relation_set.all().select_related('element'):
+            element = relation.element
+            manifest_json['elements'].append(
+                {
+                    'type': element.type,
+                    'slug': element.slug,
+                    'name': element.name,
+                    'auto-dependency': relation.auto_dependency,
+                }
+            )
+
+            response = requests.get(element.cache['urls']['export'])
+            tarinfo = tarfile.TarInfo('%s/%s' % (element.type, element.slug))
+            tarinfo.mtime = version.last_update_timestamp.timestamp()
+            tarinfo.size = int(response.headers['content-length'])
+            tar.addfile(tarinfo, fileobj=io.BytesIO(response.content))
+
+        manifest_fd = io.BytesIO(json.dumps(manifest_json, indent=2).encode())
+        tarinfo = tarfile.TarInfo('manifest.json')
+        tarinfo.size = len(manifest_fd.getvalue())
+        tarinfo.mtime = version.last_update_timestamp.timestamp()
+        tar.addfile(tarinfo, fileobj=manifest_fd)
+
+    version.bundle.save('%s.tar' % app_slug, content=ContentFile(tar_io.getvalue()))
+    version.save()
+
+    return HttpResponseRedirect(reverse('application-manifest', kwargs={'app_slug': app_slug}))
+
+
+def download(request, app_slug):
+    app = Application.objects.get(slug=app_slug)
+    version = app.version_set.order_by('-last_update_timestamp')[0]
+    response = HttpResponse(version.bundle, content_type='application/x-tar')
+    response['Content-Disposition'] = 'attachment; filename="%s"' % '%s.tar' % app_slug
+    return response
+
+
+class Install(FormView):
+    form_class = InstallForm
+    template_name = 'hobo/applications/install.html'
+    success_url = reverse_lazy('applications-home')
+
+    def form_valid(self, form):
+        tar_io = io.BytesIO(self.request.FILES['bundle'].read())
+        tar = tarfile.open(fileobj=tar_io)
+        manifest = json.loads(tar.extractfile('manifest.json').read().decode())
+        app, created = Application.objects.get_or_create(
+            slug=manifest.get('slug'), defaults={'name': manifest.get('application')}
+        )
+        app.name = manifest.get('application')
+        app.description = manifest.get('description')
+        app.editable = False
+        if not created:
+            app.relation_set.all().delete()
+        app.save()
+
+        for element_dict in manifest.get('elements'):
+            element, created = Element.objects.get_or_create(
+                type=element_dict['type'], slug=element_dict['slug'], defaults={'name': element_dict['name']}
+            )
+            element.name = element_dict['name']
+            element.save()
+
+            relation = Relation(
+                application=app,
+                element=element,
+                auto_dependency=element_dict['auto-dependency'],
+            )
+            relation.save()
+
+        version = Version(application=app)
+        version.bundle.save('%s.tar' % app.slug, content=ContentFile(tar_io.getvalue()))
+        version.save()
+
+        version.deploy()
+
+        return super().form_valid(form)
+
+
+install = Install.as_view()
diff --git a/hobo/environment/utils.py b/hobo/environment/utils.py
index ebc3e2da..b3adb4d3 100644
--- a/hobo/environment/utils.py
+++ b/hobo/environment/utils.py
@@ -27,11 +27,13 @@ from hobo.multitenant.settings_loaders import KnownServices
 from hobo.profile.utils import get_profile_dict
 
 
-def get_installed_services():
+def get_installed_services(types=None):
     from .models import AVAILABLE_SERVICES
 
     installed_services = []
     for available_service in AVAILABLE_SERVICES:
+        if types and available_service.Extra.service_id not in types:
+            continue
         installed_services.extend(available_service.objects.all())
     return installed_services
 
diff --git a/hobo/settings.py b/hobo/settings.py
index 57192c28..718f0b57 100644
--- a/hobo/settings.py
+++ b/hobo/settings.py
@@ -42,6 +42,7 @@ INSTALLED_APPS = (
     'rest_framework',
     'mellon',
     'gadjo',
+    'hobo.applications',
     'hobo.debug',
     'hobo.environment',
     'hobo.franceconnect',
diff --git a/hobo/static/css/applications.svg b/hobo/static/css/applications.svg
new file mode 100644
index 00000000..b5bc78cf
--- /dev/null
+++ b/hobo/static/css/applications.svg
@@ -0,0 +1 @@
+<svg id="ee10700a-6e76-4a82-b03e-a9751bc157fe" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="803.05238" height="591.37509" viewBox="0 0 803.05238 591.37509"><path d="M424.91688,674.27641c2.92224-23.4981,17.48511-46.65063,39.89523-54.29754a109.09788,109.09788,0,0,0,.0054,74.90374c3.44358,9.31476,8.24353,19.31641,5.0044,28.70429-2.01535,5.84137-6.94565,10.31055-12.4567,13.105-5.51141,2.79443-11.6279,4.12634-17.66788,5.43158l-1.18869.98315C428.9203,721.45561,421.99464,697.77458,424.91688,674.27641Z" transform="translate(-198.47381 -154.31245)" fill="#f0f0f0"/><path d="M465.03449,620.4232a93.25009,93.25009,0,0,0-23.18185,52.4812,40.15631,40.15631,0,0,0,.45679,12.57361,23.03162,23.03162,0,0,0,5.72827,10.68256c2.58185,2.83667,5.5513,5.43933,7.39865,8.85425a14.10232,14.10232,0,0,1,.689,11.51263c-1.631,4.678-4.84564,8.491-8.11886,12.11054-3.63428,4.01879-7.47284,8.13549-9.0177,13.46337-.1872.64557-1.17789.31739-.991-.32721,2.68777-9.26959,11.68607-14.53485,15.97736-22.88385,2.00238-3.89581,2.84286-8.41864.96566-12.531-1.64154-3.59613-4.70135-6.28259-7.34039-9.1322a24.57672,24.57672,0,0,1-5.9902-10.23658,37.12347,37.12347,0,0,1-.93851-12.50964,90.469,90.469,0,0,1,6.60736-27.49286A94.90381,94.90381,0,0,1,464.344,619.64061c.44623-.4997,1.13385.28632.69055.78271Z" transform="translate(-198.47381 -154.31245)" fill="#fff"/><path d="M442.13355,666.58873A13.99051,13.99051,0,0,1,431.484,651.93315a.52227.52227,0,0,1,1.0426.05206,12.95492,12.95492,0,0,0,9.93414,13.61249C443.11408,665.753,442.78315,666.74315,442.13355,666.58873Z" transform="translate(-198.47381 -154.31245)" fill="#fff"/><path d="M446.53916,694.89464a26.96549,26.96549,0,0,0,12.04175-15.53015c.18945-.64484,1.18024-.3169.991.32721a28.04826,28.04826,0,0,1-12.56339,16.13507C446.42982,696.17,445.9636,695.23607,446.53916,694.89464Z" transform="translate(-198.47381 -154.31245)" fill="#fff"/><path d="M452.33448,637.95433a7.91916,7.91916,0,0,0,7.50482-.381c.57407-.35046,1.03971.584.46936.93213a8.87421,8.87421,0,0,1-8.30139.43988.53934.53934,0,0,1-.33191-.65912A.52444.52444,0,0,1,452.33448,637.95433Z" transform="translate(-198.47381 -154.31245)" fill="#fff"/><path d="M533.12128,661.94939c-.35223.229-.70446.458-1.05712.69586a104.2919,104.2919,0,0,0-13.335,10.411c-.326.29071-.652.59015-.969.88965a109.94776,109.94776,0,0,0-23.87842,32.68658,106.77244,106.77244,0,0,0-5.84863,15.13214c-2.15814,7.16095-3.92835,15.09687-8.20032,20.95423a18.31835,18.31835,0,0,1-1.427,1.76159H439.80021c-.08774-.044-.1759-.07928-.26407-.12335l-1.54141.0705c.06192-.273.132-.55493.194-.82794.03528-.15857.07913-.31708.11441-.47565.02624-.10571.05289-.21143.07052-.30829.00861-.03522.01764-.07043.02625-.09686.01764-.09687.04431-.185.06192-.27307q.58122-2.365,1.19778-4.72992c0-.00885,0-.00885.00861-.01764a156.9946,156.9946,0,0,1,13.212-34.686c.17633-.32586.35223-.66058.5462-.98651a101.88533,101.88533,0,0,1,9.15164-13.88147,90.0712,90.0712,0,0,1,5.99832-6.86145,74.90082,74.90082,0,0,1,18.74329-14.04c13.84637-7.31067,29.87671-10.11157,44.67444-5.64593C532.37254,661.71165,532.74284,661.8261,533.12128,661.94939Z" transform="translate(-198.47381 -154.31245)" fill="#f0f0f0"/><path d="M533.03452,662.43938A93.24986,93.24986,0,0,0,482.9278,690.3856a40.1569,40.1569,0,0,0-7.20544,10.31434,23.03161,23.03161,0,0,0-1.858,11.97827c.35357,3.81939,1.15756,7.68524.57657,11.52411a14.10252,14.10252,0,0,1-6.38129,9.607c-4.11878,2.75317-8.98114,3.86218-13.77384,4.7815-5.32138,1.02069-10.86477,1.99658-15.306,5.32049-.53812.40271-1.13157-.45581-.59427-.85791,7.727-5.783,18.08166-4.56946,26.53466-8.652,3.94434-1.905,7.33847-5.01019,8.31559-9.424.85443-3.85962.02877-7.84686-.36267-11.711a24.57678,24.57678,0,0,1,1.38028-11.77984,37.12469,37.12469,0,0,1,6.78231-10.55329,90.47,90.47,0,0,1,21.82822-17.97345,94.90373,94.90373,0,0,1,30.09042-11.56115c.65714-.13031.73294.91125.08011,1.04071Z" transform="translate(-198.47381 -154.31245)" fill="#fff"/><path d="M486.95457,685.512a13.99047,13.99047,0,0,1,.32061-18.1134c.44547-.50245,1.24717.16619.80109.66931a12.95487,12.95487,0,0,0-.26379,16.84985C488.24061,685.43517,487.38026,686.02647,486.95457,685.512Z" transform="translate(-198.47381 -154.31245)" fill="#fff"/><path d="M473.43006,710.76518a26.96573,26.96573,0,0,0,18.96488-5.15c.53952-.40082,1.13311.45758.59426.85791a28.04838,28.04838,0,0,1-19.74557,5.319C472.5749,711.7177,472.765,710.69127,473.43006,710.76518Z" transform="translate(-198.47381 -154.31245)" fill="#fff"/><path d="M512.3393,668.7907a7.91928,7.91928,0,0,0,6.22156,4.21423c.66934.06579.47855,1.09222-.18643,1.02685a8.87419,8.87419,0,0,1-6.893-4.64679.53933.53933,0,0,1,.13181-.72607.52444.52444,0,0,1,.7261.13184Z" transform="translate(-198.47381 -154.31245)" fill="#fff"/><path d="M458.76688,336.75688H331.0413a14.90431,14.90431,0,0,1-14.88721-14.88769V252.30374A14.90346,14.90346,0,0,1,331.0413,237.417H458.76688a14.90347,14.90347,0,0,1,14.88721,14.88672v69.56543A14.90431,14.90431,0,0,1,458.76688,336.75688Z" transform="translate(-198.47381 -154.31245)" fill="#e6e6e6"/><path d="M653.76688,336.75688H526.0413a14.90431,14.90431,0,0,1-14.88721-14.88769V252.30374A14.90346,14.90346,0,0,1,526.0413,237.417H653.76688a14.90345,14.90345,0,0,1,14.88721,14.88672v69.56543A14.90433,14.90433,0,0,1,653.76688,336.75688Z" transform="translate(-198.47381 -154.31245)" fill="#386ede"/><path d="M848.76688,336.75688H721.0413a14.90431,14.90431,0,0,1-14.88721-14.88769V252.30374A14.90346,14.90346,0,0,1,721.0413,237.417H848.76688a14.90345,14.90345,0,0,1,14.88721,14.88672v69.56543A14.90433,14.90433,0,0,1,848.76688,336.75688Z" transform="translate(-198.47381 -154.31245)" fill="#e6e6e6"/><path d="M458.76688,491.75688H331.0413a14.90431,14.90431,0,0,1-14.88721-14.88769V407.30376A14.90349,14.90349,0,0,1,331.0413,392.417H458.76688a14.90348,14.90348,0,0,1,14.88721,14.88672v69.56543A14.90431,14.90431,0,0,1,458.76688,491.75688Z" transform="translate(-198.47381 -154.31245)" fill="#f2f2f2"/><path d="M653.76688,491.75688H526.0413a14.90431,14.90431,0,0,1-14.88721-14.88769V407.30376A14.90349,14.90349,0,0,1,526.0413,392.417H653.76688a14.90346,14.90346,0,0,1,14.88721,14.88672v69.56543A14.90433,14.90433,0,0,1,653.76688,491.75688Z" transform="translate(-198.47381 -154.31245)" fill="#e6e6e6"/><g id="e3e4f7c8-e232-44d3-85d4-7e764218aef0" data-name="aa405d94-515b-444f-88dd-2c52df39214d"><circle id="fcc64bf3-a6e1-406f-af2a-c5c9529d985b" data-name="ede3a9f0-5e02-4455-ac7e-751a5f8c3692" cx="464.31155" cy="327.43306" r="22.81223" fill="#386ede"/><path id="a515bf60-7ad9-4961-925b-281b4504c434" data-name="ba039ca8-d148-45ed-9781-bacb7304e881" d="M672.25828,479.82985h-7.75611v-7.75613a1.82507,1.82507,0,0,0-1.82507-1.82511h0a1.82508,1.82508,0,0,0-1.82507,1.82511v7.75613h-7.75611a1.82507,1.82507,0,0,0-1.82507,1.8251h0a1.82508,1.82508,0,0,0,1.82507,1.82511h7.75617v7.75613a1.82507,1.82507,0,0,0,1.82507,1.8251h0a1.82508,1.82508,0,0,0,1.82508-1.82507V483.48h7.756a1.82508,1.82508,0,0,0,1.82507-1.82511h0a1.82507,1.82507,0,0,0-1.82507-1.8251h0Z" transform="translate(-198.47381 -154.31245)" fill="#fff"/></g><g id="e103fff6-fe82-4519-942e-bf1bf1b0aca3" data-name="bbf41f8a-587e-4352-8304-6d86282586e3"><circle id="add300b1-303e-47da-8a54-54886e1d1e76" data-name="e4c91425-bc08-43ed-9b2d-9a6452dd6ab0" cx="662.31155" cy="176.43306" r="22.81224" fill="#386ede"/><path id="ac71a829-1528-4702-ba68-9cb7b8b12af7" data-name="b08e8960-896e-4763-a2dc-6c3da3e5d175" d="M870.25828,328.82985h-7.75611v-7.75613a1.82507,1.82507,0,0,0-1.82507-1.82511h0a1.82508,1.82508,0,0,0-1.82507,1.82511v7.75613h-7.75611a1.82507,1.82507,0,0,0-1.82507,1.8251h0a1.82508,1.82508,0,0,0,1.82507,1.82511h7.75617v7.75613a1.82507,1.82507,0,0,0,1.82507,1.8251h0a1.82508,1.82508,0,0,0,1.82508-1.82507V332.48h7.756a1.82508,1.82508,0,0,0,1.82507-1.82511h0a1.82507,1.82507,0,0,0-1.82507-1.8251h0Z" transform="translate(-198.47381 -154.31245)" fill="#fff"/></g><g id="eec1f109-4866-4d4c-80db-d160015753dd" data-name="b77ad267-181d-433f-b31f-af61e58bf4a6"><circle id="ad2802dd-75a3-400f-a980-dd7e255328e3" data-name="e8aef769-5476-47e9-8295-ae809767f9aa" cx="269.31155" cy="174.43306" r="22.81223" fill="#386ede"/><path id="a152d324-d2c4-475a-92fe-001d0469b5e0" data-name="efbebd4e-dbd2-4103-8d2c-f0ca8346a3bf" d="M477.25828,326.82985h-7.75614v-7.75613a1.82509,1.82509,0,0,0-1.82507-1.82511h0a1.8251,1.8251,0,0,0-1.8251,1.82511h0v7.75613h-7.75614a1.8251,1.8251,0,0,0-1.8251,1.8251h0a1.8251,1.8251,0,0,0,1.82507,1.82511h7.75614v7.75613a1.82509,1.82509,0,0,0,1.8251,1.8251h0a1.82508,1.82508,0,0,0,1.8251-1.82507V330.48h7.75614a1.8251,1.8251,0,0,0,1.8251-1.82511h0a1.8251,1.8251,0,0,0-1.8251-1.8251h0Z" transform="translate(-198.47381 -154.31245)" fill="#fff"/></g><path d="M638.57566,297.23225a9.06978,9.06978,0,0,1-7.3208,11.82447l-10.54828,72.00079-17.303-8.451,17.40372-71.8703a9.11882,9.11882,0,0,1,17.76831-3.504Z" transform="translate(-198.47381 -154.31245)" fill="#9e616a"/><path d="M512.5893,527.83214a9.06961,9.06961,0,0,0-3.492-13.46173l7.11621-76.58634-14.90473,7.63986-4.654,73.80091a9.11882,9.11882,0,0,0,15.93442,8.6073Z" transform="translate(-198.47381 -154.31245)" fill="#9e616a"/><polygon points="324.298 334.784 388.259 332.324 391.949 265.903 318.148 265.903 324.298 334.784" fill="#9e616a"/><polygon points="312.427 569.355 323.357 569.354 328.556 527.197 312.425 527.197 312.427 569.355" fill="#9e616a"/><path d="M508.18961,718.27562l17.27774-1.03125v7.40253l16.42643,11.34466a4.6239,4.6239,0,0,1-2.62748,8.429H518.69651l-3.5455-7.32221-1.38434,7.32221h-7.75561Z" transform="translate(-198.47381 -154.31245)" fill="#2f2e41"/><polygon points="361.041 569.355 371.971 569.354 377.17 527.197 361.039 527.197 361.041 569.355" fill="#9e616a"/><path d="M521.88435,473.5041s-19.13755,15.45038-8.76307,70.62845c-1.5798.1883.20908,3.30231.20908,3.30231l1.497,44.95953,1.15683,5.1529-3.84607,8.31463,1.58551,9.034-4.888,81.9917,18.05719,2.33508L544.173,614.07915l3.2909-9.57257,3.3009-8.39075-1.78055-8.94769,1.46033-13.88788,9.96188-46.829,2.43869,81.21808-3.403,7.75378,1.89376,8.5116-4.219,4.51819-1.66748,75.98993,19.25439.90063,15.9311-76.60315-1.23529-9.77545,2.1195-7.22791V594.85387l3.63025-7.96808,7.914-43.44751s2.38742-33.95831-15.03183-73.91895Z" transform="translate(-198.47381 -154.31245)" fill="#2f2e41"/><path d="M529.68385,365.01326s-24.17157,4.78046-28.33655,25.39468-6.84455,67.26288-6.84455,67.26288l.91214,6.49622,23.38169,5.42178,14.92358-65.36273Z" transform="translate(-198.47381 -154.31245)" fill="#3f3d56"/><path d="M584.84711,371.97145s2.45014-3.43347,16.17112-3.963c.56491-.02179-.13456-1.14252-.13456-1.14252l6.59879-32.25272,21.9906,12.17527-3.7121,33.813L589.6254,421.70213Z" transform="translate(-198.47381 -154.31245)" fill="#3f3d56"/><path d="M571.13981,468.12233c-10.3526.00079-25.936-1.14172-49.39554-4.62451a7.165,7.165,0,0,1-6.01373-5.99893,5.42418,5.42418,0,0,1-.91135-6.16007l.02179-.04278-.53155-.6265a5.80077,5.80077,0,0,1-.38681-7.01147l-7.2652-51.97477a21.89569,21.89569,0,0,1,15.22772-24.02145l2.682-.83508,10.14248-13.26648.2.00232,32.5607.4335,10.56662,13.41824,17.08081,4.02756.04438.26,3.96609,22.914c2.06241,11.91773-2.66794,16.606-8.70578,27.0863l1.77213,29.43436.804,1.46082a3.95918,3.95918,0,0,1-.94793,4.96774,7.71178,7.71178,0,0,1,.10507,4.7724l-.41794,1.38065c.00079.0397-.11054.60083-1.01254,1.2857C589.09928,466.23891,584.4304,468.12233,571.13981,468.12233Z" transform="translate(-198.47381 -154.31245)" fill="#3f3d56"/><path d="M556.80378,718.27562l17.27774-1.03125v7.40253l16.42642,11.34466a4.6239,4.6239,0,0,1-2.62747,8.429H567.31068l-3.54551-7.32221-1.38433,7.32221h-7.75565Z" transform="translate(-198.47381 -154.31245)" fill="#2f2e41"/><circle cx="549.75371" cy="320.56323" r="25.52654" transform="matrix(0.16018, -0.98709, 0.98709, 0.16018, -53.20484, 657.55723)" fill="#9e616a"/><path d="M564.67988,295.61836c-9.35593-6.43451-17.59488-7.41464-24.36267-5.58966a8.89677,8.89677,0,0,0-3.55887-3.16012c-5.27838-13.30008-21.34168-21.28745-35.127-17.40467-13.87448,3.9079-23.44757,19.27674-20.83691,33.45273,1.61789,8.787,7.14792,16.39591,9.37222,25.04932,4.30573,16.75143-6.19781,35.94018-22.62927,41.34,15.55756,3.12384,32.68268-6.37912,38.264-21.23346,2.71084-7.215,2.87433-15.09237,3.219-22.79211a75.56441,75.56441,0,0,1,1.9426-15.783,33.72225,33.72225,0,0,1,2.51172-6.72034A22.99686,22.99686,0,0,1,524.4806,292.0992a8.778,8.778,0,0,0,1.28183,7.90252c-3.6485,4.57053-5.63455,9.51328-5.63455,12.47333-7.57935,13.8526,3.25924,25.75144,17.11218,33.3306a28.38088,28.38088,0,0,0,18.13275,3.16122l.57956-2.474.78522,2.22855,2.62121,7.47122q4.5358,2.19425,8.993,4.81055c-1.12893-5.30145-2.04214-10.59311-2.67059-15.767-.87006-7.04193,2.16293-17.52557,4.3508-23.90094a37.632,37.632,0,0,0,2.06033-12.2128v-.18173l2.13034,6.8428,7.75577-3.3085C580.53559,304.1989,581.97852,294.80348,564.67988,295.61836Z" transform="translate(-198.47381 -154.31245)" fill="#2f2e41"/><path d="M883.647,745.68755H316.353a1.19069,1.19069,0,0,1,0-2.38135H883.647a1.19069,1.19069,0,0,1,0,2.38135Z" transform="translate(-198.47381 -154.31245)" fill="#ccc"/><circle id="aeef577c-289f-4c95-850d-f6939249b5af" data-name="e4c91425-bc08-43ed-9b2d-9a6452dd6ab0" cx="266.70383" cy="330.43084" r="22.81224" fill="#e6e6e6"/><path d="M808.41264,471.63265H728.39982a9.33671,9.33671,0,0,1-9.326-9.3263V418.72756a9.33618,9.33618,0,0,1,9.326-9.3257h80.01282a9.33618,9.33618,0,0,1,9.326,9.32568v43.57879A9.33671,9.33671,0,0,1,808.41264,471.63265Z" transform="translate(-198.47381 -154.31245)" fill="#f2f2f2"/><g id="a3c861bb-19cc-462b-bc3a-1f29faec5e60" data-name="bc9af32e-f7cd-4c85-a4cc-5543d455b8de"><circle id="a24c8b8a-c30a-477e-8412-dbddb638fe0a" data-name="b56288f5-6382-4006-aaf1-aded98dde780" cx="611.11771" cy="287.50408" r="12.11453" fill="#386ede"/><path id="ac585bde-7a48-4e1f-a3d4-d671a5b024fe" data-name="b9aaef6b-2cfc-46ac-8dc0-30fdc28925de" d="M814.62215,440.79921h-4.119v-4.11893a.96921.96921,0,0,0-.96923-.96923h0a.96924.96924,0,0,0-.96923.96921v4.119H804.4458a.96923.96923,0,0,0-.96923.96921v0h0a.96921.96921,0,0,0,.96923.96923h4.11894v4.11892a.96921.96921,0,0,0,.96923.96923h0a.96923.96923,0,0,0,.96923-.96921v-4.11894h4.119a.96924.96924,0,0,0,.96923-.96921v0h0a.96921.96921,0,0,0-.96923-.96923Z" transform="translate(-198.47381 -154.31245)" fill="#fff"/></g></svg>
diff --git a/hobo/static/css/build-application.svg b/hobo/static/css/build-application.svg
new file mode 100644
index 00000000..36d63dba
--- /dev/null
+++ b/hobo/static/css/build-application.svg
@@ -0,0 +1 @@
+<svg id="bb8d8cc3-2630-43b3-871f-91b3ed5692fc" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="904.88583" height="742" viewBox="0 0 904.88583 742"><polygon points="686.055 724.756 670.765 724.755 663.491 665.781 686.057 665.782 686.055 724.756" fill="#ffb6b6"/><path d="M837.51088,818.57692l-49.30025-.00183v-.62357a19.19007,19.19007,0,0,1,19.189-19.18872h.00122l30.11091.00122Z" transform="translate(-147.55709 -79)" fill="#2f2e41"/><polygon points="801.699 711.547 787.146 716.236 762.135 662.335 783.614 655.414 801.699 711.547" fill="#ffb6b6"/><path d="M957.51324,803.4578l-46.92469,15.11912-.19125-.59352a19.19005,19.19005,0,0,1,12.37881-24.14934l.00116-.00038,28.66-9.23414Z" transform="translate(-147.55709 -79)" fill="#2f2e41"/><circle cx="809.34809" cy="504.31001" r="19.09176" fill="#386ede"/><path d="M981.90481,618.14334c-.34382.18518-.684.35878-1.02442.532l-40.31-74.84561c.332-.18889.66413-.37741,1.008-.56259a42.52255,42.52255,0,0,1,40.32646,74.8762Z" transform="translate(-147.55709 -79)" fill="#3f3d56"/><path d="M944.68387,580.11939,939.0498,501.9506l1.07159-26.08994-21.95992,4.88623,8.08884,102.42635a11.44566,11.44566,0,1,0,18.43356-3.05385Z" transform="translate(-147.55709 -79)" fill="#ffb7b7"/><path d="M845.31021,511.217l-1.39631-26.19047-14.09344-11.45092L827.874,498.748l-51.88329,59.9845a11.44505,11.44505,0,1,0,13.25561,11.29506c0-.15408-.017-.30381-.023-.45639Z" transform="translate(-147.55709 -79)" fill="#ffb7b7"/><path d="M847.83725,502.83344,803.55709,559l-15-16,31-45,19.42316-85.3219a9.00378,9.00378,0,0,1,5.25966-4.68893,8.86575,8.86575,0,0,1,6.92027.4972l.25118.12731Z" transform="translate(-147.55709 -79)" fill="#386ede"/><path d="M923.94463,556l-14.066-97.7742.02322-.09032L924.34756,401.348l1.35352-.15742A15.77033,15.77033,0,0,1,942.874,413.67543L943.94463,559Z" transform="translate(-147.55709 -79)" fill="#386ede"/><path d="M928.38283,386.015H855.27312v-33.0315a36.55486,36.55486,0,1,1,73.10971,0Z" transform="translate(-147.55709 -79)" fill="#2f2e41"/><circle cx="890.98556" cy="357.12825" r="29.87879" transform="translate(2.69469 888.62749) rotate(-61.33681)" fill="#ffb7b7"/><circle cx="757.85329" cy="232.88951" r="19.88951" fill="#2f2e41"/><path d="M936.55709,358.709H858.79648v-.44042c0-20.15652,17.44151-36.55486,38.8803-36.55486s38.88031,16.39834,38.88031,36.55486Z" transform="translate(-147.55709 -79)" fill="#2f2e41"/><path d="M837.30468,774.28026l-28.88989-2.06348-.03858-.42285c-12.66089-138.21338-5.4768-229.499,20.22388-257.064l7.25195-13.56592-3.9707-22.83057c-10.60352-27.92969,7.20605-48.35986,8.55225-49.84473l5.74072-17.22168a6.51128,6.51128,0,0,1,5.50488-4.41113l78.01514-7.97851.05078.49755-.05078-.49755a6.49986,6.49986,0,0,1,7.14306,5.97607l.00684.0918-.02588.08838-28.43847,95.76806,2.49267,32.40772L944.436,765.13866,915.52978,771.333l-.13379-.43945L862.05761,596.06248Z" transform="translate(-147.55709 -79)" fill="#2f2e41"/><path d="M764.55709,636.5h-610a6.50745,6.50745,0,0,1-6.5-6.5V86a6.50745,6.50745,0,0,1,6.5-6.5h610a6.50753,6.50753,0,0,1,6.5,6.5V630A6.50753,6.50753,0,0,1,764.55709,636.5Z" transform="translate(-147.55709 -79)" fill="#fff"/><path d="M356.24944,103.43079V244.01555H355.963a70.29238,70.29238,0,0,1,0-140.58476Z" transform="translate(-147.55709 -79)" fill="#386ede"/><path d="M340.34247,135.11562v77.2151h-.15733a38.60755,38.60755,0,0,1,0-77.2151Z" transform="translate(-147.55709 -79)" fill="#fff"/><polygon points="312.543 281 100.146 281 100.146 93.723 138.114 93.723 138.114 95.723 102.146 95.723 102.146 279 312.543 279 312.543 281" fill="#3f3d56"/><circle cx="341.1804" cy="309.07154" r="19.09176" fill="#386ede"/><circle cx="519.08087" cy="493.91446" r="19.09176" fill="#3f3d56"/><circle cx="100.79782" cy="278.69829" r="19.09176" fill="#386ede"/><polygon points="312.543 280.434 360.272 280.434 360.272 232.705 264.813 232.705 264.813 328.163 312.543 328.163 312.543 280.434" fill="#3f3d56"/><rect x="414.94401" y="40.05132" width="209.14152" height="26.03421" fill="#e4e4e4"/><rect x="414.94401" y="96.45879" width="209.14152" height="26.03421" fill="#e4e4e4"/><path d="M709.16051,264.84292c0,.39053-.00848.77236-.01738,1.1542H624.13278c-.0089-.38184-.01737-.76367-.01737-1.1542a42.52255,42.52255,0,0,1,85.0451,0Z" transform="translate(-147.55709 -79)" fill="#e4e4e4"/><path d="M709.16051,567.42123c0,.39052-.00848.77236-.01738,1.1542H624.13278c-.0089-.38184-.01737-.76368-.01737-1.1542a42.52255,42.52255,0,0,1,85.0451,0Z" transform="translate(-147.55709 -79)" fill="#e4e4e4"/><rect x="518.08081" y="228.36523" width="2" height="217.42334" fill="#3f3d56"/><path d="M421.04853,546.88025a65.08773,65.08773,0,0,1-64.79909,65.08554V481.79471A65.08773,65.08773,0,0,1,421.04853,546.88025Z" transform="translate(-147.55709 -79)" fill="#386ede"/><path d="M766.43575,639H153.76387a6.21411,6.21411,0,0,1-6.20678-6.207V85.207A6.21411,6.21411,0,0,1,153.76387,79H766.43575a6.21412,6.21412,0,0,1,6.20679,6.207V632.793A6.21412,6.21412,0,0,1,766.43575,639ZM153.76387,81a4.21159,4.21159,0,0,0-4.20678,4.207V632.793A4.21159,4.21159,0,0,0,153.76387,637H766.43575a4.21167,4.21167,0,0,0,4.20679-4.207V85.207A4.21167,4.21167,0,0,0,766.43575,81Z" transform="translate(-147.55709 -79)" fill="#3f3d56"/><path d="M355.24947,245.01953V102.42676l1.004.0039a71.29307,71.29307,0,0,1,0,142.585Zm2-140.57764v138.5625a69.29321,69.29321,0,0,0,0-138.5625Z" transform="translate(-147.55709 -79)" fill="#3f3d56"/><path d="M357.24947,612.96582H355.963a66.08545,66.08545,0,1,1,0-132.1709h1.2865Zm-2-130.167a64.08551,64.08551,0,0,0,0,128.16308Z" transform="translate(-147.55709 -79)" fill="#3f3d56"/><path d="M666.6379,308.36523a43.28328,43.28328,0,0,1-43.50489-42.3413l-.02734-1.02686h87.06445l-.02734,1.02686A43.283,43.283,0,0,1,666.6379,308.36523Zm-41.46582-41.36816a41.5217,41.5217,0,0,0,82.93164,0Z" transform="translate(-147.55709 -79)" fill="#3f3d56"/><rect x="1" y="372" width="623.08545" height="2" fill="#3f3d56"/><rect x="1" y="185.99707" width="623.08545" height="2" fill="#3f3d56"/><rect x="415.39038" y="1" width="2" height="558" fill="#3f3d56"/><rect x="207.69226" y="1" width="2" height="558" fill="#3f3d56"/><circle cx="145.92379" cy="466.14463" r="19.09176" fill="#386ede"/><path d="M1051.44291,821h-381a1,1,0,0,1,0-2h381a1,1,0,0,1,0,2Z" transform="translate(-147.55709 -79)" fill="#cacaca"/><path d="M721.97734,818.98441c5.62838-18.13749.16075-38.76475-12.67716-52.54826a50.03693,50.03693,0,0,0-12.496-9.75441,1.50667,1.50667,0,0,0-1.81774,2.35586l2.5707,2.78174V759.698c-4.97606,4.64241-14.04638,1.101-14.95058-5.49862a11.31659,11.31659,0,0,1,3.42285-9.36476,10.74267,10.74267,0,0,1,10.20109-2.53135,13.348,13.348,0,0,1,8.74935,6.92766,14.767,14.767,0,0,1,.41642,11.19372c-2.74369,8.01876-11.49776,11.78914-19.48606,9.89977-8.15254-1.92822-13.99436-9.614-14.26081-17.91466a19.75346,19.75346,0,0,1,12.6194-18.62539c8.55783-3.16183,17.8492-.00063,25.07752,4.88676a56.44761,56.44761,0,0,1,17.57858,20.01556,57.753,57.753,0,0,1,7.07158,25.17383c.23295,7.25524-1.3078,15.497-6.65822,20.79705a16.4303,16.4303,0,0,1-4.38134,3.07615l1.51416,2.59041a56.674,56.674,0,0,0,27.98132-54.95041,57.41367,57.41367,0,0,0-5.47031-18.85381c-.84769-1.73431-3.43578-.2154-2.59041,1.51416a53.87588,53.87588,0,0,1,4.69032,32.384,53.6037,53.6037,0,0,1-26.12508,37.31569c-1.69095.97135-.20395,3.43316,1.51416,2.59041,8.07832-3.96249,11.6019-12.92989,12.37857-21.42363.88675-9.69762-1.63541-19.76923-5.71991-28.51533A60.92244,60.92244,0,0,0,712.198,737.01962c-8.04352-5.86637-18.93911-9.74427-28.72673-6.12806-8.75369,3.23419-15.117,12.09946-14.82189,21.51822.2962,9.45307,6.82361,18.34609,16.10063,20.71841,9.25367,2.36635,19.37327-1.71312,23.1035-10.76088a17.98912,17.98912,0,0,0,.35682-13.1791,16.20638,16.20638,0,0,0-9.20892-9.1364,14.34294,14.34294,0,0,0-12.76667.8315,14.52122,14.52122,0,0,0-6.55349,10.146,11.6174,11.6174,0,0,0,4.01712,10.80743,13.24166,13.24166,0,0,0,10.97121,2.64384,10.67206,10.67206,0,0,0,5.00886-2.66126,1.52536,1.52536,0,0,0,0-2.12132l-2.5707-2.78174L695.29,759.27215c15.72577,8.63312,25.56863,26.19162,25.9,43.9961a47.262,47.262,0,0,1-2.10547,14.91864c-.5735,1.84811,2.32153,2.63855,2.89284.79752Z" transform="translate(-147.55709 -79)" fill="#f0f0f0"/></svg>
diff --git a/hobo/static/css/style.scss b/hobo/static/css/style.scss
index dc09e9ad..72662063 100644
--- a/hobo/static/css/style.scss
+++ b/hobo/static/css/style.scss
@@ -287,3 +287,49 @@ a.button.button-paragraph p {
 a.button.button-paragraph:hover p {
 	color: white;
 }
+
+.application-elements {
+	max-height: 40vh;
+	overflow-y: scroll;
+	label {
+		display: block;
+		max-width: 40em;
+	}
+}
+
+#application-empty {
+	background: url(build-application.svg) bottom right no-repeat;
+	background-size: auto 90%;
+	width: 100%;
+	height: 80vh;
+}
+
+#no-applications {
+	background: url(applications.svg) bottom center no-repeat;
+	background-size: auto 90%;
+	width: 100%;
+	height: 80vh;
+}
+
+#applications {
+	display: flex;
+	flex-wrap: wrap;
+	justify-content: space-between;
+	.application {
+		background: white;
+		flex: 1;
+		min-width: calc(50% - 0.5em);
+		max-width: calc(50% - 0.5em);
+		color: #3c3c33;
+		box-sizing: border-box;
+		border: 1px solid #ccc;
+		margin-bottom: 1rem;
+		padding: 0 1em;
+		h3 {
+			margin-top: 1em;
+		}
+		.buttons {
+			justify-content: flex-end;
+		}
+	}
+}
diff --git a/hobo/urls.py b/hobo/urls.py
index 9839436c..daa15170 100644
--- a/hobo/urls.py
+++ b/hobo/urls.py
@@ -18,6 +18,7 @@ from django.conf import settings
 from django.conf.urls import include, url
 from django.contrib import admin
 
+from .applications.urls import urlpatterns as applications_urls
 from .debug.urls import urlpatterns as debug_urls
 from .emails.urls import urlpatterns as emails_urls
 from .environment.urls import urlpatterns as environment_urls
@@ -43,6 +44,7 @@ urlpatterns = [
     url(r'^seo/', decorated_includes(admin_required, include(seo_urls))),
     url(r'^sms/', decorated_includes(admin_required, include(sms_urls))),
     url(r'^debug/', decorated_includes(admin_required, include(debug_urls))),
+    url(r'^applications/', decorated_includes(admin_required, include(applications_urls))),
     url(r'^api/health/$', health_json, name='health-json'),
     url(r'^menu.json$', menu_json, name='menu_json'),
     url(r'^hobos.json$', hobo),
diff --git a/tests/test_application.py b/tests/test_application.py
new file mode 100644
index 00000000..54dbc65d
--- /dev/null
+++ b/tests/test_application.py
@@ -0,0 +1,222 @@
+import io
+import json
+import tarfile
+
+import pytest
+from httmock import HTTMock
+from test_manager import login
+from webtest import Upload
+
+from hobo.applications.models import Application
+from hobo.environment.models import Wcs
+
+pytestmark = pytest.mark.django_db
+
+WCS_AVAILABLE_OBJECTS = {
+    "data": [
+        {
+            "id": "forms",
+            "text": "Forms",
+            "singular": "Form",
+            "urls": {"list": "https://wcs.example.invalid/api/export-import/forms/"},
+        },
+        {
+            "id": "cards",
+            "text": "Card Models",
+            "singular": "Card Model",
+            "urls": {"list": "https://wcs.example.invalid/api/export-import/cards/"},
+        },
+        {
+            "id": "workflows",
+            "text": "Workflows",
+            "singular": "Workflow",
+            "urls": {"list": "https://wcs.example.invalid/api/export-import/workflows/"},
+        },
+        {
+            "id": "blocks",
+            "text": "Blocks",
+            "singular": "Block of fields",
+            "minor": True,
+            "urls": {"list": "https://wcs.example.invalid/api/export-import/blocks/"},
+        },
+        {
+            "id": "data-sources",
+            "text": "Data Sources",
+            "singular": "Data Source",
+            "minor": True,
+            "urls": {"list": "https://wcs.example.invalid/api/export-import/data-sources/"},
+        },
+        {
+            "id": "mail-templates",
+            "text": "Mail Templates",
+            "singular": "Mail Template",
+            "minor": True,
+            "urls": {"list": "https://wcs.example.invalid/api/export-import/mail-templates/"},
+        },
+        {
+            "id": "wscalls",
+            "text": "Webservice Calls",
+            "singular": "Webservice Call",
+            "minor": True,
+            "urls": {"list": "https://wcs.example.invalid/api/export-import/wscalls/"},
+        },
+    ]
+}
+
+WCS_AVAILABLE_FORMS = {
+    "data": [
+        {
+            "id": "test-form",
+            "text": "Test Form",
+            "type": "forms",
+            "urls": {
+                "export": "https://wcs.example.invalid/api/export-import/forms/test-form/",
+                "dependencies": "https://wcs.example.invalid/api/export-import/forms/test-form/dependencies/",
+            },
+        },
+        {
+            "id": "test2-form",
+            "text": "Second Test Form",
+            "type": "forms",
+            "urls": {
+                "export": "https://wcs.example.invalid/api/export-import/forms/test2-form/",
+                "dependencies": "https://wcs.example.invalid/api/export-import/forms/test2-form/dependencies/",
+            },
+        },
+    ]
+}
+
+WCS_FORM_DEPENDENCIES = {
+    "data": [
+        {
+            "id": "test-card",
+            "text": "Test Card",
+            "type": "cards",
+            "urls": {
+                "export": "https://wcs.example.invalid/api/export-import/cards/test-card/",
+                "dependencies": "https://wcs.example.invalid/api/export-import/cards/test-card/dependencies/",
+            },
+        }
+    ]
+}
+
+
+def mocked_http(url, request):
+    assert '&signature=' in url.query
+    if url.netloc == 'wcs.example.invalid' and url.path == '/api/export-import/':
+        return {'content': json.dumps(WCS_AVAILABLE_OBJECTS), 'status_code': 200}
+
+    if url.path == '/api/export-import/forms/':
+        return {'content': json.dumps(WCS_AVAILABLE_FORMS), 'status_code': 200}
+
+    if url.path == '/api/export-import/forms/test-form/dependencies/':
+        return {'content': json.dumps(WCS_FORM_DEPENDENCIES), 'status_code': 200}
+
+    if url.path.endswith('/dependencies/'):
+        return {'content': json.dumps({'data': []}), 'status_code': 200}
+
+    if url.path == '/api/export-import/forms/test-form/':
+        return {'content': '<formdef/>', 'status_code': 200, 'headers': {'content-length': '10'}}
+
+    if url.path == '/api/export-import/cards/test-card/':
+        return {'content': '<carddef/>', 'status_code': 200, 'headers': {'content-length': '10'}}
+
+    if url.path == '/api/export-import/bundle-import/':
+        return {'content': '{}', 'status_code': 200}
+
+
+def test_create_application(app, admin_user, settings):
+    Wcs.objects.create(base_url='https://wcs.example.invalid', slug='foobar', title='Foobar')
+
+    settings.KNOWN_SERVICES = {
+        'wcs': {
+            'foobar': {
+                'title': 'Foobar',
+                'url': 'https://wcs.example.invalid/',
+                'orig': 'example.org',
+                'secret': 'xxx',
+            }
+        }
+    }
+
+    login(app)
+
+    resp = app.get('/applications/')
+    resp = resp.click('Create')
+    resp.form['name'] = 'Test'
+    resp = resp.form.submit()
+
+    with HTTMock(mocked_http):
+        resp = resp.follow()
+        assert 'You should now assemble the different parts of your application.' in resp.text
+        assert '/add/forms/' in resp
+        resp = resp.click('Forms')
+        assert resp.form.fields['elements'][0]._value == 'test-form'
+        assert resp.form.fields['elements'][1]._value == 'test2-form'
+        resp.form.fields['elements'][0].checked = True
+        resp = resp.form.submit().follow()
+        assert Application.objects.get(slug='test').elements.count() == 1
+        element = Application.objects.get(slug='test').elements.all()[0]
+        assert element.slug == 'test-form'
+
+        resp = resp.click('Scan dependencies').follow()
+        assert Application.objects.get(slug='test').elements.count() == 2
+        assert 'Test Card' in resp.text
+
+        resp = resp.click('Generate application bundle').follow()
+        resp = resp.click('Download')
+        assert resp.content_type == 'application/x-tar'
+        # uncompressed tar, primitive check of contents
+        assert b'<formdef/>' in resp.content
+        assert b'<carddef/>' in resp.content
+
+
+@pytest.fixture
+def app_bundle():
+    tar_io = io.BytesIO()
+    with tarfile.open(mode='w', fileobj=tar_io) as tar:
+        manifest_json = {
+            'application': 'Test',
+            'slug': 'test',
+            'description': '',
+            'elements': [
+                {'type': 'forms', 'slug': 'test', 'name': 'test', 'auto-dependency': False},
+                {'type': 'blocks', 'slug': 'test', 'name': 'test', 'auto-dependency': True},
+                {'type': 'workflows', 'slug': 'test', 'name': 'test', 'auto-dependency': True},
+            ],
+        }
+        manifest_fd = io.BytesIO(json.dumps(manifest_json, indent=2).encode())
+        tarinfo = tarfile.TarInfo('manifest.json')
+        tarinfo.size = len(manifest_fd.getvalue())
+        tar.addfile(tarinfo, fileobj=manifest_fd)
+
+    return tar_io.getvalue()
+
+
+def test_deploy_application(app, admin_user, settings, app_bundle):
+    Application.objects.all().delete()
+    Wcs.objects.create(base_url='https://wcs.example.invalid', slug='foobar', title='Foobar')
+
+    settings.KNOWN_SERVICES = {
+        'wcs': {
+            'foobar': {
+                'title': 'Foobar',
+                'url': 'https://wcs.example.invalid/',
+                'orig': 'example.org',
+                'secret': 'xxx',
+            }
+        }
+    }
+
+    login(app)
+
+    resp = app.get('/applications/')
+    for i in range(2):
+        resp = resp.click('Install')
+        resp.form['bundle'] = Upload('app.tar', app_bundle, 'application/x-tar')
+        with HTTMock(mocked_http):
+            resp = resp.form.submit().follow()
+
+        assert Application.objects.count() == 1
+        assert Application.objects.get(slug='test').name == 'Test'
+        assert Application.objects.get(slug='test').elements.count() == 3
-- 
2.34.1

