diff --git a/README b/README
index 86432c2..2410fcf 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 .
+
+
+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 0000000..e69de29
diff --git a/hobo/applications/forms.py b/hobo/applications/forms.py
new file mode 100644
index 0000000..1401aaa
--- /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 .
+
+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 0000000..b279a9a
--- /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 0000000..e69de29
diff --git a/hobo/applications/models.py b/hobo/applications/models.py
new file mode 100644
index 0000000..f4bc094
--- /dev/null
+++ b/hobo/applications/models.py
@@ -0,0 +1,104 @@
+# 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 .
+
+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):
+ SUPPORTED_MODULES = ('wcs',)
+
+ 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 '' % 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 '' % (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 '' % (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 '' % self.application.slug
+
+ def deploy(self):
+ bundle_content = self.bundle.read()
+ for service_id, services in getattr(settings, 'KNOWN_SERVICES', {}).items():
+ if service_id not in Application.SUPPORTED_MODULES:
+ 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 0000000..616334e
--- /dev/null
+++ b/hobo/applications/templates/hobo/applications/add-element.html
@@ -0,0 +1,45 @@
+{% extends "hobo/applications/home.html" %}
+{% load i18n %}
+
+{% block appbar %}
+