add section to create/deploy applications (#60699)
This commit is contained in:
parent
00f897753b
commit
62220edc86
38
README
38
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.
|
||||
|
|
|
@ -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'))
|
|
@ -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'
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
||||
|
||||
<a class="pk-button" href="{% url 'application-generate' app_slug=app.slug %}">{% trans "Generate application bundle" %}</a>
|
||||
{% if versions %}
|
||||
|
||||
<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 %}
|
|
@ -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',
|
||||
),
|
||||
]
|
|
@ -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)
|
|
@ -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()
|
|
@ -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:
|
||||
|
|
|
@ -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 |
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue