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
|
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.)
|
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
|
from hobo.profile.utils import get_profile_dict
|
||||||
|
|
||||||
|
|
||||||
def get_installed_services():
|
def get_installed_services(types=None):
|
||||||
from .models import AVAILABLE_SERVICES, Hobo
|
from .models import AVAILABLE_SERVICES, Hobo
|
||||||
|
|
||||||
installed_services = []
|
installed_services = []
|
||||||
for available_service in AVAILABLE_SERVICES:
|
for available_service in AVAILABLE_SERVICES:
|
||||||
|
if types and available_service.Extra.service_id not in types:
|
||||||
|
continue
|
||||||
if available_service is Hobo:
|
if available_service is Hobo:
|
||||||
installed_services.extend(available_service.objects.filter(local=False))
|
installed_services.extend(available_service.objects.filter(local=False))
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -42,6 +42,7 @@ INSTALLED_APPS = (
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'mellon',
|
'mellon',
|
||||||
'gadjo',
|
'gadjo',
|
||||||
|
'hobo.applications',
|
||||||
'hobo.debug',
|
'hobo.debug',
|
||||||
'hobo.environment',
|
'hobo.environment',
|
||||||
'hobo.franceconnect',
|
'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 {
|
a.button.button-paragraph:hover p {
|
||||||
color: white;
|
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.conf.urls import include, url
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .applications.urls import urlpatterns as applications_urls
|
||||||
from .debug.urls import urlpatterns as debug_urls
|
from .debug.urls import urlpatterns as debug_urls
|
||||||
from .emails.urls import urlpatterns as emails_urls
|
from .emails.urls import urlpatterns as emails_urls
|
||||||
from .environment.urls import urlpatterns as environment_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'^seo/', decorated_includes(admin_required, include(seo_urls))),
|
||||||
url(r'^sms/', decorated_includes(admin_required, include(sms_urls))),
|
url(r'^sms/', decorated_includes(admin_required, include(sms_urls))),
|
||||||
url(r'^debug/', decorated_includes(admin_required, include(debug_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'^api/health/$', health_json, name='health-json'),
|
||||||
url(r'^menu.json$', menu_json, name='menu_json'),
|
url(r'^menu.json$', menu_json, name='menu_json'),
|
||||||
url(r'^hobos.json$', hobo),
|
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