Skip to content

Commit

Permalink
Form may have get and set methods for fields
Browse files Browse the repository at this point in the history
  • Loading branch information
jace committed Sep 21, 2020
1 parent 192a850 commit b57f5c2
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 18 deletions.
73 changes: 69 additions & 4 deletions baseframe/forms/form.py
Expand Up @@ -128,7 +128,8 @@ def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if {'edit_obj', 'edit_model', 'edit_parent', 'edit_id'} & set(cls.__expects__):
raise TypeError(
"This form has __expects__ parameters that are reserved by the base form"
"This form has __expects__ parameters that are reserved by the base"
" form"
)

if set(cls.__dict__.keys()) & set(cls.__expects__):
Expand Down Expand Up @@ -160,6 +161,69 @@ def __init__(self, *args, **kwargs):
self.edit_id = None
self.set_queries()

def populate_obj(self, obj):
"""
Populates the attributes of the passed `obj` with data from the form's
fields.
If the form has a ``set_<fieldname>`` method, it will be called with the object
in place of the field's ``populate_obj`` method. The custom method is then
responsible for populating the object with that field's value.
This method overrides the default implementation in WTForms to support custom
set methods.
"""
for name, field in iteritems(self._fields):
if hasattr(self, 'set_' + name):
getattr(self, 'set_' + name)(obj)
else:
field.populate_obj(obj, name)

def process(self, formdata=None, obj=None, data=None, **kwargs):
"""
Take form, object data, and keyword arg input and have the fields
process them.
:param formdata:
Used to pass data coming from the enduser, usually `request.POST` or
equivalent.
:param obj:
If `formdata` is empty or not provided, this object is checked for
attributes matching form field names, which will be used for field
values. If the form has a ``get_<fieldname>`` method, it will be called
with the object as an attribute and is expected to return the value
:param data:
If provided, must be a dictionary of data. This is only used if
`formdata` is empty or not provided and `obj` does not contain
an attribute named the same as the field.
:param `**kwargs`:
If `formdata` is empty or not provided and `obj` does not contain
an attribute named the same as a field, form will assign the value
of a matching keyword argument to the field, if one exists.
This method overrides the default implementation in WTForms to support custom
load methods.
"""
formdata = self.meta.wrap_formdata(self, formdata)

if data is not None:
# Preserved comment from WTForms source:
# XXX we want to eventually process 'data' as a new entity.
# Temporarily, this can simply be merged with kwargs.
kwargs = dict(data, **kwargs)

for (name, field) in iteritems(self._fields):
# This `if` condition is the only change from the WTForms source. It must be
# synced with the `process` method in future WTForms releases.
if obj is not None and hasattr(self, 'get_' + name):
field.process(formdata, getattr(self, 'get_' + name)(obj))
elif obj is not None and hasattr(obj, name):
field.process(formdata, getattr(obj, name))
elif name in kwargs:
field.process(formdata, kwargs[name])
else:
field.process(formdata)

def validate(self, send_signals=True):
success = super(Form, self).validate()
for attr in self.__returns__:
Expand All @@ -176,8 +240,8 @@ def send_signals(self):
form_validation_success.send(self)

