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