From aee3fb80931d4c38775ca56c1e0df108ed2a7a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= Date: Mon, 10 Apr 2023 10:14:01 +0200 Subject: [PATCH] applications: add possibility to define parameters (#76463) --- .../applications/migrations/0013_parameter.py | 41 ++++++ hobo/applications/models.py | 42 +++++++ .../templates/hobo/applications/manifest.html | 33 +++++ .../hobo/applications/parameter-add.html | 17 +++ .../parameter-confirm-delete.html | 20 +++ .../hobo/applications/parameter-edit.html | 17 +++ .../applications/parameter-value-edit.html | 17 +++ hobo/applications/urls.py | 20 +++ hobo/applications/views.py | 74 +++++++++++ hobo/static/css/style.scss | 5 + tests/test_application.py | 118 +++++++++++++++++- 11 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 hobo/applications/migrations/0013_parameter.py create mode 100644 hobo/applications/templates/hobo/applications/parameter-add.html create mode 100644 hobo/applications/templates/hobo/applications/parameter-confirm-delete.html create mode 100644 hobo/applications/templates/hobo/applications/parameter-edit.html create mode 100644 hobo/applications/templates/hobo/applications/parameter-value-edit.html diff --git a/hobo/applications/migrations/0013_parameter.py b/hobo/applications/migrations/0013_parameter.py new file mode 100644 index 0000000..41d883f --- /dev/null +++ b/hobo/applications/migrations/0013_parameter.py @@ -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' + ), + ), + ], + ), + ] diff --git a/hobo/applications/models.py b/hobo/applications/models.py index 2283a13..831f6ee 100644 --- a/hobo/applications/models.py +++ b/hobo/applications/models.py @@ -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() diff --git a/hobo/applications/templates/hobo/applications/manifest.html b/hobo/applications/templates/hobo/applications/manifest.html index b74d0dd..c30f89f 100644 --- a/hobo/applications/templates/hobo/applications/manifest.html +++ b/hobo/applications/templates/hobo/applications/manifest.html @@ -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 %} +
+

{% trans "Parameters" %} + {% if app.editable %} + ({% trans "add" %}) + {% endif %} +

+ {% if app.editable and has_parameters %} + + {% elif app.editable %} +

{% trans "No parameters defined." %}

+ {% endif %} + {% if has_parameters %} +
+ {% for parameter in parameters_qs %} +

+ + {{ parameter.variable.value|default:"-" }} + ({% trans 'change' %}) +

+ {% endfor %} +
+ {% endif %} +
+ {% endif %} + {% endwith %} {% endblock %} diff --git a/hobo/applications/templates/hobo/applications/parameter-add.html b/hobo/applications/templates/hobo/applications/parameter-add.html new file mode 100644 index 0000000..bd74ac0 --- /dev/null +++ b/hobo/applications/templates/hobo/applications/parameter-add.html @@ -0,0 +1,17 @@ +{% extends "hobo/applications/home.html" %} +{% load gadjo i18n %} + +{% block appbar %} +

{% trans "New Parameter" %}

+{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {{ form|with_template }} +
+ + {% trans "Cancel" %} +
+
+{% endblock %} diff --git a/hobo/applications/templates/hobo/applications/parameter-confirm-delete.html b/hobo/applications/templates/hobo/applications/parameter-confirm-delete.html new file mode 100644 index 0000000..256f0cb --- /dev/null +++ b/hobo/applications/templates/hobo/applications/parameter-confirm-delete.html @@ -0,0 +1,20 @@ +{% extends "hobo/applications/home.html" %} +{% load gadjo i18n %} + +{% block appbar %} +

{% trans "Delete Parameter" %}

+{% endblock %} + +{% block content %} +
+ {% csrf_token %} +

+ {% trans 'Are you sure you want to remove this parameter?' %} +

+
+ + {% trans "Cancel" %} +
+
+{% endblock %} + diff --git a/hobo/applications/templates/hobo/applications/parameter-edit.html b/hobo/applications/templates/hobo/applications/parameter-edit.html new file mode 100644 index 0000000..dfc0a12 --- /dev/null +++ b/hobo/applications/templates/hobo/applications/parameter-edit.html @@ -0,0 +1,17 @@ +{% extends "hobo/applications/home.html" %} +{% load gadjo i18n %} + +{% block appbar %} +

{% trans "Edit Parameter" %}

+{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {{ form|with_template }} +
+ + {% trans "Cancel" %} +
+
+{% endblock %} diff --git a/hobo/applications/templates/hobo/applications/parameter-value-edit.html b/hobo/applications/templates/hobo/applications/parameter-value-edit.html new file mode 100644 index 0000000..dfc0a12 --- /dev/null +++ b/hobo/applications/templates/hobo/applications/parameter-value-edit.html @@ -0,0 +1,17 @@ +{% extends "hobo/applications/home.html" %} +{% load gadjo i18n %} + +{% block appbar %} +

{% trans "Edit Parameter" %}

+{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {{ form|with_template }} +
+ + {% trans "Cancel" %} +
+
+{% endblock %} diff --git a/hobo/applications/urls.py b/hobo/applications/urls.py index f42993d..09c38d5 100644 --- a/hobo/applications/urls.py +++ b/hobo/applications/urls.py @@ -45,6 +45,26 @@ urlpatterns = [ views.delete_element, name='application-delete-element', ), + re_path( + r'^manifest/(?P[\w-]+)/add-parameter/$', + views.add_parameter, + name='application-add-parameter', + ), + re_path( + r'^manifest/(?P[\w-]+)/edit-parameter/(?P\d+)/$', + views.edit_parameter, + name='application-edit-parameter', + ), + re_path( + r'^manifest/(?P[\w-]+)/delete-parameter/(?P\d+)/$', + views.delete_parameter, + name='application-delete-parameter', + ), + re_path( + r'^manifest/(?P[\w-]+)/parameter-value/(?P[\w_]+)/$', + views.change_parameter_value, + name='application-change-parameter-value', + ), re_path( r'^manifest/(?P[\w-]+)/job/(?P\d+)/$', views.async_job, diff --git a/hobo/applications/views.py b/hobo/applications/views.py index 6a4fbb9..3d0963a 100644 --- a/hobo/applications/views.py +++ b/hobo/applications/views.py @@ -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() diff --git a/hobo/static/css/style.scss b/hobo/static/css/style.scss index efbd923..8354ee3 100644 --- a/hobo/static/css/style.scss +++ b/hobo/static/css/style.scss @@ -381,3 +381,8 @@ ul.objects-list.application-content { } } } + +div.app-parameters--values label { + display: block; + font-weight: bold; +} diff --git a/tests/test_application.py b/tests/test_application.py index aef231b..b4f9fec 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -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)