def errors_with_data(self):
# Convert lazy_gettext error strings into unicode so they don't cause problems downstream
# (like when pickling)
# Convert lazy_gettext error strings into unicode so they don't cause problems
# downstream (like when pickling)
return {
name: {
'data': f.data,
Expand Down Expand Up @@ -268,7 +332,8 @@ class DynamicForm(Form):
itemparams[paramname] = item[paramname]
filters.append(filter_registry[itemname][0](**itemparams))

# TODO: Also validate the parameters in fielddata, like with validators above
# TODO: Also validate the parameters in fielddata, like with validators
# above
setattr(
DynamicForm,
name,
Expand Down
28 changes: 28 additions & 0 deletions pyproject.toml
Expand Up @@ -23,3 +23,31 @@ exclude = '''
| baseframe/static
)/
'''

[tool.isort]
multi_line_output = 3
include_trailing_comma = true
line_length = 88
order_by_type = true
use_parentheses = true
from_first = true
known_future_library = ['__future__', 'six', 'future']
known_first_party = ['baseframe', 'coaster', 'flask_lastuser']
known_sqlalchemy = ['alembic', 'sqlalchemy', 'sqlalchemy_utils', 'flask_sqlalchemy', 'psycopg2']
known_flask = [
'flask',
'werkzeug',
'itsdangerous',
'speaklater',
'wtforms',
'webassets',
'flask_assets',
'flask_babelhg',
'flask_flatpages',
'flask_mail',
'flask_migrate',
'flask_rq2',
'flask_wtf',
]
default_section = 'THIRDPARTY'
sections = ['FUTURE', 'STDLIB', 'SQLALCHEMY', 'FLASK', 'THIRDPARTY', 'FIRSTPARTY', 'LOCALFOLDER']
14 changes: 0 additions & 14 deletions setup.cfg
Expand Up @@ -14,20 +14,6 @@ max-line-length = 88
[pydocstyle]
ignore = D100, D101, D102, D103, D104, D107

[isort]
multi_line_output = 3
include_trailing_comma = true
line_length = 88
order_by_type = true
use_parentheses = true
from_first = true
known_future_library = six, future
known_first_party = baseframe, coaster, flask-lastuser
known_sqlalchemy = alembic, sqlalchemy, sqlalchemy_utils, flask_sqlalchemy, psycopg2
known_flask = flask, werkzeug, itsdangerous, speaklater, wtforms, webassets, flask_assets, flask_flatpages, flask_mail, flask_migrate, flask_rq2, flask_babelhg, flask_wtf
default_section = THIRDPARTY
sections = FUTURE, STDLIB, SQLALCHEMY, FLASK, THIRDPARTY, FIRSTPARTY, LOCALFOLDER

# Bandit config for flake8-bandit. There's another copy in .pre-commit-config.yaml
[bandit]
exclude = tests, migrations, instance
13 changes: 13 additions & 0 deletions tests/conftest.py
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
import pytest

from .fixtures import app1 as app


@pytest.fixture(scope='module')
def test_client():
client = app.test_client()
ctx = app.app_context()
ctx.push()
yield client
ctx.pop()
150 changes: 150 additions & 0 deletions tests/test_form.py
@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-

from __future__ import unicode_literals

from werkzeug.datastructures import MultiDict

import pytest

import baseframe.forms as forms

# Fake password hasher, only suitable for re-use within a single process
password_hash = hash


class SimpleUser:
# Fields:
# fullname: Name of the user
# company: Name of user's company
# pw_hash: User's hashed password

def _set_password(self, value):
self.pw_hash = password_hash(value)

password = property(fset=_set_password)

def __init__(self, fullname, company, password):
self.fullname = fullname
self.company = company
self.password = password

def password_is(self, candidate):
return self.pw_hash == password_hash(candidate)


class GetSetForm(forms.Form):
firstname = forms.StringField("First name")
lastname = forms.StringField("Last name")
company = forms.StringField("Company") # Test for NOT having get_/set_company
password = forms.PasswordField("Password")
confirm_password = forms.PasswordField("Confirm password")

def get_firstname(self, obj):
return obj.fullname.split(' ', 1)[0]

def get_lastname(self, obj):
parts = obj.fullname.split(' ', 1)
if len(parts) > 1:
return parts[-1]
return ''

def get_password(self, obj):
return ''

def get_confirm_password(self, obj):
return ''

def set_firstname(self, obj):
pass

def set_lastname(self, obj):
obj.fullname = self.firstname.data + " " + self.lastname.data

def set_password(self, obj):
obj.password = self.password.data

def set_confirm_password(self, obj):
pass


@pytest.fixture
def user():
return SimpleUser(fullname="Test user", company="Test company", password="test")


def test_no_obj(test_client):
"""Test that the form can be initialized without an object."""
form = GetSetForm(meta={'csrf': False})

# Confirm form is bkank
assert form.firstname.data is None
assert form.lastname.data is None
assert form.company.data is None
assert form.password.data is None
assert form.confirm_password.data is None


def test_get(test_client, user):
"""Test that the form loads values from the provided object."""
form = GetSetForm(obj=user, meta={'csrf': False})

# Confirm form loaded from user object
assert form.firstname.data == "Test"
assert form.lastname.data == "user"
assert form.company.data == "Test company"
assert form.password.data == ''
assert form.confirm_password.data == ''


def test_get_formdata(test_client, user):
"""Test that the form preferentially loads from form data."""
form = GetSetForm(
formdata=MultiDict(
{
'firstname': "Ffirst",
'lastname': "Flast",
'company': "Form company",
'password': "Test123",
'confirm_password': "Mismatched",
}
),
obj=user,
meta={'csrf': False},
)

# Confirm form loaded from formdata instead of user object
assert form.firstname.data == "Ffirst"
assert form.lastname.data == "Flast"
assert form.company.data == "Form company"
assert form.password.data == "Test123"
assert form.confirm_password.data == "Mismatched"


def test_set(test_client, user):
"""Test that the form populates an object with or without set methods."""
form = GetSetForm(
formdata=MultiDict(
{
'firstname': "Ffirst",
'lastname': "Flast",
'company': "Form company",
'password': "Test123",
'confirm_password': "Mismatched",
}
),
obj=user,
meta={'csrf': False},
)

# Check user object before and after populating
assert user.fullname == "Test user"
assert user.company == "Test company"
assert user.password_is("test")

form.populate_obj(user)

assert user.fullname == "Ffirst Flast"
assert user.company == "Form company"
assert not user.password_is("test")
assert user.password_is("Test123")
assert not hasattr(user, 'confirm_password')

0 comments on commit b57f5c2

Please sign in to comment.