Disabled field is considered for validation in WTForms and Flask

I defined my own validator for this problem:

from wtforms.validators import Optional

class OptionalIfDisabled(Optional):

    def __call__(self, form, field):
        if field.render_kw is not None and field.render_kw.get('disabled', False):
            field.flags.disabled = True
            super(OptionalIfDisabled, self).__call__(form, field)

And then I defined a new base for my forms:

from wtforms.form import Form

class BaseForm(Form):

    def populate_obj(self, obj):
        for name, field in self._fields.items():
            if not field.flags.disabled:
                field.populate_obj(obj, name)

Now every form can extend the BaseForm and disable fields like this:

from wtforms.fields import StringField, SubmitField

class TeamForm(BaseForm):
    team = StringField(label='Team Name', 
                       validators=[OptionalIfDisabled(), InputRequired()]
    submit = SubmitField(label='Submit')

    def __init__(self, *args, **kwargs):
        super(TeamForm, self).__init__(*args, **kwargs)
        # disable the fields if you want to
        if some_condition:
            self.team.render_kw = {'disabled': True}

After validation of the TeamForm, you can use populate_obj to copy the enabled form data in any object. It will ignore the disabled fields.


  1. Create a custom validator
from wtforms.validators import Optional
class DisabledValidator(Optional):
    """
    do nothing
    """
    pass
  1. Let's create a custom rule basing on the form.rule
from flask_admin.form.rules import Field
class EasyCustomFieldRule(Field):
    def __init__(self, field_name, render_field='lib.render_field', field_args={}):
        super(self.__class__, self).__init__(field_name, render_field)
        self.extra_field_args = field_args

    def __call__(self, form, form_opts=None, field_args={}):
        field = getattr(form, self.field_name)
        if self.extra_field_args.get('disabled'):
            field.validators.append(DisabledValidator())

        field_args.update(self.extra_field_args)
        return super(self.__class__, self).__call__(form, form_opts, field_args)
  1. Override write some functions of wtforms.form
from wtforms.form import Form
from wtforms.compat import iteritems
class BaseForm(Form):
    """
    重写部分方法,以适应disabled的Field
    """

    def validate(self):
        """
        Validates the form by calling `validate` on each field, passing any
        extra `Form.validate_<fieldname>` validators to the field validator.
        """
        extra = {}
        for name in self._fields:
            inline = getattr(self.__class__, 'validate_%s' % name, None)
            if inline is not None:
                extra[name] = [inline]

        return self.validate_(extra)

    def validate_(self, extra_validators=None):
        self._errors = None
        success = True
        for name, field in iteritems(self._fields):
            is_disabled = False
            for v in field.validators:
                if isinstance(v, DisabledValidator):
                    field.flags.disabled = True
                    is_disabled = True
                    break
            if is_disabled:
                continue

            if extra_validators is not None and name in extra_validators:
                extra = extra_validators[name]
            else:
                extra = tuple()
            if not field.validate(self, extra):
                success = False
        return success

    def populate_obj(self, obj):
        for name, field in self._fields.items():
            if not field.flags.disabled:
                field.populate_obj(obj, name)
  1. set the form_base_class in your ModelView, and set the form_edit_rules or the form_create_rules with EasyCustomFieldRule
from flask_admin.contrib.sqla import ModelView
class MyTestModelView(ModelView):
    ...
    form_base_class = BaseForm
    ...

    form_edit_rules = (
        EasyCustomFieldRule('column0', field_args={'disabled': True}),
        'column1', 'column2'
    )
  1. Just testing...

This is actually an interesting problem, and the way WTForms solves it is intentionally something that requires explicitness, because it has to do with security and not allowing users to fake input.

So the intent is, that "managers" cannot edit the name, while "admins" can.

At first glance this seems obvious, just disable the field in HTML, and write your view like this:

def edit_team():
    form = TeamForm(request.POST, obj=team)
    if request.POST and form.validate():
        form.populate_obj(team) # <-- This is the dangerous part here
        return redirect('/teams')
    return render('edit_team.html')

As written, this is a major security risk, because the disabled property in HTML forms is client-side only. Anyone with an HTML inspector (ie FireBug, webkit document inspector, etc) can remove this property, or someone could simply make a request like so:

POST /edit_team/7 HTTP/1.0
Content-Type: application/x-urlencoded

team=EVILTEAMNAME&title=foo

The issue then is of course, how do we gate this properly on the server-side, corresponding to the appropriate way of doing this? The correct approach with WTForms is to not have the field in the first place. There's a few ways to do this, one is to use form composition and have e.g. ManagerTeamForm and AdminTeamForm (sometimes this is better) but other times it's easier to use del to remove specific fields.

So here's how you would write your view, and not have the validation issues:

def edit_team():
    form = TeamForm(request.POST, obj=team)
    if user.role == 'manager':
        del form.name
    if request.POST and form.validate():
        form.populate_obj(team)
        return redirect('/teams')
    return render('edit_team.html')

And a quick modification to the template:

<html>
<body>
<form action="" method=POST>
    {% if 'name' in form %}
        {{ form.name() }}
    {% else %}
        {{ team.name|e }}
    {% endif %}
    {{ form.title }}
    -- submit button --
</form>
</body>
</html>

Some pieces of reference for wtforms best-practices:

  • WTForms 'Solving Specific Problems'
  • Dangers of Using forms as a backing store (WTForms google group) Post 1 / Post 2
  • StackOverflow: WTForms 'readonly' attribute

You need to make the name field optional when defining the form.

name = wtf.TextField("Team Name", validators=[validators.Optional()])

Then in your views, pass a variable called "role" and set it to either manager or admin depending on the user.

<form action="" method=POST>
{% if role == 'manager' % }
    {{ form.name(disabled=True) }}
{% else % }
    {{ form.name() }}
{{ form.title }}
-- submit button --
</form>