add section to create/deploy applications (#60699)

This commit is contained in:
Frédéric Péters 2022-01-08 16:07:08 +01:00
parent 00f897753b
commit 62220edc86
23 changed files with 1162 additions and 1 deletions

38
README
View File

@ -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.

View File

View File

@ -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'))

View File

@ -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'
),
),
]

View File

104
hobo/applications/models.py Normal file
View File

@ -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 <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):
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 '<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 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

View File

@ -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 %}

View File

@ -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 %}

View File

@ -0,0 +1,14 @@
{% extends "hobo/base.html" %}
{% load i18n %}
{% block appbar %}<h2>{% trans "Metadata" %}</h2>{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Submit" %}</button>
<a class="cancel" href="..">{% trans "Cancel" %}</a>
</div>
{% endblock %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

40
hobo/applications/urls.py Normal file
View File

@ -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',
),
]

View File

@ -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)

300
hobo/applications/views.py Normal file
View File

@ -0,0 +1,300 @@
# 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 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/')
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')
if created:
# mark as non-editable only newly deployed applications, this allows
# overwriting a local application and keep on developing it.
app.editable = False
else:
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()

View File

@ -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, Hobo
installed_services = []
for available_service in AVAILABLE_SERVICES:
if types and available_service.Extra.service_id not in types:
continue
if available_service is Hobo:
installed_services.extend(available_service.objects.filter(local=False))
else:

View File

@ -42,6 +42,7 @@ INSTALLED_APPS = (
'rest_framework',
'mellon',
'gadjo',
'hobo.applications',
'hobo.debug',
'hobo.environment',
'hobo.franceconnect',

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -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;
}
}
}

View File

@ -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),

229
tests/test_application.py Normal file
View File

@ -0,0 +1,229 @@
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
# edit metadata
resp = resp.click('Metadata')
resp.form['description'] = 'Lorem ipsum'
resp = resp.form.submit().follow()
# add forms
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