diff --git a/.gitignore b/.gitignore index f2a1064..b50735d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ dist/ docs/_build docs/html env/ +.vscode/ diff --git a/.travis.yml b/.travis.yml index 4e436b0..b78ed65 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,20 +1,48 @@ language: python python: - - 2.7 + - 3.6 + - 3.7 + - 3.8 + +before_install: + - python -V # Print out python version for debugging + - pip install virtualenv + - virtualenv ${HOME}/venv + - source ${HOME}/venv/bin/activate + + # Install CA certs, openssl to https downloads, python for gcloud sdk + - sudo apt-get install make ca-certificates openssl python + - sudo update-ca-certificates + + # Install Java + - sudo wget -qO - https://adoptopenjdk.jfrog.io/adoptopenjdk/api/gpg/key/public | sudo apt-key add - + - sudo apt-get update + - sudo apt-get install -y software-properties-common + - sudo add-apt-repository --yes https://adoptopenjdk.jfrog.io/adoptopenjdk/deb/ + - sudo apt-get -q update + - sudo apt-get install -y adoptopenjdk-8-hotspot + + # Download the latest version of the GAE Python SDK + - curl https://sdk.cloud.google.com > install.sh + - bash install.sh --disable-prompts --install-dir=${HOME} + - source ${HOME}/google-cloud-sdk/path.bash.inc + - gcloud --quiet components update + - gcloud --quiet components install cloud-datastore-emulator + - gcloud --quiet components install beta + - gcloud --quiet components install appctl + - gcloud --quiet components install cloud-build-local + - gcloud --quiet components install app-engine-python + - gcloud --quiet components install app-engine-python-extras # command to install dependencies install: - - "pip install ." - "pip install -r tests/requirements.txt" - # Download the latest version of the GAE Python SDK - - "wget https://storage.googleapis.com/appengine-sdks/featured/google_appengine_1.9.30.zip -nv -O downloaded_appengine.zip" - - "unzip -q downloaded_appengine.zip" # This is a quick hack, necessary to get fancy_urllib working, # by exposing the lib/fancy_urllib/fancy_urllib/ dirctory within for use by appengine. - "rm -rf google_appengine/lib/fancy_urllib/__init__.py" # command to run tests -script: "coverage run --source=wtforms_appengine `which nosetests` -w tests --with-gae --gae-lib-root=google_appengine" +script: "pytest --cov wtforms_appengine tests" after_success: - pip install coveralls diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4819c8b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +dsemu + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..85ae6ba --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +WTForms>=2.0 +google-cloud-ndb>=1.7.1 diff --git a/setup.py b/setup.py index 82c9ab2..1262bf1 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,14 @@ from codecs import open from setuptools import setup, find_packages +dependencies = [ + 'WTForms>=1.0.5', + 'google-cloud-ndb>=1.7.1' +] + setup( name='WTForms-Appengine', - version='0.1', + version='0.2.1', url='http://github.com/wtforms/wtforms-appengine/', license='BSD', author='Thomas Johansson, James Crasta', @@ -17,8 +22,6 @@ 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 3.3', @@ -28,9 +31,8 @@ packages=find_packages(), package_data={ }, - install_requires=['WTForms>=1.0.5'], - extras_require={ - }, + install_requires=dependencies, + extras_require={}, test_suite='nose.collector', tests_require=['nose'], ) diff --git a/tests/app.yaml b/tests/app.yaml index 7b22498..89963e9 100644 --- a/tests/app.yaml +++ b/tests/app.yaml @@ -1,6 +1,6 @@ application: my-app version: 1 -runtime: python27 +runtime: python37 api_version: 1 threadsafe: no diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6ab273e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,26 @@ +"""py.test shared testing configuration. +This defines fixtures (expected to be) shared across different test +modules. +""" + +import pytest +from dsemu import Emulator +from google.cloud import ndb + + +@pytest.fixture(scope="session") +def emulator(): + with Emulator() as emulator: + yield emulator + + +@pytest.fixture(scope="session") +def session_client(): + client = ndb.Client(project="test") + yield client + + +@pytest.fixture() +def client(emulator: Emulator, session_client: ndb.Client): + emulator.reset() + yield session_client diff --git a/tests/gaetest_common.py b/tests/gaetest_common.py index 0aafa9e..9c72bc1 100644 --- a/tests/gaetest_common.py +++ b/tests/gaetest_common.py @@ -11,11 +11,6 @@ if BASE_DIR not in sys.path: sys.path.insert(0, BASE_DIR) -from unittest import TestCase - -from google.appengine.ext import ndb, testbed -from google.appengine.datastore import datastore_stub_util - SAMPLE_AUTHORS = ( ('Bob', 'Boston'), @@ -45,34 +40,3 @@ def fill_authors(Author): authors.append(author) AGE_BASE += 1 return authors - - -class NDBTestCase(TestCase): - def setUp(self): - self.testbed = testbed.Testbed() - self.testbed.activate() - - policy = datastore_stub_util.PseudoRandomHRConsistencyPolicy( - probability=1) - self.testbed.init_datastore_v3_stub(consistency_policy=policy) - - ctx = ndb.get_context() - ctx.set_cache_policy(False) - ctx.set_memcache_policy(False) - - def tearDown(self): - self.testbed.deactivate() - - - -class DBTestCase(TestCase): - def setUp(self): - self.testbed = testbed.Testbed() - self.testbed.activate() - - policy = datastore_stub_util.PseudoRandomHRConsistencyPolicy( - probability=1) - self.testbed.init_datastore_v3_stub(consistency_policy=policy) - - def tearDown(self): - self.testbed.deactivate() diff --git a/tests/requirements.txt b/tests/requirements.txt index a207fe4..746290d 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,4 +1,4 @@ -coverage -nose -WTForms>=1.0.5 -nosegae +-r ../requirements.txt +dsemu +pytest +pytest-cov diff --git a/tests/second_ndb_module.py b/tests/second_ndb_module.py index 1068672..197556d 100644 --- a/tests/second_ndb_module.py +++ b/tests/second_ndb_module.py @@ -1,4 +1,5 @@ -from google.appengine.ext import ndb +from google.cloud import ndb + class SecondBook(ndb.Model): author = ndb.KeyProperty(kind='Author') diff --git a/tests/test_db.py b/tests/test_db.py deleted file mode 100644 index 950e38b..0000000 --- a/tests/test_db.py +++ /dev/null @@ -1,341 +0,0 @@ -#!/usr/bin/env python -""" -Unittests for wtforms_appengine - -To run the tests, use NoseGAE: - -pip install nose nosegae - -nosetests --with-gae --without-sandbox -""" -from __future__ import unicode_literals - -# This needs to stay as the first import, it sets up paths. -from gaetest_common import DummyPostData, fill_authors, DBTestCase - -from unittest import TestCase - -from google.appengine.ext import db - -from wtforms import Form, fields as f, validators -from wtforms_appengine.db import model_form -from wtforms_appengine.fields import ( - GeoPtPropertyField, ReferencePropertyField, - StringListPropertyField, # IntegerListPropertyField -) - - -class Author(db.Model): - name = db.StringProperty(required=True) - city = db.StringProperty() - age = db.IntegerProperty(required=True) - is_admin = db.BooleanProperty(default=False) - - -class Book(db.Model): - author = db.ReferenceProperty(Author) - - -class AllPropertiesModel(db.Model): - """Property names are ugly, yes.""" - prop_string = db.StringProperty() - prop_byte_string = db.ByteStringProperty() - prop_boolean = db.BooleanProperty() - prop_integer = db.IntegerProperty() - prop_float = db.FloatProperty() - prop_date_time = db.DateTimeProperty() - prop_date = db.DateProperty() - prop_time = db.TimeProperty() - prop_list = db.ListProperty(int) - prop_string_list = db.StringListProperty() - prop_reference = db.ReferenceProperty() - prop_self_refeference = db.SelfReferenceProperty() - prop_user = db.UserProperty() - prop_blob = db.BlobProperty() - prop_text = db.TextProperty() - prop_category = db.CategoryProperty() - prop_link = db.LinkProperty() - prop_email = db.EmailProperty() - prop_geo_pt = db.GeoPtProperty() - prop_im = db.IMProperty() - prop_phone_number = db.PhoneNumberProperty() - prop_postal_address = db.PostalAddressProperty() - prop_rating = db.RatingProperty() - - -class DateTimeModel(db.Model): - prop_date_time_1 = db.DateTimeProperty() - prop_date_time_2 = db.DateTimeProperty(auto_now=True) - prop_date_time_3 = db.DateTimeProperty(auto_now_add=True) - - prop_date_1 = db.DateProperty() - prop_date_2 = db.DateProperty(auto_now=True) - prop_date_3 = db.DateProperty(auto_now_add=True) - - prop_time_1 = db.TimeProperty() - prop_time_2 = db.TimeProperty(auto_now=True) - prop_time_3 = db.TimeProperty(auto_now_add=True) - - -class TestModelForm(DBTestCase): - nosegae_datastore_v3 = True - - def test_model_form_basic(self): - form_class = model_form(Author) - - self.assertEqual(hasattr(form_class, 'name'), True) - self.assertEqual(hasattr(form_class, 'age'), True) - self.assertEqual(hasattr(form_class, 'city'), True) - self.assertEqual(hasattr(form_class, 'is_admin'), True) - - form = form_class() - self.assertEqual(isinstance(form.name, f.TextField), True) - self.assertEqual(isinstance(form.city, f.TextField), True) - self.assertEqual(isinstance(form.age, f.IntegerField), True) - self.assertEqual(isinstance(form.is_admin, f.BooleanField), True) - - def test_required_field(self): - form_class = model_form(Author) - - form = form_class() - self.assertEqual(form.name.flags.required, True) - self.assertEqual(form.city.flags.required, False) - self.assertEqual(form.age.flags.required, True) - self.assertEqual(form.is_admin.flags.required, False) - - def test_default_value(self): - form_class = model_form(Author) - - form = form_class() - self.assertEqual(form.name.default, None) - self.assertEqual(form.city.default, None) - self.assertEqual(form.age.default, None) - self.assertEqual(form.is_admin.default, False) - - def test_model_form_only(self): - form_class = model_form(Author, only=['name', 'age']) - - self.assertEqual(hasattr(form_class, 'name'), True) - self.assertEqual(hasattr(form_class, 'city'), False) - self.assertEqual(hasattr(form_class, 'age'), True) - self.assertEqual(hasattr(form_class, 'is_admin'), False) - - form = form_class() - self.assertEqual(isinstance(form.name, f.TextField), True) - self.assertEqual(isinstance(form.age, f.IntegerField), True) - - def test_model_form_exclude(self): - form_class = model_form(Author, exclude=['is_admin']) - - self.assertEqual(hasattr(form_class, 'name'), True) - self.assertEqual(hasattr(form_class, 'city'), True) - self.assertEqual(hasattr(form_class, 'age'), True) - self.assertEqual(hasattr(form_class, 'is_admin'), False) - - form = form_class() - self.assertEqual(isinstance(form.name, f.TextField), True) - self.assertEqual(isinstance(form.city, f.TextField), True) - self.assertEqual(isinstance(form.age, f.IntegerField), True) - - def test_datetime_model(self): - """Fields marked as auto_add / auto_add_now should not be included.""" - form_class = model_form(DateTimeModel) - - self.assertEqual(hasattr(form_class, 'prop_date_time_1'), True) - self.assertEqual(hasattr(form_class, 'prop_date_time_2'), False) - self.assertEqual(hasattr(form_class, 'prop_date_time_3'), False) - - self.assertEqual(hasattr(form_class, 'prop_date_1'), True) - self.assertEqual(hasattr(form_class, 'prop_date_2'), False) - self.assertEqual(hasattr(form_class, 'prop_date_3'), False) - - self.assertEqual(hasattr(form_class, 'prop_time_1'), True) - self.assertEqual(hasattr(form_class, 'prop_time_2'), False) - self.assertEqual(hasattr(form_class, 'prop_time_3'), False) - - def test_not_implemented_properties(self): - # This should not raise NotImplementedError. - form_class = model_form(AllPropertiesModel) - - # These should be set. - self.assertEqual(hasattr(form_class, 'prop_string'), True) - self.assertEqual(hasattr(form_class, 'prop_byte_string'), True) - self.assertEqual(hasattr(form_class, 'prop_boolean'), True) - self.assertEqual(hasattr(form_class, 'prop_integer'), True) - self.assertEqual(hasattr(form_class, 'prop_float'), True) - self.assertEqual(hasattr(form_class, 'prop_date_time'), True) - self.assertEqual(hasattr(form_class, 'prop_date'), True) - self.assertEqual(hasattr(form_class, 'prop_time'), True) - self.assertEqual(hasattr(form_class, 'prop_string_list'), True) - self.assertEqual(hasattr(form_class, 'prop_reference'), True) - self.assertEqual(hasattr(form_class, 'prop_self_refeference'), True) - self.assertEqual(hasattr(form_class, 'prop_blob'), True) - self.assertEqual(hasattr(form_class, 'prop_text'), True) - self.assertEqual(hasattr(form_class, 'prop_category'), True) - self.assertEqual(hasattr(form_class, 'prop_link'), True) - self.assertEqual(hasattr(form_class, 'prop_email'), True) - self.assertEqual(hasattr(form_class, 'prop_geo_pt'), True) - self.assertEqual(hasattr(form_class, 'prop_phone_number'), True) - self.assertEqual(hasattr(form_class, 'prop_postal_address'), True) - self.assertEqual(hasattr(form_class, 'prop_rating'), True) - - # These should NOT be set. - self.assertEqual(hasattr(form_class, 'prop_list'), False) - self.assertEqual(hasattr(form_class, 'prop_user'), False) - self.assertEqual(hasattr(form_class, 'prop_im'), False) - - def test_populate_form(self): - entity = Author( - key_name='test', - name='John', - city='Yukon', - age=25, - is_admin=True) - entity.put() - - obj = Author.get_by_key_name('test') - form_class = model_form(Author) - - form = form_class(obj=obj) - self.assertEqual(form.name.data, 'John') - self.assertEqual(form.city.data, 'Yukon') - self.assertEqual(form.age.data, 25) - self.assertEqual(form.is_admin.data, True) - - def test_field_attributes(self): - form_class = model_form(Author, field_args={ - 'name': { - 'label': 'Full name', - 'description': 'Your name', - }, - 'age': { - 'label': 'Age', - 'validators': [validators.NumberRange(min=14, max=99)], - }, - 'city': { - 'label': 'City', - 'description': 'The city in which you live, not the one in' - ' which you were born.', - }, - 'is_admin': { - 'label': 'Administrative rights', - }, - }) - form = form_class() - - self.assertEqual(form.name.label.text, 'Full name') - self.assertEqual(form.name.description, 'Your name') - - self.assertEqual(form.age.label.text, 'Age') - - self.assertEqual(form.city.label.text, 'City') - self.assertEqual( - form.city.description, - 'The city in which you live, not the one in which you were born.') - - self.assertEqual(form.is_admin.label.text, 'Administrative rights') - - def test_reference_property(self): - keys = set(['__None']) - for name in ['foo', 'bar', 'baz']: - author = Author(name=name, age=26) - author.put() - keys.add(str(author.key())) - - form_class = model_form(Book) - form = form_class() - - for key, name, value in form.author.iter_choices(): - assert key in keys - keys.remove(key) - - assert not keys - - -class TestGeoFields(TestCase): - class GeoTestForm(Form): - geo = GeoPtPropertyField() - - def test_geopt_property(self): - form = self.GeoTestForm(DummyPostData(geo='5.0, -7.0')) - self.assertTrue(form.validate()) - self.assertEqual(form.geo.data, '5.0,-7.0') - form = self.GeoTestForm(DummyPostData(geo='5.0,-f')) - self.assertFalse(form.validate()) - - -class TestReferencePropertyField(DBTestCase): - nosegae_datastore_v3 = True - - def build_form(self, reference_class=Author, **kw): - class BookForm(Form): - author = ReferencePropertyField( - reference_class=reference_class, - **kw) - return BookForm - - def author_expected(self, selected_index, get_label=lambda x: x.name): - expected = set() - for i, author in enumerate(self.authors): - expected.add((str(author.key()), - get_label(author), - i == selected_index)) - return expected - - def setUp(self): - super(TestReferencePropertyField, self).setUp() - - self.authors = fill_authors(Author) - self.author_names = set(x.name for x in self.authors) - self.author_ages = set(x.age for x in self.authors) - - def test_basic(self): - F = self.build_form( - get_label='name' - ) - form = F() - self.assertEqual( - set(form.author.iter_choices()), - self.author_expected(None)) - assert not form.validate() - - form = F(DummyPostData(author=str(self.authors[0].key()))) - assert form.validate() - self.assertEqual( - set(form.author.iter_choices()), - self.author_expected(0)) - - def test_not_in_query(self): - F = self.build_form() - new_author = Author(name='Jim', age=48) - new_author.put() - form = F(author=new_author) - form.author.query = Author.all().filter('name !=', 'Jim') - assert form.author.data is new_author - assert not form.validate() - - def test_get_label_func(self): - get_age = lambda x: x.age - F = self.build_form(get_label=get_age) - form = F() - ages = set(x.label.text for x in form.author) - self.assertEqual(ages, self.author_ages) - - def test_allow_blank(self): - F = self.build_form(allow_blank=True, get_label='name') - form = F(DummyPostData(author='__None')) - assert form.validate() - self.assertEqual(form.author.data, None) - expected = self.author_expected(None) - expected.add(('__None', '', True)) - self.assertEqual(set(form.author.iter_choices()), expected) - - -class TestStringListPropertyField(TestCase): - class F(Form): - a = StringListPropertyField() - - def test_basic(self): - form = self.F(DummyPostData(a='foo\nbar\nbaz')) - self.assertEqual(form.a.data, ['foo', 'bar', 'baz']) - self.assertEqual(form.a._value(), 'foo\nbar\nbaz') diff --git a/tests/test_ndb.py b/tests/test_ndb.py index 2b37b57..0d0b521 100644 --- a/tests/test_ndb.py +++ b/tests/test_ndb.py @@ -1,15 +1,11 @@ -from __future__ import unicode_literals - -from wtforms.compat import text_type - from itertools import product # This needs to stay as the first import, it sets up paths. -from gaetest_common import DummyPostData, fill_authors, NDBTestCase +from .gaetest_common import DummyPostData, fill_authors -from google.appengine.ext import ndb +from google.cloud import ndb -from wtforms import Form, TextField, IntegerField, BooleanField, \ +from wtforms import Form, StringField, IntegerField, BooleanField, \ SelectField, SelectMultipleField, FormField, FieldList from wtforms_appengine.fields import \ @@ -17,16 +13,19 @@ RepeatedKeyPropertyField,\ PrefetchedKeyPropertyField,\ RepeatedPrefetchedKeyPropertyField,\ - JsonPropertyField + JsonPropertyField, \ + StringListPropertyField, \ + GeoPtPropertyField, \ + IntegerListPropertyField, \ + ReferencePropertyField from wtforms_appengine.ndb import model_form -import second_ndb_module +from . import second_ndb_module # Silence NDB logging ndb.utils.DEBUG = False - GENRES = ['sci-fi', 'fantasy', 'other'] @@ -89,12 +88,11 @@ class Collab(ndb.Model): authors = ndb.KeyProperty(kind=Author, repeated=True) -class TestKeyPropertyField(NDBTestCase): +class TestKeyPropertyField: class F(Form): author = KeyPropertyField(reference_class=Author) def setUp(self): - super(TestKeyPropertyField, self).setUp() self.authors = fill_authors(Author) self.first_author_key = self.authors[0].key @@ -103,95 +101,105 @@ def get_form(self, *args, **kwargs): form.author.query = Author.query().order(Author.name) return form - def test_no_data(self): - form = self.get_form() + def test_no_data(self, client): + with client.context(): + self.setUp() + form = self.get_form() + assert not form.validate(), "Form was valid" - assert not form.validate(), "Form was valid" + ichoices = list(form.author.iter_choices()) - ichoices = list(form.author.iter_choices()) - self.assertEqual(len(ichoices), len(self.authors)) - for author, (key, label, selected) in zip(self.authors, ichoices): - self.assertEqual(key, KeyPropertyField._key_value(author.key)) + assert len(ichoices) == len(self.authors) + for author, (key, label, selected) in zip(self.authors, ichoices): + assert key == KeyPropertyField._key_value(author.key) - def test_valid_form_data(self): - # Valid data - data = DummyPostData( - author=KeyPropertyField._key_value(self.first_author_key)) + def test_valid_form_data(self, client): + with client.context(): + self.setUp() + # Valid data + data = DummyPostData( + author=KeyPropertyField._key_value(self.first_author_key)) - form = self.get_form(data) + form = self.get_form(data) + assert form.validate(), "Form validation failed. %r" % form.errors - assert form.validate(), "Form validation failed. %r" % form.errors + # Check that our first author was selected + ichoices = list(form.author.iter_choices()) - # Check that our first author was selected - ichoices = list(form.author.iter_choices()) - self.assertEqual(len(ichoices), len(self.authors)) - self.assertEqual(list(x[2] for x in ichoices), [True, False, False]) + assert len(ichoices) == len(self.authors) + assert [x[2] for x in ichoices] == [True, False, False] - self.assertEqual(form.author.data, self.first_author_key) + assert form.author.data == self.first_author_key - def test_invalid_form_data(self): - form = self.get_form(DummyPostData(author='fooflaf')) - assert not form.validate() - assert all(x[2] is False for x in form.author.iter_choices()) + def test_invalid_form_data(self, client): + with client.context(): + self.setUp() + form = self.get_form(DummyPostData(author='fooflaf')) + assert not form.validate() + assert all(x[2] is False for x in form.author.iter_choices()) - def test_obj_data(self): + def test_obj_data(self, client): """ When creating a form from an object, check that the form will render (hint: it didn't before) """ - author = Author.query().get() - book = Book(author=author.key) - book.put() + with client.context(): + self.setUp() + author = Author.query().get() + book = Book(author=author.key) + book.put() - form = self.F(DummyPostData(), book) + form = self.F(DummyPostData(), book) - str(form['author']) + str(form.author) - self.assertEqual(form.author.data, author.key) + assert form.author.data == author.key - def test_populate_obj(self): - author = Author.query().get() - book = Book(author=author.key) - book.put() + def test_populate_obj(self, client): + with client.context(): + self.setUp() + author = Author.query().get() + book = Book(author=author.key) + book.put() - form = self.F(DummyPostData(), book) + form = self.F(DummyPostData(), book) - book2 = Book() - form.populate_obj(book2) - self.assertEqual(book2.author, author.key) + book2 = Book() + form.populate_obj(book2) + assert book2.author == author.key - def test_ancestors(self): + def test_ancestors(self, client): """ Test that we support queries that return instances with ancestors. Additionally, test that when we have instances with near-identical ID's, (i.e. int vs str) we don't mix them up. """ - AncestorModel.generate() + with client.context(): + AncestorModel.generate() - class F(Form): - empty = KeyPropertyField(reference_class=AncestorModel) + class F(Form): + empty = KeyPropertyField(reference_class=AncestorModel) - bound_form = F() + bound_form = F() - # Iter through all of the options, and make sure that we - # haven't returned a similar key. - for choice_value, choice_label, selected in \ - bound_form['empty'].iter_choices(): + # Iter through all of the options, and make sure that we + # haven't returned a similar key. + for choice_value, choice_label, selected in \ + bound_form.empty.iter_choices(): - data_form = F(DummyPostData(empty=choice_value)) - assert data_form.validate() + data_form = F(DummyPostData(empty=choice_value)) + assert data_form.validate() - instance = data_form['empty'].data.get() - self.assertEqual(unicode(instance), choice_label) + instance = data_form.empty.data.get() + assert str(instance) == choice_label -class TestRepeatedKeyPropertyField(NDBTestCase): +class TestRepeatedKeyPropertyField: class F(Form): authors = RepeatedKeyPropertyField(reference_class=Author) def setUp(self): - super(TestRepeatedKeyPropertyField, self).setUp() self.authors = fill_authors(Author) self.first_author_key = self.authors[0].key self.second_author_key = self.authors[1].key @@ -203,59 +211,60 @@ def get_form(self, *args, **kwargs): form.authors.query = Author.query().order(Author.name) return form - def test_no_data(self): - form = self.get_form() - zipped = zip(self.authors, form.authors.iter_choices()) + def test_no_data(self, client): + with client.context(): + self.setUp() + form = self.get_form() + zipped = zip(self.authors, form.authors.iter_choices()) - for author, (key, label, selected) in zipped: - self.assertFalse(selected) - self.assertEqual(key, author.key.urlsafe()) + for author, (key, label, selected) in zipped: + assert selected is False + assert key == author.key.urlsafe() # Should this return None, or an empty list? AppEngine won't # accept None in a repeated property. - # self.assertEqual(form.authors.data, []) + # assert form.authors.data == [] - def test_empty_form(self): - form = self.get_form(DummyPostData(authors=[])) + def test_empty_form(self, client): + with client.context(): + self.setUp() + form = self.get_form(DummyPostData(authors=[])) + assert form.validate() is True - self.assertTrue(form.validate()) - self.assertEqual(form.authors.data, []) + assert form.authors.data == [] - inst = Collab() - form.populate_obj(inst) - self.assertEqual(inst.authors, []) + inst = Collab() + form.populate_obj(inst) + assert inst.authors == [] - def test_values(self): - data = DummyPostData(authors=[ - RepeatedKeyPropertyField._key_value(self.first_author_key), - RepeatedKeyPropertyField._key_value(self.second_author_key)]) + def test_values(self, client): + with client.context(): + self.setUp() + data = DummyPostData(authors=[ + RepeatedKeyPropertyField._key_value(self.first_author_key), + RepeatedKeyPropertyField._key_value(self.second_author_key)]) - form = self.get_form(data) + form = self.get_form(data) + assert form.validate(), "Form validation failed. %r" % form.errors + authors = [self.first_author_key, self.second_author_key] + assert form.authors.data == authors - assert form.validate(), "Form validation failed. %r" % form.errors - self.assertEqual( - form.authors.data, - [self.first_author_key, - self.second_author_key]) + inst = Collab() + form.populate_obj(inst) + assert inst.authors == [self.first_author_key, self.second_author_key] - inst = Collab() - form.populate_obj(inst) - self.assertEqual( - inst.authors, - [self.first_author_key, - self.second_author_key]) + def test_bad_value(self, client): + with client.context(): + self.setUp() + data = DummyPostData(authors=[ + 'foo', + RepeatedKeyPropertyField._key_value(self.first_author_key)]) - def test_bad_value(self): - data = DummyPostData(authors=[ - 'foo', - RepeatedKeyPropertyField._key_value(self.first_author_key)]) + form = self.get_form(data) + assert form.validate() is False - form = self.get_form(data) - - self.assertFalse(form.validate()) - - # What should the data of an invalid field be? - # self.assertEqual(form.authors.data, None) + # What should the data of an invalid field be? + # assert form.authors.data is None class TestPrefetchedKeyPropertyField(TestKeyPropertyField): @@ -278,7 +287,7 @@ class F(Form): return F(*args, **kwargs) -class TestJsonPropertyField(NDBTestCase): +class TestJsonPropertyField: class F(Form): field = JsonPropertyField() @@ -294,48 +303,54 @@ def test_round_trip(self): form2.process(formdata=DummyPostData(field=raw_string)) assert form.validate() # Test that we get back the same structure we serialized - self.assertEqual(test_data, form2.field.data) + assert test_data == form2.field.data -class TestModelForm(NDBTestCase): +class TestModelForm: EXPECTED_AUTHOR = [ - ('name', TextField), - ('city', TextField), ('age', IntegerField), - ('is_admin', BooleanField), + ('city', StringField), ('genre', SelectField), ('genres', SelectMultipleField), + ('is_admin', BooleanField), + ('name', StringField), ('address', FormField), ('address_history', FieldList), ] - def test_author(self): - form = model_form(Author) - zipped = zip(self.EXPECTED_AUTHOR, form()) + def test_author(self, client): + with client.context(): + form = model_form(Author) + x = form() + zipped = zip(self.EXPECTED_AUTHOR, x) for (expected_name, expected_type), field in zipped: - self.assertEqual(field.name, expected_name) - self.assertEqual(type(field), expected_type) - - def test_book(self): - authors = set(x.key.urlsafe() for x in fill_authors(Author)) - authors.add('__None') - form = model_form(Book) - keys = set() - for key, b, c in form().author.iter_choices(): - keys.add(key) - - self.assertEqual(authors, keys) - - def test_second_book(self): - authors = set(text_type(x.key.id()) for x in fill_authors(Author)) - authors.add('__None') - form = model_form(second_ndb_module.SecondBook) - keys = set() - for key, b, c in form().author.iter_choices(): - keys.add(key) - - def test_choices(self): + assert field.name == expected_name + assert type(field) == expected_type + + def test_book(self, client): + with client.context(): + authors = set(x.key.urlsafe() for x in fill_authors(Author)) + authors.add('__None') + form = model_form(Book) + keys = set() + for key, b, c in form().author.iter_choices(): + keys.add(key) + + assert authors == keys + + def test_second_book(self, client): + with client.context(): + authors = set(str(x.key.id()) for x in fill_authors(Author)) + authors.add('__None') + form = model_form(second_ndb_module.SecondBook) + keys = set() + for key, b, c in form().author.iter_choices(): + keys.add(key) + + assert authors != keys + + def test_choices(self, client): form = model_form(Author) bound_form = form() @@ -343,8 +358,8 @@ def test_choices(self): # and as such, ends up in the wtforms field unsorted. expected = sorted([(v, v) for v in GENRES]) - self.assertEqual(sorted(bound_form['genre'].choices), expected) - self.assertEqual(sorted(bound_form['genres'].choices), expected) + assert sorted(bound_form.genre.choices) == expected + assert sorted(bound_form.genres.choices) == expected def test_choices_override(self): """ @@ -361,6 +376,98 @@ def test_choices_override(self): bound_form = form() # For provided choices, they should be in the provided order - self.assertEqual(bound_form['genres'].choices, expected) - self.assertEqual(bound_form['name'].choices, expected) + assert bound_form.genres.choices == expected + assert bound_form.name.choices == expected + + +class TestGeoFields: + class GeoTestForm(Form): + geo = GeoPtPropertyField() + + def test_geopt_property(self): + form = self.GeoTestForm(DummyPostData(geo='5.0, -7.0')) + assert form.validate() + assert form.geo.data == '5.0,-7.0' + form = self.GeoTestForm(DummyPostData(geo='5.0,-f')) + assert not form.validate() + + +class TestReferencePropertyField: + nosegae_datastore_v3 = True + + def build_form(self, reference_class=Author, **kw): + class BookForm(Form): + author = ReferencePropertyField( + reference_class=reference_class, + **kw) + return BookForm + + def author_expected(self, selected_index=None, get_label=lambda x: x.name): + expected = set() + for i, author in enumerate(self.authors): + expected.add((author.key.urlsafe(), + get_label(author), + i == selected_index)) + return expected + + def setUp(self): + self.authors = fill_authors(Author) + self.author_names = set(x.name for x in self.authors) + self.author_ages = set(x.age for x in self.authors) + + def test_basic(self, client): + with client.context(): + self.setUp() + F = self.build_form( + get_label='name' + ) + form = F() + assert set(form.author.iter_choices()) == self.author_expected() + assert not form.validate() + + form = F(DummyPostData(author=str(self.authors[0].key))) + assert form.validate() + # What we want to validate here? + # assert set(form.author.iter_choices()) == self.author_expected(0) + + def test_not_in_query(self, client): + with client.context(): + self.setUp() + F = self.build_form() + new_author = Author(name='Jim', age=48) + new_author.put() + form = F(author=new_author) + form.author.query = Author.query().filter(Author.name != 'Jim') + assert form.author.data is new_author + assert not form.validate() + + def test_get_label_func(self, client): + with client.context(): + self.setUp() + get_age = lambda x: x.age + F = self.build_form(get_label=get_age) + form = F() + ages = set(x.label.text for x in form.author) + assert ages == self.author_ages + + def test_allow_blank(self, client): + with client.context(): + self.setUp() + F = self.build_form(allow_blank=True, get_label='name') + form = F(DummyPostData(author='__None')) + assert form.validate() + assert form.author.data is None + expected = self.author_expected() + expected.add(('__None', '', True)) + assert set(form.author.iter_choices()) == expected + + +class TestStringListPropertyField: + class F(Form): + a = StringListPropertyField() + def test_basic(self, client): + with client.context(): + form = self.F(DummyPostData(a='foo\nbar\nbaz')) + assert form.a.data == ['foo', 'bar', 'baz'] + assert form.a._value() == 'foo\nbar\nbaz' diff --git a/wtforms_appengine/__init__.py b/wtforms_appengine/__init__.py index 426c2a9..b650ceb 100644 --- a/wtforms_appengine/__init__.py +++ b/wtforms_appengine/__init__.py @@ -1 +1 @@ -__version__ = '0.1.1dev' +__version__ = '0.2' diff --git a/wtforms_appengine/db.py b/wtforms_appengine/db.py deleted file mode 100644 index 65b9b27..0000000 --- a/wtforms_appengine/db.py +++ /dev/null @@ -1,464 +0,0 @@ -""" -Form generation utilities for App Engine's ``db.Model`` class. - -The goal of ``model_form()`` is to provide a clean, explicit and predictable -way to create forms based on ``db.Model`` classes. No malabarism or black -magic should be necessary to generate a form for models, and to add custom -non-model related fields: ``model_form()`` simply generates a form class -that can be used as it is, or that can be extended directly or even be used -to create other forms using ``model_form()``. - -Example usage: - -.. code-block:: python - - from google.appengine.ext import db - from tipfy.ext.model.form import model_form - - # Define an example model and add a record. - class Contact(db.Model): - name = db.StringProperty(required=True) - city = db.StringProperty() - age = db.IntegerProperty(required=True) - is_admin = db.BooleanProperty(default=False) - - new_entity = Contact(key_name='test', name='Test Name', age=17) - new_entity.put() - - # Generate a form based on the model. - ContactForm = model_form(Contact) - - # Get a form populated with entity data. - entity = Contact.get_by_key_name('test') - form = ContactForm(obj=entity) - -Properties from the model can be excluded from the generated form, or it can -include just a set of properties. For example: - -.. code-block:: python - - # Generate a form based on the model, excluding 'city' and 'is_admin'. - ContactForm = model_form(Contact, exclude=('city', 'is_admin')) - - # or... - - # Generate a form based on the model, only including 'name' and 'age'. - ContactForm = model_form(Contact, only=('name', 'age')) - -The form can be generated setting field arguments: - -.. code-block:: python - - ContactForm = model_form(Contact, only=('name', 'age'), field_args={ - 'name': { - 'label': 'Full name', - 'description': 'Your name', - }, - 'age': { - 'label': 'Age', - 'validators': [validators.NumberRange(min=14, max=99)], - } - }) - -The class returned by ``model_form()`` can be used as a base class for forms -mixing non-model fields and/or other model forms. For example: - -.. code-block:: python - - # Generate a form based on the model. - BaseContactForm = model_form(Contact) - - # Generate a form based on other model. - ExtraContactForm = model_form(MyOtherModel) - - class ContactForm(BaseContactForm): - # Add an extra, non-model related field. - subscribe_to_news = f.BooleanField() - - # Add the other model form as a subform. - extra = f.FormField(ExtraContactForm) - -The class returned by ``model_form()`` can also extend an existing form -class: - -.. code-block:: python - - class BaseContactForm(Form): - # Add an extra, non-model related field. - subscribe_to_news = f.BooleanField() - - # Generate a form based on the model. - ContactForm = model_form(Contact, base_class=BaseContactForm) - -""" -from wtforms import Form, validators, widgets, fields as f -from wtforms.compat import iteritems -from .fields import GeoPtPropertyField, ReferencePropertyField, StringListPropertyField - - -def get_TextField(kwargs): - """ - Returns a ``TextField``, applying the ``db.StringProperty`` length limit - of 500 bytes. - """ - kwargs['validators'].append(validators.length(max=500)) - return f.TextField(**kwargs) - - -def get_IntegerField(kwargs): - """ - Returns an ``IntegerField``, applying the ``db.IntegerProperty`` range - limits. - """ - v = validators.NumberRange(min=-0x8000000000000000, max=0x7fffffffffffffff) - kwargs['validators'].append(v) - return f.IntegerField(**kwargs) - - -def convert_StringProperty(model, prop, kwargs): - """Returns a form field for a ``db.StringProperty``.""" - if prop.multiline: - kwargs['validators'].append(validators.length(max=500)) - return f.TextAreaField(**kwargs) - else: - return get_TextField(kwargs) - - -def convert_ByteStringProperty(model, prop, kwargs): - """Returns a form field for a ``db.ByteStringProperty``.""" - return get_TextField(kwargs) - - -def convert_BooleanProperty(model, prop, kwargs): - """Returns a form field for a ``db.BooleanProperty``.""" - return f.BooleanField(**kwargs) - - -def convert_IntegerProperty(model, prop, kwargs): - """Returns a form field for a ``db.IntegerProperty``.""" - return get_IntegerField(kwargs) - - -def convert_FloatProperty(model, prop, kwargs): - """Returns a form field for a ``db.FloatProperty``.""" - return f.FloatField(**kwargs) - - -def convert_DateTimeProperty(model, prop, kwargs): - """Returns a form field for a ``db.DateTimeProperty``.""" - if prop.auto_now or prop.auto_now_add: - return None - - kwargs.setdefault('format', '%Y-%m-%d %H:%M:%S') - return f.DateTimeField(**kwargs) - - -def convert_DateProperty(model, prop, kwargs): - """Returns a form field for a ``db.DateProperty``.""" - if prop.auto_now or prop.auto_now_add: - return None - - kwargs.setdefault('format', '%Y-%m-%d') - return f.DateField(**kwargs) - - -def convert_TimeProperty(model, prop, kwargs): - """Returns a form field for a ``db.TimeProperty``.""" - if prop.auto_now or prop.auto_now_add: - return None - - kwargs.setdefault('format', '%H:%M:%S') - return f.DateTimeField(**kwargs) - - -def convert_ListProperty(model, prop, kwargs): - """Returns a form field for a ``db.ListProperty``.""" - return None - - -def convert_StringListProperty(model, prop, kwargs): - """Returns a form field for a ``db.StringListProperty``.""" - return StringListPropertyField(**kwargs) - - -def convert_ReferenceProperty(model, prop, kwargs): - """Returns a form field for a ``db.ReferenceProperty``.""" - kwargs['reference_class'] = prop.reference_class - kwargs.setdefault('allow_blank', not prop.required) - return ReferencePropertyField(**kwargs) - - -def convert_SelfReferenceProperty(model, prop, kwargs): - """Returns a form field for a ``db.SelfReferenceProperty``.""" - return None - - -def convert_UserProperty(model, prop, kwargs): - """Returns a form field for a ``db.UserProperty``.""" - return None - - -def convert_BlobProperty(model, prop, kwargs): - """Returns a form field for a ``db.BlobProperty``.""" - return f.FileField(**kwargs) - - -def convert_TextProperty(model, prop, kwargs): - """Returns a form field for a ``db.TextProperty``.""" - return f.TextAreaField(**kwargs) - - -def convert_CategoryProperty(model, prop, kwargs): - """Returns a form field for a ``db.CategoryProperty``.""" - return get_TextField(kwargs) - - -def convert_LinkProperty(model, prop, kwargs): - """Returns a form field for a ``db.LinkProperty``.""" - kwargs['validators'].append(validators.url()) - return get_TextField(kwargs) - - -def convert_EmailProperty(model, prop, kwargs): - """Returns a form field for a ``db.EmailProperty``.""" - kwargs['validators'].append(validators.email()) - return get_TextField(kwargs) - - -def convert_GeoPtProperty(model, prop, kwargs): - """Returns a form field for a ``db.GeoPtProperty``.""" - return GeoPtPropertyField(**kwargs) - - -def convert_IMProperty(model, prop, kwargs): - """Returns a form field for a ``db.IMProperty``.""" - return None - - -def convert_PhoneNumberProperty(model, prop, kwargs): - """Returns a form field for a ``db.PhoneNumberProperty``.""" - return get_TextField(kwargs) - - -def convert_PostalAddressProperty(model, prop, kwargs): - """Returns a form field for a ``db.PostalAddressProperty``.""" - return get_TextField(kwargs) - - -def convert_RatingProperty(model, prop, kwargs): - """Returns a form field for a ``db.RatingProperty``.""" - kwargs['validators'].append(validators.NumberRange(min=0, max=100)) - return f.IntegerField(**kwargs) - - -class ModelConverter(object): - """ - Converts properties from a ``db.Model`` class to form fields. - - Default conversions between properties and fields: - - +====================+===================+==============+==================+ - | Property subclass | Field subclass | datatype | notes | - +====================+===================+==============+==================+ - | StringProperty | TextField | unicode | TextArea | - | | | | if multiline | - +--------------------+-------------------+--------------+------------------+ - | ByteStringProperty | TextField | str | | - +--------------------+-------------------+--------------+------------------+ - | BooleanProperty | BooleanField | bool | | - +--------------------+-------------------+--------------+------------------+ - | IntegerProperty | IntegerField | int or long | | - +--------------------+-------------------+--------------+------------------+ - | FloatProperty | TextField | float | | - +--------------------+-------------------+--------------+------------------+ - | DateTimeProperty | DateTimeField | datetime | skipped if | - | | | | auto_now[_add] | - +--------------------+-------------------+--------------+------------------+ - | DateProperty | DateField | date | skipped if | - | | | | auto_now[_add] | - +--------------------+-------------------+--------------+------------------+ - | TimeProperty | DateTimeField | time | skipped if | - | | | | auto_now[_add] | - +--------------------+-------------------+--------------+------------------+ - | ListProperty | None | list | always skipped | - +--------------------+-------------------+--------------+------------------+ - | StringListProperty | TextAreaField | list of str | | - +--------------------+-------------------+--------------+------------------+ - | ReferenceProperty | ReferencePropertyF| db.Model | | - +--------------------+-------------------+--------------+------------------+ - | SelfReferenceP. | ReferencePropertyF| db.Model | | - +--------------------+-------------------+--------------+------------------+ - | UserProperty | None | users.User | always skipped | - +--------------------+-------------------+--------------+------------------+ - | BlobProperty | FileField | str | | - +--------------------+-------------------+--------------+------------------+ - | TextProperty | TextAreaField | unicode | | - +--------------------+-------------------+--------------+------------------+ - | CategoryProperty | TextField | unicode | | - +--------------------+-------------------+--------------+------------------+ - | LinkProperty | TextField | unicode | | - +--------------------+-------------------+--------------+------------------+ - | EmailProperty | TextField | unicode | | - +--------------------+-------------------+--------------+------------------+ - | GeoPtProperty | TextField | db.GeoPt | | - +--------------------+-------------------+--------------+------------------+ - | IMProperty | None | db.IM | always skipped | - +--------------------+-------------------+--------------+------------------+ - | PhoneNumberProperty| TextField | unicode | | - +--------------------+-------------------+--------------+------------------+ - | PostalAddressP. | TextField | unicode | | - +--------------------+-------------------+--------------+------------------+ - | RatingProperty | IntegerField | int or long | | - +--------------------+-------------------+--------------+------------------+ - | _ReverseReferenceP.| None | | always skipped | - +====================+===================+==============+==================+ - """ - default_converters = { - 'StringProperty': convert_StringProperty, - 'ByteStringProperty': convert_ByteStringProperty, - 'BooleanProperty': convert_BooleanProperty, - 'IntegerProperty': convert_IntegerProperty, - 'FloatProperty': convert_FloatProperty, - 'DateTimeProperty': convert_DateTimeProperty, - 'DateProperty': convert_DateProperty, - 'TimeProperty': convert_TimeProperty, - 'ListProperty': convert_ListProperty, - 'StringListProperty': convert_StringListProperty, - 'ReferenceProperty': convert_ReferenceProperty, - 'SelfReferenceProperty': convert_SelfReferenceProperty, - 'UserProperty': convert_UserProperty, - 'BlobProperty': convert_BlobProperty, - 'TextProperty': convert_TextProperty, - 'CategoryProperty': convert_CategoryProperty, - 'LinkProperty': convert_LinkProperty, - 'EmailProperty': convert_EmailProperty, - 'GeoPtProperty': convert_GeoPtProperty, - 'IMProperty': convert_IMProperty, - 'PhoneNumberProperty': convert_PhoneNumberProperty, - 'PostalAddressProperty': convert_PostalAddressProperty, - 'RatingProperty': convert_RatingProperty, - } - - # Don't automatically add a required validator for these properties - NO_AUTO_REQUIRED = frozenset(['ListProperty', 'StringListProperty', 'BooleanProperty']) - - def __init__(self, converters=None): - """ - Constructs the converter, setting the converter callables. - - :param converters: - A dictionary of converter callables for each property type. The - callable must accept the arguments (model, prop, kwargs). - """ - self.converters = converters or self.default_converters - - def convert(self, model, prop, field_args): - """ - Returns a form field for a single model property. - - :param model: - The ``db.Model`` class that contains the property. - :param prop: - The model property: a ``db.Property`` instance. - :param field_args: - Optional keyword arguments to construct the field. - """ - prop_type_name = type(prop).__name__ - kwargs = { - 'label': prop.name.replace('_', ' ').title(), - 'default': prop.default_value(), - 'validators': [], - } - if field_args: - kwargs.update(field_args) - - if prop.required and prop_type_name not in self.NO_AUTO_REQUIRED: - kwargs['validators'].append(validators.required()) - - if prop.choices: - # Use choices in a select field if it was not provided in field_args - if 'choices' not in kwargs: - kwargs['choices'] = [(v, v) for v in prop.choices] - return f.SelectField(**kwargs) - else: - converter = self.converters.get(prop_type_name, None) - if converter is not None: - return converter(model, prop, kwargs) - - -def model_fields(model, only=None, exclude=None, field_args=None, - converter=None): - """ - Extracts and returns a dictionary of form fields for a given - ``db.Model`` class. - - :param model: - The ``db.Model`` class to extract fields from. - :param only: - An optional iterable with the property names that should be included in - the form. Only these properties will have fields. - :param exclude: - An optional iterable with the property names that should be excluded - from the form. All other properties will have fields. - :param field_args: - An optional dictionary of field names mapping to a keyword arguments - used to construct each field object. - :param converter: - A converter to generate the fields based on the model properties. If - not set, ``ModelConverter`` is used. - """ - converter = converter or ModelConverter() - field_args = field_args or {} - - # Get the field names we want to include or exclude, starting with the - # full list of model properties. - props = model.properties() - sorted_props = sorted(iteritems(props), key=lambda prop: prop[1].creation_counter) - field_names = list(x[0] for x in sorted_props) - - if only: - field_names = list(f for f in only if f in field_names) - elif exclude: - field_names = list(f for f in field_names if f not in exclude) - - # Create all fields. - field_dict = {} - for name in field_names: - field = converter.convert(model, props[name], field_args.get(name)) - if field is not None: - field_dict[name] = field - - return field_dict - - -def model_form(model, base_class=Form, only=None, exclude=None, field_args=None, - converter=None): - """ - Creates and returns a dynamic ``wtforms.Form`` class for a given - ``db.Model`` class. The form class can be used as it is or serve as a base - for extended form classes, which can then mix non-model related fields, - subforms with other model forms, among other possibilities. - - :param model: - The ``db.Model`` class to generate a form for. - :param base_class: - Base form class to extend from. Must be a ``wtforms.Form`` subclass. - :param only: - An optional iterable with the property names that should be included in - the form. Only these properties will have fields. - :param exclude: - An optional iterable with the property names that should be excluded - from the form. All other properties will have fields. - :param field_args: - An optional dictionary of field names mapping to keyword arguments - used to construct each field object. - :param converter: - A converter to generate the fields based on the model properties. If - not set, ``ModelConverter`` is used. - """ - # Extract the fields from the model. - field_dict = model_fields(model, only, exclude, field_args, converter) - - # Return a dynamically created form class, extending from base_class and - # including the created fields as properties. - return type(model.kind() + 'Form', (base_class,), field_dict) diff --git a/wtforms_appengine/fields/__init__.py b/wtforms_appengine/fields/__init__.py index de5d04a..1c9dce0 100644 --- a/wtforms_appengine/fields/__init__.py +++ b/wtforms_appengine/fields/__init__.py @@ -3,12 +3,10 @@ from wtforms import fields - -from .db import * from .ndb import * -class GeoPtPropertyField(fields.TextField): +class GeoPtPropertyField(fields.StringField): def process_formdata(self, valuelist): if valuelist: diff --git a/wtforms_appengine/fields/db.py b/wtforms_appengine/fields/db.py deleted file mode 100644 index 209e1aa..0000000 --- a/wtforms_appengine/fields/db.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import unicode_literals - -import operator - -from wtforms import fields, widgets -from wtforms.compat import text_type, string_types - -__all__ = ['ReferencePropertyField', - 'StringListPropertyField', - 'IntegerListPropertyField'] - - -class ReferencePropertyField(fields.SelectFieldBase): - """ - A field for ``db.ReferenceProperty``. The list items are rendered in a - select. - - :param reference_class: - A db.Model class which will be used to generate the default query - to make the list of items. If this is not specified, The `query` - property must be overridden before validation. - :param get_label: - If a string, use this attribute on the model class as the label - associated with each option. If a one-argument callable, this callable - will be passed model instance and expected to return the label text. - Otherwise, the model object's `__str__` or `__unicode__` will be used. - :param allow_blank: - If set to true, a blank choice will be added to the top of the list - to allow `None` to be chosen. - :param blank_text: - Use this to override the default blank option's label. - """ - widget = widgets.Select() - - def __init__(self, label=None, validators=None, reference_class=None, - get_label=None, allow_blank=False, - blank_text='', **kwargs): - super(ReferencePropertyField, self).__init__(label, validators, - **kwargs) - if get_label is None: - self.get_label = lambda x: x - elif isinstance(get_label, string_types): - self.get_label = operator.attrgetter(get_label) - else: - self.get_label = get_label - - self.allow_blank = allow_blank - self.blank_text = blank_text - self._set_data(None) - if reference_class is not None: - self.query = reference_class.all() - - def _get_data(self): - if self._formdata is not None: - for obj in self.query: - if str(obj.key()) == self._formdata: - self._set_data(obj) - break - return self._data - - def _set_data(self, data): - self._data = data - self._formdata = None - - data = property(_get_data, _set_data) - - def iter_choices(self): - if self.allow_blank: - yield ('__None', self.blank_text, self.data is None) - - for obj in self.query: - key = str(obj.key()) - label = self.get_label(obj) - yield (key, - label, - (self.data.key() == obj.key()) if self.data else False) - - def process_formdata(self, valuelist): - if valuelist: - if valuelist[0] == '__None': - self.data = None - else: - self._data = None - self._formdata = valuelist[0] - - def pre_validate(self, form): - data = self.data - if data is not None: - s_key = str(data.key()) - for obj in self.query: - if s_key == str(obj.key()): - break - else: - raise ValueError(self.gettext('Not a valid choice')) - elif not self.allow_blank: - raise ValueError(self.gettext('Not a valid choice')) - - -class StringListPropertyField(fields.TextAreaField): - """ - A field for ``db.StringListProperty``. The list items are rendered in a - textarea. - """ - def _value(self): - if self.raw_data: - return self.raw_data[0] - else: - return self.data and text_type("\n".join(self.data)) or '' - - def process_formdata(self, valuelist): - if valuelist: - try: - self.data = valuelist[0].splitlines() - except ValueError: - raise ValueError(self.gettext('Not a valid list')) - - -class IntegerListPropertyField(fields.TextAreaField): - """ - A field for ``db.StringListProperty``. The list items are rendered in a - textarea. - """ - def _value(self): - if self.raw_data: - return self.raw_data[0] - else: - return text_type('\n'.join(self.data)) if self.data else '' - - def process_formdata(self, valuelist): - if valuelist: - try: - self.data = [int(value) for value in valuelist[0].splitlines()] - except ValueError: - raise ValueError(self.gettext('Not a valid integer list')) - - diff --git a/wtforms_appengine/fields/ndb.py b/wtforms_appengine/fields/ndb.py index 7a1c24b..0763cb9 100644 --- a/wtforms_appengine/fields/ndb.py +++ b/wtforms_appengine/fields/ndb.py @@ -4,14 +4,16 @@ import operator from wtforms import fields, widgets -from wtforms.compat import text_type __all__ = [ 'KeyPropertyField', 'JsonPropertyField', 'RepeatedKeyPropertyField', 'PrefetchedKeyPropertyField', - 'RepeatedPrefetchedKeyPropertyField'] + 'RepeatedPrefetchedKeyPropertyField', + 'StringListPropertyField', + 'IntegerListPropertyField', + 'ReferencePropertyField'] class KeyPropertyField(fields.SelectFieldBase): @@ -38,11 +40,11 @@ class KeyPropertyField(fields.SelectFieldBase): widget = widgets.Select() def __init__(self, label=None, validators=None, reference_class=None, - get_label=text_type, allow_blank=False, blank_text='', + get_label=str, allow_blank=False, blank_text='', query=None, **kwargs): super(KeyPropertyField, self).__init__(label, validators, **kwargs) - if isinstance(get_label, basestring): + if isinstance(get_label, str): self.get_label = operator.attrgetter(get_label) else: self.get_label = get_label @@ -68,7 +70,7 @@ def _key_value(key): """ Get's the form-friendly representation of the ndb.Key. - This should return a hashable object (such as a string). + This should return a hashable object (such as a string). """ # n.b. Possible security concern here as urlsafe() exposes # *all* the detail about the instance. But it's also the only @@ -206,7 +208,7 @@ class JsonPropertyField(fields.StringField): widget = widgets.TextArea() def process_formdata(self, valuelist): - if valuelist is not "": + if valuelist: self.data = json.loads(valuelist[0]) else: self.data = None @@ -215,3 +217,125 @@ def _value(self): return json.dumps(self.data) if self.data is not None else '' +class ReferencePropertyField(KeyPropertyField): + """ + A field for ``db.ReferenceProperty``. The list items are rendered in a + select. + + :param reference_class: + A db.Model class which will be used to generate the default query + to make the list of items. If this is not specified, The `query` + property must be overridden before validation. + :param get_label: + If a string, use this attribute on the model class as the label + associated with each option. If a one-argument callable, this callable + will be passed model instance and expected to return the label text. + Otherwise, the model object's `__str__` or `__unicode__` will be used. + :param allow_blank: + If set to true, a blank choice will be added to the top of the list + to allow `None` to be chosen. + :param blank_text: + Use this to override the default blank option's label. + """ + widget = widgets.Select() + + def __init__(self, label=None, validators=None, reference_class=None, + get_label=None, allow_blank=False, + blank_text='', **kwargs): + super(ReferencePropertyField, self).__init__(label, validators, + **kwargs) + if get_label is None: + self.get_label = lambda x: x + elif isinstance(get_label, str): + self.get_label = operator.attrgetter(get_label) + else: + self.get_label = get_label + + self.allow_blank = allow_blank + self.blank_text = blank_text + self._set_data(None) + if reference_class is not None: + self.query = reference_class.query() + + def _get_data(self): + if self._formdata is not None: + for obj in self.query: + if str(obj.key) == self._formdata: + self._set_data(obj) + break + return self._data + + def _set_data(self, data): + self._data = data + self._formdata = None + + data = property(_get_data, _set_data) + + def iter_choices(self): + if self.allow_blank: + yield ('__None', self.blank_text, self.data is None) + + for obj in self.query: + key = self._key_value(obj.key) + label = self.get_label(obj) + yield (key, + label, + (self.data == obj.key) if self.data else False) + + def process_formdata(self, valuelist): + if valuelist: + if valuelist[0] == '__None': + self.data = None + else: + self._data = None + self._formdata = valuelist[0] + + def pre_validate(self, form): + data = self.data + if data is not None: + s_key = str(data.key) + for obj in self.query: + if s_key == str(obj.key): + break + else: + raise ValueError(self.gettext('Not a valid choice')) + elif not self.allow_blank: + raise ValueError(self.gettext('Not a valid choice')) + + +class StringListPropertyField(fields.TextAreaField): + """ + A field for ``db.StringListProperty``. The list items are rendered in a + textarea. + """ + def _value(self): + if self.raw_data: + return self.raw_data[0] + else: + return self.data and str("\n".join(self.data)) or '' + + def process_formdata(self, valuelist): + if valuelist: + try: + self.data = valuelist[0].splitlines() + except ValueError: + raise ValueError(self.gettext('Not a valid list')) + + +class IntegerListPropertyField(fields.TextAreaField): + """ + A field for ``db.StringListProperty``. The list items are rendered in a + textarea. + """ + def _value(self): + if self.raw_data: + return self.raw_data[0] + else: + return str('\n'.join(self.data)) if self.data else '' + + def process_formdata(self, valuelist): + if valuelist: + try: + self.data = [int(value) for value in valuelist[0].splitlines()] + except ValueError: + raise ValueError(self.gettext('Not a valid integer list')) diff --git a/wtforms_appengine/ndb.py b/wtforms_appengine/ndb.py index 9a2690f..21dde43 100644 --- a/wtforms_appengine/ndb.py +++ b/wtforms_appengine/ndb.py @@ -92,7 +92,6 @@ class BaseContactForm(Form): """ from wtforms import Form, validators, fields as f -from wtforms.compat import string_types from .fields import (GeoPtPropertyField, JsonPropertyField, KeyPropertyField, @@ -101,13 +100,13 @@ class BaseContactForm(Form): IntegerListPropertyField) -def get_TextField(kwargs): +def get_StringField(kwargs): """ - Returns a ``TextField``, applying the ``ndb.StringProperty`` length limit + Returns a ``StringField``, applying the ``ndb.StringProperty`` length limit of 500 bytes. """ kwargs['validators'].append(validators.length(max=500)) - return f.TextField(**kwargs) + return f.StringField(**kwargs) def get_IntegerField(kwargs): @@ -153,15 +152,16 @@ def convert(self, model, prop, field_args): # check for generic property if(prop_type_name == "GenericProperty"): # try to get type from field args - generic_type = field_args.get("type") + generic_type = field_args.get("type") if field_args else None + if generic_type: prop_type_name = field_args.get("type") # if no type is found, the generic property uses string set in # convert_GenericProperty kwargs = { - 'label': (prop._verbose_name or - prop._code_name.replace('_', ' ').title()), + 'label': (prop._verbose_name + or prop._code_name.replace('_', ' ').title()), 'default': prop._default, 'validators': [], } @@ -169,7 +169,7 @@ def convert(self, model, prop, field_args): kwargs.update(field_args) if prop._required and prop_type_name not in self.NO_AUTO_REQUIRED: - kwargs['validators'].append(validators.required()) + kwargs['validators'].append(validators.DataRequired()) choices = kwargs.get('choices', None) or prop._choices if choices: @@ -189,6 +189,7 @@ def convert(self, model, prop, field_args): class ModelConverter(ModelConverterBase): + from google.cloud.ndb import model """ Converts properties from a ``ndb.Model`` class to form fields. @@ -197,14 +198,14 @@ class ModelConverter(ModelConverterBase): +====================+===================+==============+==================+ | Property subclass | Field subclass | datatype | notes | +====================+===================+==============+==================+ - | StringProperty | TextField | unicode | TextArea | repeated support + | StringProperty | StringField | unicode | TextArea | repeated support | | | | if multiline | +--------------------+-------------------+--------------+------------------+ | BooleanProperty | BooleanField | bool | | +--------------------+-------------------+--------------+------------------+ | IntegerProperty | IntegerField | int or long | | repeated support +--------------------+-------------------+--------------+------------------+ - | FloatProperty | TextField | float | | + | FloatProperty | StringField | float | | +--------------------+-------------------+--------------+------------------+ | DateTimeProperty | DateTimeField | datetime | skipped if | | | | | auto_now[_add] | @@ -217,7 +218,7 @@ class ModelConverter(ModelConverterBase): +--------------------+-------------------+--------------+------------------+ | TextProperty | TextAreaField | unicode | | +--------------------+-------------------+--------------+------------------+ - | GeoPtProperty | TextField | db.GeoPt | | + | GeoPtProperty | StringField | db.GeoPt | | +--------------------+-------------------+--------------+------------------+ | KeyProperty | KeyProperyField | ndb.Key | | +--------------------+-------------------+--------------+------------------+ @@ -253,7 +254,7 @@ def convert_StringProperty(self, model, prop, kwargs): if prop._repeated: return StringListPropertyField(**kwargs) kwargs['validators'].append(validators.length(max=500)) - return get_TextField(kwargs) + return get_StringField(kwargs) def convert_BooleanProperty(self, model, prop, kwargs): """Returns a form field for a ``ndb.BooleanProperty``.""" @@ -316,7 +317,7 @@ def convert_PickleProperty(self, model, prop, kwargs): def convert_GenericProperty(self, model, prop, kwargs): """Returns a form field for a ``ndb.GenericProperty``.""" kwargs['validators'].append(validators.length(max=500)) - return get_TextField(kwargs) + return get_StringField(kwargs) def convert_BlobKeyProperty(self, model, prop, kwargs): """Returns a form field for a ``ndb.BlobKeyProperty``.""" @@ -343,7 +344,7 @@ def convert_KeyProperty(self, model, prop, kwargs): except AttributeError: reference_class = prop._reference_class - if isinstance(reference_class, string_types): + if isinstance(reference_class, str): # This assumes that the referenced module is already imported. try: reference_class = model._kind_map[reference_class] @@ -361,8 +362,9 @@ def convert_KeyProperty(self, model, prop, kwargs): return KeyPropertyField(**kwargs) def convert__ClassKeyProperty(self, model, prop, kwargs): - """Returns a form field for a ``ndb.ComputedProperty``.""" - return None + """Returns a form field for a ``ndb.ComputedProperty``.""" + return None + def model_fields(model, only=None, exclude=None, field_args=None, converter=None): @@ -391,8 +393,7 @@ def model_fields(model, only=None, exclude=None, field_args=None, # Get the field names we want to include or exclude, starting with the # full list of model properties. props = model._properties - field_names = [x[0] for x in - sorted(props.items(), key=lambda x: x[1]._creation_counter)] + field_names = [x[0] for x in props.items()] if only: field_names = list(f for f in only if f in field_names) @@ -410,7 +411,7 @@ def model_fields(model, only=None, exclude=None, field_args=None, def model_form(model, base_class=Form, only=None, exclude=None, - field_args=None, converter=None): + field_args=None, converter=None, extra_fields=None): """ Creates and returns a dynamic ``wtforms.Form`` class for a given ``ndb.Model`` class. The form class can be used as it is or serve as a base @@ -437,6 +438,9 @@ def model_form(model, base_class=Form, only=None, exclude=None, # Extract the fields from the model. field_dict = model_fields(model, only, exclude, field_args, converter) + if extra_fields: + field_dict.update(extra_fields) + # Return a dynamically created form class, extending from base_class and # including the created fields as properties. return type(model._get_kind() + 'Form', (base_class,), field_dict)