loutilities 3.11.1.dev1__tar.gz → 3.12.0.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {loutilities-3.11.1.dev1/loutilities.egg-info → loutilities-3.12.0.dev1}/PKG-INFO +1 -1
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables.py +7 -1
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/version.py +1 -1
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1/loutilities.egg-info}/PKG-INFO +1 -1
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities.egg-info/SOURCES.txt +2 -1
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/tests/test_sqlalchemy_helpers.py +2 -2
- loutilities-3.12.0.dev1/tests/test_tables.py +447 -0
- loutilities-3.12.0.dev1/tests/test_user_tables.py +283 -0
- loutilities-3.11.1.dev1/tests/test_tables.py +0 -224
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/.gitattributes +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/.gitignore +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/README.md +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/__init__.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/agegrade.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/apikey.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/applytemplate.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/bfile.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/boolexpr.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/config.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/configparser.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/csvu.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/csvwt.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/extconfigparser.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/filetrigger.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/filtercsv.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/filters.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/flask/__init__.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/flask/user/__init__.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/flask/user/views.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/flask_helpers/__init__.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/flask_helpers/as_blueprint.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/flask_helpers/blueprints.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/flask_helpers/decorators.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/flask_helpers/mailer.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/geo.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/googleauth.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/kmlutils.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/makerst.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/namesplitter.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/nesteddict.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/nicknames.csv +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/nicknames.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/renderrun.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/sqlalchemy_helpers.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/readme.md +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/background-post-data-manager.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/branding.css +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/buttons.colvis.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/charts.css +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/charts.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/datatables-childrow.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/datatables.css +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/datatables.dataRender.datetime.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/datatables.dataRender.ellipsis.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/datatables.dataRender.googledoc.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/datatables.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/editor-saeditor.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/editor.buttons.editchildrowrefresh.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/editor.buttons.editrefresh.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/editor.buttons.separator.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/editor.ckeditor5.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/editor.css +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/editor.displayController.onPage.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/editor.fieldType.display.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/editor.googledoc.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/editor.select2.mymethods.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/eventscalendar.css +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/filters.css +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/filters.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/jquery.ui.dialog-clickoutside.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/jqueryui.theme.adjust.css +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/legend.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/mutex-promise.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/user/admin/beforedatatables.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/user/admin/groups.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/static/utils.js +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/bare-layout.jinja2 +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/datatables.html +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/datatables.jinja2 +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/layout-base.jinja2 +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/layout.html +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/layout.jinja2 +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/security/change_password.jinja2 +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/security/email/reset_instructions.html +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/security/email/reset_instructions.txt +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/security/forgot_password.jinja2 +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/security/login_user.jinja2 +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/security/reset_password.jinja2 +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/tables-assets/templates/select-view.jinja2 +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/textreader.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/timeu.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/transform.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/user/__init__.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/user/applogging.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/user/audit_mixin.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/user/model.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/user/roles.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/user/scripts/__init__.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/user/scripts/users_init.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/user/settings.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/user/tablefiles.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/user/tables.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/user/views/__init__.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/user/views/userrole.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/wxextensions.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities/xmldict.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities.egg-info/dependency_links.txt +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities.egg-info/entry_points.txt +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities.egg-info/not-zip-safe +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities.egg-info/requires.txt +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/loutilities.egg-info/top_level.txt +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/setup.cfg +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/setup.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/tests/__init__.py +0 -0
- {loutilities-3.11.1.dev1 → loutilities-3.12.0.dev1}/tests/models.py +0 -0
|
@@ -1095,7 +1095,13 @@ class CrudApi(MethodView):
|
|
|
1095
1095
|
# DataTables options string, data: and buttons: are passed separately
|
|
1096
1096
|
# self.dtoptions can update what we come up with
|
|
1097
1097
|
dt_options = {
|
|
1098
|
-
'dom': '<"H"lBpfr>t<"F"i>',
|
|
1098
|
+
# 'dom': '<"H"lBpfr>t<"F"i>',
|
|
1099
|
+
'layout':{
|
|
1100
|
+
'topStart': ['pageLength', 'buttons'],
|
|
1101
|
+
'topEnd': ['search', 'paging'],
|
|
1102
|
+
'bottomStart': ['info'],
|
|
1103
|
+
'bottomEnd': None,
|
|
1104
|
+
},
|
|
1099
1105
|
'columns': [
|
|
1100
1106
|
],
|
|
1101
1107
|
'rowId': self.idSrc,
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# this string is used for the version string in the documentation, as well as the egg
|
|
2
|
-
__version__ = '3.
|
|
2
|
+
__version__ = '3.12.0.dev1'
|
|
@@ -164,7 +164,7 @@ class SqlalchemyHelpersTest(unittest.TestCase):
|
|
|
164
164
|
del currinst.id
|
|
165
165
|
del currinst._sa_instance_state
|
|
166
166
|
del newinst._sa_instance_state
|
|
167
|
-
self.
|
|
167
|
+
self.assertEqual(newinst.__dict__, currinst.__dict__)
|
|
168
168
|
|
|
169
169
|
def test_insert_or_update_insert(self):
|
|
170
170
|
oldrecords = self.populate_severalattrs(5)
|
|
@@ -197,4 +197,4 @@ class SqlalchemyHelpersTest(unittest.TestCase):
|
|
|
197
197
|
del currinst.id
|
|
198
198
|
del currinst._sa_instance_state
|
|
199
199
|
del newinst._sa_instance_state
|
|
200
|
-
self.
|
|
200
|
+
self.assertEqual(newinst.__dict__, currinst.__dict__)
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
'''
|
|
2
|
+
test_tables - tests for loutilities.tables
|
|
3
|
+
'''
|
|
4
|
+
# standard
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
# pypi
|
|
8
|
+
from flask import Flask, json
|
|
9
|
+
from sqlalchemy import create_engine
|
|
10
|
+
from sqlalchemy.orm import sessionmaker, scoped_session
|
|
11
|
+
from sqlalchemy.pool import StaticPool
|
|
12
|
+
|
|
13
|
+
# homegrown
|
|
14
|
+
from loutilities import tables
|
|
15
|
+
from .models import User, Address, Base
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# DbWrapper: presents the db.session interface expected by DbCrudApi
|
|
20
|
+
# (Flask-SQLAlchemy uses db.session; here we wrap a scoped_session)
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
class DbWrapper:
|
|
23
|
+
def __init__(self, session):
|
|
24
|
+
self.session = session
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _make_engine():
|
|
28
|
+
"""In-memory SQLite engine with StaticPool so all connections share one DB."""
|
|
29
|
+
return create_engine(
|
|
30
|
+
'sqlite://',
|
|
31
|
+
connect_args={'check_same_thread': False},
|
|
32
|
+
poolclass=StaticPool,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make_scoped_session(engine):
|
|
37
|
+
"""Create a scoped session and bind query_property to model classes."""
|
|
38
|
+
Session = scoped_session(sessionmaker(bind=engine))
|
|
39
|
+
# query_property lets Model.query work like Flask-SQLAlchemy
|
|
40
|
+
User.query = Session.query_property()
|
|
41
|
+
Address.query = Session.query_property()
|
|
42
|
+
return Session
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ===========================================================================
|
|
46
|
+
# Standalone utility function tests
|
|
47
|
+
# ===========================================================================
|
|
48
|
+
|
|
49
|
+
class TestIsJsonable(unittest.TestCase):
|
|
50
|
+
def test_serializable_types(self):
|
|
51
|
+
self.assertTrue(tables.is_jsonable({'a': 1}))
|
|
52
|
+
self.assertTrue(tables.is_jsonable([1, 2, 3]))
|
|
53
|
+
self.assertTrue(tables.is_jsonable('string'))
|
|
54
|
+
self.assertTrue(tables.is_jsonable(42))
|
|
55
|
+
self.assertTrue(tables.is_jsonable(None))
|
|
56
|
+
|
|
57
|
+
def test_non_serializable(self):
|
|
58
|
+
self.assertFalse(tables.is_jsonable({1, 2, 3})) # set is not JSON-serializable
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestCopyopts(unittest.TestCase):
|
|
62
|
+
def test_dict(self):
|
|
63
|
+
d = {'a': 1, 'b': [2, 3], 'c': {'d': 4}}
|
|
64
|
+
self.assertEqual(tables.copyopts(d), d)
|
|
65
|
+
|
|
66
|
+
def test_list(self):
|
|
67
|
+
lst = [1, 'x', {'y': 2}]
|
|
68
|
+
self.assertEqual(tables.copyopts(lst), lst)
|
|
69
|
+
|
|
70
|
+
def test_non_serializable_becomes_str(self):
|
|
71
|
+
result = tables.copyopts({'a': {1, 2}})
|
|
72
|
+
self.assertIsInstance(result['a'], str)
|
|
73
|
+
|
|
74
|
+
def test_scalar(self):
|
|
75
|
+
self.assertEqual(tables.copyopts(42), 42)
|
|
76
|
+
self.assertEqual(tables.copyopts('hello'), 'hello')
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestGetDbattr(unittest.TestCase):
|
|
80
|
+
"""get_dbattr traverses using type() at each intermediate level."""
|
|
81
|
+
|
|
82
|
+
def test_simple(self):
|
|
83
|
+
class Obj:
|
|
84
|
+
value = 7
|
|
85
|
+
self.assertEqual(tables.get_dbattr(Obj(), 'value'), 7)
|
|
86
|
+
|
|
87
|
+
def test_dotted(self):
|
|
88
|
+
# Intermediate traversal uses type(getattr(...)), so inner.value must be a class attribute
|
|
89
|
+
class Inner:
|
|
90
|
+
value = 99
|
|
91
|
+
class Outer:
|
|
92
|
+
inner = Inner()
|
|
93
|
+
self.assertEqual(tables.get_dbattr(Outer(), 'inner.value'), 99)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestGetattrdeep(unittest.TestCase):
|
|
97
|
+
def test_simple(self):
|
|
98
|
+
class Obj:
|
|
99
|
+
pass
|
|
100
|
+
obj = Obj()
|
|
101
|
+
obj.name = 'hello'
|
|
102
|
+
self.assertEqual(tables.getattrdeep(obj, 'name'), 'hello')
|
|
103
|
+
|
|
104
|
+
def test_dotted(self):
|
|
105
|
+
class Inner:
|
|
106
|
+
pass
|
|
107
|
+
class Outer:
|
|
108
|
+
pass
|
|
109
|
+
inner = Inner()
|
|
110
|
+
inner.val = 42
|
|
111
|
+
outer = Outer()
|
|
112
|
+
outer.inner = inner
|
|
113
|
+
self.assertEqual(tables.getattrdeep(outer, 'inner.val'), 42)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestSetattrdeep(unittest.TestCase):
|
|
117
|
+
def test_simple(self):
|
|
118
|
+
class Obj:
|
|
119
|
+
pass
|
|
120
|
+
obj = Obj()
|
|
121
|
+
tables.setattrdeep(obj, 'x', 100)
|
|
122
|
+
self.assertEqual(obj.x, 100)
|
|
123
|
+
|
|
124
|
+
def test_dotted(self):
|
|
125
|
+
class Inner:
|
|
126
|
+
pass
|
|
127
|
+
class Outer:
|
|
128
|
+
pass
|
|
129
|
+
inner = Inner()
|
|
130
|
+
outer = Outer()
|
|
131
|
+
outer.inner = inner
|
|
132
|
+
tables.setattrdeep(outer, 'inner.val', 55)
|
|
133
|
+
self.assertEqual(inner.val, 55)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestGetRequestAction(unittest.TestCase):
|
|
137
|
+
def test_present(self):
|
|
138
|
+
self.assertEqual(tables.get_request_action({'action': 'create'}), 'create')
|
|
139
|
+
self.assertEqual(tables.get_request_action({'action': 'remove'}), 'remove')
|
|
140
|
+
|
|
141
|
+
def test_absent(self):
|
|
142
|
+
self.assertIsNone(tables.get_request_action({}))
|
|
143
|
+
self.assertIsNone(tables.get_request_action({'other': 'x'}))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestGetRequestData(unittest.TestCase):
|
|
147
|
+
def test_basic_parse(self):
|
|
148
|
+
form = {
|
|
149
|
+
'data[1][name]': 'Alice',
|
|
150
|
+
'data[1][age]': '30',
|
|
151
|
+
'data[2][name]': 'Bob',
|
|
152
|
+
}
|
|
153
|
+
data = tables.get_request_data(form)
|
|
154
|
+
self.assertEqual(data[1]['name'], 'Alice')
|
|
155
|
+
self.assertEqual(data[1]['age'], '30')
|
|
156
|
+
self.assertEqual(data[2]['name'], 'Bob')
|
|
157
|
+
|
|
158
|
+
def test_action_key_ignored(self):
|
|
159
|
+
form = {'action': 'create', 'data[1][name]': 'Test'}
|
|
160
|
+
data = tables.get_request_data(form)
|
|
161
|
+
self.assertNotIn('action', data)
|
|
162
|
+
|
|
163
|
+
def test_string_id(self):
|
|
164
|
+
form = {'data[abc][x]': '1'}
|
|
165
|
+
data = tables.get_request_data(form)
|
|
166
|
+
self.assertEqual(data['abc']['x'], '1')
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ===========================================================================
|
|
170
|
+
# DataTablesEditor tests
|
|
171
|
+
# ===========================================================================
|
|
172
|
+
|
|
173
|
+
class TestDataTablesEditor(unittest.TestCase):
|
|
174
|
+
def setUp(self):
|
|
175
|
+
class Row:
|
|
176
|
+
pass
|
|
177
|
+
self.Row = Row
|
|
178
|
+
self.dte = tables.DataTablesEditor(
|
|
179
|
+
dbmapping={'name': 'name', 'age': 'age'},
|
|
180
|
+
formmapping={'name': 'name', 'age': 'age'},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def test_get_response_data_simple(self):
|
|
184
|
+
row = self.Row()
|
|
185
|
+
row.name = 'Alice'
|
|
186
|
+
row.age = 30
|
|
187
|
+
data = self.dte.get_response_data(row)
|
|
188
|
+
self.assertEqual(data['name'], 'Alice')
|
|
189
|
+
self.assertEqual(data['age'], 30)
|
|
190
|
+
|
|
191
|
+
def test_set_dbrow_simple(self):
|
|
192
|
+
row = self.Row()
|
|
193
|
+
self.dte.set_dbrow({'name': 'Bob', 'age': '25'}, row)
|
|
194
|
+
self.assertEqual(row.name, 'Bob')
|
|
195
|
+
self.assertEqual(row.age, '25')
|
|
196
|
+
|
|
197
|
+
def test_readonly_field_not_written(self):
|
|
198
|
+
dte = tables.DataTablesEditor(
|
|
199
|
+
dbmapping={'name': '__readonly__', 'age': 'age'},
|
|
200
|
+
formmapping={'name': 'name', 'age': 'age'},
|
|
201
|
+
)
|
|
202
|
+
row = self.Row()
|
|
203
|
+
row.name = 'original'
|
|
204
|
+
dte.set_dbrow({'name': 'changed', 'age': '25'}, row)
|
|
205
|
+
self.assertEqual(row.name, 'original') # skipped because __readonly__
|
|
206
|
+
|
|
207
|
+
def test_null2emptystring_get(self):
|
|
208
|
+
dte = tables.DataTablesEditor({'name': 'name'}, {'name': 'name'}, null2emptystring=True)
|
|
209
|
+
row = self.Row()
|
|
210
|
+
row.name = None
|
|
211
|
+
data = dte.get_response_data(row)
|
|
212
|
+
self.assertEqual(data['name'], '')
|
|
213
|
+
|
|
214
|
+
def test_skip_formmapping_field(self):
|
|
215
|
+
dte = tables.DataTablesEditor({}, {'name': 'name', 'computed': '__skip__'})
|
|
216
|
+
row = self.Row()
|
|
217
|
+
row.name = 'x'
|
|
218
|
+
data = dte.get_response_data(row)
|
|
219
|
+
self.assertNotIn('computed', data)
|
|
220
|
+
|
|
221
|
+
def test_callable_formmapping(self):
|
|
222
|
+
dte = tables.DataTablesEditor({}, {'upper': lambda r: r.name.upper()})
|
|
223
|
+
row = self.Row()
|
|
224
|
+
row.name = 'alice'
|
|
225
|
+
self.assertEqual(dte.get_response_data(row)['upper'], 'ALICE')
|
|
226
|
+
|
|
227
|
+
def test_callable_dbmapping(self):
|
|
228
|
+
dte = tables.DataTablesEditor({'name': lambda form: form.get('name', '').strip()}, {})
|
|
229
|
+
row = self.Row()
|
|
230
|
+
dte.set_dbrow({'name': ' Bob '}, row)
|
|
231
|
+
self.assertEqual(row.name, 'Bob')
|
|
232
|
+
|
|
233
|
+
def test_response_hook_called(self):
|
|
234
|
+
calls = []
|
|
235
|
+
def hook(data):
|
|
236
|
+
calls.append(True)
|
|
237
|
+
data['_extra'] = 'added'
|
|
238
|
+
self.dte.set_response_hook(hook)
|
|
239
|
+
row = self.Row()
|
|
240
|
+
row.name = 'Test'
|
|
241
|
+
row.age = 1
|
|
242
|
+
data = self.dte.get_response_data(row)
|
|
243
|
+
self.assertTrue(calls)
|
|
244
|
+
self.assertEqual(data['_extra'], 'added')
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ===========================================================================
|
|
248
|
+
# CrudApi abstract method tests
|
|
249
|
+
# The base class raises tables.NotImplementedError for open/nexttablerow/close/permission.
|
|
250
|
+
# ===========================================================================
|
|
251
|
+
|
|
252
|
+
class TestCrudApiAbstractMethods(unittest.TestCase):
|
|
253
|
+
def setUp(self):
|
|
254
|
+
# Minimum kwargs to avoid TypeError in __init__ (rule defaults to /endpoint)
|
|
255
|
+
self.api = tables.CrudApi(app=Flask(__name__), endpoint='testapi')
|
|
256
|
+
|
|
257
|
+
def test_open_raises(self):
|
|
258
|
+
with self.assertRaises(tables.NotImplementedError):
|
|
259
|
+
self.api.open()
|
|
260
|
+
|
|
261
|
+
def test_nexttablerow_raises(self):
|
|
262
|
+
with self.assertRaises(tables.NotImplementedError):
|
|
263
|
+
self.api.nexttablerow()
|
|
264
|
+
|
|
265
|
+
def test_close_raises(self):
|
|
266
|
+
with self.assertRaises(tables.NotImplementedError):
|
|
267
|
+
self.api.close()
|
|
268
|
+
|
|
269
|
+
def test_permission_raises(self):
|
|
270
|
+
with self.assertRaises(tables.NotImplementedError):
|
|
271
|
+
self.api.permission()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ===========================================================================
|
|
275
|
+
# DbCrudApi integration tests using in-memory SQLite
|
|
276
|
+
# ===========================================================================
|
|
277
|
+
|
|
278
|
+
class TestDbCrudApiIntegration(unittest.TestCase):
|
|
279
|
+
def setUp(self):
|
|
280
|
+
self.app = Flask(__name__)
|
|
281
|
+
self.app.config['TESTING'] = True
|
|
282
|
+
|
|
283
|
+
self.engine = _make_engine()
|
|
284
|
+
Base.metadata.create_all(self.engine)
|
|
285
|
+
self.Session = _make_scoped_session(self.engine)
|
|
286
|
+
self.db = DbWrapper(self.Session)
|
|
287
|
+
|
|
288
|
+
class UserApi(tables.DbCrudApi):
|
|
289
|
+
def permission(self):
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
self.api = UserApi(
|
|
293
|
+
app=self.app,
|
|
294
|
+
endpoint='userapi',
|
|
295
|
+
rule='/users',
|
|
296
|
+
db=self.db,
|
|
297
|
+
model=User,
|
|
298
|
+
dbmapping={'name': 'name'},
|
|
299
|
+
formmapping={'name': 'name'},
|
|
300
|
+
clientcolumns=[
|
|
301
|
+
{'data': 'name', 'name': 'name', 'label': 'Name', '_unique': True}
|
|
302
|
+
],
|
|
303
|
+
)
|
|
304
|
+
self.api.register()
|
|
305
|
+
self.client = self.app.test_client()
|
|
306
|
+
|
|
307
|
+
def tearDown(self):
|
|
308
|
+
Base.metadata.drop_all(self.engine)
|
|
309
|
+
self.Session.remove()
|
|
310
|
+
|
|
311
|
+
def test_create_returns_row_data(self):
|
|
312
|
+
resp = self.client.post('/users/rest', data={
|
|
313
|
+
'action': 'create',
|
|
314
|
+
'data[0][name]': 'Alice',
|
|
315
|
+
})
|
|
316
|
+
self.assertEqual(resp.status_code, 200)
|
|
317
|
+
data = json.loads(resp.data)
|
|
318
|
+
self.assertIn('data', data)
|
|
319
|
+
self.assertEqual(data['data'][0]['name'], 'Alice')
|
|
320
|
+
|
|
321
|
+
def test_get_returns_list_of_rows(self):
|
|
322
|
+
self.client.post('/users/rest', data={'action': 'create', 'data[0][name]': 'Bob'})
|
|
323
|
+
resp = self.client.get('/users/rest')
|
|
324
|
+
self.assertEqual(resp.status_code, 200)
|
|
325
|
+
data = json.loads(resp.data)
|
|
326
|
+
self.assertIsInstance(data, list)
|
|
327
|
+
self.assertTrue(any(row.get('name') == 'Bob' for row in data))
|
|
328
|
+
|
|
329
|
+
def test_update_row(self):
|
|
330
|
+
# SQLite auto-increment gives the first inserted row id=1
|
|
331
|
+
self.client.post('/users/rest', data={'action': 'create', 'data[0][name]': 'Carol'})
|
|
332
|
+
resp = self.client.put('/users/rest/1', data={
|
|
333
|
+
'action': 'edit',
|
|
334
|
+
'data[1][name]': 'Caroline',
|
|
335
|
+
})
|
|
336
|
+
self.assertEqual(resp.status_code, 200)
|
|
337
|
+
data = json.loads(resp.data)
|
|
338
|
+
self.assertIn('data', data)
|
|
339
|
+
self.assertEqual(data['data'][0]['name'], 'Caroline')
|
|
340
|
+
|
|
341
|
+
def test_delete_row(self):
|
|
342
|
+
self.client.post('/users/rest', data={'action': 'create', 'data[0][name]': 'Dave'})
|
|
343
|
+
# delete action comes from request.args (formrequest=False)
|
|
344
|
+
resp = self.client.delete('/users/rest/1', query_string={'action': 'remove'})
|
|
345
|
+
self.assertEqual(resp.status_code, 200)
|
|
346
|
+
resp = self.client.get('/users/rest')
|
|
347
|
+
data = json.loads(resp.data)
|
|
348
|
+
self.assertFalse(any(row.get('name') == 'Dave' for row in data))
|
|
349
|
+
|
|
350
|
+
def test_duplicate_unique_returns_error_json(self):
|
|
351
|
+
"""Creating a duplicate unique value returns HTTP 200 with error/fieldErrors."""
|
|
352
|
+
self.client.post('/users/rest', data={'action': 'create', 'data[0][name]': 'Eve'})
|
|
353
|
+
resp = self.client.post('/users/rest', data={'action': 'create', 'data[0][name]': 'Eve'})
|
|
354
|
+
self.assertEqual(resp.status_code, 200)
|
|
355
|
+
data = json.loads(resp.data)
|
|
356
|
+
self.assertTrue('error' in data or 'fieldErrors' in data)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ===========================================================================
|
|
360
|
+
# DbCrudApiRolePermissions tests
|
|
361
|
+
# ===========================================================================
|
|
362
|
+
|
|
363
|
+
def _mock_user(roles):
|
|
364
|
+
class MockUser:
|
|
365
|
+
def __init__(self, r):
|
|
366
|
+
self._roles = r
|
|
367
|
+
def has_role(self, role):
|
|
368
|
+
return role in self._roles
|
|
369
|
+
return MockUser(roles)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class TestDbCrudApiRolePermissions(unittest.TestCase):
|
|
373
|
+
_ep_counter = 0
|
|
374
|
+
|
|
375
|
+
def _make_api(self, **extra):
|
|
376
|
+
TestDbCrudApiRolePermissions._ep_counter += 1
|
|
377
|
+
ep = 'roleapi_{}'.format(TestDbCrudApiRolePermissions._ep_counter)
|
|
378
|
+
|
|
379
|
+
# Inline subclass: disable auth decorator and expose current_user as property
|
|
380
|
+
class RoleApi(tables.DbCrudApiRolePermissions):
|
|
381
|
+
decorators = [] # bypass auth_required() for unit testing
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def current_user(self):
|
|
385
|
+
return self._mock_user
|
|
386
|
+
|
|
387
|
+
api = RoleApi(
|
|
388
|
+
app=self.app,
|
|
389
|
+
endpoint=ep,
|
|
390
|
+
rule='/' + ep,
|
|
391
|
+
db=self.db,
|
|
392
|
+
model=User,
|
|
393
|
+
dbmapping={'name': 'name'},
|
|
394
|
+
formmapping={'name': 'name'},
|
|
395
|
+
clientcolumns=[{'data': 'name', 'name': 'name', 'label': 'Name'}],
|
|
396
|
+
**extra,
|
|
397
|
+
)
|
|
398
|
+
return api
|
|
399
|
+
|
|
400
|
+
def setUp(self):
|
|
401
|
+
self.app = Flask(__name__)
|
|
402
|
+
self.engine = _make_engine()
|
|
403
|
+
Base.metadata.create_all(self.engine)
|
|
404
|
+
self.Session = _make_scoped_session(self.engine)
|
|
405
|
+
self.db = DbWrapper(self.Session)
|
|
406
|
+
|
|
407
|
+
def tearDown(self):
|
|
408
|
+
Base.metadata.drop_all(self.engine)
|
|
409
|
+
self.Session.remove()
|
|
410
|
+
|
|
411
|
+
def test_roles_accepted_grants_permission(self):
|
|
412
|
+
api = self._make_api(roles_accepted=['admin'])
|
|
413
|
+
api._mock_user = _mock_user(['admin'])
|
|
414
|
+
self.assertTrue(api.permission())
|
|
415
|
+
|
|
416
|
+
def test_roles_accepted_denies_permission(self):
|
|
417
|
+
api = self._make_api(roles_accepted=['admin'])
|
|
418
|
+
api._mock_user = _mock_user(['user'])
|
|
419
|
+
self.assertFalse(api.permission())
|
|
420
|
+
|
|
421
|
+
def test_no_roles_configured_grants_permission(self):
|
|
422
|
+
api = self._make_api()
|
|
423
|
+
api._mock_user = _mock_user([])
|
|
424
|
+
self.assertTrue(api.permission())
|
|
425
|
+
|
|
426
|
+
def test_roles_required_all_must_be_present(self):
|
|
427
|
+
api = self._make_api(roles_required=['admin', 'superuser'])
|
|
428
|
+
api._mock_user = _mock_user(['admin']) # only one role → denied
|
|
429
|
+
self.assertFalse(api.permission())
|
|
430
|
+
api._mock_user = _mock_user(['admin', 'superuser']) # both → granted
|
|
431
|
+
self.assertTrue(api.permission())
|
|
432
|
+
|
|
433
|
+
def test_roles_accepted_and_required_raises(self):
|
|
434
|
+
with self.assertRaises(tables.ParameterError):
|
|
435
|
+
self._make_api(roles_accepted=['admin'], roles_required=['superuser'])
|
|
436
|
+
|
|
437
|
+
def test_roles_accepted_string_normalised_to_list(self):
|
|
438
|
+
api = self._make_api(roles_accepted='admin')
|
|
439
|
+
self.assertEqual(api.roles_accepted, ['admin'])
|
|
440
|
+
|
|
441
|
+
def test_roles_required_string_normalised_to_list(self):
|
|
442
|
+
api = self._make_api(roles_required='admin')
|
|
443
|
+
self.assertEqual(api.roles_required, ['admin'])
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
if __name__ == '__main__':
|
|
447
|
+
unittest.main()
|