applications: add possibility to define parameters (#76463)

This commit is contained in:
Frédéric Péters 2023-04-10 10:14:01 +02:00
parent 5f0177d901
commit aee3fb8093
11 changed files with 403 additions and 1 deletions

View File

@ -0,0 +1,41 @@
# Generated by Django 3.2.16 on 2023-04-10 15:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('applications', '0012_visible'),
]
operations = [
migrations.CreateModel(
name='Parameter',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('label', models.CharField(max_length=100, verbose_name='Label')),
(
'name',
models.CharField(
help_text='Variable name, it is useful to prefix it with an unique application identifier.',
max_length=100,
verbose_name='Identifier',
),
),
(
'default_value',
models.CharField(blank=True, max_length=100, null=True, verbose_name='Default value'),
),
(
'application',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to='applications.application'
),
),
],
),
]

View File

@ -30,6 +30,7 @@ from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from hobo.environment.models import Variable
from hobo.environment.utils import get_installed_services
from .utils import Requests
@ -206,6 +207,27 @@ class Application(models.Model):
)
class Parameter(models.Model):
application = models.ForeignKey(Application, on_delete=models.CASCADE)
label = models.CharField(max_length=100, verbose_name=_('Label'))
name = models.CharField(
max_length=100,
verbose_name=_('Identifier'),
help_text=_('Variable name, it is useful to prefix it with an unique application identifier.'),
)
default_value = models.CharField(max_length=100, verbose_name=_('Default value'), blank=True, null=True)
def as_dict(self):
return {'label': self.label, 'name': self.name, 'default_value': self.default_value}
@property
def variable(self):
variable, _ = Variable.objects.get_or_create(
name=self.name, defaults={'value': self.default_value or '', 'auto': True, 'label': self.label}
)
return variable
class Element(models.Model):
type = models.CharField(max_length=100, verbose_name=_('Type'))
slug = models.SlugField(max_length=500, verbose_name=_('Slug'))
@ -281,6 +303,7 @@ class Version(models.Model):
'version_number': self.number,
'version_notes': self.notes,
'elements': [],
'parameters': [x.as_dict() for x in self.application.parameter_set.all()],
}
for element, relation in elements.values():
@ -320,6 +343,7 @@ class Version(models.Model):
def deploy(self, job=None):
bundle_content = self.bundle.read()
self.deploy_parameters(bundle_content)
self.deploy_roles(bundle_content)
self.do_something_with_bundle(bundle_content, 'deploy', job=job)
self.application.refresh_elements(cache_only=True)
@ -365,6 +389,24 @@ class Version(models.Model):
return service
return None
def deploy_parameters(self, bundle):
tar_io = io.BytesIO(bundle)
with tarfile.open(fileobj=tar_io) as tar:
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
for parameter in manifest.get('parameters') or []:
param, _ = Parameter.objects.get_or_create(
name=parameter.get('name'), application=self.application
)
param.label = parameter.get('label')
param.default_value = parameter.get('default_value')
param.save()
variable, _ = Variable.objects.get_or_create(
name=parameter.get('name'),
defaults={'auto': True, 'value': parameter.get('default_value') or ''},
)
variable.label = parameter.get('label')
variable.save()
def deploy_roles(self, bundle):
tar_io = io.BytesIO(bundle)
service = self.get_authentic_service()

View File

@ -76,5 +76,38 @@
{% endfor %}
{% endfor %}
{% endif %}
{% with has_parameters=app.parameter_set.exists parameters_qs=app.parameter_set.all %}
{% if app.editable or has_parameters %}
<div class="app-parameters--section">
<h3>{% trans "Parameters" %}
{% if app.editable %}
<a rel="popup" href="{% url 'application-add-parameter' app_slug=app.slug %}">({% trans "add" %})</a>
{% endif %}
</h3>
{% if app.editable and has_parameters %}
<ul class="objects-list single-links app-parameters--list">
{% for parameter in parameters_qs %}
<li><a rel="popup" href="{% url 'application-edit-parameter' app_slug=app.slug pk=parameter.id %}">{{ parameter.label }}</a>
<a rel="popup" class="delete" href="{% url 'application-delete-parameter' app_slug=app.slug pk=parameter.id %}">{% trans "remove" %}</a>
</li>
{% endfor %}
</ul>
{% elif app.editable %}
<p>{% trans "No parameters defined." %}</p>
{% endif %}
{% if has_parameters %}
<div class="app-parameters--values">
{% for parameter in parameters_qs %}
<p>
<label>{{ parameter.label }}</label>
{{ parameter.variable.value|default:"-" }}
(<a rel="popup" href="{% url 'application-change-parameter-value' app_slug=app.slug name=parameter.variable.name %}">{% trans 'change' %}</a>)
</p>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% endwith %}
</aside>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "hobo/applications/home.html" %}
{% load gadjo i18n %}
{% block appbar %}
<h2>{% trans "New Parameter" %}</h2>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button class="submit-button">{% trans "Add" %}</button>
<a class="cancel" href="..">{% trans "Cancel" %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "hobo/applications/home.html" %}
{% load gadjo i18n %}
{% block appbar %}
<h2>{% trans "Delete Parameter" %}</h2>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
<p>
{% trans 'Are you sure you want to remove this parameter?' %}
</p>
<div class="buttons">
<button class="delete-button">{% trans "Delete" %}</button>
<a class="cancel" href="../../">{% trans "Cancel" %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "hobo/applications/home.html" %}
{% load gadjo i18n %}
{% block appbar %}
<h2>{% trans "Edit Parameter" %}</h2>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="../../">{% trans "Cancel" %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "hobo/applications/home.html" %}
{% load gadjo i18n %}
{% block appbar %}
<h2>{% trans "Edit Parameter" %}</h2>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="../../">{% trans "Cancel" %}</a>
</div>
</form>
{% endblock %}

