applications: add possibility to define parameters (#76463) #34

Merged
fpeters merged 2 commits from wip/76463-application-parameters into main 2023-05-30 08:45:13 +02:00
12 changed files with 475 additions and 12 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 %}

has_parameters ?

has_parameters ?

Oui, modifié. (j'ai mis toutes les modifications dans un commit séparé, pour mieux les voir).

Oui, modifié. (j'ai mis toutes les modifications dans un commit séparé, pour mieux les voir).
<div class="app-parameters--values">
{% for parameter in parameters_qs %}

potentiellement 2 fois le meme qs que ligne 89

potentiellement 2 fois le meme qs que ligne 89

Je l'ai posé dans le {% with %} pour que ça soit partagé, en imaginant que ça permet ainsi d'économiser la requête.

Je l'ai posé dans le {% with %} pour que ça soit partagé, en imaginant que ça permet ainsi d'économiser la requête.
<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)

j'aurais bien ajouté editable=True au qs :)
(et get_object_or_404 permet d'éviter une 500, au cas où)

j'aurais bien ajouté editable=True au qs :) (et get_object_or_404 permet d'éviter une 500, au cas où)

Voilà, passé à get_object_or_404 + editable=True.

Voilà, passé à get_object_or_404 + 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']})

là aussi, restriction sur Application editable=True ?

là aussi, restriction sur Application editable=True ?

Fait, différemment ici,

+    def get_queryset(self):
+        return (
+            super()
+            .get_queryset()
+            .filter(application__editable=True, application__slug=self.kwargs['app_slug'])
+        )
Fait, différemment ici, ``` + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(application__editable=True, application__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()

idem editable = True

idem editable = True

Fait de la même manière que l'update.

Fait de la même manière que l'update.
.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

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: hobo 0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-04-21 10:25+0200\n"
"PO-Revision-Date: 2023-04-17 18:00+0200\n"
"POT-Creation-Date: 2023-05-30 08:34+0200\n"
"PO-Revision-Date: 2023-05-30 08:36+0200\n"
"Last-Translator: Frederic Peters <fpeters@entrouvert.com>\n"
"Language: French\n"
"MIME-Version: 1.0\n"
@ -84,6 +84,26 @@ msgstr ""
msgid "Failed to unlink application in module %s (%s)"
msgstr "Erreur lors de la déliaison dans le module %s (%s)"
#: hobo/applications/models.py
msgid "Label"
msgstr "Libellé"
#: hobo/applications/models.py
msgid "Identifier"
msgstr "Identifiant"
#: hobo/applications/models.py
msgid ""
"Variable name, it is useful to prefix it with an unique application "
"identifier."
msgstr ""
"Nom de variable, il peut être utile de le préfixer par un identifiant "
"spécifique à lapplication."
#: hobo/applications/models.py
msgid "Default value"
msgstr "Valeur par défaut"
#: hobo/applications/models.py
msgid "Type"
msgstr "Type"
@ -150,6 +170,7 @@ msgstr "Filtrer :"
#: hobo/applications/templates/hobo/applications/add-element.html
#: hobo/applications/templates/hobo/applications/manifest.html
#: hobo/applications/templates/hobo/applications/parameter-add.html
msgid "Add"
msgstr "Ajouter"
@ -160,6 +181,10 @@ msgstr "Ajouter"
#: hobo/applications/templates/hobo/applications/element_confirm_delete.html
#: hobo/applications/templates/hobo/applications/generate.html
#: hobo/applications/templates/hobo/applications/install.html
#: hobo/applications/templates/hobo/applications/parameter-add.html
#: hobo/applications/templates/hobo/applications/parameter-confirm-delete.html
#: hobo/applications/templates/hobo/applications/parameter-edit.html
#: hobo/applications/templates/hobo/applications/parameter-value-edit.html
#: hobo/applications/templates/hobo/applications/update.html
#: hobo/matomo/templates/hobo/matomo_disable.html
#: hobo/matomo/templates/hobo/matomo_enable_auto.html
@ -188,6 +213,7 @@ msgstr "Ses composants ne seront pas désinstallés."
#: hobo/applications/templates/hobo/applications/app_confirm_delete.html
#: hobo/applications/templates/hobo/applications/element_confirm_delete.html
#: hobo/applications/templates/hobo/applications/manifest.html
#: hobo/applications/templates/hobo/applications/parameter-confirm-delete.html
msgid "Delete"
msgstr "Supprimer"
@ -289,6 +315,50 @@ msgstr ""
"Vous pouvez maintenant assembler les différents éléments de votre "
"application."
#: hobo/applications/templates/hobo/applications/manifest.html
msgid "Parameters"
msgstr "Paramètres"
#: hobo/applications/templates/hobo/applications/manifest.html
msgid "add"
msgstr "ajouter"
#: hobo/applications/templates/hobo/applications/manifest.html
msgid "No parameters defined."
msgstr "Pas de paramètres définis."
#: hobo/applications/templates/hobo/applications/manifest.html
msgid "change"
msgstr "modifier"
#: hobo/applications/templates/hobo/applications/parameter-add.html
msgid "New Parameter"
msgstr "Nouveau paramètre"
#: hobo/applications/templates/hobo/applications/parameter-confirm-delete.html
msgid "Delete Parameter"
msgstr "Suppression du paramètre"
#: hobo/applications/templates/hobo/applications/parameter-confirm-delete.html
msgid "Are you sure you want to remove this parameter?"
msgstr "Êtes-vous sûr·e de vouloir supprimer ce paramètre ?"
#: hobo/applications/templates/hobo/applications/parameter-edit.html
#: hobo/applications/templates/hobo/applications/parameter-value-edit.html
msgid "Edit Parameter"
msgstr "Modification de paramètre"
#: hobo/applications/templates/hobo/applications/parameter-edit.html
#: hobo/applications/templates/hobo/applications/parameter-value-edit.html
#: hobo/debug/templates/hobo/debug_home.html
#: hobo/matomo/templates/hobo/matomo_home.html
#: hobo/profile/templates/profile/attributedefinition_form.html
#: hobo/profile/templates/profile/edit_full_name_template.html
#: hobo/seo/templates/hobo/robots_txt.html
#: hobo/seo/templates/hobo/seo_home.html
msgid "Save"
msgstr "Enregistrer"
#: hobo/applications/templates/hobo/applications/versions.html
msgid "Versions"
msgstr "Versions"
@ -359,15 +429,6 @@ msgstr "Liste des adresses IP pour lesquelles activer le débogage"
msgid "Debugging"
msgstr "Débogage"
#: hobo/debug/templates/hobo/debug_home.html
#: hobo/matomo/templates/hobo/matomo_home.html
#: hobo/profile/templates/profile/attributedefinition_form.html
#: hobo/profile/templates/profile/edit_full_name_template.html
#: hobo/seo/templates/hobo/robots_txt.html
#: hobo/seo/templates/hobo/seo_home.html
msgid "Save"
msgstr "Enregistrer"
#: hobo/debug/templates/hobo/debug_home.html
msgid "Remove current IP"
msgstr "Retirer votre adresse IP actuelle"

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)