écran de configuration de l’apparence par défaut des pages de login/authz (#75139) #35

Merged
pmarillonnet merged 3 commits from wip/75139-neutral-authz-theme-bo-config into main 2023-05-02 09:22:01 +02:00
12 changed files with 210 additions and 4 deletions

View File

@ -44,9 +44,14 @@ from authentic2.forms.fields import (
)
from authentic2.forms.mixins import SlugMixin
from authentic2.forms.profile import BaseUserForm
from authentic2.models import APIClient, PasswordReset, Service
from authentic2.models import APIClient, PasswordReset, Service, Setting
from authentic2.passwords import generate_password, get_min_password_strength
from authentic2.utils.misc import send_email_change_email, send_password_reset_mail, send_templated_mail
from authentic2.utils.misc import (
RUNTIME_SETTINGS,
send_email_change_email,
send_password_reset_mail,
send_templated_mail,
)
from authentic2.validators import EmailValidator
from . import app_settings, fields, utils
@ -985,3 +990,22 @@ class ServiceForm(forms.ModelForm):
class Meta:
model = Service
fields = ['name', 'slug', 'ou', 'unauthorized_url']
class ServicesSettingsForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for setting in Setting.objects.filter_namespace('sso'):
if RUNTIME_SETTINGS[setting.key]['type'] == 'url':
field = forms.URLField
else:
field = forms.CharField
self.fields[setting.key] = field(
initial=setting.value,
label=RUNTIME_SETTINGS[setting.key]['name'],
required=False,
)
if RUNTIME_SETTINGS[setting.key]['type'] == 'colour':
self.fields[setting.key].widget = forms.TextInput(attrs={'type': 'color'})

View File

@ -17,7 +17,7 @@
from django.contrib import messages
from django.utils.translation import gettext as _
from authentic2.models import Service
from authentic2.models import Service, Setting
from . import forms, role_views, tables, views
@ -150,3 +150,24 @@ class ServiceDeleteView(views.BaseDeleteView):
delete_service = ServiceDeleteView.as_view()
class ServicesSettingsView(views.FormView):
template_name = 'authentic2/manager/services_settings.html'
form_class = forms.ServicesSettingsForm
title = _('Edit services-related settings')
success_url = '..'
permissions = ['authentic2.change_service']
def form_valid(self, form):
for key, value in form.cleaned_data.items():
try:
setting = Setting.objects.get(key=key)
except Setting.DoesNotExist:
continue
setting.value = value
setting.save()
return super().form_valid(form)
services_settings = ServicesSettingsView.as_view()

View File

@ -14,6 +14,7 @@
{% if view.can_add %}
<a href="{% url "a2-manager-add-oidc-service" %}">{% trans "Add OIDC service" %}</a>
{% endif %}
<a rel="popup" href="{% url "a2-manager-services-settings" %}">{% trans "Settings" %}</a>
</span>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "authentic2/manager/form.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'a2-manager-services' %}">{% trans 'Services' %}</a>
<a href="#">{% trans "Edit services configuration" %}</a>
{% endblock %}
{% block page_title %}
{% trans "Edit services configuration" %}
{% endblock %}

View File

@ -173,6 +173,7 @@ urlpatterns = required(
path('organizational-units/import/', ou_views.ous_import, name='a2-manager-ous-import'),
# Services
path('services/', service_views.listing, name='a2-manager-services'),
path('services/settings/', service_views.services_settings, name='a2-manager-services-settings'),
path('services/<int:service_pk>/', service_views.service_detail, name='a2-manager-service'),
path(
'services/<int:service_pk>/settings/',

View File

@ -112,5 +112,10 @@ class AttributeManager(managers.QueryManager.from_queryset(GetByNameQuerySet)):
pass
class SettingManager(models.Manager):
def filter_namespace(self, ns):
return self.filter(key__startswith=f'{ns}:')
ServiceManager = BaseServiceManager.from_queryset(ServiceQuerySet)
AttributeValueManager = managers.QueryManager.from_queryset(AttributeValueQuerySet)

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.18 on 2023-04-11 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentic2', '0045_auto_20221222_1013'),
]
operations = [
migrations.CreateModel(
name='Setting',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('key', models.CharField(max_length=128, unique=True, verbose_name='key')),
('value', models.JSONField(blank=True, verbose_name='value')),
],
),
]

View File

@ -0,0 +1,39 @@
from django.db import migrations
def initialize_services_runtime_settings(apps, schema_editor):
from authentic2.utils.misc import RUNTIME_SETTINGS
Setting = apps.get_model('authentic2', 'Setting')
if Setting.objects.filter(key__startswith='sso:').count() == 4:
return
for key, data in RUNTIME_SETTINGS.items():

Je ne mettrai pas ça en base, je ferai plutôt un dictionnaire global avec la déclaration des settings disponibles.

SETTINGS = [
   'sso:default_service_colour': {
        'name': _('Default service colour'),
        'type': 'colour',
   },
}

Un peu comme les types d'attributs, on devrait d'ailleurs essayer de partager quelque chose ici à terme, mais là pour 4 attributs de type url, string et couleur on s'en passera.

Je ne mettrai pas ça en base, je ferai plutôt un dictionnaire global avec la déclaration des settings disponibles. ``` SETTINGS = [ 'sso:default_service_colour': { 'name': _('Default service colour'), 'type': 'colour', }, } ``` Un peu comme les types d'attributs, on devrait d'ailleurs essayer de partager quelque chose ici à terme, mais là pour 4 attributs de type url, string et couleur on s'en passera.

Plutôt une liste quand même si on veut être certain que l'ordre soit conservé (les dicos sont ordonnées depuis python 3.x mais bon je ne mettrai pas ma main à couper que ça ne changera pas, je ne sais pas si on peut vraiment en dépendre), une liste de :

import typing

class SettingDescriptor(typing.NamedTuple):
    name: str
    label: str
    type: str

par exemple.

Plutôt une liste quand même si on veut être certain que l'ordre soit conservé (les dicos sont ordonnées depuis python 3.x mais bon je ne mettrai pas ma main à couper que ça ne changera pas, je ne sais pas si on peut vraiment en dépendre), une liste de : ``` import typing class SettingDescriptor(typing.NamedTuple): name: str label: str type: str ``` par exemple.

Plutôt une liste quand même si on veut être certain que l'ordre soit conservé (les dicos sont ordonnées depuis python 3.x mais bon je ne mettrai pas ma main à couper que ça ne changera pas, je ne sais pas si on peut vraiment en dépendre),

On peut vraiment en dépendre.

Changed in version 3.7: Dictionary order is guaranteed to be insertion order. This behavior was an implementation detail of CPython from 3.6. -- https://docs.python.org/3/library/stdtypes.html#dict

> Plutôt une liste quand même si on veut être certain que l'ordre soit conservé (les dicos sont ordonnées depuis python 3.x mais bon je ne mettrai pas ma main à couper que ça ne changera pas, je ne sais pas si on peut vraiment en dépendre), On peut vraiment en dépendre. Changed in version 3.7: Dictionary order is guaranteed to be insertion order. This behavior was an implementation detail of CPython from 3.6. -- https://docs.python.org/3/library/stdtypes.html#dict

Va pour le dico alors.

Va pour le dico alors.
Setting.objects.get_or_create(
key=key,
defaults={
'value': data['value'],
},
)
def clear_services_runtime_settings(apps, schema_editor):
Setting = apps.get_model('authentic2', 'Setting')
# default config has been extended, do not try to revert it
if Setting.objects.filter(key__startswith='sso:').count() != 4:
return
Setting.objects.filter(key__startswith='sso:').delete()
class Migration(migrations.Migration):
dependencies = [
('authentic2', '0046_runtimesetting'),
]
operations = [
migrations.RunPython(
initialize_services_runtime_settings, reverse_code=clear_services_runtime_settings
),
]

View File

@ -843,3 +843,10 @@ class SMSCode(models.Model):
duration = cls.CODE_DURATION
expires = expires or (timezone.now() + datetime.timedelta(seconds=duration))
return cls.objects.create(kind=kind, phone=phone, expires=expires)
class Setting(models.Model):

Appeler ça setting ou config j'ai déjà mal aux doigts de devoir écrire Runtime :)

Appeler ça setting ou config j'ai déjà mal aux doigts de devoir écrire Runtime :)
key = models.CharField(verbose_name=_('key'), max_length=128, unique=True)
value = JSONField(verbose_name=_('value'), blank=True)
objects = managers.SettingManager()

View File

@ -45,6 +45,7 @@ from django.urls import reverse
from django.utils import html, timezone
from django.utils.encoding import iri_to_uri, uri_to_iri
from django.utils.formats import localize
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from authentic2.saml.saml2utils import filter_attribute_private_key, filter_element_private_key
@ -1352,3 +1353,27 @@ def parse_phone_number(phonenumber):
except phonenumbers.NumberParseException:
pass
return parsed_pn
RUNTIME_SETTINGS = {
'sso:default_service_colour': {
'name': _('Default service colour'),
'value': '',
'type': 'colour',
},
'sso:default_service_logo_url': {
'name': _('Default service logo URL'),
'value': '',
'type': 'url',
},
'sso:default_service_name': {
'name': _('Default service name'),
'value': '',
'type': None,
},
'sso:default_service_home_url': {
Review

La différence setting_type/widget ne me paraît pas utile à ce stade, on peu se contenter d'un champ type avec trois valeurs url, colour et text, text étant la valeur par défaut si c'est None / absent.

La différence setting_type/widget ne me paraît pas utile à ce stade, on peu se contenter d'un champ type avec trois valeurs url, colour et text, text étant la valeur par défaut si c'est None / absent.
Review

Ok, j’ai fait la modif.

Ok, j’ai fait la modif.
'name': _('Default service home URL'),
'value': '',
'type': 'url',
},
}

View File

@ -33,7 +33,7 @@ from authentic2.a2_rbac.models import OrganizationalUnit as OU
from authentic2.a2_rbac.models import Permission, Role
from authentic2.a2_rbac.utils import get_default_ou, get_operation
from authentic2.apps.journal.models import Event
from authentic2.models import Service
from authentic2.models import Service, Setting
from authentic2.validators import EmailValidator
from .utils import assert_event, get_link_from_mail, login, request_select2, text_content
@ -1358,6 +1358,40 @@ def test_manager_service_search(app, admin, ou1):
assert 'Example Service' not in resp.text
def test_manager_services_settings(app, admin):
for setting in Setting.objects.filter_namespace('sso'):
assert setting.value == ''
resp = login(app, admin, 'a2-manager-services-settings')
resp.form.submit()
for setting in Setting.objects.filter_namespace('sso'):
assert setting.value == ''
resp = app.get(reverse('a2-manager-services-settings'))
resp.form.set('sso:default_service_home_url', 'https://www.example.com/')
resp.form.set('sso:default_service_logo_url', 'https://www.example.com/logo.png')
resp.form.set('sso:default_service_colour', '#dedede')
resp.form.set('sso:default_service_name', 'Some default name')
resp.form.submit()
assert Setting.objects.get(key='sso:default_service_home_url').value == 'https://www.example.com/'
assert Setting.objects.get(key='sso:default_service_logo_url').value == 'https://www.example.com/logo.png'
assert Setting.objects.get(key='sso:default_service_colour').value == '#dedede'
assert Setting.objects.get(key='sso:default_service_name').value == 'Some default name'
resp = app.get(reverse('a2-manager-services-settings'))
resp.form.set('sso:default_service_home_url', 'https://www2.example.com/')
resp.form.set('sso:default_service_logo_url', '')
resp.form.set('sso:default_service_name', 'Some other name')
resp.form.submit()
assert Setting.objects.get(key='sso:default_service_home_url').value == 'https://www2.example.com/'
assert Setting.objects.get(key='sso:default_service_logo_url').value == ''
assert Setting.objects.get(key='sso:default_service_colour').value == '#dedede'
assert Setting.objects.get(key='sso:default_service_name').value == 'Some other name'
def test_manager_menu_json(app, admin):
expected = [
{

View File

@ -58,3 +58,17 @@ def test_migration_custom_user_0028_user_email_verified_date(transactional_db, m
User = new_apps.get_model('custom_user', 'User')
user = User.objects.get()
assert user.email_verified_date == user.date_joined
def test_migration_custom_user_0047_initialize_services_runtime_settings(transactional_db, migration):
old_apps = migration.before([('authentic2', '0046_runtimesetting')])
Setting = old_apps.get_model('authentic2', 'Setting')
assert Setting.objects.count() == 0
new_apps = migration.apply([('authentic2', '0047_initialize_services_runtime_settings')])
Setting = new_apps.get_model('authentic2', 'Setting')
assert Setting.objects.count() == 4
assert Setting.objects.filter(key__startswith='sso:').count() == 4
for setting in Setting.objects.filter(key__startswith='sso:'):
assert setting.value == ''