View File

@ -45,6 +45,26 @@ urlpatterns = [
views.delete_element,
name='application-delete-element',
),
re_path(
r'^manifest/(?P<app_slug>[\w-]+)/add-parameter/$',
views.add_parameter,
name='application-add-parameter',
),
re_path(
r'^manifest/(?P<app_slug>[\w-]+)/edit-parameter/(?P<pk>\d+)/$',
views.edit_parameter,
name='application-edit-parameter',
),
re_path(
r'^manifest/(?P<app_slug>[\w-]+)/delete-parameter/(?P<pk>\d+)/$',
views.delete_parameter,
name='application-delete-parameter',
),
re_path(
r'^manifest/(?P<app_slug>[\w-]+)/parameter-value/(?P<name>[\w_]+)/$',
views.change_parameter_value,
name='application-change-parameter-value',
),
re_path(
r'^manifest/(?P<app_slug>[\w-]+)/job/(?P<pk>\d+)/$',
views.async_job,

View File

@ -31,12 +31,15 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, FormView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from hobo.environment.models import Variable
from .forms import GenerateForm, InstallForm, MetadataForm
from .models import (
STATUS_CHOICES,
Application,
AsyncJob,
Element,
Parameter,
Relation,
UnlinkError,
Version,
@ -451,3 +454,74 @@ class AsyncJobView(DetailView):
async_job = AsyncJobView.as_view()
class AddParameterView(CreateView):
template_name = 'hobo/applications/parameter-add.html'
model = Parameter
fields = ['label', 'name', 'default_value']
def dispatch(self, *args, **kwargs):
self.application = get_object_or_404(Application, slug=kwargs['app_slug'], editable=True)
return super().dispatch(*args, **kwargs)
def form_valid(self, form):
form.instance.application = self.application
return super().form_valid(form)
def get_success_url(self):
return reverse('application-manifest', kwargs={'app_slug': self.kwargs['app_slug']})
add_parameter = AddParameterView.as_view()
class EditParameterView(UpdateView):
template_name = 'hobo/applications/parameter-edit.html'
model = Parameter
fields = ['label', 'name', 'default_value']
def get_queryset(self):
return (
super()
.get_queryset()
.filter(application__editable=True, application__slug=self.kwargs['app_slug'])
)
def get_success_url(self):
return reverse('application-manifest', kwargs={'app_slug': self.kwargs['app_slug']})
edit_parameter = EditParameterView.as_view()
class DeleteParameterView(DeleteView):
template_name = 'hobo/applications/parameter-confirm-delete.html'
model = Parameter
def get_queryset(self):
return (
super()
.get_queryset()
.filter(application__editable=True, application__slug=self.kwargs['app_slug'])
)
def get_success_url(self):
return reverse('application-manifest', kwargs={'app_slug': self.kwargs['app_slug']})
delete_parameter = DeleteParameterView.as_view()
class ChangeParameterValueView(UpdateView):
template_name = 'hobo/applications/parameter-value-edit.html'
model = Variable
fields = ['value']
slug_field = 'name'
slug_url_kwarg = 'name'
def get_success_url(self):
return reverse('application-manifest', kwargs={'app_slug': self.kwargs['app_slug']})
change_parameter_value = ChangeParameterValueView.as_view()

View File

@ -381,3 +381,8 @@ ul.objects-list.application-content {
}
}
}
div.app-parameters--values label {
display: block;
font-weight: bold;
}

View File

