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 %}
+
+{% 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 %}
+
+{% 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 %}
+
+{% 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 %}
+
+{% 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)