3
0
Fork 0

Compare commits

...

11 commits

Author SHA1 Message Date
Lance Edgar 49b13306c4 bump: version 0.19.0 → 0.19.1 2025-01-06 17:00:27 -06:00
Lance Edgar b3f1f8b6d9 fix: improve built-in grid renderer logic
- add `render_batch_id()`
- allow kwargs for `render_currency()`
- caller may specify built-in renderer w/ string identifier
2025-01-06 16:56:31 -06:00
Lance Edgar 2de08ad50d fix: allow session injection for ObjectRef constructor
for sake of simpler tests
2025-01-06 16:48:42 -06:00
Lance Edgar 7895ce4676 tests: move WebTestCase to wuttaweb.testing module 2025-01-06 16:47:48 -06:00
Lance Edgar 5cec585fdf fix: improve rendering for batch row status 2025-01-02 23:14:10 -06:00
Lance Edgar 86ffb5d58f fix: add basic support for row grid "view" action links
still no actual "view row" support just yet, but subclass can
implement however they like..
2025-01-02 22:52:32 -06:00
Lance Edgar 170afe650b fix: add "xref buttons" tool panel for master view
also add `url` param for `MasterView.make_button()`
2025-01-02 22:35:43 -06:00
Lance Edgar 0631b8e16b fix: add WuttaQuantity schema type, widget 2025-01-02 21:28:55 -06:00
Lance Edgar a219f3e30d fix: remove session param from some form schema, widget classes
this was originally used for injecting the test session, but i wound
up using mock instead elsewhere, so this is just for consistency
2025-01-02 21:09:31 -06:00
Lance Edgar a612bf3846 fix: add grid renderers for bool, currency, quantity
also set bool renderer by default when possible
2025-01-02 20:13:04 -06:00
Lance Edgar 08a895a07b fix: use proper bulma styles for markdown content
cf. https://bulma.io/documentation/elements/content/
2024-12-29 20:07:10 -06:00
33 changed files with 761 additions and 315 deletions

View file

@ -5,6 +5,26 @@ All notable changes to wuttaweb will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.19.1 (2025-01-06)
### Fix
- improve built-in grid renderer logic
- allow session injection for ObjectRef constructor
- improve rendering for batch row status
- add basic support for row grid "view" action links
- add "xref buttons" tool panel for master view
- add WuttaQuantity schema type, widget
- remove `session` param from some form schema, widget classes
- add grid renderers for bool, currency, quantity
- use proper bulma styles for markdown content
- use span element for readonly money field widget render
- include grid filters for all column properties of model class
- use app handler to render error string, when progress fails
- add schema node type, widget for "money" (currency) fields
- exclude FK fields by default, for model forms
- fix style for header title text
## v0.19.0 (2024-12-23) ## v0.19.0 (2024-12-23)
### Feat ### Feat

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttaWeb" name = "WuttaWeb"
version = "0.19.0" version = "0.19.1"
description = "Web App for Wutta Framework" description = "Web App for Wutta Framework"
readme = "README.md" readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
@ -45,7 +45,7 @@ dependencies = [
"SQLAlchemy-Utils", "SQLAlchemy-Utils",
"waitress", "waitress",
"WebHelpers2", "WebHelpers2",
"WuttJamaican[db]>=0.19.1", "WuttJamaican[db]>=0.19.2",
"zope.sqlalchemy>=1.5", "zope.sqlalchemy>=1.5",
] ]

View file

