custom_user : pouvoir définir qu’un attribut multivalué prend ses valeurs dans un ensemble bien défini (#86937) #256

Open
pmarillonnet wants to merge 4 commits from wip/86937-accounts-multivalued-attribute-multiselect into main
9 changed files with 335 additions and 7 deletions

View File

@ -151,6 +151,36 @@ class EditProfileForm(NextUrlFormMixin, BaseUserForm):
pass
class EditRestrictedAttributeForm(forms.Form):
def __init__(self, *args, **kwargs):
attribute = kwargs.pop('attribute')
user = kwargs.pop('user')
super().__init__(*args, **kwargs)
if not attribute.multiple:
field = forms.ChoiceField
widget = forms.RadioSelect
else:
field = forms.MultipleChoiceField
widget = forms.CheckboxSelectMultiple
self.fields[attribute.name] = field(
label=attribute.label,
widget=widget(),
choices=((el.name, el.displayed_label) for el in attribute.valueset_elements.all()),
required=attribute.required,
)
# populate initial values
if qs := models.AttributeRestrictedValueSetElement.objects.filter(
attribute=attribute,
serialized_content__in=models.AttributeValue.objects.with_owner(user)
.filter(attribute=attribute)
.values_list('content', flat=True),
):
initial = list(qs.values_list('name', flat=True))
self.fields[attribute.name].initial = initial if attribute.multiple else initial[0]
def modelform_factory(model, **kwargs):
"""Build a modelform for the given model,

View File

@ -0,0 +1,41 @@
# Generated by Django 3.2.18 on 2024-03-06 09:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentic2', '0050_initialize_users_advanced_configuration'),
]
operations = [
migrations.CreateModel(
name='AttributeRestrictedValueSetElement',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('serialized_content', models.TextField(verbose_name='Serialized value')),
('displayed_label', models.CharField(max_length=255, verbose_name='Displayed label')),
('name', models.CharField(max_length=255, verbose_name='Element name')),
(
'attribute',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='valueset_elements',
to='authentic2.attribute',
verbose_name='attribute',
),
),
],
options={
'unique_together': {
('attribute', 'serialized_content'),
('attribute', 'name'),
('attribute', 'displayed_label'),
},
},
),
]

View File

@ -146,6 +146,26 @@ class LogoutUrl(LogoutUrlAbstract):
verbose_name_plural = _('logout URL')
class AttributeRestrictedValueSetElement(models.Model):
attribute = models.ForeignKey(
'Attribute', verbose_name=_('attribute'), on_delete=models.CASCADE, related_name='valueset_elements'
)
serialized_content = models.TextField(verbose_name=_('Serialized value'))
displayed_label = models.CharField(verbose_name=_('Displayed label'), max_length=255)
name = models.CharField(verbose_name=_('Element name'), max_length=255)
def to_python(self):
deserialize = self.attribute.get_kind()['deserialize']
return deserialize(self.content)
class Meta:
unique_together = (
('attribute', 'name'),
('attribute', 'displayed_label'),
('attribute', 'serialized_content'),
)
class Attribute(models.Model):
label = models.CharField(verbose_name=_('label'), max_length=63, unique=True)
description = models.TextField(verbose_name=_('description'), blank=True)

View File

@ -74,6 +74,9 @@
{% if allow_profile_edit %}
<p><a href="{% url 'profile_edit' %}">{% trans "Edit account data" %}</a></p>
{% endif %}
{% for attribute in restricted_attributes %}
<p><a href="{% url 'profile_edit_restricted_attribute' attribute=attribute.name %}">{% blocktranslate with label=attribute.label %}Edit your profile attribute “{{ label }}”{% endblocktranslate %}</a></p>
{% endfor %}
{% if allow_authorization_management %}
<p><a href="{% url 'consents' %}">{% trans "Manage service authorizations" %}</a></p>
{% endif %}

View File

@ -0,0 +1,24 @@
{% extends "authentic2/base-page.html" %}
{% load i18n gadjo %}
{% block title %}
{{ view.title }}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="..">{% trans "Your account" %}</a>
<a href="">{{ title }}</a>
{% endblock %}
{% block content %}
<form enctype="multipart/form-data" method="post">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button class="submit-button">{% trans "Submit" %}</button>
<button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button>
</div>
</form>
{% endblock %}

View File

@ -54,6 +54,11 @@ accounts_urlpatterns = [
path('edit/', views.edit_profile, name='profile_edit'),
path('edit/required/', views.edit_required_profile, name='profile_required_edit'),
re_path(r'^edit/(?P<scope>[-\w]+)/$', views.edit_profile, name='profile_edit_with_scope'),
re_path(
r'^edit_restricted_attribute/(?P<attribute>[-\w]+)/$',
views.edit_restricted_attribute,
name='profile_edit_restricted_attribute',
),
path('change-email/', views.email_change, name='email-change'),
path('change-email/verify/', views.email_change_verify, name='email-change-verify'),
path('change-phone/', views.phone_change, name='phone-change'),

View File

@ -113,13 +113,13 @@ class EditProfile(HomeURLMixin, cbv.HookMixin, cbv.TemplateNamesMixin, UpdateVie
else:
field_name = field
try:
attribute = models.Attribute.objects.get(name=field_name)
attribute = models.Attribute.objects.get(name=field_name, multiple=False)
except models.Attribute.DoesNotExist:
editable_profile_fields.append(field)
else:
if attribute.user_editable:
editable_profile_fields.append(field)
attributes = models.Attribute.objects.filter(user_editable=True)
attributes = models.Attribute.objects.filter(user_visible=True, user_editable=True, multiple=False)
if scopes:
scopes = set(scopes)
default_fields = [
@ -179,6 +179,65 @@ edit_profile = decorators.setting_enabled('A2_PROFILE_CAN_EDIT_PROFILE')(
)
class EditRestrictedAttributeView(HomeURLMixin, cbv.HookMixin, cbv.TemplateNamesMixin, FormView):
template_names = ['authentic2/accounts_edit_restricted_attribute.html']
form_class = profile_forms.EditRestrictedAttributeForm
success_url = reverse_lazy('account_management')
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
kwargs['attribute'] = self.attribute
return kwargs
def dispatch(self, request, *args, **kwargs):
self.attribute = get_object_or_404(models.Attribute, name=kwargs['attribute'])
if not self.attribute.valueset_elements.all():
messages.info(
request,
_(f'Attribute “{self.attribute.label}” does not restrict its values.'),
)
return utils_misc.redirect(request, 'account_management')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['title'] = self.get_title()
return ctx
def get_title(self):
return _(f'Edit your profile attribute “{self.attribute.label}')
def form_valid(self, form):
response = super().form_valid(form)
if self.attribute.name in form.cleaned_data:
if self.attribute.multiple:
atvs = list(
models.AttributeRestrictedValueSetElement.objects.filter(
attribute=self.attribute,
name__in=form.cleaned_data[self.attribute.name],
).values_list('serialized_content', flat=True)
)
setattr(self.request.user.attributes, self.attribute.name, atvs)
else:
atv = models.AttributeRestrictedValueSetElement.objects.get(
attribute=self.attribute,
name=form.cleaned_data[self.attribute.name],
).serialized_content
setattr(self.request.user.attributes, self.attribute.name, atv)
self.request.user.save()
messages.info(
self.request,
_(f'Your profile attribute “{self.attribute.label}” has been updated.'),
)
return response
edit_restricted_attribute = decorators.setting_enabled('A2_PROFILE_CAN_EDIT_PROFILE')(
login_required(EditRestrictedAttributeView.as_view())
)
class EditRequired(EditProfile):
template_names = ['authentic2/accounts_edit_required.html']
@ -766,7 +825,7 @@ class ProfileView(HomeURLMixin, cbv.TemplateNamesMixin, TemplateView):
attribute = None
if attribute:
if not attribute.user_visible:
if not attribute.user_visible or attribute.valueset_elements.all():
continue
html_value = attribute.get_kind().get('html_value', lambda a, b: b)
qs = models.AttributeValue.objects.with_owner(request.user)
@ -829,6 +888,13 @@ class ProfileView(HomeURLMixin, cbv.TemplateNamesMixin, TemplateView):
and authenticator.phone_identifier_field.user_editable
and not authenticator.phone_identifier_field.disabled
)
restricted_attributes = models.Attribute.objects.filter(
user_visible=True,
user_editable=True,
disabled=False,
valueset_elements__isnull=False,
).distinct()
context.update(
{
'frontends_block': blocks,
@ -843,6 +909,7 @@ class ProfileView(HomeURLMixin, cbv.TemplateNamesMixin, TemplateView):
# TODO: deprecated should be removed when publik-base-theme is updated
'allow_password_change': utils_misc.user_can_change_password(request=request),
'federation_management': federation_management,
'restricted_attributes': restricted_attributes,
}
)

View File

@ -20,7 +20,7 @@ from django.urls import reverse
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.models import Attribute
from authentic2.models import Attribute, AttributeRestrictedValueSetElement
from authentic2_idp_oidc.models import OIDCClient
from . import utils
@ -531,3 +531,139 @@ def test_account_view_boolean(app, simple_user, settings):
simple_user.attributes.accept = False
resp = app.get(reverse('account_management'))
assert 'Vrai' not in resp.text
def test_accounts_edit_restricted_attribute_view(app, simple_user, settings):
attribute = Attribute.objects.create(
name='favourite_colors',
label='Favourite colors',
kind='string',
user_visible=True,
user_editable=True,
multiple=True,
)
attribute2 = Attribute.objects.create(
name='favourite_shape',
label='Favourite shape',
kind='string',
user_visible=True,
user_editable=True,
multiple=False,
)
for color, label in (('b', 'Blue'), ('g', 'Green'), ('f', 'Fuschia'), ('c', 'Crimson')):
AttributeRestrictedValueSetElement.objects.create(
attribute=attribute,
serialized_content=color,
displayed_label=label,
name=label.lower(),
)
for shape, label in (('s', 'Square'), ('c', 'Circle'), ('h', 'Hexagon'), ('o', 'Octogon')):
AttributeRestrictedValueSetElement.objects.create(
attribute=attribute2,
serialized_content=shape,
displayed_label=label,
name=label.lower(),
)
assert not simple_user.attributes.favourite_colors
assert attribute.valueset_elements.all()
assert not simple_user.attributes.favourite_shape
assert attribute2.valueset_elements.all()
simple_user.attributes.favourite_colors = ['b', 'f']
simple_user.attributes.favourite_shape = 'o'
simple_user.save()
utils.login(app, simple_user)
resp = app.get(reverse('account_management'))
assert (
resp.pyquery('a[href="/accounts/edit_restricted_attribute/favourite_colors/"]')[0].text_content()
== 'Edit your profile attribute “Favourite colors”'
)
assert (
resp.pyquery('a[href="/accounts/edit_restricted_attribute/favourite_shape/"]')[0].text_content()
== 'Edit your profile attribute “Favourite shape”'
)
resp = app.get(reverse('profile_edit_restricted_attribute', kwargs={'attribute': 'favourite_colors'}))
assert len(resp.pyquery('ul#id_favourite_colors li')) == 4
# test labels
assert {label.text_content().strip() for label in resp.pyquery('ul#id_favourite_colors li label')} == {
'Blue',
'Green',
'Fuschia',
'Crimson',
}
# test input
assert {input.get('value') for input in resp.pyquery('ul#id_favourite_colors li label input')} == {
'blue',
'green',
'fuschia',
'crimson',
}
# test input type
assert all(
input.get('type') == 'checkbox' for input in resp.pyquery('ul#id_favourite_colors li label input')
)
# test initial values
assert {
input.get('value') for input in resp.pyquery('ul#id_favourite_colors li label input[checked]')
} == {
'blue',
'fuschia',
}
resp.form.set('favourite_colors', ['fuschia', 'crimson'])
resp = resp.form.submit()
assert resp.location == '/accounts/'
resp = resp.follow()
assert (
resp.pyquery('ul.messages li.info')[0].text_content()
== 'Your profile attribute “Favourite colors” has been updated.'
)
simple_user.refresh_from_db()
assert set(simple_user.attributes.favourite_colors) == {'f', 'c'} # fuschia, crimson
resp = app.get(reverse('profile_edit_restricted_attribute', kwargs={'attribute': 'favourite_shape'}))
assert len(resp.pyquery('div[role="radiogroup"] label')) == 4
# test labels
assert {label.text_content().strip() for label in resp.pyquery('div[role="radiogroup"] label')} == {
'Square',
'Circle',
'Hexagon',
'Octogon',
}
# test input
assert {input.get('value') for input in resp.pyquery('div[role="radiogroup"] label input')} == {
'square',
'circle',
'hexagon',
'octogon',
}
# test input type
assert all(input.get('type') == 'radio' for input in resp.pyquery('div[role="radiogroup"] label input'))
# test initial value
assert {input.get('value') for input in resp.pyquery('div[role="radiogroup"] label input[checked]')} == {
'octogon',
}
resp.form.set('favourite_shape', 'hexagon')
resp = resp.form.submit()
assert resp.location == '/accounts/'
resp = resp.follow()
assert (
resp.pyquery('ul.messages li.info')[0].text_content()
== 'Your profile attribute “Favourite shape” has been updated.'
)
simple_user.refresh_from_db()
assert simple_user.attributes.favourite_shape == 'h' # hexagon

View File

@ -348,7 +348,9 @@ def test_attribute_model(app, db, settings, mailoutbox):
models.Attribute.objects.create(
label='Nom', name='nom', asked_on_registration=True, user_visible=True, kind='string'
)
models.Attribute.objects.create(label='Profession', name='profession', user_editable=True, kind='string')
models.Attribute.objects.create(
label='Profession', name='profession', user_visible=True, user_editable=True, kind='string'
)
response = app.get(reverse('registration_register'))
response.form.set('email', 'testbot@entrouvert.com')
@ -398,8 +400,8 @@ def test_attribute_model(app, db, settings, mailoutbox):
assert 'Nom' in response.text
assert 'Doe' in response.text
assert 'Profession' not in response.text
assert 'pompier' not in response.text
assert 'Profession' in response.text
assert 'pompier' in response.text
assert 'Prénom' not in response.text
assert 'John' not in response.text