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.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from hobo.environment.models import Variable
from hobo.environment.utils import get_installed_services from hobo.environment.utils import get_installed_services
from .utils import Requests 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): class Element(models.Model):
type = models.CharField(max_length=100, verbose_name=_('Type')) type = models.CharField(max_length=100, verbose_name=_('Type'))
slug = models.SlugField(max_length=500, verbose_name=_('Slug')) slug = models.SlugField(max_length=500, verbose_name=_('Slug'))
@ -281,6 +303,7 @@ class Version(models.Model):
'version_number': self.number, 'version_number': self.number,
'version_notes': self.notes, 'version_notes': self.notes,
'elements': [], 'elements': [],
'parameters': [x.as_dict() for x in self.application.parameter_set.all()],
} }
for element, relation in elements.values(): for element, relation in elements.values():
@ -320,6 +343,7 @@ class Version(models.Model):
def deploy(self, job=None): def deploy(self, job=None):
bundle_content = self.bundle.read() bundle_content = self.bundle.read()
self.deploy_parameters(bundle_content)
self.deploy_roles(bundle_content) self.deploy_roles(bundle_content)
self.do_something_with_bundle(bundle_content, 'deploy', job=job) self.do_something_with_bundle(bundle_content, 'deploy', job=job)
self.application.refresh_elements(cache_only=True) self.application.refresh_elements(cache_only=True)
@ -365,6 +389,24 @@ class Version(models.Model):
return service return service
return None 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): def deploy_roles(self, bundle):
tar_io = io.BytesIO(bundle) tar_io = io.BytesIO(bundle)
service = self.get_authentic_service() service = self.get_authentic_service()

View File

@ -76,5 +76,38 @@
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
{% endif %} {% 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> </aside>
{% endblock %} {% 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, views.delete_element,
name='application-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( re_path(
r'^manifest/(?P<app_slug>[\w-]+)/job/(?P<pk>\d+)/$', r'^manifest/(?P<app_slug>[\w-]+)/job/(?P<pk>\d+)/$',
views.async_job, 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 import DetailView, FormView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from hobo.environment.models import Variable
from .forms import GenerateForm, InstallForm, MetadataForm from .forms import GenerateForm, InstallForm, MetadataForm
from .models import ( from .models import (
STATUS_CHOICES, STATUS_CHOICES,
Application, Application,
AsyncJob, AsyncJob,
Element, Element,
Parameter,
Relation, Relation,
UnlinkError, UnlinkError,
Version, Version,
@ -451,3 +454,74 @@ class AsyncJobView(DetailView):
async_job = AsyncJobView.as_view() 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, AsyncJob,
DeploymentError, DeploymentError,
Element, Element,
Parameter,
Relation, Relation,
ScanError, ScanError,
Version, Version,
) )
from hobo.environment.models import Authentic, Wcs from hobo.environment.models import Authentic, Variable, Wcs
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@ -822,6 +823,10 @@ def get_bundle(with_icon=False):
'auto-dependency': True, '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()) manifest_fd = io.BytesIO(json.dumps(manifest_json, indent=2).encode())
tarinfo = tarfile.TarInfo('manifest.json') 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') job = AsyncJob.objects.latest('pk')
assert job.status == 'completed' assert job.status == 'completed'
assert job.progression_urls == {'wcs': {'Foobar': 'https://wcs.example.invalid/api/jobs/job-uuid/'}} 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 return version
resp = app.get('/applications/') resp = app.get('/applications/')
@ -1247,3 +1254,112 @@ def test_job_status_page(app, admin_user, settings):
job.refresh_from_db() job.refresh_from_db()
assert job.status == 'failed' assert job.status == 'failed'
assert job.exception == 'Failed to deploy module wcs' 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)