@ -17,11 +17,12 @@ from hobo.applications.models import (
AsyncJob,
DeploymentError,
Element,
Parameter,
Relation,
ScanError,
Version,
)
from hobo.environment.models import Authentic, Wcs
from hobo.environment.models import Authentic, Variable, Wcs
pytestmark = pytest.mark.django_db
@ -822,6 +823,10 @@ def get_bundle(with_icon=False):
'auto-dependency': True,
},
],
'parameters': [
{'name': 'app_param1', 'label': 'Parameter 1', 'default_value': 'plop'},
{'name': 'app_param2', 'label': 'Parameter 2', 'default_value': None},
],
}
manifest_fd = io.BytesIO(json.dumps(manifest_json, indent=2).encode())
tarinfo = tarfile.TarInfo('manifest.json')
@ -883,6 +888,8 @@ def test_deploy_application(app, admin_user, settings, app_bundle, app_bundle_wi
job = AsyncJob.objects.latest('pk')
assert job.status == 'completed'
assert job.progression_urls == {'wcs': {'Foobar': 'https://wcs.example.invalid/api/jobs/job-uuid/'}}
assert Parameter.objects.filter(application=application).count() == 2
assert Variable.objects.filter(name__startswith='app_').count() == 2
return version
resp = app.get('/applications/')
@ -1247,3 +1254,112 @@ def test_job_status_page(app, admin_user, settings):
job.refresh_from_db()
assert job.status == 'failed'
assert job.exception == 'Failed to deploy module wcs'
def test_create_application_parameters(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()
# add a form (an element is required to have the generate app button)
resp = resp.click('Forms')
resp.form.fields['elements'][4].checked = True
resp = resp.form.submit().follow()
assert resp.pyquery('.app-parameters--section h3 a')
resp = resp.click('add')
resp.form['label'] = 'Foo'
resp.form['name'] = 'app_foo'
resp.form['default_value'] = 'xxx'
resp = resp.form.submit().follow()
assert Parameter.objects.filter(name='app_foo').exists()
assert resp.pyquery('.app-parameters--list li')
resp = resp.click(href=resp.pyquery('.app-parameters--list li a:not(.delete)').attr.href)
assert resp.form['label'].value == 'Foo'
assert resp.form['name'].value == 'app_foo'
assert resp.form['default_value'].value == 'xxx'
resp = resp.click('Cancel')
resp = resp.click(href=resp.pyquery('.app-parameters--values a').attr.href)
assert resp.form['value'].value == 'xxx'
resp.form['value'] = 'changed'
resp = resp.form.submit().follow()
assert Variable.objects.get(name='app_foo').value == 'changed'
resp = resp.click('Generate application bundle')
resp.form['number'] = '1.0'
resp.form['notes'] = 'Foo bar blah.'
resp = resp.form.submit().follow()
resp_download = resp.click('Download')
assert resp_download.content_type == 'application/x-tar'
# uncompressed tar, primitive check of contents
assert b'app_foo' in resp_download.content
resp = resp.click(href=resp.pyquery('.app-parameters--list li a.delete').attr.href)
resp = resp.form.submit().follow()
assert not Parameter.objects.filter(name='app_foo').exists()
@pytest.fixture
def app_bundle_parameters():
tar_io = io.BytesIO()
with tarfile.open(mode='w', fileobj=tar_io) as tar:
manifest_json = {
'application': 'Test',
'slug': 'test',
'description': '',
'elements': [],
'parameters': [{"label": "Foo", "name": "app_foo", "default_value": "xxx"}],
}
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_parameters(app, admin_user, settings, app_bundle_parameters):
Parameter.objects.all().delete()
Variable.objects.all().delete()
Application.objects.all().delete()
login(app)
resp = app.get('/applications/')
resp = resp.click('Install')
resp.form['bundle'] = Upload('app.tar', app_bundle_parameters, 'application/x-tar')
resp = resp.form.submit().follow()
assert Variable.objects.get(name='app_foo').value == 'xxx'
resp = resp.click('Details')
assert resp.pyquery('.app-parameters--section h3')
assert not resp.pyquery('.app-parameters--section h3 a')
resp = resp.click(href=resp.pyquery('.app-parameters--values a').attr.href)
assert resp.form['value'].value == 'xxx'
resp.form['value'] = 'changed'
resp = resp.form.submit().follow()
assert Variable.objects.get(name='app_foo').value == 'changed'
# check parameter cannot be edited or deleted
parameter = Parameter.objects.all().first()
resp = app.get(f'/applications/manifest/test/edit-parameter/{parameter.id}/', status=404)
resp = app.get(f'/applications/manifest/test/delete-parameter/{parameter.id}/', status=404)