Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Forms may have get and set methods for fields (resolves #321) #322

Merged
merged 1 commit into from Sep 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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')