environment: add kebab menu to services on homepage (#64924)

- kebab menu on services allows to :
  - edit them
  - delete them (when available)
- main kebab menu has now an extra option to create a new service
This commit is contained in:
Yann Weber 2024-01-31 16:50:35 +01:00
parent e6c0555f78
commit e635dd1818
7 changed files with 221 additions and 37 deletions

View File

@ -0,0 +1,62 @@
{% extends "hobo/base.html" %}
{% load i18n service %}
{% block appbar %}
<h2>{% trans 'Add a service' %}</h2>
{% endblock %}
{% block content %}
<form method="post">
<div id="form-content">
<span>{% trans 'Add new service:' %}</span>
<span id="new-service">
{% for service in available_services %}
<p>
<a rel="popup" data-service="{{ service.id }}" href="{% url 'create-service' service=service.id %}">{{ service.label }}</a>a
</p>
{% endfor %}
</span>
{% block buttons %}
<div class="buttons">
<a class="cancel" href="{% url 'environment-home' %}">{% trans 'Cancel' %}</a>
</div>
{% endblock %}
</div>
<script>
$(function() {
/* turn the new service links into a select box */
var select_new_service = $('<select><option></option></select>').insertAfter($('#new-service'));
$('#new-service').hide();
$('#new-service a').each(function(index, element) {
var text = $(element).text();
var option = $('<option value="' + $(element).data('service') + '">' + text + "</option>"
).appendTo(select_new_service);
});
$(select_new_service).change(function() {
var service_id = $(this).val();
if (service_id) {
$('#new-service a[data-service=' + service_id + ']').click();
$(this).val('');
}
});
$('a.update-variable').hide();
$('p.variable label, p.variable input').click(function() {
$(this).parent().find('a.update-variable').click();
});
$("div[data-wants-check='true']").each(function(index, element) {
$(element).operational_check();
});
$('button.enable-on-change').each(function(index, element) {
var button = $(element);
$(element).parent('form').find('input').on('change keydown',
function() { $(button).prop('disabled', null); });
});
});
</script>
</form>
{% endblock %}

View File

@ -0,0 +1,24 @@
{% extends "hobo/base.html" %}
{% load i18n service %}
{% block appbar %}
<h2>{{ model_name }}</h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<div id="form-content">
{% csrf_token %}
{{ object|as_update_form }}
</div>
{% block buttons %}
<div class="buttons">
<button class="submit-button">{% trans 'Save' %}</button>
<a class="cancel" href="{% url 'environment-home' %}">{% trans 'Cancel' %}</a>
</div>
{% endblock %}
</form>
{% endblock %}

View File

@ -33,6 +33,7 @@ urlpatterns = [
views.operational_check_view,
name='operational-check',
),
path('select_create_service', views.ServiceSelectCreateView.as_view(), name='environment-create-service'),
re_path(r'^new-(?P<service>\w+)$', views.ServiceCreateView.as_view(), name='create-service'),
re_path(
r'^save-(?P<service>\w+)/(?P<slug>[\w-]+)$', views.ServiceUpdateView.as_view(), name='save-service'

View File

@ -124,8 +124,17 @@ class VariableDeleteView(DeleteView):
return reverse_lazy('environment-home')
class ServiceSelectCreateView(TemplateView):
template_name = 'environment/select_new_service.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['available_services'] = [AvailableService(x) for x in AVAILABLE_SERVICES if x.is_enabled()]
return context
class ServiceCreateView(CreateView):
success_url = reverse_lazy('environment-home')
success_url = reverse_lazy('home')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@ -141,7 +150,7 @@ class ServiceCreateView(CreateView):
return initial
def get_template_names(self):
return 'environment/service_form.html'
return 'environment/service_create_form.html'
def get(self, request, *args, **kwargs):
self.service_id = kwargs.pop('service')
@ -161,7 +170,7 @@ class ServiceCreateView(CreateView):
class ServiceUpdateView(UpdateView):
success_url = reverse_lazy('environment-home')
success_url = reverse_lazy('home')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@ -169,7 +178,7 @@ class ServiceUpdateView(UpdateView):
return context
def get_template_names(self):
return 'environment/service_form.html'
return 'environment/service_edit_form.html'
def get(self, request, *args, **kwargs):
self.service_id = kwargs.pop('service')
@ -191,7 +200,7 @@ class ServiceUpdateView(UpdateView):
class ServiceDeleteView(DeleteView):
success_url = reverse_lazy('environment-home')
success_url = reverse_lazy('home')
template_name = 'environment/generic_confirm_delete.html'
context_object_name = 'object'

View File

@ -6,6 +6,7 @@
<span class="actions">
<a class="extra-actions-menu-opener"></a>
<ul class="extra-actions-menu">
<li><a rel="popup" href="{% url 'environment-create-service' %}">{% trans 'Add new service' %}</a></li>
<li><a rel="popup" href="{% url 'environment-import' %}">{% trans 'Import' %}</a></li>
<li><a href="{% url 'environment-export' %}">{% trans 'Export' %}</a></li>
</ul>
@ -17,18 +18,35 @@
{% if services %}
<div class="services">
{% for service in services %}
<a href="{{ service.base_url }}" class="service-link" data-service-slug="{{ service.slug }}">
<div class="service" data-service-slug="{{ service.slug }}">
<h3 class="service-title">{{ service.title }} <span class="service-url">{{ service.base_url }}</span></h3>
<p class="service-status-items">
<span class="checking">{% trans "checking..." %}</span>
<span style="display: none" class="dns">{% trans "DNS" %}</span>
<span style="display: none" class="certificate">{% trans "Certificate" %}</span>
<span style="display: none" class="web">{% trans "Web" %}</span>
<span style="display: none" class="security">{% trans "Security" %}</span>
</p>
<div class="service-link" data-service-slug="{{ service.slug }}">
<a href="{{ service.base_url }}" data-service-slug="{{ service.slug }}">
<div class="service" data-service-slug="{{ service.slug }}">
<h3 class="service-title">{{ service.title }} <span class="service-url">{{ service.base_url }}</span></h3>
<p class="service-status-items">
<span class="checking">{% trans "checking..." %}</span>
{% if not service.is_operational and service.wants_frequent_checks %}
<span style="display: none" class="info being-deployed">{% trans 'This service is still being deployed.' %}</span>
{% endif %}
<span style="display: none" class="dns">{% trans "DNS" %}</span>
<span style="display: none" class="certificate">{% trans "Certificate" %}</span>
<span style="display: none" class="web">{% trans "Web" %}</span>
<span style="display: none" class="security">{% trans "Security" %}</span>
</p>
</div>
</a>
<div class="menu-opener">
<span class="actions">
<a class="extra-actions-menu-opener"></a>
<ul class="extra-actions-menu">
<li><a rel="popup" href="{% url 'save-service' service=service.Extra.service_id slug=service.slug %}">{% trans 'Edit' %}</a></li>
{% if not service.is_operational and not service.wants_frequent_checks %}
<li><a rel="popup" href="{% url 'delete-service' service=service.Extra.service_id slug=service.slug %}">{% trans 'Delete service' %}</a></li>
{% endif %}
</ul>
</span>
</div>
</a>
</div>
{% endfor %}
</div>
{% else %}

View File

@ -4,11 +4,22 @@ import pytest
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.db.utils import IntegrityError
from django.urls import reverse
from django.utils import timezone
from webtest import Upload
from hobo.environment import models as environment_models
from hobo.environment.models import AVAILABLE_SERVICES, Combo, Passerelle, ServiceBase, Variable
from hobo.environment.models import (
AVAILABLE_SERVICES,
Authentic,
Chrono,
Combo,
Hobo,
Passerelle,
ServiceBase,
Variable,
Wcs,
)
from hobo.environment.utils import get_installed_services_dict
from hobo.profile.models import AttributeDefinition
@ -122,39 +133,67 @@ def test_base_url_field_validator():
combo.save()
def test_service_creation_filling(app, admin_user, monkeypatch):
@pytest.mark.parametrize(
'service_name,',
['authentic', 'chrono', 'combo', 'hobo', 'passerelle', 'wcs'],
)
def test_service_creation_filling(app, admin_user, monkeypatch, service_name):
from django.http.request import HttpRequest
monkeypatch.setattr(HttpRequest, 'get_host', lambda x: 'test.example.net')
app = login(app)
response = app.get('/sites/new-combo')
assert 'value="http://portal.example.net"' in response.text
response = app.get('/sites/new-%s' % service_name)
slug = response.pyquery('#id_slug').val()
url = response.pyquery('#id_base_url').val()
assert url == 'http://%s.example.net' % slug
monkeypatch.setattr(HttpRequest, 'get_host', lambda x: 'hobo-test.example.net')
monkeypatch.setattr(HttpRequest, 'get_host', lambda x: 'some-test.example.net')
app = login(app)
response = app.get('/sites/new-combo')
assert 'value="http://portal-test.example.net"' in response.text
response = app.get('/sites/new-%s' % service_name)
slug = response.pyquery('#id_slug').val()
url = response.pyquery('#id_base_url').val()
assert url == 'http://%s-test.example.net' % slug
def test_service_creation_url_validation(app, admin_user, monkeypatch):
@pytest.mark.parametrize(
'service_name,service_cls',
[
('authentic', Authentic),
('chrono', Chrono),
('combo', Combo),
('hobo', Hobo),
('passerelle', Passerelle),
('wcs', Wcs),
],
)
def test_service_creation_url_validation(app, admin_user, monkeypatch, service_name, service_cls):
app = login(app)
response = app.get('/sites/new-combo')
response = app.get('/sites/new-%s' % service_name)
form = response.form
form['title'] = 'test'
form['base_url'] = 'http://portal-test.example.net'
form['base_url'] = 'http://some-test.example.net'
response = form.submit()
assert 'not resolvable' in response
monkeypatch.setattr(environment_models, 'is_resolvable', lambda x: True)
form = response.form
form.fields['slug'][0].value += '-uniq'
response = form.submit()
assert 'no valid certificate' in response
assert not Combo.objects.exists()
if service_name == 'hobo':
assert service_cls.objects.count() == 1
else:
assert not service_cls.objects.exists()
monkeypatch.setattr(environment_models, 'has_valid_certificate', lambda x: True)
form = response.form
form.fields['slug'][0].value += '-uniq'
response = form.submit()
assert Combo.objects.exists()
if service_name == 'hobo':
assert service_cls.objects.count() == 2
else:
assert service_cls.objects.exists()
def test_home_view(app, admin_user, settings):
@ -295,16 +334,47 @@ def test_variable_delete_view(app, admin_user):
assert Variable.objects.count() == 0
def test_service_update_view(app, admin_user):
@pytest.mark.parametrize(
'service_name,service_cls',
[
('authentic', Authentic),
('chrono', Chrono),
('combo', Combo),
('hobo', Hobo),
('passerelle', Passerelle),
('wcs', Wcs),
],
)
def test_service_update_view(app, admin_user, monkeypatch, service_name, service_cls):
import socket
monkeypatch.setattr(socket, 'gethostbyname', lambda _: '127.1.2.3')
monkeypatch.setattr(environment_models, 'has_valid_certificate', lambda x: True)
app = login(app)
Combo.objects.create(
base_url='https://combo.agglo.love', template_name='...portal-user...', slug='portal'
)
response = app.get('/sites/save-combo/portal')
response.form['title'] = 'foobar'
response = app.get('/sites/new-%s' % service_name)
form = response.form
form['title'] = 'test-%s' % service_name
hostname = '%s-test.agglo.love' % service_name
form['base_url'] = 'https://%s/foobar' % hostname
slug = 'slug-%s' % service_name
form['slug'] = slug
form.submit()
if service_name == 'authentic':
# Fake operationnal authentic with IDP set
authentic_service = service_cls.objects.filter(slug=slug)[0]
authentic_service.use_as_idp_for_self = True
authentic_service.save()
monkeypatch.setattr(authentic_service, 'is_operational', lambda _: True)
response = app.get(reverse('save-service', kwargs={'service': service_name, 'slug': slug}))
response.form['title'] = 'foobar-%s' % service_name
if service_name == 'authentic':
response.form['use_as_idp_for_self'] = False
response = response.form.submit()
assert response.location == '/sites/'
assert Combo.objects.all()[0].title == 'foobar'
assert response.location == '/'
assert service_cls.objects.filter(slug=slug)[0].title == ('foobar-%s' % service_name)
if service_name == 'authentic':
assert not service_cls.objects.filter(slug=slug)[0].use_as_idp_for_self
def test_service_save_extra_variables(app, admin_user, settings):
@ -334,7 +404,7 @@ def test_service_delete_view(app, admin_user):
response = app.get('/sites/delete-combo/portal')
assert response.html.find('h2').text == 'Removal of "foo"'
response = response.form.submit()
assert response.location == '/sites/'
assert response.location == '/'
assert Combo.objects.count() == 0