1
0
Fork 0

feat: add basic support for wutta-continuum

and i mean *basic* - so far..  eventually will expose version history
for viewing etc.

unfortunately got carried away and reorganized the api docs a little
while i was at it..
This commit is contained in:
Lance Edgar 2024-08-27 21:11:44 -05:00
parent 24f5ee1dcc
commit 71728718d8
53 changed files with 308 additions and 76 deletions

View file

@ -1,10 +0,0 @@
Package API
===========
This is the "raw" API documentation for the ``wuttaweb`` package.
.. toctree::
:maxdepth: 2
wuttaweb/index

View file

@ -0,0 +1,6 @@
``wuttaweb.conf``
=================
.. automodule:: wuttaweb.conf
:members:

View file

@ -0,0 +1,6 @@
``wuttaweb.db.continuum``
=========================
.. automodule:: wuttaweb.db.continuum
:members:

View file

@ -0,0 +1,6 @@
``wuttaweb.db.sess``
====================
.. automodule:: wuttaweb.db.sess
:members:

6
docs/api/wuttaweb.rst Normal file
View file

@ -0,0 +1,6 @@
``wuttaweb``
============
.. automodule:: wuttaweb
:members:

View file

@ -1,38 +0,0 @@
``wuttaweb``
============
.. automodule:: wuttaweb
.. toctree::
:maxdepth: 1
app
auth
db
forms
forms.base
forms.schema
forms.widgets
grids
grids.base
grids.filters
handler
helpers
menus
progress
static
subscribers
util
views
views.auth
views.base
views.common
views.essential
views.master
views.people
views.progress
views.roles
views.settings
views.upgrades
views.users

View file

@ -34,6 +34,7 @@ intersphinx_mapping = {
'rattail-manual': ('https://rattailproject.org/docs/rattail-manual/', None), 'rattail-manual': ('https://rattailproject.org/docs/rattail-manual/', None),
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
'wutta-continuum': ('https://rattailproject.org/docs/wutta-continuum/', None),
} }

View file

@ -11,13 +11,53 @@ project.
.. _test coverage: https://buildbot.rattailproject.org/coverage/wuttaweb/ .. _test coverage: https://buildbot.rattailproject.org/coverage/wuttaweb/
However as you can see..the API should be fairly well documented but
the narrative docs are pretty scant. That will eventually change.
.. toctree:: .. toctree::
:maxdepth: 3 :maxdepth: 2
:caption: Contents: :caption: Documentation:
glossary glossary
narr/index narr/templates/index
api/index
.. toctree::
:maxdepth: 1
:caption: Package API:
api/wuttaweb
api/wuttaweb.app
api/wuttaweb.auth
api/wuttaweb.conf
api/wuttaweb.db
api/wuttaweb.db.continuum
api/wuttaweb.db.sess
api/wuttaweb.forms
api/wuttaweb.forms.base
api/wuttaweb.forms.schema
api/wuttaweb.forms.widgets
api/wuttaweb.grids
api/wuttaweb.grids.base
api/wuttaweb.grids.filters
api/wuttaweb.handler
api/wuttaweb.helpers
api/wuttaweb.menus
api/wuttaweb.progress
api/wuttaweb.static
api/wuttaweb.subscribers
api/wuttaweb.util
api/wuttaweb.views
api/wuttaweb.views.auth
api/wuttaweb.views.base
api/wuttaweb.views.common
api/wuttaweb.views.essential
api/wuttaweb.views.master
api/wuttaweb.views.people
api/wuttaweb.views.progress
api/wuttaweb.views.roles
api/wuttaweb.views.settings
api/wuttaweb.views.upgrades
api/wuttaweb.views.users
Indices and tables Indices and tables

View file

@ -1,8 +0,0 @@
Documentation
=============
.. toctree::
:maxdepth: 2
templates/index

View file

