From 1352752db8d2a017f0afcf48b205e451f9a123b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Mon, 24 Jul 2023 18:07:35 +0200 Subject: [PATCH] refactor: utilities for form field readonliness --- canaille/app/forms.py | 30 +++++++++++++++++++++++++++++ canaille/core/account.py | 13 ++++++++----- canaille/core/forms.py | 10 +++++++--- tests/app/test_forms.py | 6 +++--- tests/core/test_groups.py | 22 ++++++++++++++++----- tests/core/test_invitation.py | 4 ++-- tests/core/test_profile_settings.py | 8 +++----- 7 files changed, 70 insertions(+), 23 deletions(-) diff --git a/canaille/app/forms.py b/canaille/app/forms.py index af7f8c3e..78b72f0a 100644 --- a/canaille/app/forms.py +++ b/canaille/app/forms.py @@ -215,3 +215,33 @@ class DateTimeUTCField(wtforms.DateTimeLocalField): self.data = None raise ValueError(self.gettext("Not a valid datetime value.")) + + +class ReadOnly: + """ + Set a field readonly. + Validation fails if the form data is different than the + field object data, or if unset, from the field default data. + """ + + def __init__(self): + self.field_flags = {"readonly": True} + + def __call__(self, form, field): + if field.data != field.object_data: + raise wtforms.ValidationError(field.gettext("This field cannot be edited")) + + +def is_readonly(field): + return field.render_kw and "readonly" in field.render_kw + + +def set_readonly(field): + field.render_kw = field.render_kw or {} + field.render_kw["readonly"] = True + field.validators = list(field.validators) + [ReadOnly()] + + +def set_writable(field): + del field.render_kw["readonly"] + field.validators = [v for v in field.validators if not isinstance(v, ReadOnly)] diff --git a/canaille/core/account.py b/canaille/core/account.py index 4b39c0d7..891e0e3a 100644 --- a/canaille/core/account.py +++ b/canaille/core/account.py @@ -17,6 +17,9 @@ from canaille.app.flask import render_htmx_template from canaille.app.flask import request_is_htmx from canaille.app.flask import smtp_needed from canaille.app.flask import user_needed +from canaille.app.forms import is_readonly +from canaille.app.forms import set_readonly +from canaille.app.forms import set_writable from canaille.app.forms import TableForm from canaille.backends import BaseBackend from flask import abort @@ -321,12 +324,12 @@ def registration(data, hash): form["groups"] = wtforms.SelectMultipleField( _("Groups"), choices=[(group.id, group.display_name) for group in models.Group.query()], - render_kw={"readonly": "true"}, ) + set_readonly(form["groups"]) form.process(CombinedMultiDict((request.files, request.form)) or None, data=data) - if "readonly" in form["user_name"].render_kw and invitation.user_name_editable: - del form["user_name"].render_kw["readonly"] + if is_readonly(form["user_name"]) and invitation.user_name_editable: + set_writable(form["user_name"]) form["password1"].validators = [ wtforms.validators.DataRequired(), @@ -371,8 +374,8 @@ def profile_creation(user): form.process(CombinedMultiDict((request.files, request.form)) or None) for field in form: - if field.render_kw and "readonly" in field.render_kw: - del field.render_kw["readonly"] + if is_readonly(field): + set_writable(field) if not request.form or form.form_control(): return render_template( diff --git a/canaille/core/forms.py b/canaille/core/forms.py index c477886b..c0e12ba4 100644 --- a/canaille/core/forms.py +++ b/canaille/core/forms.py @@ -4,6 +4,8 @@ from canaille.app.forms import BaseForm from canaille.app.forms import DateTimeUTCField from canaille.app.forms import Form from canaille.app.forms import is_uri +from canaille.app.forms import ReadOnly +from canaille.app.forms import set_readonly from canaille.app.forms import unique_values from canaille.app.i18n import native_language_name_from_code from flask import current_app @@ -311,8 +313,7 @@ def profile_form(write_field_names, readonly_field_names, user=None): form.user = user for field in form: if field.name in readonly_field_names - write_field_names: - field.render_kw = field.render_kw or {} - field.render_kw["readonly"] = "true" + set_readonly(field) return form @@ -334,7 +335,10 @@ class CreateGroupForm(Form): class EditGroupForm(Form): display_name = wtforms.StringField( _("Name"), - validators=[wtforms.validators.DataRequired()], + validators=[ + wtforms.validators.DataRequired(), + ReadOnly(), + ], render_kw={ "readonly": "true", }, diff --git a/tests/app/test_forms.py b/tests/app/test_forms.py index 71487dff..12e27ba9 100644 --- a/tests/app/test_forms.py +++ b/tests/app/test_forms.py @@ -410,7 +410,7 @@ def test_fieldlist_add_readonly(testclient, logged_user, configuration): configuration["ACL"]["DEFAULT"]["READ"].append("phone_numbers") res = testclient.get("/profile/user") - assert res.form["phone_numbers-0"].attrs["readonly"] + assert "readonly" in res.form["phone_numbers-0"].attrs assert "phone_numbers-1" not in res.form.fields data = { @@ -429,8 +429,8 @@ def test_fieldlist_remove_readonly(testclient, logged_user, configuration): logged_user.save() res = testclient.get("/profile/user") - assert res.form["phone_numbers-0"].attrs["readonly"] - assert res.form["phone_numbers-1"].attrs["readonly"] + assert "readonly" in res.form["phone_numbers-0"].attrs + assert "readonly" in res.form["phone_numbers-1"].attrs data = { "csrf_token": res.form["csrf_token"].value, diff --git a/tests/core/test_groups.py b/tests/core/test_groups.py index c0f6253c..bdaeca1e 100644 --- a/tests/core/test_groups.py +++ b/tests/core/test_groups.py @@ -165,15 +165,27 @@ def test_moderator_can_create_edit_and_delete_group( form["display_name"] = "bar2" form["description"] = ["yolo2"] - res = form.submit(name="action", value="edit").follow() + res = form.submit(name="action", value="edit") + assert res.flashes == [("error", "Group edition failed.")] + res.mustcontain("This field cannot be edited") + + bar_group = models.Group.get(display_name="bar") + assert bar_group.display_name == "bar" + assert bar_group.description == ["yolo"] + assert models.Group.get(display_name="bar2") is None + + # Group description can be edited + res = testclient.get("/groups/bar", status=200) + form = res.forms["editgroupform"] + form["description"] = ["yolo2"] + + res = form.submit(name="action", value="edit") + assert res.flashes == [("success", "The group bar has been sucessfully edited.")] + res = res.follow() bar_group = models.Group.get(display_name="bar") assert bar_group.display_name == "bar" assert bar_group.description == ["yolo2"] - assert models.Group.get(display_name="bar2") is None - members = bar_group.members - for member in members: - res.mustcontain(member.formatted_name[0]) # Group is deleted res = res.forms["editgroupform"].submit(name="action", value="confirm-delete") diff --git a/tests/core/test_invitation.py b/tests/core/test_invitation.py index 94e7eabf..31913d0f 100644 --- a/tests/core/test_invitation.py +++ b/tests/core/test_invitation.py @@ -25,7 +25,7 @@ def test_invitation(testclient, logged_admin, foo_group, smtpd): res = testclient.get(url, status=200) assert res.form["user_name"].value == "someone" - assert res.form["user_name"].attrs["readonly"] + assert "readonly" in res.form["user_name"].attrs assert res.form["emails-0"].value == "someone@domain.tld" assert res.form["groups"].value == [foo_group.id] @@ -303,7 +303,7 @@ def test_groups_are_saved_even_when_user_does_not_have_read_permission( res = testclient.get(f"/register/{b64}/{hash}", status=200) assert res.form["groups"].value == [foo_group.id] - assert res.form["groups"].attrs["readonly"] + assert "readonly" in res.form["groups"].attrs res.form["password1"] = "whatever" res.form["password2"] = "whatever" diff --git a/tests/core/test_profile_settings.py b/tests/core/test_profile_settings.py index 693d5094..eb5ee744 100644 --- a/tests/core/test_profile_settings.py +++ b/tests/core/test_profile_settings.py @@ -19,14 +19,12 @@ def test_edition( assert logged_user.groups == [foo_group] assert foo_group.members == [logged_user] assert bar_group.members == [admin] - assert res.form["groups"].attrs["readonly"] - assert res.form["user_name"].attrs["readonly"] + assert "readonly" in res.form["groups"].attrs + assert "readonly" in res.form["user_name"].attrs res.form["user_name"] = "toto" res = res.form.submit(name="action", value="edit") - assert res.flashes == [("success", "Profile updated successfully.")] - res = res.follow() - + assert res.flashes == [("error", "Profile edition failed.")] logged_user.reload() assert logged_user.user_name == ["user"]