Repository: wtforms/wtforms (python)

Issue:
HTML `readonly` attribute support
Currently wtforms does not handle the [HTML readonly attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/readonly). One can make a field read-only at rendering with the `render_kw` attribute.

On several projects I have been programmatically setting readonliness to fields, depending on the context, the user permissions etc. In the end I find myself writing the same tests and the same security guards again and again. Before processing the form I need to curate the readonly fields to protect against users sending modified values anyways. I also need to write the associated unit tests. This feels like boilerplate that could be avoided if wtforms would take care of this.

The simpler solution I can think of is to define a `readonly` flag for Fields. The presence of the `readonly` flag would add the HTML `readonly` attribute, and make the `formdata` values to be ignored by [Field.process](https://github.com/wtforms/wtforms/blob/0524993ee57a957c8444df2b090c00464ef9bb2a/src/wtforms/fields/core.py#L292). I did not have played with the code to verify my hypothesis though.

We could have a similar thing for the `disabled` attribute.

@davidism any thoughts?

Related discussions on SO:
https://stackoverflow.com/a/16576294/2180332
https://stackoverflow.com/a/9849156/2180332

Related ticket: #217

Pull request description:
implement a readonly and a disabled validator
Fixes #792 

This adds two validators [readonly](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/readonly) and [disabled](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled).

The validators adds the corresponding flags on the widgets on which this is authorized, according to MDN.

The `readonly` validator raises a `ValidationError` if the formdata is different than the object data for a given form. The `disabled` validator raises a `ValidationError` if the formdata is different than `None`.

Readonly fields are sent by the browser, and disabled ones are not. Either ways those validators prevents the user from cheating and submitting different values that the ones they have been presented.

If someone wants a readonly field but does not want to enforce the immutability, they still can use render_kw.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 649fcf5..b993f04 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -20,6 +20,8 @@ Unreleased
     defaults :pr:`765`
 -   Move to pyproject.toml :pr:`796`
 -   URL validator takes a ``allow_ip`` parameter :pr:`800`
+-   Implement :class:`~validators.ReadOnly` and
+    :class:`~validators.Disabled `:pr:`788`
 
 Version 3.0.1
 -------------
diff --git a/docs/validators.rst b/docs/validators.rst
index 818c018..336bc9f 100644
--- a/docs/validators.rst
+++ b/docs/validators.rst
@@ -73,6 +73,9 @@ Built-in validators
 
 .. autoclass:: wtforms.validators.NoneOf
 
+.. autoclass:: wtforms.validators.ReadOnly
+
+.. autoclass:: wtforms.validators.Disabled
 
 .. _custom-validators:
 
diff --git a/src/wtforms/validators.py b/src/wtforms/validators.py
index 730ba17..59134f2 100644
--- a/src/wtforms/validators.py
+++ b/src/wtforms/validators.py
@@ -33,6 +33,10 @@ __all__ = (
     "UUID",
     "ValidationError",
     "StopValidation",
+    "readonly",
+    "ReadOnly",
+    "disabled",
+    "Disabled",
 )
 
 
@@ -678,6 +682,39 @@ class HostnameValidation:
         return True
 
 
+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 ValidationError(field.gettext("This field cannot be edited"))
+
+
+class Disabled:
+    """
+    Set a field disabled.
+
+    Validation fails if the form data has any value.
+    """
+
+    def __init__(self):
+        self.field_flags = {"disabled": True}
+
+    def __call__(self, form, field):
+        if field.raw_data is not None:
+            raise ValidationError(
+                field.gettext("This field is disabled and cannot have a value")
+            )
+
+
 email = Email
 equal_to = EqualTo
 ip_address = IPAddress
@@ -691,3 +728,5 @@ regexp = Regexp
 url = URL
 any_of = AnyOf
 none_of = NoneOf
+readonly = ReadOnly
+disabled = Disabled
diff --git a/src/wtforms/widgets/core.py b/src/wtforms/widgets/core.py
index 57b547d..345d9c5 100644
--- a/src/wtforms/widgets/core.py
+++ b/src/wtforms/widgets/core.py
@@ -161,7 +161,7 @@ class Input:
     """
 
     html_params = staticmethod(html_params)
-    validation_attrs = ["required"]
+    validation_attrs = ["required", "disabled"]
 
     def __init__(self, input_type=None):
         if input_type is not None:
@@ -185,7 +185,14 @@ class TextInput(Input):
     """
 
     input_type = "text"
-    validation_attrs = ["required", "maxlength", "minlength", "pattern"]
+    validation_attrs = [
+        "required",
+        "disabled",
+        "readonly",
+        "maxlength",
+        "minlength",
+        "pattern",
+    ]
 
 
 class PasswordInput(Input):
@@ -198,7 +205,14 @@ class PasswordInput(Input):
     """
 
     input_type = "password"
-    validation_attrs = ["required", "maxlength", "minlength", "pattern"]
+    validation_attrs = [
+        "required",
+        "disabled",
+        "readonly",
+        "maxlength",
+        "minlength",
+        "pattern",
+    ]
 
     def __init__(self, hide_value=True):
         self.hide_value = hide_value
@@ -259,7 +273,7 @@ class FileInput(Input):
     """
 
     input_type = "file"
-    validation_attrs = ["required", "accept"]
+    validation_attrs = ["required", "disabled", "accept"]
 
     def __init__(self, multiple=False):
         super().__init__()
@@ -297,7 +311,7 @@ class TextArea:
     `rows` and `cols` ought to be passed as keyword args when rendering.
     """
 
-    validation_attrs = ["required", "maxlength", "minlength"]
+    validation_attrs = ["required", "disabled", "readonly", "maxlength", "minlength"]
 
     def __call__(self, field, **kwargs):
         kwargs.setdefault("id", field.id)
@@ -327,7 +341,7 @@ class Select:
     `choices` is a iterable of `(value, label, selected)` tuples.
     """
 
-    validation_attrs = ["required"]
+    validation_attrs = ["required", "disabled"]
 
     def __init__(self, multiple=False):
         self.multiple = multiple
@@ -385,7 +399,14 @@ class SearchInput(Input):
     """
 
     input_type = "search"
-    validation_attrs = ["required", "maxlength", "minlength", "pattern"]
+    validation_attrs = [
+        "required",
+        "disabled",
+        "readonly",
+        "maxlength",
+        "minlength",
+        "pattern",
+    ]
 
 
 class TelInput(Input):
@@ -394,7 +415,14 @@ class TelInput(Input):
     """
 
     input_type = "tel"
-    validation_attrs = ["required", "maxlength", "minlength", "pattern"]
+    validation_attrs = [
+        "required",
+        "disabled",
+        "readonly",
+        "maxlength",
+        "minlength",
+        "pattern",
+    ]
 
 
 class URLInput(Input):
@@ -403,7 +431,14 @@ class URLInput(Input):
     """
 
     input_type = "url"
-    validation_attrs = ["required", "maxlength", "minlength", "pattern"]
+    validation_attrs = [
+        "required",
+        "disabled",
+        "readonly",
+        "maxlength",
+        "minlength",
+        "pattern",
+    ]
 
 
 class EmailInput(Input):
@@ -412,7 +447,14 @@ class EmailInput(Input):
     """
 
     input_type = "email"
-    validation_attrs = ["required", "maxlength", "minlength", "pattern"]
+    validation_attrs = [
+        "required",
+        "disabled",
+        "readonly",
+        "maxlength",
+        "minlength",
+        "pattern",
+    ]
 
 
 class DateTimeInput(Input):
@@ -421,7 +463,7 @@ class DateTimeInput(Input):
     """
 
     input_type = "datetime"
-    validation_attrs = ["required", "max", "min", "step"]
+    validation_attrs = ["required", "disabled", "readonly", "max", "min", "step"]
 
 
 class DateInput(Input):
@@ -430,7 +472,7 @@ class DateInput(Input):
     """
 
     input_type = "date"
-    validation_attrs = ["required", "max", "min", "step"]
+    validation_attrs = ["required", "disabled", "readonly", "max", "min", "step"]
 
 
 class MonthInput(Input):
@@ -439,7 +481,7 @@ class MonthInput(Input):
     """
 
     input_type = "month"
-    validation_attrs = ["required", "max", "min", "step"]
+    validation_attrs = ["required", "disabled", "readonly", "max", "min", "step"]
 
 
 class WeekInput(Input):
@@ -448,7 +490,7 @@ class WeekInput(Input):
     """
 
     input_type = "week"
-    validation_attrs = ["required", "max", "min", "step"]
+    validation_attrs = ["required", "disabled", "readonly", "max", "min", "step"]
 
 
 class TimeInput(Input):
@@ -457,7 +499,7 @@ class TimeInput(Input):
     """
 
     input_type = "time"
-    validation_attrs = ["required", "max", "min", "step"]
+    validation_attrs = ["required", "disabled", "readonly", "max", "min", "step"]
 
 
 class DateTimeLocalInput(Input):
@@ -466,7 +508,7 @@ class DateTimeLocalInput(Input):
     """
 
     input_type = "datetime-local"
-    validation_attrs = ["required", "max", "min", "step"]
+    validation_attrs = ["required", "disabled", "readonly", "max", "min", "step"]
 
 
 class NumberInput(Input):
@@ -475,7 +517,7 @@ class NumberInput(Input):
     """
 
     input_type = "number"
-    validation_attrs = ["required", "max", "min", "step"]
+    validation_attrs = ["required", "disabled", "readonly", "max", "min", "step"]
 
     def __init__(self, step=None, min=None, max=None):
         self.step = step
@@ -498,7 +540,7 @@ class RangeInput(Input):
     """
 
     input_type = "range"
-    validation_attrs = ["required", "max", "min", "step"]
+    validation_attrs = ["required", "disabled", "max", "min", "step"]
 
     def __init__(self, step=None):
         self.step = step


Test patch:
diff --git a/tests/validators/test_disabled.py b/tests/validators/test_disabled.py
new file mode 100644
index 0000000..4ae8e4b
--- /dev/null
+++ b/tests/validators/test_disabled.py
@@ -0,0 +1,21 @@
+from tests.common import DummyPostData
+
+from wtforms import Form
+from wtforms import StringField
+from wtforms.validators import Disabled
+
+
+def test_disabled():
+    class F(Form):
+        disabled = StringField(validators=[Disabled()])
+
+    form = F(disabled="foobar")
+    assert "disabled" in form.disabled.flags
+    assert (
+        form.disabled()
+        == '<input disabled id="disabled" name="disabled" type="text" value="foobar">'
+    )
+    assert form.validate()
+
+    form = F(DummyPostData(disabled=["foobar"]))
+    assert not form.validate()
diff --git a/tests/validators/test_readonly.py b/tests/validators/test_readonly.py
new file mode 100644
index 0000000..b08327b
--- /dev/null
+++ b/tests/validators/test_readonly.py
@@ -0,0 +1,38 @@
+from tests.common import DummyPostData
+
+from wtforms import Form
+from wtforms import StringField
+from wtforms.validators import readonly
+
+
+def test_readonly():
+    class F(Form):
+        ro = StringField(validators=[readonly()])
+
+    form = F(ro="foobar")
+    assert "readonly" in form.ro.flags
+    assert form.ro() == '<input id="ro" name="ro" readonly type="text" value="foobar">'
+    assert form.validate()
+
+    form = F(DummyPostData(ro=["foobar"]), data={"ro": "foobar"})
+    assert form.validate()
+
+    form = F(DummyPostData(ro=["foobar"]), data={"ro": "foobarbaz"})
+    assert not form.validate()
+
+    form = F(DummyPostData(ro=["foobar"]))
+    assert not form.validate()
+
+
+def test_readonly_with_default():
+    class F(Form):
+        ro = StringField(validators=[readonly()], default="foobar")
+
+    form = F(DummyPostData(ro=["foobar"]))
+    assert form.validate()
+
+    form = F(DummyPostData(ro=["foobar"]), data={"ro": "foobarbaz"})
+    assert not form.validate()
+
+    form = F(DummyPostData(ro=["foobarbaz"]), data={"ro": "foobarbaz"})
+    assert form.validate()