@ -42,12 +42,13 @@ dependencies = [
"pyramid_tm", "pyramid_tm",
"waitress", "waitress",
"WebHelpers2", "WebHelpers2",
"WuttJamaican[db,email]>=0.13.0", "WuttJamaican[db,email]>=0.13.2",
"zope.sqlalchemy>=1.5", "zope.sqlalchemy>=1.5",
] ]
[project.optional-dependencies] [project.optional-dependencies]
continuum = ["Wutta-Continuum"]
docs = ["Sphinx", "furo"] docs = ["Sphinx", "furo"]
tests = ["pytest-cov", "tox"] tests = ["pytest-cov", "tox"]
@ -60,6 +61,10 @@ main = "wuttaweb.app:main"
wuttaweb = "wuttaweb.app:WebAppProvider" wuttaweb = "wuttaweb.app:WebAppProvider"
[project.entry-points."wutta.config.extensions"]
wuttaweb = "wuttaweb.conf:WuttaWebConfigExtension"
[project.urls] [project.urls]
Homepage = "https://wuttaproject.org/" Homepage = "https://wuttaproject.org/"
Repository = "https://forgejo.wuttaproject.org/wutta/wuttaweb" Repository = "https://forgejo.wuttaproject.org/wutta/wuttaweb"

View file

@ -98,7 +98,8 @@ class WuttaSecurityPolicy:
for current user for current user
:param db_session: Optional :term:`db session` to use, instead of :param db_session: Optional :term:`db session` to use, instead of
:class:`wuttaweb.db.Session`. Probably only useful for tests. :class:`wuttaweb.db.sess.Session`. Probably only useful for
tests.
""" """
def __init__(self, db_session=None): def __init__(self, db_session=None):

43
src/wuttaweb/conf.py Normal file
View file

@ -0,0 +1,43 @@
# -*- coding: utf-8; -*-
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Config Extension
"""
from wuttjamaican.conf import WuttaConfigExtension
class WuttaWebConfigExtension(WuttaConfigExtension):
"""
Config extension for WuttaWeb.
This sets the default plugin for SQLAlchemy-Continuum. Which is
only relevant if Wutta-Continuum is installed and enabled. For
more info see :doc:`wutta-continuum:index`.
"""
key = 'wuttaweb'
def configure(self, config):
""" """
config.setdefault('wutta_continuum.wutta_plugin_spec',
'wuttaweb.db.continuum:WuttaWebContinuumPlugin')

View file

@ -0,0 +1,31 @@
# -*- coding: utf-8; -*-
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Database Utilities
The following are available from this ``wuttaweb.db`` namespace:
* :class:`~wuttaweb.db.sess.Session`
"""
from .sess import Session

View file