@ -177,25 +177,41 @@ class WuttaMoney(colander.Money):
return widgets.WuttaMoneyInputWidget(self.request, **kwargs) return widgets.WuttaMoneyInputWidget(self.request, **kwargs)
class WuttaQuantity(colander.Decimal):
"""
Custom schema type for "quantity" fields.
This is a subclass of :class:`colander:colander.Decimal` but uses
:class:`~wuttaweb.forms.widgets.WuttaQuantityWidget` by default.
:param request: Current :term:`request` object.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
def widget_maker(self, **kwargs):
""" """
return widgets.WuttaQuantityWidget(self.request, **kwargs)
class WuttaSet(colander.Set): class WuttaSet(colander.Set):
""" """
Custom schema type for :class:`python:set` fields. Custom schema type for :class:`python:set` fields.
This is a subclass of :class:`colander.Set`, but adds This is a subclass of :class:`colander.Set`.
Wutta-related params to the constructor.
:param request: Current :term:`request` object. :param request: Current :term:`request` object.
:param session: Optional :term:`db session` to use instead of
:class:`wuttaweb.db.sess.Session`.
""" """
def __init__(self, request, session=None): def __init__(self, request):
super().__init__() super().__init__()
self.request = request self.request = request
self.config = self.request.wutta_config self.config = self.request.wutta_config
self.app = self.config.get_app() self.app = self.config.get_app()
self.session = session or Session()
class ObjectRef(colander.SchemaType): class ObjectRef(colander.SchemaType):
@ -231,16 +247,16 @@ class ObjectRef(colander.SchemaType):
self, self,
request, request,
empty_option=None, empty_option=None,
session=None,
*args, *args,
**kwargs, **kwargs,
): ):
# nb. allow session injection for tests
self.session = kwargs.pop('session', Session())
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.request = request self.request = request
self.config = self.request.wutta_config self.config = self.request.wutta_config
self.app = self.config.get_app() self.app = self.config.get_app()
self.model_instance = None self.model_instance = None
self.session = session or Session()
if empty_option: if empty_option:
if empty_option is True: if empty_option is True:
@ -472,7 +488,7 @@ class RoleRefs(WuttaSet):
:returns: Instance of :returns: Instance of
:class:`~wuttaweb.forms.widgets.RoleRefsWidget`. :class:`~wuttaweb.forms.widgets.RoleRefsWidget`.
""" """
kwargs.setdefault('session', self.session) session = kwargs.setdefault('session', Session())
if 'values' not in kwargs: if 'values' not in kwargs:
model = self.app.model model = self.app.model
@ -480,20 +496,20 @@ class RoleRefs(WuttaSet):
# avoid built-ins which cannot be assigned to users # avoid built-ins which cannot be assigned to users
avoid = { avoid = {
auth.get_role_authenticated(self.session), auth.get_role_authenticated(session),
auth.get_role_anonymous(self.session), auth.get_role_anonymous(session),
} }
avoid = set([role.uuid for role in avoid]) avoid = set([role.uuid for role in avoid])
# also avoid admin unless current user is root # also avoid admin unless current user is root
if not self.request.is_root: if not self.request.is_root:
avoid.add(auth.get_role_administrator(self.session).uuid) avoid.add(auth.get_role_administrator(session).uuid)
# everything else can be (un)assigned for users # everything else can be (un)assigned for users
roles = self.session.query(model.Role)\ roles = session.query(model.Role)\
.filter(~model.Role.uuid.in_(avoid))\ .filter(~model.Role.uuid.in_(avoid))\
.order_by(model.Role.name)\ .order_by(model.Role.name)\
.all() .all()
values = [(role.uuid.hex, role.name) for role in roles] values = [(role.uuid.hex, role.name) for role in roles]
kwargs['values'] = values kwargs['values'] = values
@ -518,7 +534,7 @@ class UserRefs(WuttaSet):
:returns: Instance of :returns: Instance of
:class:`~wuttaweb.forms.widgets.UserRefsWidget`. :class:`~wuttaweb.forms.widgets.UserRefsWidget`.
""" """
kwargs.setdefault('session', self.session) kwargs.setdefault('session', Session())
return widgets.UserRefsWidget(self.request, **kwargs) return widgets.UserRefsWidget(self.request, **kwargs)
@ -548,7 +564,7 @@ class Permissions(WuttaSet):
:returns: Instance of :returns: Instance of
:class:`~wuttaweb.forms.widgets.PermissionsWidget`. :class:`~wuttaweb.forms.widgets.PermissionsWidget`.
""" """
kwargs.setdefault('session', self.session) kwargs.setdefault('session', Session())
kwargs.setdefault('permissions', self.permissions) kwargs.setdefault('permissions', self.permissions)
if 'values' not in kwargs: if 'values' not in kwargs:

View file

@ -136,26 +136,21 @@ class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
Custom widget for :class:`python:set` fields. Custom widget for :class:`python:set` fields.
This is a subclass of This is a subclass of
:class:`deform:deform.widget.CheckboxChoiceWidget`, but adds :class:`deform:deform.widget.CheckboxChoiceWidget`.
Wutta-related params to the constructor.
:param request: Current :term:`request` object. :param request: Current :term:`request` object.
:param session: Optional :term:`db session` to use instead of
:class:`wuttaweb.db.sess.Session`.
It uses these Deform templates: It uses these Deform templates:
* ``checkbox_choice`` * ``checkbox_choice``
* ``readonly/checkbox_choice`` * ``readonly/checkbox_choice``
""" """
def __init__(self, request, session=None, *args, **kwargs): def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.request = request self.request = request
self.config = self.request.wutta_config self.config = self.request.wutta_config
self.app = self.config.get_app() self.app = self.config.get_app()
self.session = session or Session()
class WuttaDateTimeWidget(DateTimeInputWidget): class WuttaDateTimeWidget(DateTimeInputWidget):
@ -231,6 +226,42 @@ class WuttaMoneyInputWidget(MoneyInputWidget):
return super().serialize(field, cstruct, **kw) return super().serialize(field, cstruct, **kw)
class WuttaQuantityWidget(TextInputWidget):
"""
Custom widget for "quantity" fields. This is used by default for
:class:`~wuttaweb.forms.schema.WuttaQuantity` type nodes.
The main purpose of this widget is to leverage
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`
for the readonly display.
This is a subclass of
:class:`deform:deform.widget.TextInputWidget` and uses these
Deform templates:
* ``textinput``
:param request: Current :term:`request` object.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get('readonly', self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag('span')
cstruct = decimal.Decimal(cstruct)
return HTML.tag('span', c=[self.app.render_quantity(cstruct)])
return super().serialize(field, cstruct, **kw)
class FileDownloadWidget(Widget): class FileDownloadWidget(Widget):
""" """
Widget for use with :class:`~wuttaweb.forms.schema.FileDownload` Widget for use with :class:`~wuttaweb.forms.schema.FileDownload`

View file

@ -116,7 +116,8 @@ class Grid:
Dict of column (cell) value renderer overrides. Dict of column (cell) value renderer overrides.
See also :meth:`set_renderer()`. See also :meth:`set_renderer()` and
:meth:`set_default_renderers()`.
.. attribute:: row_class .. attribute:: row_class
@ -388,7 +389,6 @@ class Grid:
self.key = key self.key = key
self.data = data self.data = data
self.labels = labels or {} self.labels = labels or {}
self.renderers = renderers or {}
self.row_class = row_class self.row_class = row_class
self.actions = actions or [] self.actions = actions or []
self.linked_columns = linked_columns or [] self.linked_columns = linked_columns or []
@ -398,6 +398,10 @@ class Grid:
self.app = self.config.get_app() self.app = self.config.get_app()
self.set_columns(columns or self.get_columns()) self.set_columns(columns or self.get_columns())
self.renderers = {}
if renderers:
for key, val in renderers.items():
self.set_renderer(key, val)
self.set_default_renderers() self.set_default_renderers()
self.set_tools(tools) self.set_tools(tools)
@ -592,8 +596,29 @@ class Grid:
grid = Grid(request, columns=['foo', 'bar']) grid = Grid(request, columns=['foo', 'bar'])
grid.set_renderer('foo', render_foo) grid.set_renderer('foo', render_foo)
For convenience, in lieu of a renderer callable, you may
specify one of the following strings, which will be
interpreted as a built-in renderer callable, as shown below:
* ``'batch_id'`` -> :meth:`render_batch_id()`
* ``'boolean'`` -> :meth:`render_boolean()`
* ``'currency'`` -> :meth:`render_currency()`
* ``'datetime'`` -> :meth:`render_datetime()`
* ``'quantity'`` -> :meth:`render_quantity()`
Renderer overrides are tracked via :attr:`renderers`. Renderer overrides are tracked via :attr:`renderers`.
""" """
builtins = {
'batch_id': self.render_batch_id,
'boolean': self.render_boolean,
'currency': self.render_currency,
'datetime': self.render_datetime,
'quantity': self.render_quantity,
}
if renderer in builtins:
renderer = builtins[renderer]
if kwargs: if kwargs:
renderer = functools.partial(renderer, **kwargs) renderer = functools.partial(renderer, **kwargs)
self.renderers[key] = renderer self.renderers[key] = renderer
@ -602,15 +627,18 @@ class Grid:
""" """
Set default column value renderers, where applicable. Set default column value renderers, where applicable.
This will add new entries to :attr:`renderers` for columns This is called automatically from the class constructor. It
whose data type implies a default renderer should be used. will add new entries to :attr:`renderers` for columns whose
This is generally only possible if :attr:`model_class` is set data type implies a default renderer. This is only possible
to a valid SQLAlchemy mapped class. if :attr:`model_class` is set to a SQLAlchemy mapped class.
This (for now?) only looks for This only looks for a couple of data types, and configures as
:class:`sqlalchemy:sqlalchemy.types.DateTime` columns and if follows:
any are found, they are configured to use
:meth:`render_datetime()`. * :class:`sqlalchemy:sqlalchemy.types.Boolean` ->
:meth:`render_boolean()`
* :class:`sqlalchemy:sqlalchemy.types.DateTime` ->
:meth:`render_datetime()`
""" """
if not self.model_class: if not self.model_class:
return return
@ -626,6 +654,8 @@ class Grid:
column = prop.columns[0] column = prop.columns[0]
if isinstance(column.type, sa.DateTime): if isinstance(column.type, sa.DateTime):
self.set_renderer(key, self.render_datetime) self.set_renderer(key, self.render_datetime)
elif isinstance(column.type, sa.Boolean):
self.set_renderer(key, self.render_boolean)
def set_link(self, key, link=True): def set_link(self, key, link=True):
""" """
@ -1753,23 +1783,80 @@ class Grid:
# rendering methods # rendering methods
############################## ##############################
def render_batch_id(self, obj, key, value):
"""
Column renderer for batch ID values.
This is not used automatically but you can use it explicitly::
grid.set_renderer('foo', 'batch_id')
"""
if value is None:
return ""
batch_id = int(value)
return f'{batch_id:08d}'
def render_boolean(self, obj, key, value):
"""
Column renderer for boolean values.
This calls
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_boolean()`
for the return value.
This may be used automatically per
:meth:`set_default_renderers()` or you can use it explicitly::
grid.set_renderer('foo', grid.render_boolean)
"""
return self.app.render_boolean(value)
def render_currency(self, obj, key, value, **kwargs):
"""
Column renderer for currency values.
This calls
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()`
for the return value.
This is not used automatically but you can use it explicitly::
grid.set_renderer('foo', 'currency')
grid.set_renderer('foo', 'currency', scale=4)
"""
return self.app.render_currency(value, **kwargs)
def render_datetime(self, obj, key, value): def render_datetime(self, obj, key, value):
""" """
Default cell value renderer for Column renderer for :class:`python:datetime.datetime` values.
:class:`sqlalchemy:sqlalchemy.types.DateTime` columns, which
calls This calls
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()` :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()`
for the return value. for the return value.
This may be used automatically per This may be used automatically per
:meth:`set_default_renderers()` or you can use it explicitly :meth:`set_default_renderers()` or you can use it explicitly::
for any :class:`python:datetime.datetime` column with::
grid.set_renderer('foo', grid.render_datetime) grid.set_renderer('foo', grid.render_datetime)
""" """
dt = getattr(obj, key) dt = getattr(obj, key)
return self.app.render_datetime(dt) return self.app.render_datetime(dt)
def render_quantity(self, obj, key, value):
"""
Column renderer for quantity values.
This calls
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`
for the return value.
This is not used automatically but you can use it explicitly::
grid.set_renderer('foo', grid.render_quantity)
"""
return self.app.render_quantity(value)
def render_table_element( def render_table_element(
self, self,
form=None, form=None,

View file

@ -1,19 +1,6 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" /> <%inherit file="/master/view.mako" />
<%def name="extra_styles()">
${parent.extra_styles()}
<style>
## TODO: should we do something like this site-wide?
## (so far this is the only place we use markdown)
.markdown p {
margin-bottom: 1.5rem;
}
</style>
</%def>
<%def name="tool_panels()"> <%def name="tool_panels()">
${parent.tool_panels()} ${parent.tool_panels()}
${self.tool_panel_execution()} ${self.tool_panel_execution()}
@ -65,7 +52,7 @@
<p class="block has-text-weight-bold"> <p class="block has-text-weight-bold">
What will happen when this batch is executed? What will happen when this batch is executed?
</p> </p>
<div class="markdown"> <div class="content">
${execution_described|n} ${execution_described|n}
</div> </div>
${h.form(master.get_action_url('execute', batch), ref='executeForm')} ${h.form(master.get_action_url('execute', batch), ref='executeForm')}

View file

@ -33,6 +33,21 @@
% endif % endif
</%def> </%def>
<%def name="tool_panels()">
${parent.tool_panels()}
${self.tool_panel_xref()}
</%def>
<%def name="tool_panel_xref()">
% if xref_buttons:
<wutta-tool-panel heading="Cross-Reference">
% for button in xref_buttons:
${button}
% endfor
</wutta-tool-panel>
% endif
</%def>
<%def name="render_vue_templates()"> <%def name="render_vue_templates()">
${parent.render_vue_templates()} ${parent.render_vue_templates()}
% if master.has_rows: % if master.has_rows:

84
src/wuttaweb/testing.py Normal file
View file

@ -0,0 +1,84 @@
# -*- 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/>.
#
################################################################################
"""
WuttaWeb - test utilities
"""
from unittest.mock import MagicMock
import fanstatic
from pyramid import testing
from wuttjamaican.testing import DataTestCase
from wuttaweb import subscribers
class WebTestCase(DataTestCase):
"""
Base class for test suites requiring a full (typical) web app.
"""
def setUp(self):
self.setup_web()
def setup_web(self):
self.setup_db()
self.request = self.make_request()
self.pyramid_config = testing.setUp(request=self.request, settings={
'wutta_config': self.config,
'mako.directories': ['wuttaweb:templates'],
'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
})
# init web
self.pyramid_config.include('pyramid_deform')
self.pyramid_config.include('pyramid_mako')
self.pyramid_config.add_directive('add_wutta_permission_group',
'wuttaweb.auth.add_permission_group')
self.pyramid_config.add_directive('add_wutta_permission',
'wuttaweb.auth.add_permission')
self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
'pyramid.events.BeforeRender')
self.pyramid_config.include('wuttaweb.static')
# nb. mock out fanstatic env..good enough for now to avoid errors..
needed = fanstatic.init_needed()
self.request.environ[fanstatic.NEEDED] = needed
# setup new request w/ anonymous user
event = MagicMock(request=self.request)
subscribers.new_request(event)
def user_getter(request, **kwargs): pass
subscribers.new_request_set_user(event, db_session=self.session,
user_getter=user_getter)
def tearDown(self):
self.teardown_web()
def teardown_web(self):
testing.tearDown()
self.teardown_db()
def make_request(self):
return testing.DummyRequest()

View file

@ -53,7 +53,7 @@ class BatchMasterView(MasterView):
labels = { labels = {
'id': "Batch ID", 'id': "Batch ID",
'status_code': "Batch Status", 'status_code': "Status",
} }
sort_defaults = ('id', 'desc') sort_defaults = ('id', 'desc')
@ -62,6 +62,10 @@ class BatchMasterView(MasterView):
rows_title = "Batch Rows" rows_title = "Batch Rows"
rows_sort_defaults = 'sequence' rows_sort_defaults = 'sequence'
row_labels = {
'status_code': "Status",
}
def __init__(self, request, context=None): def __init__(self, request, context=None):
super().__init__(request, context=context) super().__init__(request, context=context)
self.batch_handler = self.get_batch_handler() self.batch_handler = self.get_batch_handler()
@ -381,6 +385,12 @@ class BatchMasterView(MasterView):
g.set_label('sequence', "Seq.", column_only=True) g.set_label('sequence', "Seq.", column_only=True)
g.set_renderer('status_code', self.render_row_status)
def render_row_status(self, row, key, value):
""" """
return row.STATUS.get(value, value)
############################## ##############################
# configuration # configuration
############################## ##############################

View file

@ -372,6 +372,20 @@ class MasterView(View):
List of columns for the row grid. List of columns for the row grid.
This is optional; see also :meth:`get_row_grid_columns()`. This is optional; see also :meth:`get_row_grid_columns()`.
This is optional; see also :meth:`get_row_grid_columns()`.
.. attribute:: rows_viewable
Boolean indicating whether the row model supports "viewing" -
i.e. it should have a "View" action in the row grid.
(For now) If you enable this, you must also override
:meth:`get_row_action_url_view()`.
.. note::
This eventually will cause there to be a ``row_view`` route
to be configured as well.
""" """
############################## ##############################
@ -409,6 +423,7 @@ class MasterView(View):
rows_sort_defaults = None rows_sort_defaults = None
rows_paginated = True rows_paginated = True
rows_paginate_on_backend = True rows_paginate_on_backend = True
rows_viewable = False
# current action # current action
listing = False listing = False
@ -601,6 +616,8 @@ class MasterView(View):
context['rows_grid'] = grid context['rows_grid'] = grid
context['xref_buttons'] = self.get_xref_buttons(obj)
return self.render_to_response('view', context) return self.render_to_response('view', context)
############################## ##############################
@ -1569,6 +1586,7 @@ class MasterView(View):
label, label,
variant=None, variant=None,
primary=False, primary=False,
url=None,
**kwargs, **kwargs,
): ):
""" """
@ -1595,10 +1613,17 @@ class MasterView(View):
avoids the Buefy vs. Oruga confusion, and the avoids the Buefy vs. Oruga confusion, and the
implementation can change in the future. implementation can change in the future.
:param url: Specify this (instead of ``href``) to make the
button act like a link. This will yield something like:
``<b-button tag="a" href="{url}">``
:param \**kwargs: All remaining kwargs are passed to the :param \**kwargs: All remaining kwargs are passed to the
underlying ``HTML.tag()`` call, so will be rendered as underlying ``HTML.tag()`` call, so will be rendered as
attributes on the button tag. attributes on the button tag.
**NB.** You cannot specify a ``tag`` kwarg, for technical
reasons.
:returns: HTML literal for the button element. Will be something :returns: HTML literal for the button element. Will be something
along the lines of: along the lines of:
@ -1620,7 +1645,45 @@ class MasterView(View):
elif primary: elif primary:
btn_kw['type'] = 'is-primary' btn_kw['type'] = 'is-primary'
return HTML.tag('b-button', **btn_kw) if url:
btn_kw['href'] = url
button = HTML.tag('b-button', **btn_kw)
if url:
# nb. unfortunately HTML.tag() calls its first arg 'tag'
# and so we can't pass a kwarg with that name...so instead
# we patch that into place manually
button = str(button)
button = button.replace('<b-button ',
'<b-button tag="a" ')
button = HTML.literal(button)
return button
def get_xref_buttons(self, obj):
"""
Should return a list of "cross-reference" buttons to be shown
when viewing the given object.
Default logic always returns empty list; subclass can override
as needed.
If applicable, this method should do its own permission checks
and only include the buttons current user should be allowed to
see/use.
See also :meth:`make_button()` - example::
def get_xref_buttons(self, product):
buttons = []
if self.request.has_perm('external_products.view'):
url = self.request.route_url('external_products.view',
id=product.external_id)
buttons.append(self.make_button("View External", url=url))
return buttons
"""
return []
def make_progress(self, key, **kwargs): def make_progress(self, key, **kwargs):
""" """
@ -2366,6 +2429,16 @@ class MasterView(View):
kwargs.setdefault('paginated', self.rows_paginated) kwargs.setdefault('paginated', self.rows_paginated)
kwargs.setdefault('paginate_on_backend', self.rows_paginate_on_backend) kwargs.setdefault('paginate_on_backend', self.rows_paginate_on_backend)
if 'actions' not in kwargs:
actions = []
if self.rows_viewable:
actions.append(self.make_grid_action('view', icon='eye',
url=self.get_row_action_url_view))
if actions:
kwargs['actions'] = actions
grid = self.make_grid(**kwargs) grid = self.make_grid(**kwargs)
self.configure_row_grid(grid) self.configure_row_grid(grid)
grid.load_settings() grid.load_settings()
@ -2484,6 +2557,16 @@ class MasterView(View):
labels.update(cls.row_labels) labels.update(cls.row_labels)
return labels return labels
def get_row_action_url_view(self, row, i):
"""
Must return the "view" action url for the given row object.
Only relevant if :attr:`rows_viewable` is true.
There is no default logic; subclass must override if needed.
"""
raise NotImplementedError
############################## ##############################
# class methods # class methods
############################## ##############################

View file

@ -4,9 +4,8 @@ from unittest.mock import patch, MagicMock
import pytest import pytest
from tests.util import WebTestCase
from wuttaweb.db import continuum as mod from wuttaweb.db import continuum as mod
from wuttaweb.testing import WebTestCase
class TestWuttaWebContinuumPlugin(WebTestCase): class TestWuttaWebContinuumPlugin(WebTestCase):

View file

@ -12,7 +12,7 @@ from sqlalchemy import orm
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb.forms import schema as mod from wuttaweb.forms import schema as mod
from wuttaweb.forms import widgets from wuttaweb.forms import widgets
from tests.util import DataTestCase, WebTestCase from wuttaweb.testing import DataTestCase, WebTestCase
class TestWutaDateTime(TestCase): class TestWutaDateTime(TestCase):
@ -89,6 +89,15 @@ class TestWuttaMoney(WebTestCase):
self.assertIsInstance(widget, widgets.WuttaMoneyInputWidget) self.assertIsInstance(widget, widgets.WuttaMoneyInputWidget)
class TestWuttaQuantity(WebTestCase):
def test_widget_maker(self):
enum = self.app.enum
typ = mod.WuttaQuantity(self.request)
widget = typ.widget_maker()
self.assertIsInstance(widget, widgets.WuttaQuantityWidget)
class TestObjectRef(DataTestCase): class TestObjectRef(DataTestCase):
def setUp(self): def setUp(self):
@ -155,9 +164,10 @@ class TestObjectRef(DataTestCase):
self.session.commit() self.session.commit()
self.assertIsNotNone(person.uuid) self.assertIsNotNone(person.uuid)
with patch.object(mod.ObjectRef, 'model_class', new=model.Person): with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request, session=self.session) with patch.object(mod, 'Session', return_value=self.session):
value = typ.deserialize(node, person.uuid) typ = mod.ObjectRef(self.request)
self.assertIs(value, person) value = typ.deserialize(node, person.uuid)
self.assertIs(value, person)
def test_dictify(self): def test_dictify(self):
model = self.app.model model = self.app.model
@ -181,42 +191,46 @@ class TestObjectRef(DataTestCase):
value = typ.objectify(None) value = typ.objectify(None)
self.assertIsNone(value) self.assertIsNone(value)
# model instance with patch.object(mod, 'Session', return_value=self.session):
person = model.Person(full_name="Betty Boop")
self.session.add(person)
self.session.commit()
self.assertIsNotNone(person.uuid)
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
# can specify as uuid # model instance
typ = mod.ObjectRef(self.request, session=self.session) person = model.Person(full_name="Betty Boop")
value = typ.objectify(person.uuid) self.session.add(person)
self.assertIs(value, person) self.session.commit()
self.assertIsNotNone(person.uuid)
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
# or can specify object proper # can specify as uuid
typ = mod.ObjectRef(self.request, session=self.session) typ = mod.ObjectRef(self.request)
value = typ.objectify(person) value = typ.objectify(person.uuid)
self.assertIs(value, person) self.assertIs(value, person)
# error if not found # or can specify object proper
with patch.object(mod.ObjectRef, 'model_class', new=model.Person): typ = mod.ObjectRef(self.request)
typ = mod.ObjectRef(self.request, session=self.session) value = typ.objectify(person)
self.assertRaises(ValueError, typ.objectify, 'WRONG-UUID') self.assertIs(value, person)
# error if not found
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request)
self.assertRaises(ValueError, typ.objectify, 'WRONG-UUID')
def test_get_query(self): def test_get_query(self):
model = self.app.model model = self.app.model
with patch.object(mod.ObjectRef, 'model_class', new=model.Person): with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request, session=self.session) with patch.object(mod, 'Session', return_value=self.session):
query = typ.get_query() typ = mod.ObjectRef(self.request)
self.assertIsInstance(query, orm.Query) query = typ.get_query()
self.assertIsInstance(query, orm.Query)
def test_sort_query(self): def test_sort_query(self):
model = self.app.model model = self.app.model
with patch.object(mod.ObjectRef, 'model_class', new=model.Person): with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request, session=self.session) with patch.object(mod, 'Session', return_value=self.session):
query = typ.get_query() typ = mod.ObjectRef(self.request)
sorted_query = typ.sort_query(query) query = typ.get_query()
self.assertIs(sorted_query, query) sorted_query = typ.sort_query(query)
self.assertIs(sorted_query, query)
def test_widget_maker(self): def test_widget_maker(self):
model = self.app.model model = self.app.model
@ -226,90 +240,98 @@ class TestObjectRef(DataTestCase):
# basic # basic
with patch.object(mod.ObjectRef, 'model_class', new=model.Person): with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request, session=self.session) with patch.object(mod, 'Session', return_value=self.session):
widget = typ.widget_maker() typ = mod.ObjectRef(self.request)
self.assertEqual(len(widget.values), 1) widget = typ.widget_maker()
self.assertEqual(widget.values[0][1], "Betty Boop") self.assertEqual(len(widget.values), 1)
self.assertEqual(widget.values[0][1], "Betty Boop")
# empty option # empty option
with patch.object(mod.ObjectRef, 'model_class', new=model.Person): with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request, session=self.session, empty_option=True) with patch.object(mod, 'Session', return_value=self.session):
widget = typ.widget_maker() typ = mod.ObjectRef(self.request, empty_option=True)
self.assertEqual(len(widget.values), 2) widget = typ.widget_maker()
self.assertEqual(widget.values[0][1], "(none)") self.assertEqual(len(widget.values), 2)
self.assertEqual(widget.values[1][1], "Betty Boop") self.assertEqual(widget.values[0][1], "(none)")
self.assertEqual(widget.values[1][1], "Betty Boop")
class TestPersonRef(WebTestCase): class TestPersonRef(WebTestCase):
def test_sort_query(self): def test_sort_query(self):
typ = mod.PersonRef(self.request, session=self.session) with patch.object(mod, 'Session', return_value=self.session):
query = typ.get_query() typ = mod.PersonRef(self.request)
self.assertIsInstance(query, orm.Query) query = typ.get_query()
sorted_query = typ.sort_query(query) self.assertIsInstance(query, orm.Query)
self.assertIsInstance(sorted_query, orm.Query) sorted_query = typ.sort_query(query)
self.assertIsNot(sorted_query, query) self.assertIsInstance(sorted_query, orm.Query)
self.assertIsNot(sorted_query, query)
def test_get_object_url(self): def test_get_object_url(self):
self.pyramid_config.add_route('people.view', '/people/{uuid}') self.pyramid_config.add_route('people.view', '/people/{uuid}')
model = self.app.model model = self.app.model
typ = mod.PersonRef(self.request, session=self.session) with patch.object(mod, 'Session', return_value=self.session):
typ = mod.PersonRef(self.request)
person = model.Person(full_name="Barney Rubble") person = model.Person(full_name="Barney Rubble")
self.session.add(person) self.session.add(person)
self.session.commit() self.session.commit()
url = typ.get_object_url(person) url = typ.get_object_url(person)
self.assertIsNotNone(url) self.assertIsNotNone(url)
self.assertIn(f'/people/{person.uuid}', url) self.assertIn(f'/people/{person.uuid}', url)
class TestRoleRef(WebTestCase): class TestRoleRef(WebTestCase):
def test_sort_query(self): def test_sort_query(self):
typ = mod.RoleRef(self.request, session=self.session) with patch.object(mod, 'Session', return_value=self.session):
query = typ.get_query() typ = mod.RoleRef(self.request)
self.assertIsInstance(query, orm.Query) query = typ.get_query()
sorted_query = typ.sort_query(query) self.assertIsInstance(query, orm.Query)
self.assertIsInstance(sorted_query, orm.Query) sorted_query = typ.sort_query(query)
self.assertIsNot(sorted_query, query) self.assertIsInstance(sorted_query, orm.Query)
self.assertIsNot(sorted_query, query)
def test_get_object_url(self): def test_get_object_url(self):
self.pyramid_config.add_route('roles.view', '/roles/{uuid}') self.pyramid_config.add_route('roles.view', '/roles/{uuid}')
model = self.app.model model = self.app.model
typ = mod.RoleRef(self.request, session=self.session) with patch.object(mod, 'Session', return_value=self.session):
typ = mod.RoleRef(self.request)
role = model.Role(name='Manager') role = model.Role(name='Manager')
self.session.add(role) self.session.add(role)
self.session.commit() self.session.commit()
url = typ.get_object_url(role) url = typ.get_object_url(role)
self.assertIsNotNone(url) self.assertIsNotNone(url)
self.assertIn(f'/roles/{role.uuid}', url) self.assertIn(f'/roles/{role.uuid}', url)
class TestUserRef(WebTestCase): class TestUserRef(WebTestCase):
def test_sort_query(self): def test_sort_query(self):
typ = mod.UserRef(self.request, session=self.session) with patch.object(mod, 'Session', return_value=self.session):
query = typ.get_query() typ = mod.UserRef(self.request)
self.assertIsInstance(query, orm.Query) query = typ.get_query()
sorted_query = typ.sort_query(query) self.assertIsInstance(query, orm.Query)
self.assertIsInstance(sorted_query, orm.Query) sorted_query = typ.sort_query(query)
self.assertIsNot(sorted_query, query) self.assertIsInstance(sorted_query, orm.Query)
self.assertIsNot(sorted_query, query)
def test_get_object_url(self): def test_get_object_url(self):
self.pyramid_config.add_route('users.view', '/users/{uuid}') self.pyramid_config.add_route('users.view', '/users/{uuid}')
model = self.app.model model = self.app.model
typ = mod.UserRef(self.request, session=self.session) with patch.object(mod, 'Session', return_value=self.session):
typ = mod.UserRef(self.request)
user = model.User(username='barney') user = model.User(username='barney')
self.session.add(user) self.session.add(user)
self.session.commit() self.session.commit()
url = typ.get_object_url(user) url = typ.get_object_url(user)
self.assertIsNotNone(url) self.assertIsNotNone(url)
self.assertIn(f'/users/{user.uuid}', url) self.assertIn(f'/users/{user.uuid}', url)
class TestUserRefs(DataTestCase): class TestUserRefs(DataTestCase):
@ -320,9 +342,10 @@ class TestUserRefs(DataTestCase):
def test_widget_maker(self): def test_widget_maker(self):
model = self.app.model model = self.app.model
typ = mod.UserRefs(self.request, session=self.session) with patch.object(mod, 'Session', return_value=self.session):
widget = typ.widget_maker() typ = mod.UserRefs(self.request)
self.assertIsInstance(widget, widgets.UserRefsWidget) widget = typ.widget_maker()
self.assertIsInstance(widget, widgets.UserRefsWidget)
class TestRoleRefs(DataTestCase): class TestRoleRefs(DataTestCase):
@ -341,20 +364,22 @@ class TestRoleRefs(DataTestCase):
self.session.add(blokes) self.session.add(blokes)
self.session.commit() self.session.commit()
# with root access, default values include: admin, blokes with patch.object(mod, 'Session', return_value=self.session):
self.request.is_root = True
typ = mod.RoleRefs(self.request, session=self.session)
widget = typ.widget_maker()
self.assertEqual(len(widget.values), 2)
self.assertEqual(widget.values[0][1], "Administrator")
self.assertEqual(widget.values[1][1], "Blokes")
# without root, default values include: blokes # with root access, default values include: admin, blokes
self.request.is_root = False self.request.is_root = True
typ = mod.RoleRefs(self.request, session=self.session) typ = mod.RoleRefs(self.request)
widget = typ.widget_maker() widget = typ.widget_maker()
self.assertEqual(len(widget.values), 1) self.assertEqual(len(widget.values), 2)
self.assertEqual(widget.values[0][1], "Blokes") self.assertEqual(widget.values[0][1], "Administrator")
self.assertEqual(widget.values[1][1], "Blokes")
# without root, default values include: blokes
self.request.is_root = False
typ = mod.RoleRefs(self.request)
widget = typ.widget_maker()
self.assertEqual(len(widget.values), 1)
self.assertEqual(widget.values[0][1], "Blokes")
class TestPermissions(DataTestCase): class TestPermissions(DataTestCase):

View file

@ -10,9 +10,10 @@ from pyramid import testing
from wuttaweb import grids from wuttaweb import grids
from wuttaweb.forms import widgets as mod from wuttaweb.forms import widgets as mod
from wuttaweb.forms import schema
from wuttaweb.forms.schema import (FileDownload, PersonRef, RoleRefs, UserRefs, Permissions, from wuttaweb.forms.schema import (FileDownload, PersonRef, RoleRefs, UserRefs, Permissions,
WuttaDateTime, EmailRecipients) WuttaDateTime, EmailRecipients)
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestObjectRefWidget(WebTestCase): class TestObjectRefWidget(WebTestCase):
@ -32,31 +33,33 @@ class TestObjectRefWidget(WebTestCase):
self.session.add(person) self.session.add(person)
self.session.commit() self.session.commit()
# standard (editable) with patch.object(schema, 'Session', return_value=self.session):
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
widget = self.make_widget()
field = self.make_field(node)
html = widget.serialize(field, person.uuid)
self.assertIn('<b-select ', html)
# readonly # standard (editable)
node = colander.SchemaNode(PersonRef(self.request, session=self.session)) node = colander.SchemaNode(PersonRef(self.request))
node.model_instance = person widget = self.make_widget()
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, readonly=True) self.assertIn('<b-select ', html)
self.assertIn('Betty Boop', html)
self.assertNotIn('<a', html)
# with hyperlink # readonly
node = colander.SchemaNode(PersonRef(self.request, session=self.session)) node = colander.SchemaNode(PersonRef(self.request))
node.model_instance = person node.model_instance = person
widget = self.make_widget(url=lambda p: '/foo') 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)
self.assertIn('<a', html) self.assertNotIn('<a', html)
self.assertIn('href="/foo"', html)
# with hyperlink
node = colander.SchemaNode(PersonRef(self.request))
node.model_instance = person
widget = self.make_widget(url=lambda p: '/foo')
field = self.make_field(node)
html = widget.serialize(field, person.uuid, readonly=True)
self.assertIn('Betty Boop', html)
self.assertIn('<a', html)
self.assertIn('href="/foo"', html)
def test_get_template_values(self): def test_get_template_values(self):
model = self.app.model model = self.app.model
@ -64,22 +67,24 @@ class TestObjectRefWidget(WebTestCase):
self.session.add(person) self.session.add(person)
self.session.commit() self.session.commit()
# standard with patch.object(schema, 'Session', return_value=self.session):
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)
# readonly w/ empty option # standard
node = colander.SchemaNode(PersonRef(self.request, session=self.session, node = colander.SchemaNode(PersonRef(self.request))
empty_option=('_empty_', '(empty)'))) widget = self.make_widget()
widget = self.make_widget(readonly=True, url=lambda obj: '/foo') field = self.make_field(node)
field = self.make_field(node) values = widget.get_template_values(field, person.uuid, {})
values = widget.get_template_values(field, '_empty_', {}) self.assertIn('cstruct', values)
self.assertIn('cstruct', values) self.assertNotIn('url', values)
self.assertNotIn('url', values)
# readonly w/ empty option
node = colander.SchemaNode(PersonRef(self.request,
empty_option=('_empty_', '(empty)')))
widget = self.make_widget(readonly=True, url=lambda obj: '/foo')
field = self.make_field(node)
values = widget.get_template_values(field, '_empty_', {})
self.assertIn('cstruct', values)
self.assertNotIn('url', values)
class TestWuttaDateTimeWidget(WebTestCase): class TestWuttaDateTimeWidget(WebTestCase):
@ -120,7 +125,7 @@ class TestWuttaMoneyInputWidget(WebTestCase):
return mod.WuttaMoneyInputWidget(self.request, **kwargs) return mod.WuttaMoneyInputWidget(self.request, **kwargs)
def test_serialize(self): def test_serialize(self):
node = colander.SchemaNode(WuttaDateTime()) node = colander.SchemaNode(schema.WuttaMoney(self.request))
field = self.make_field(node) field = self.make_field(node)
widget = self.make_widget() widget = self.make_widget()
amount = decimal.Decimal('12.34') amount = decimal.Decimal('12.34')
@ -138,6 +143,36 @@ class TestWuttaMoneyInputWidget(WebTestCase):
self.assertEqual(result, '<span></span>') self.assertEqual(result, '<span></span>')
class TestWuttaQuantityWidget(WebTestCase):
def make_field(self, node, **kwargs):
# TODO: not sure why default renderer is in use even though
# pyramid_deform was included in setup? but this works..
kwargs.setdefault('renderer', deform.Form.default_renderer)
return deform.Field(node, **kwargs)
def make_widget(self, **kwargs):
return mod.WuttaQuantityWidget(self.request, **kwargs)
def test_serialize(self):
node = colander.SchemaNode(schema.WuttaQuantity(self.request))
field = self.make_field(node)
widget = self.make_widget()
amount = decimal.Decimal('42.00')
# editable widget has normal text input
result = widget.serialize(field, str(amount))
self.assertIn('<b-input', result)
# readonly is rendered per app convention
result = widget.serialize(field, str(amount), readonly=True)
self.assertEqual(result, '<span>42</span>')
# readonly w/ null value
result = widget.serialize(field, None, readonly=True)
self.assertEqual(result, '<span></span>')
class TestFileDownloadWidget(WebTestCase): class TestFileDownloadWidget(WebTestCase):
def make_field(self, node, **kwargs): def make_field(self, node, **kwargs):
@ -230,28 +265,29 @@ class TestRoleRefsWidget(WebTestCase):
self.session.commit() self.session.commit()
# nb. we let the field construct the widget via our type # nb. we let the field construct the widget via our type
node = colander.SchemaNode(RoleRefs(self.request, session=self.session)) with patch.object(schema, 'Session', return_value=self.session):
field = self.make_field(node) node = colander.SchemaNode(RoleRefs(self.request))
widget = field.widget field = self.make_field(node)
widget = field.widget
# readonly values list includes admin # readonly values list includes admin
html = widget.serialize(field, {admin.uuid, blokes.uuid}, readonly=True) html = widget.serialize(field, {admin.uuid, blokes.uuid}, readonly=True)
self.assertIn(admin.name, html) self.assertIn(admin.name, html)
self.assertIn(blokes.name, html) self.assertIn(blokes.name, html)
# editable values list *excludes* admin (by default) # editable values list *excludes* admin (by default)
html = widget.serialize(field, {admin.uuid, blokes.uuid}) html = widget.serialize(field, {admin.uuid, blokes.uuid})
self.assertNotIn(str(admin.uuid.hex), html) self.assertNotIn(str(admin.uuid.hex), html)
self.assertIn(str(blokes.uuid.hex), html) self.assertIn(str(blokes.uuid.hex), html)
# but admin is included for root user # but admin is included for root user
self.request.is_root = True self.request.is_root = True
node = colander.SchemaNode(RoleRefs(self.request, session=self.session)) node = colander.SchemaNode(RoleRefs(self.request))
field = self.make_field(node) field = self.make_field(node)
widget = field.widget widget = field.widget
html = widget.serialize(field, {admin.uuid, blokes.uuid}) html = widget.serialize(field, {admin.uuid, blokes.uuid})
self.assertIn(str(admin.uuid.hex), html) self.assertIn(str(admin.uuid.hex), html)
self.assertIn(str(blokes.uuid.hex), html) self.assertIn(str(blokes.uuid.hex), html)
class TestUserRefsWidget(WebTestCase): class TestUserRefsWidget(WebTestCase):
@ -266,35 +302,37 @@ class TestUserRefsWidget(WebTestCase):
model = self.app.model model = self.app.model
# nb. we let the field construct the widget via our type # nb. we let the field construct the widget via our type
node = colander.SchemaNode(UserRefs(self.request, session=self.session)) # node = colander.SchemaNode(UserRefs(self.request, session=self.session))
field = self.make_field(node) with patch.object(schema, 'Session', return_value=self.session):
widget = field.widget node = colander.SchemaNode(UserRefs(self.request))
field = self.make_field(node)
widget = field.widget
# readonly is required # readonly is required
self.assertRaises(NotImplementedError, widget.serialize, field, set()) self.assertRaises(NotImplementedError, widget.serialize, field, set())
self.assertRaises(NotImplementedError, widget.serialize, field, set(), readonly=False) self.assertRaises(NotImplementedError, widget.serialize, field, set(), readonly=False)
# empty # empty
html = widget.serialize(field, set(), readonly=True) html = widget.serialize(field, set(), readonly=True)
self.assertEqual(html, '<span></span>') self.assertEqual(html, '<span></span>')
# with data, no actions # with data, no actions
user = model.User(username='barney') user = model.User(username='barney')
self.session.add(user) self.session.add(user)
self.session.commit() self.session.commit()
html = widget.serialize(field, {user.uuid}, readonly=True)
self.assertIn('<b-table ', html)
self.assertNotIn('Actions', html)
self.assertNotIn('View', html)
self.assertNotIn('Edit', html)
# with view/edit actions
with patch.object(self.request, 'is_root', new=True):
html = widget.serialize(field, {user.uuid}, readonly=True) html = widget.serialize(field, {user.uuid}, readonly=True)
self.assertIn('<b-table ', html) self.assertIn('<b-table ', html)
self.assertIn('Actions', html) self.assertNotIn('Actions', html)
self.assertIn('View', html) self.assertNotIn('View', html)
self.assertIn('Edit', html) self.assertNotIn('Edit', html)
# with view/edit actions
with patch.object(self.request, 'is_root', new=True):
html = widget.serialize(field, {user.uuid}, readonly=True)
self.assertIn('<b-table ', html)
self.assertIn('Actions', html)
self.assertIn('View', html)
self.assertIn('Edit', html)
class TestPermissionsWidget(WebTestCase): class TestPermissionsWidget(WebTestCase):
@ -318,7 +356,7 @@ class TestPermissionsWidget(WebTestCase):
} }
# nb. we let the field construct the widget via our type # nb. we let the field construct the widget via our type
node = colander.SchemaNode(Permissions(self.request, permissions, session=self.session)) node = colander.SchemaNode(Permissions(self.request, permissions))
field = self.make_field(node) field = self.make_field(node)
widget = field.widget widget = field.widget

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
import datetime import datetime
import decimal
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -15,7 +16,7 @@ from wuttaweb.grids import base as mod
from wuttaweb.grids.filters import GridFilter, StringAlchemyFilter, default_sqlalchemy_filters from wuttaweb.grids.filters import GridFilter, StringAlchemyFilter, default_sqlalchemy_filters
from wuttaweb.util import FieldList from wuttaweb.util import FieldList
from wuttaweb.forms import Form from wuttaweb.forms import Form
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestGrid(WebTestCase): class TestGrid(WebTestCase):
@ -207,6 +208,11 @@ class TestGrid(WebTestCase):
self.assertIsNot(grid.renderers['foo'], render2) self.assertIsNot(grid.renderers['foo'], render2)
self.assertEqual(grid.renderers['foo'](None, None, None), 42) self.assertEqual(grid.renderers['foo'](None, None, None), 42)
# can use built-in string shortcut
grid.set_renderer('foo', 'quantity')
obj = MagicMock(foo=42.00)
self.assertEqual(grid.renderers['foo'](obj, 'foo', 42.00), '42')
def test_set_default_renderer(self): def test_set_default_renderer(self):
model = self.app.model model = self.app.model
@ -232,6 +238,17 @@ class TestGrid(WebTestCase):
self.assertIn('created', grid.renderers) self.assertIn('created', grid.renderers)
self.assertIs(grid.renderers['created'], myrender) self.assertIs(grid.renderers['created'], myrender)
# renderer set for boolean mapped field
grid = self.make_grid(model_class=model.Upgrade)
self.assertIn('executing', grid.renderers)
self.assertIsNot(grid.renderers['executing'], myrender)
# renderer *not* set for boolean, if override present
grid = self.make_grid(model_class=model.Upgrade,
renderers={'executing': myrender})
self.assertIn('executing', grid.renderers)
self.assertIs(grid.renderers['executing'], myrender)
def test_linked_columns(self): def test_linked_columns(self):
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.linked_columns, []) self.assertEqual(grid.linked_columns, [])
@ -1331,6 +1348,73 @@ class TestGrid(WebTestCase):
# rendering methods # rendering methods
############################## ##############################
def test_render_batch_id(self):
grid = self.make_grid(columns=['foo', 'bar'])
# null
obj = MagicMock(foo=None)
self.assertEqual(grid.render_batch_id(obj, 'foo', None), "")
# int
obj = MagicMock(foo=42)
self.assertEqual(grid.render_batch_id(obj, 'foo', 42), "00000042")
def test_render_boolean(self):
grid = self.make_grid(columns=['foo', 'bar'])
# null
obj = MagicMock(foo=None)
self.assertEqual(grid.render_boolean(obj, 'foo', None), "")
# true
obj = MagicMock(foo=True)
self.assertEqual(grid.render_boolean(obj, 'foo', True), "Yes")
# false
obj = MagicMock(foo=False)
self.assertEqual(grid.render_boolean(obj, 'foo', False), "No")
def test_render_currency(self):
grid = self.make_grid(columns=['foo', 'bar'])
obj = MagicMock()
# null
self.assertEqual(grid.render_currency(obj, 'foo', None), '')
# basic decimal example
value = decimal.Decimal('42.00')
self.assertEqual(grid.render_currency(obj, 'foo', value), '$42.00')
# basic float example
value = 42.00
self.assertEqual(grid.render_currency(obj, 'foo', value), '$42.00')
# decimal places will be rounded
value = decimal.Decimal('42.12345')
self.assertEqual(grid.render_currency(obj, 'foo', value), '$42.12')
# negative numbers get parens
value = decimal.Decimal('-42.42')
self.assertEqual(grid.render_currency(obj, 'foo', value), '($42.42)')
def test_render_quantity(self):
grid = self.make_grid(columns=['foo', 'bar'])
obj = MagicMock()
# null
self.assertEqual(grid.render_quantity(obj, 'foo', None), "")
# integer decimals become integers
value = decimal.Decimal('1.000')
self.assertEqual(grid.render_quantity(obj, 'foo', value), "1")
# but decimal places are preserved
value = decimal.Decimal('1.234')
self.assertEqual(grid.render_quantity(obj ,'foo', value), "1.234")
# zero is *not* empty string (with this renderer)
self.assertEqual(grid.render_quantity(obj, 'foo', 0), "0")
def test_render_datetime(self): def test_render_datetime(self):
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])

View file

@ -9,7 +9,7 @@ import sqlalchemy as sa
from wuttjamaican.db.model import Base from wuttjamaican.db.model import Base
from wuttaweb.grids import filters as mod from wuttaweb.grids import filters as mod
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestGridFilter(WebTestCase): class TestGridFilter(WebTestCase):

View file

@ -8,7 +8,7 @@ from pyramid import testing
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb import auth as mod from wuttaweb import auth as mod
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestLoginUser(TestCase): class TestLoginUser(TestCase):

View file

@ -4,7 +4,7 @@ from wuttaweb import handler as mod, static
from wuttaweb.forms import Form from wuttaweb.forms import Form
from wuttaweb.grids import Grid from wuttaweb.grids import Grid
from wuttaweb.menus import MenuHandler from wuttaweb.menus import MenuHandler
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestWebHandler(WebTestCase): class TestWebHandler(WebTestCase):

View file

@ -4,7 +4,7 @@ from unittest import TestCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from wuttaweb import menus as mod from wuttaweb import menus as mod
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestMenuHandler(WebTestCase): class TestMenuHandler(WebTestCase):

View file

@ -1,14 +1,7 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest import TestCase
from unittest.mock import MagicMock
import fanstatic
from pyramid import testing
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttjamaican.testing import FileConfigTestCase from wuttjamaican.testing import FileConfigTestCase
from wuttaweb import subscribers
from wuttaweb.menus import MenuHandler from wuttaweb.menus import MenuHandler
@ -39,56 +32,6 @@ class DataTestCase(FileConfigTestCase):
self.teardown_files() self.teardown_files()
class WebTestCase(DataTestCase):
"""
Base class for test suites requiring a full (typical) web app.
"""
def setUp(self):
self.setup_web()
def setup_web(self):
self.setup_db()
self.request = self.make_request()
self.pyramid_config = testing.setUp(request=self.request, settings={
'wutta_config': self.config,
'mako.directories': ['wuttaweb:templates'],
'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
})
# init web
self.pyramid_config.include('pyramid_deform')
self.pyramid_config.include('pyramid_mako')
self.pyramid_config.add_directive('add_wutta_permission_group',
'wuttaweb.auth.add_permission_group')
self.pyramid_config.add_directive('add_wutta_permission',
'wuttaweb.auth.add_permission')
self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
'pyramid.events.BeforeRender')
self.pyramid_config.include('wuttaweb.static')
# nb. mock out fanstatic env..good enough for now to avoid errors..
needed = fanstatic.init_needed()
self.request.environ[fanstatic.NEEDED] = needed
# setup new request w/ anonymous user
event = MagicMock(request=self.request)
subscribers.new_request(event)
def user_getter(request, **kwargs): pass
subscribers.new_request_set_user(event, db_session=self.session,
user_getter=user_getter)
def tearDown(self):
self.teardown_web()
def teardown_web(self):
testing.tearDown()
self.teardown_db()
def make_request(self):
return testing.DummyRequest()
class NullMenuHandler(MenuHandler): class NullMenuHandler(MenuHandler):
""" """
Dummy menu handler for testing. Dummy menu handler for testing.

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestIncludeMe(WebTestCase): class TestIncludeMe(WebTestCase):

View file

@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
from pyramid.httpexceptions import HTTPFound, HTTPForbidden from pyramid.httpexceptions import HTTPFound, HTTPForbidden
from wuttaweb.views import auth as mod from wuttaweb.views import auth as mod
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestAuthView(WebTestCase): class TestAuthView(WebTestCase):

View file

@ -5,7 +5,7 @@ from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound
from wuttaweb.views import base as mod from wuttaweb.views import base as mod
from wuttaweb.forms import Form from wuttaweb.forms import Form
from wuttaweb.grids import Grid, GridAction from wuttaweb.grids import Grid, GridAction
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestView(WebTestCase): class TestView(WebTestCase):

View file

@ -10,7 +10,7 @@ from wuttjamaican.db import model
from wuttjamaican.batch import BatchHandler from wuttjamaican.batch import BatchHandler
from wuttaweb.views import MasterView, batch as mod from wuttaweb.views import MasterView, batch as mod
from wuttaweb.progress import SessionProgress from wuttaweb.progress import SessionProgress
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class MockBatch(model.BatchMixin, model.Base): class MockBatch(model.BatchMixin, model.Base):
@ -367,6 +367,12 @@ class TestBatchMasterView(WebTestCase):
self.assertIn('sequence', grid.labels) self.assertIn('sequence', grid.labels)
self.assertEqual(grid.labels['sequence'], "Seq.") self.assertEqual(grid.labels['sequence'], "Seq.")
def test_render_row_status(self):
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=None):
view = self.make_view()
row = MagicMock(foo=1, STATUS={1: 'bar'})
self.assertEqual(view.render_row_status(row, 'foo', 1), 'bar')
def test_defaults(self): def test_defaults(self):
# nb. coverage only # nb. coverage only
with patch.object(mod.BatchMasterView, 'model_class', new=MockBatch, create=True): with patch.object(mod.BatchMasterView, 'model_class', new=MockBatch, create=True):

View file

@ -5,7 +5,7 @@ from unittest.mock import patch
import colander import colander
from wuttaweb.views import common as mod from wuttaweb.views import common as mod
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestCommonView(WebTestCase): class TestCommonView(WebTestCase):

View file

@ -9,7 +9,7 @@ from pyramid.httpexceptions import HTTPNotFound
from pyramid.response import Response from pyramid.response import Response
from wuttaweb.views import email as mod from wuttaweb.views import email as mod
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestEmailSettingViews(WebTestCase): class TestEmailSettingViews(WebTestCase):

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from wuttaweb.views import essential as mod from wuttaweb.views import essential as mod
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestEssentialViews(WebTestCase): class TestEssentialViews(WebTestCase):

View file

@ -16,7 +16,7 @@ from wuttaweb.views import master as mod
from wuttaweb.views import View from wuttaweb.views import View
from wuttaweb.progress import SessionProgress from wuttaweb.progress import SessionProgress
from wuttaweb.subscribers import new_request_set_user from wuttaweb.subscribers import new_request_set_user
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestMasterView(WebTestCase): class TestMasterView(WebTestCase):
@ -441,6 +441,12 @@ class TestMasterView(WebTestCase):
self.assertIn('click me', html) self.assertIn('click me', html)
self.assertIn('is-primary', html) self.assertIn('is-primary', html)
# with url
html = view.make_button('click me', url='http://example.com')
self.assertIn('<b-button tag="a"', html)
self.assertIn('click me', html)
self.assertIn('href="http://example.com"', html)
def test_make_progress(self): def test_make_progress(self):
# basic # basic
@ -1649,6 +1655,18 @@ class TestMasterView(WebTestCase):
self.assertIsNone(grid.model_class) self.assertIsNone(grid.model_class)
self.assertEqual(grid.data, []) self.assertEqual(grid.data, [])
# view action
with patch.object(view, 'rows_viewable', new=True):
with patch.object(view, 'get_row_action_url_view', return_value='#'):
grid = view.make_row_model_grid(person, data=[])
self.assertEqual(len(grid.actions), 1)
self.assertEqual(grid.actions[0].key, 'view')
def test_get_row_action_url_view(self):
view = self.make_view()
row = MagicMock()
self.assertRaises(NotImplementedError, view.get_row_action_url_view, row, 0)
def test_get_rows_title(self): def test_get_rows_title(self):
view = self.make_view() view = self.make_view()

View file

@ -7,7 +7,7 @@ from sqlalchemy import orm
from pyramid.httpexceptions import HTTPNotFound from pyramid.httpexceptions import HTTPNotFound
from wuttaweb.views import people from wuttaweb.views import people
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestPersonView(WebTestCase): class TestPersonView(WebTestCase):

View file

@ -4,7 +4,7 @@ from pyramid import testing
from wuttaweb.views import progress as mod from wuttaweb.views import progress as mod
from wuttaweb.progress import get_progress_session from wuttaweb.progress import get_progress_session
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestProgressView(WebTestCase): class TestProgressView(WebTestCase):

View file

@ -8,7 +8,7 @@ import colander
from wuttaweb.views import roles as mod from wuttaweb.views import roles as mod
from wuttaweb.forms.schema import RoleRef from wuttaweb.forms.schema import RoleRef
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestRoleView(WebTestCase): class TestRoleView(WebTestCase):

View file

@ -6,7 +6,7 @@ import colander
from pyramid.httpexceptions import HTTPNotFound from pyramid.httpexceptions import HTTPNotFound
from wuttaweb.views import settings as mod from wuttaweb.views import settings as mod
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestAppInfoView(WebTestCase): class TestAppInfoView(WebTestCase):

View file

@ -8,7 +8,7 @@ from unittest.mock import patch, MagicMock
from wuttaweb.views import upgrades as mod from wuttaweb.views import upgrades as mod
from wuttjamaican.exc import ConfigurationError from wuttjamaican.exc import ConfigurationError
from wuttaweb.progress import get_progress_session from wuttaweb.progress import get_progress_session
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestUpgradeView(WebTestCase): class TestUpgradeView(WebTestCase):

View file

@ -7,7 +7,7 @@ from sqlalchemy import orm
import colander import colander
from wuttaweb.views import users as mod from wuttaweb.views import users as mod
from tests.util import WebTestCase from wuttaweb.testing import WebTestCase
class TestUserView(WebTestCase): class TestUserView(WebTestCase):