@ -0,0 +1,56 @@
# -*- coding: utf-8; -*-
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
SQLAlchemy-Continuum Plugin
"""
from pyramid.threadlocal import get_current_request
try:
from wutta_continuum.conf import WuttaContinuumPlugin
except ImportError: # pragma: no cover
pass
else:
class WuttaWebContinuumPlugin(WuttaContinuumPlugin):
"""
SQLAlchemy-Continuum manager plugin for WuttaWeb.
This tries to use the current request to obtain user and IP
address for the transaction.
"""
# TODO: should find a better way, threadlocals are bad?
# https://docs.pylonsproject.org/projects/pyramid/en/latest/api/threadlocal.html#pyramid.threadlocal.get_current_request
def get_remote_addr(self, uow, session):
""" """
request = get_current_request()
if request:
return request.client_addr
def get_user_id(self, uow, session):
""" """
request = get_current_request()
if request and request.user:
return request.user.uuid

View file

@ -128,7 +128,7 @@ class WuttaSet(colander.Set):
:param request: Current :term:`request` object. :param request: Current :term:`request` object.
:param session: Optional :term:`db session` to use instead of :param session: Optional :term:`db session` to use instead of
:class:`wuttaweb.db.Session`. :class:`wuttaweb.db.sess.Session`.
""" """
def __init__(self, request, session=None): def __init__(self, request, session=None):

View file

@ -99,6 +99,9 @@ class ObjectRefWidget(SelectWidget):
""" """ """ """
values = super().get_template_values(field, cstruct, kw) values = super().get_template_values(field, cstruct, kw)
# add url, only if rendering readonly
readonly = kw.get('readonly', self.readonly)
if readonly:
if 'url' not in values and self.url and field.schema.model_instance: if 'url' not in values and self.url and field.schema.model_instance:
values['url'] = self.url(field.schema.model_instance) values['url'] = self.url(field.schema.model_instance)
@ -134,7 +137,7 @@ class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
:param request: Current :term:`request` object. :param request: Current :term:`request` object.
:param session: Optional :term:`db session` to use instead of :param session: Optional :term:`db session` to use instead of
:class:`wuttaweb.db.Session`. :class:`wuttaweb.db.sess.Session`.
It uses these Deform templates: It uses these Deform templates:

View file

@ -147,7 +147,7 @@ def new_request_set_user(
from database, instead of :func:`default_user_getter()`. from database, instead of :func:`default_user_getter()`.
:param db_session: Optional :term:`db session` to use, :param db_session: Optional :term:`db session` to use,
instead of :class:`wuttaweb.db.Session`. instead of :class:`wuttaweb.db.sess.Session`.
This will add to the request object: This will add to the request object:

View file

@ -491,9 +491,15 @@ def get_model_fields(config, model_class=None):
try: try:
mapper = sa.inspect(model_class) mapper = sa.inspect(model_class)
except sa.exc.NoInspectionAvailable: except sa.exc.NoInspectionAvailable:
pass return
else:
fields = [prop.key for prop in mapper.iterate_properties] fields = [prop.key for prop in mapper.iterate_properties]
# nb. we never want the continuum 'versions' prop
app = config.get_app()
if app.continuum_is_enabled() and 'versions' in fields:
fields.remove('versions')
return fields return fields

0
tests/db/__init__.py Normal file
View file

View file

@ -0,0 +1,38 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch, MagicMock
import pytest
from tests.util import WebTestCase
from wuttaweb.db import continuum as mod
class TestWuttaWebContinuumPlugin(WebTestCase):
def setUp(self):
if not hasattr(mod, 'WuttaWebContinuumPlugin'):
pytest.skip("test not relevant without sqlalchemy-continuum")
self.setup_web()
def make_plugin(self):
return mod.WuttaWebContinuumPlugin()
def test_get_remote_addr(self):
plugin = self.make_plugin()
with patch.object(mod, 'get_current_request', return_value=None):
self.assertIsNone(plugin.get_remote_addr(None, self.session))
self.request.client_addr = '127.0.0.1'
self.assertEqual(plugin.get_remote_addr(None, self.session), '127.0.0.1')
def test_get_user_id(self):
plugin = self.make_plugin()
with patch.object(mod, 'get_current_request', return_value=None):
self.assertIsNone(plugin.get_user_id(None, self.session))
self.request.user = MagicMock(uuid='some-random-uuid')
self.assertEqual(plugin.get_user_id(None, self.session), 'some-random-uuid')

View file

@ -19,6 +19,9 @@ class TestObjectRefWidget(WebTestCase):
kwargs.setdefault('renderer', deform.Form.default_renderer) kwargs.setdefault('renderer', deform.Form.default_renderer)
return deform.Field(node, **kwargs) return deform.Field(node, **kwargs)
def make_widget(self, **kwargs):
return mod.ObjectRefWidget(self.request, **kwargs)
def test_serialize(self): def test_serialize(self):
model = self.app.model model = self.app.model
person = model.Person(full_name="Betty Boop") person = model.Person(full_name="Betty Boop")
@ -27,7 +30,7 @@ class TestObjectRefWidget(WebTestCase):
# standard (editable) # standard (editable)
node = colander.SchemaNode(PersonRef(self.request, session=self.session)) node = colander.SchemaNode(PersonRef(self.request, session=self.session))
widget = mod.ObjectRefWidget(self.request) widget = self.make_widget()
field = self.make_field(node) field = self.make_field(node)
html = widget.serialize(field, person.uuid) html = widget.serialize(field, person.uuid)
self.assertIn('<b-select ', html) self.assertIn('<b-select ', html)
@ -35,7 +38,7 @@ class TestObjectRefWidget(WebTestCase):
# readonly # readonly
node = colander.SchemaNode(PersonRef(self.request, session=self.session)) node = colander.SchemaNode(PersonRef(self.request, session=self.session))
node.model_instance = person node.model_instance = person
widget = mod.ObjectRefWidget(self.request) widget = self.make_widget()
field = self.make_field(node) field = self.make_field(node)
html = widget.serialize(field, person.uuid, readonly=True) html = widget.serialize(field, person.uuid, readonly=True)
self.assertIn('Betty Boop', html) self.assertIn('Betty Boop', html)
@ -44,13 +47,26 @@ class TestObjectRefWidget(WebTestCase):
# with hyperlink # with hyperlink
node = colander.SchemaNode(PersonRef(self.request, session=self.session)) node = colander.SchemaNode(PersonRef(self.request, session=self.session))
node.model_instance = person node.model_instance = person
widget = mod.ObjectRefWidget(self.request, url=lambda p: '/foo') widget = self.make_widget(url=lambda p: '/foo')
field = self.make_field(node) field = self.make_field(node)
html = widget.serialize(field, person.uuid, readonly=True) html = widget.serialize(field, person.uuid, readonly=True)
self.assertIn('Betty Boop', html) self.assertIn('Betty Boop', html)
self.assertIn('<a', html) self.assertIn('<a', html)
self.assertIn('href="/foo"', html) self.assertIn('href="/foo"', html)
def test_get_template_values(self):
model = self.app.model
person = model.Person(full_name="Betty Boop")
self.session.add(person)
self.session.commit()
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
widget = self.make_widget()
field = self.make_field(node)
values = widget.get_template_values(field, person.uuid, {})
self.assertIn('cstruct', values)
self.assertNotIn('url', values)
class TestFileDownloadWidget(WebTestCase): class TestFileDownloadWidget(WebTestCase):

View file

@ -480,6 +480,27 @@ class TestGetModelFields(TestCase):
fields = mod.get_model_fields(self.config, model.Setting) fields = mod.get_model_fields(self.config, model.Setting)
self.assertEqual(fields, ['name', 'value']) self.assertEqual(fields, ['name', 'value'])
def test_avoid_versions(self):
model = self.app.model
mapper = MagicMock(iterate_properties = [
MagicMock(key='uuid'),
MagicMock(key='full_name'),
MagicMock(key='first_name'),
MagicMock(key='middle_name'),
MagicMock(key='last_name'),
MagicMock(key='versions'),
])
with patch.object(mod, 'sa') as sa:
sa.inspect.return_value = mapper
with patch.object(self.app, 'continuum_is_enabled', return_value=True):
fields = mod.get_model_fields(self.config, model.Person)
# nb. no versions field
self.assertEqual(set(fields), set(['uuid', 'full_name', 'first_name',
'middle_name', 'last_name']))
class TestGetCsrfToken(TestCase): class TestGetCsrfToken(TestCase):

View file

@ -1,11 +1,14 @@
[tox] [tox]
envlist = py38, py39, py310, py311 envlist = py38, py39, py310, py311, nox
[testenv] [testenv]
extras = tests extras = continuum,tests
commands = pytest {posargs} commands = pytest {posargs}
[testenv:nox]
extras = tests
[testenv:coverage] [testenv:coverage]
basepython = python3.11 basepython = python3.11
commands = pytest --cov=wuttaweb --cov-report=html --cov-fail-under=100 commands = pytest --cov=wuttaweb --cov-report=html --cov-fail-under=100