From 6650ee698e6af7b167a2388755471b847acd3b5d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 24 Aug 2024 11:29:52 -0500 Subject: [PATCH] feat: add initial views for upgrades CRUD only so far, still need execute features --- docs/api/wuttaweb/index.rst | 1 + docs/api/wuttaweb/views.upgrades.rst | 6 + src/wuttaweb/forms/schema.py | 104 ++++++++++++--- src/wuttaweb/grids/base.py | 4 +- src/wuttaweb/menus.py | 5 + src/wuttaweb/util.py | 26 ++++ src/wuttaweb/views/common.py | 5 + src/wuttaweb/views/essential.py | 2 + src/wuttaweb/views/master.py | 50 +++++++ src/wuttaweb/views/upgrades.py | 187 ++++++++++++++++++++++++++ tests/forms/test_schema.py | 61 ++++++++- tests/test_util.py | 190 ++++++++++++++------------- tests/views/test_master.py | 29 +++- tests/views/test_upgrades.py | 103 +++++++++++++++ 14 files changed, 656 insertions(+), 117 deletions(-) create mode 100644 docs/api/wuttaweb/views.upgrades.rst create mode 100644 src/wuttaweb/views/upgrades.py create mode 100644 tests/views/test_upgrades.py diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 9749cae..739935b 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -32,4 +32,5 @@ views.people views.roles views.settings + views.upgrades views.users diff --git a/docs/api/wuttaweb/views.upgrades.rst b/docs/api/wuttaweb/views.upgrades.rst new file mode 100644 index 0000000..2909003 --- /dev/null +++ b/docs/api/wuttaweb/views.upgrades.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.upgrades`` +=========================== + +.. automodule:: wuttaweb.views.upgrades + :members: diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 4402fde..8538eef 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -92,6 +92,53 @@ class ObjectNode(colander.SchemaNode): raise NotImplementedError(f"you must define {class_name}.objectify()") +class WuttaEnum(colander.Enum): + """ + Custom schema type for enum fields. + + This is a subclass of :class:`colander.Enum`, but adds a + default widget (``SelectWidget``) with enum choices. + + :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): + """ """ + + if 'values' not in kwargs: + kwargs['values'] = [(getattr(e, self.attr), getattr(e, self.attr)) + for e in self.enum_cls] + + return widgets.SelectWidget(**kwargs) + + +class WuttaSet(colander.Set): + """ + Custom schema type for :class:`python:set` fields. + + This is a subclass of :class:`colander.Set`, but adds + Wutta-related params to the constructor. + + :param request: Current :term:`request` object. + + :param session: Optional :term:`db session` to use instead of + :class:`wuttaweb.db.Session`. + """ + + def __init__(self, request, session=None): + super().__init__() + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + self.session = session or Session() + + class ObjectRef(colander.SchemaType): """ Custom schema type for a model class reference field. @@ -199,7 +246,7 @@ class ObjectRef(colander.SchemaType): # fetch object from DB model = self.app.model - obj = self.session.query(self.model_class).get(value) + obj = self.session.get(self.model_class, value) # raise error if not found if not obj: @@ -247,14 +294,28 @@ class ObjectRef(colander.SchemaType): kwargs['values'] = values if 'url' not in kwargs: - kwargs['url'] = lambda person: self.request.route_url('people.view', uuid=person.uuid) + kwargs['url'] = self.get_object_url return widgets.ObjectRefWidget(self.request, **kwargs) + def get_object_url(self, obj): + """ + Returns the "view" URL for the given object, if applicable. + + This is used when rendering the field readonly. If this + method returns a URL then the field text will be wrapped with + a hyperlink, otherwise it will be shown as-is. + + Default logic always returns ``None``; subclass should + override as needed. + """ + class PersonRef(ObjectRef): """ - Custom schema type for a ``Person`` reference field. + Custom schema type for a + :class:`~wuttjamaican:wuttjamaican.db.model.base.Person` reference + field. This is a subclass of :class:`ObjectRef`. """ @@ -269,26 +330,33 @@ class PersonRef(ObjectRef): """ """ return query.order_by(self.model_class.full_name) + def get_object_url(self, person): + """ """ + return self.request.route_url('people.view', uuid=person.uuid) -class WuttaSet(colander.Set): + +class UserRef(ObjectRef): """ - Custom schema type for :class:`python:set` fields. + Custom schema type for a + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` reference + field. - This is a subclass of :class:`colander.Set`, but adds - Wutta-related params to the constructor. - - :param request: Current :term:`request` object. - - :param session: Optional :term:`db session` to use instead of - :class:`wuttaweb.db.Session`. + This is a subclass of :class:`ObjectRef`. """ - def __init__(self, request, session=None): - super().__init__() - self.request = request - self.config = self.request.wutta_config - self.app = self.config.get_app() - self.session = session or Session() + @property + def model_class(self): + """ """ + model = self.app.model + return model.User + + def sort_query(self, query): + """ """ + return query.order_by(self.model_class.username) + + def get_object_url(self, user): + """ """ + return self.request.route_url('users.view', uuid=user.uuid) class RoleRefs(WuttaSet): diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 0f2c812..51b0d7d 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -1078,6 +1078,7 @@ class Grid: :returns: A :class:`~wuttaweb.grids.filters.GridFilter` instance. """ + key = kwargs.pop('key', None) # model_property is required model_property = None @@ -1102,7 +1103,7 @@ class Grid: # make filter kwargs['model_property'] = model_property - return factory(self.request, model_property.key, **kwargs) + return factory(self.request, key or model_property.key, **kwargs) def set_filter(self, key, filterinfo=None, **kwargs): """ @@ -1132,6 +1133,7 @@ class Grid: # filtr = filterinfo raise NotImplementedError else: + kwargs['key'] = key kwargs.setdefault('label', self.get_label(key)) filtr = self.make_filter(filterinfo or key, **kwargs) diff --git a/src/wuttaweb/menus.py b/src/wuttaweb/menus.py index 84d5534..c1c47dc 100644 --- a/src/wuttaweb/menus.py +++ b/src/wuttaweb/menus.py @@ -168,6 +168,11 @@ class MenuHandler(GenericHandler): 'route': 'settings', 'perm': 'settings.list', }, + { + 'title': "Upgrades", + 'route': 'upgrades', + 'perm': 'upgrades.list', + }, ], } diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py index 0b230b4..29065fa 100644 --- a/src/wuttaweb/util.py +++ b/src/wuttaweb/util.py @@ -83,6 +83,32 @@ class FieldList(list): field, newfield) self.append(newfield) + def set_sequence(self, fields): + """ + Sort the list such that it matches the same sequence as the + given fields list. + + This does not add or remove any elements, it just + (potentially) rearranges the internal list elements. + Therefore you do not need to explicitly declare *all* fields; + just the ones you care about. + + The resulting field list will have the requested fields in + order, at the *beginning* of the list. Any unrequested fields + will remain in the same order as they were previously, but + will be placed *after* the requested fields. + + :param fields: List of fields in the desired order. + """ + unimportant = len(self) + 1 + + def getkey(field): + if field in fields: + return fields.index(field) + return unimportant + + self.sort(key=getkey) + def get_form_data(request): """ diff --git a/src/wuttaweb/views/common.py b/src/wuttaweb/views/common.py index a13fc50..2b6b084 100644 --- a/src/wuttaweb/views/common.py +++ b/src/wuttaweb/views/common.py @@ -154,6 +154,11 @@ class CommonView(View): 'settings.view', 'settings.edit', 'settings.delete', + 'upgrades.list', + 'upgrades.create', + 'upgrades.view', + 'upgrades.edit', + 'upgrades.delete', 'users.list', 'users.create', 'users.view', diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py index 1387a99..b6a05b1 100644 --- a/src/wuttaweb/views/essential.py +++ b/src/wuttaweb/views/essential.py @@ -35,6 +35,7 @@ That will in turn include the following modules: * :mod:`wuttaweb.views.people` * :mod:`wuttaweb.views.roles` * :mod:`wuttaweb.views.users` +* :mod:`wuttaweb.views.upgrades` """ @@ -47,6 +48,7 @@ def defaults(config, **kwargs): config.include(mod('wuttaweb.views.people')) config.include(mod('wuttaweb.views.roles')) config.include(mod('wuttaweb.views.users')) + config.include(mod('wuttaweb.views.upgrades')) def includeme(config): diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index a834b1d..bc6dbb7 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -1040,6 +1040,56 @@ class MasterView(View): fmt = f"${{:0,.{scale}f}}" return fmt.format(value) + def grid_render_datetime(self, record, key, value, fmt=None): + """ + Custom grid value renderer for + :class:`~python:datetime.datetime` fields. + + :param fmt: Optional format string to use instead of the + default: ``'%Y-%m-%d %I:%M:%S %p'`` + + To use this feature for your grid:: + + grid.set_renderer('my_datetime_field', self.grid_render_datetime) + + # you can also override format + grid.set_renderer('my_datetime_field', self.grid_render_datetime, + fmt='%Y-%m-%d %H:%M:%S') + """ + # nb. get new value since the one provided will just be a + # (json-safe) *string* if the original type was datetime + value = record[key] + + if value is None: + return + + return value.strftime(fmt or '%Y-%m-%d %I:%M:%S %p') + + def grid_render_enum(self, record, key, value, enum=None): + """ + Custom grid value renderer for "enum" fields. + + :param enum: Enum class for the field. This should be an + instance of :class:`~python:enum.Enum`. + + To use this feature for your grid:: + + from enum import Enum + + class MyEnum(Enum): + ONE = 1 + TWO = 2 + THREE = 3 + + grid.set_renderer('my_enum_field', self.grid_render_enum, enum=MyEnum) + """ + if enum: + original = record[key] + if original: + return original.name + + return value + def grid_render_notes(self, record, key, value, maxlen=100): """ Custom grid value renderer for "notes" fields. diff --git a/src/wuttaweb/views/upgrades.py b/src/wuttaweb/views/upgrades.py new file mode 100644 index 0000000..8d7c48c --- /dev/null +++ b/src/wuttaweb/views/upgrades.py @@ -0,0 +1,187 @@ +# -*- 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 . +# +################################################################################ +""" +Upgrade Views +""" + +from sqlalchemy import orm + +from wuttjamaican.db.model import Upgrade +from wuttaweb.views import MasterView +from wuttaweb.forms import widgets +from wuttaweb.forms.schema import UserRef, WuttaEnum + + +class UpgradeView(MasterView): + """ + Master view for upgrades. + + Default route prefix is ``upgrades``. + + Notable URLs provided by this class: + + * ``/upgrades/`` + * ``/upgrades/new`` + * ``/upgrades/XXX`` + * ``/upgrades/XXX/edit`` + * ``/upgrades/XXX/delete`` + """ + model_class = Upgrade + + grid_columns = [ + 'created', + 'description', + 'status', + 'executed', + 'executed_by', + ] + + sort_defaults = ('created', 'desc') + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + model = self.app.model + enum = self.app.enum + + # description + g.set_link('description') + + # created + g.set_renderer('created', self.grid_render_datetime) + + # created_by + g.set_link('created_by') + Creator = orm.aliased(model.User) + g.set_joiner('created_by', lambda q: q.join(Creator, + Creator.uuid == model.Upgrade.created_by_uuid)) + g.set_filter('created_by', Creator.username, + label="Created By Username") + + # status + g.set_renderer('status', self.grid_render_enum, enum=enum.UpgradeStatus) + + # executed_by + g.set_link('executed_by') + Executor = orm.aliased(model.User) + g.set_joiner('executed_by', lambda q: q.outerjoin(Executor, + Executor.uuid == model.Upgrade.executed_by_uuid)) + g.set_filter('executed_by', Executor.username, + label="Executed By Username") + + def grid_row_class(self, upgrade, data, i): + """ """ + enum = self.app.enum + if upgrade.status == enum.UpgradeStatus.EXECUTING: + return 'has-background-warning' + if upgrade.status == enum.UpgradeStatus.FAILURE: + return 'has-background-warning' + + def configure_form(self, f): + """ """ + super().configure_form(f) + enum = self.app.enum + upgrade = f.model_instance + + # never show these + f.remove('created_by_uuid', + 'executing', + 'executed_by_uuid') + + # sequence sanity + f.fields.set_sequence([ + 'description', + 'notes', + 'status', + 'created', + 'created_by', + 'executed', + 'executed_by', + ]) + + # created + if self.creating or self.editing: + f.remove('created') + + # created_by + if self.creating or self.editing: + f.remove('created_by') + else: + f.set_node('created_by', UserRef(self.request)) + + # notes + f.set_widget('notes', widgets.NotesWidget()) + + # status + if self.creating: + f.remove('status') + else: + f.set_node('status', WuttaEnum(self.request, enum.UpgradeStatus)) + + # exit_code + if self.creating or not upgrade.executed: + f.remove('exit_code') + + # executed + if self.creating or self.editing or not upgrade.executed: + f.remove('executed') + + # executed_by + if self.creating or self.editing or not upgrade.executed: + f.remove('executed_by') + else: + f.set_node('executed_by', UserRef(self.request)) + + def objectify(self, form): + """ """ + upgrade = super().objectify(form) + enum = self.app.enum + + # set user, status when creating + if self.creating: + upgrade.created_by = self.request.user + upgrade.status = enum.UpgradeStatus.PENDING + + return upgrade + + @classmethod + def defaults(cls, config): + """ """ + + # nb. Upgrade may come from custom model + wutta_config = config.registry.settings['wutta_config'] + app = wutta_config.get_app() + cls.model_class = app.model.Upgrade + + cls._defaults(config) + + +def defaults(config, **kwargs): + base = globals() + + UpgradeView = kwargs.get('UpgradeView', base['UpgradeView']) + UpgradeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index 7538301..9397e03 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -10,7 +10,7 @@ from sqlalchemy import orm from wuttjamaican.conf import WuttaConfig from wuttaweb.forms import schema as mod from wuttaweb.forms import widgets -from tests.util import DataTestCase +from tests.util import DataTestCase, WebTestCase class TestObjectNode(DataTestCase): @@ -47,6 +47,15 @@ class TestObjectNode(DataTestCase): self.assertIs(value, person) +class TestWuttaEnum(WebTestCase): + + def test_widget_maker(self): + enum = self.app.enum + typ = mod.WuttaEnum(self.request, enum.UpgradeStatus) + widget = typ.widget_maker() + self.assertIsInstance(widget, widgets.SelectWidget) + + class TestObjectRef(DataTestCase): def setUp(self): @@ -140,10 +149,17 @@ class TestObjectRef(DataTestCase): self.session.commit() self.assertIsNotNone(person.uuid) with patch.object(mod.ObjectRef, 'model_class', new=model.Person): + + # can specify as uuid typ = mod.ObjectRef(self.request, session=self.session) value = typ.objectify(person.uuid) self.assertIs(value, person) + # or can specify object proper + typ = mod.ObjectRef(self.request, session=self.session) + value = typ.objectify(person) + self.assertIs(value, person) + # error if not found with patch.object(mod.ObjectRef, 'model_class', new=model.Person): typ = mod.ObjectRef(self.request, session=self.session) @@ -186,11 +202,7 @@ class TestObjectRef(DataTestCase): self.assertEqual(widget.values[1][1], "Betty Boop") -class TestPersonRef(DataTestCase): - - def setUp(self): - self.setup_db() - self.request = testing.DummyRequest(wutta_config=self.config) +class TestPersonRef(WebTestCase): def test_sort_query(self): typ = mod.PersonRef(self.request, session=self.session) @@ -200,6 +212,43 @@ class TestPersonRef(DataTestCase): self.assertIsInstance(sorted_query, orm.Query) self.assertIsNot(sorted_query, query) + def test_get_object_url(self): + self.pyramid_config.add_route('people.view', '/people/{uuid}') + model = self.app.model + typ = mod.PersonRef(self.request, session=self.session) + + person = model.Person(full_name="Barney Rubble") + self.session.add(person) + self.session.commit() + + url = typ.get_object_url(person) + self.assertIsNotNone(url) + self.assertIn(f'/people/{person.uuid}', url) + + +class TestUserRef(WebTestCase): + + def test_sort_query(self): + typ = mod.UserRef(self.request, session=self.session) + query = typ.get_query() + self.assertIsInstance(query, orm.Query) + sorted_query = typ.sort_query(query) + self.assertIsInstance(sorted_query, orm.Query) + self.assertIsNot(sorted_query, query) + + def test_get_object_url(self): + self.pyramid_config.add_route('users.view', '/users/{uuid}') + model = self.app.model + typ = mod.UserRef(self.request, session=self.session) + + user = model.User(username='barney') + self.session.add(user) + self.session.commit() + + url = typ.get_object_url(user) + self.assertIsNotNone(url) + self.assertIn(f'/users/{user.uuid}', url) + class TestUserRefs(DataTestCase): diff --git a/tests/test_util.py b/tests/test_util.py index 021394b..0f54932 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -9,13 +9,13 @@ from fanstatic import Library, Resource from pyramid import testing from wuttjamaican.conf import WuttaConfig -from wuttaweb import util +from wuttaweb import util as mod class TestFieldList(TestCase): def test_insert_before(self): - fields = util.FieldList(['f1', 'f2']) + fields = mod.FieldList(['f1', 'f2']) self.assertEqual(fields, ['f1', 'f2']) # typical @@ -29,7 +29,7 @@ class TestFieldList(TestCase): self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ']) def test_insert_after(self): - fields = util.FieldList(['f1', 'f2']) + fields = mod.FieldList(['f1', 'f2']) self.assertEqual(fields, ['f1', 'f2']) # typical @@ -42,6 +42,14 @@ class TestFieldList(TestCase): fields.insert_after('f3', 'ZZZ') self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2', 'ZZZ']) + def test_set_sequence(self): + fields = mod.FieldList(['f5', 'f1', 'f3', 'f4', 'f2']) + + # setting sequence will only "sort" for explicit fields. + # other fields remain in original order, but at the end. + fields.set_sequence(['f1', 'f2', 'f3']) + self.assertEqual(fields, ['f1', 'f2', 'f3', 'f5', 'f4']) + class TestGetLibVer(TestCase): @@ -51,153 +59,153 @@ class TestGetLibVer(TestCase): self.request.wutta_config = self.config def test_buefy_default(self): - version = util.get_libver(self.request, 'buefy') + version = mod.get_libver(self.request, 'buefy') self.assertEqual(version, 'latest') def test_buefy_custom_old(self): self.config.setdefault('wuttaweb.buefy_version', '0.9.29') - version = util.get_libver(self.request, 'buefy') + version = mod.get_libver(self.request, 'buefy') self.assertEqual(version, '0.9.29') def test_buefy_custom_old_tailbone(self): self.config.setdefault('tailbone.libver.buefy', '0.9.28') - version = util.get_libver(self.request, 'buefy', prefix='tailbone') + version = mod.get_libver(self.request, 'buefy', prefix='tailbone') self.assertEqual(version, '0.9.28') def test_buefy_custom_new(self): self.config.setdefault('wuttaweb.libver.buefy', '0.9.29') - version = util.get_libver(self.request, 'buefy') + version = mod.get_libver(self.request, 'buefy') self.assertEqual(version, '0.9.29') def test_buefy_configured_only(self): - version = util.get_libver(self.request, 'buefy', configured_only=True) + version = mod.get_libver(self.request, 'buefy', configured_only=True) self.assertIsNone(version) def test_buefy_default_only(self): self.config.setdefault('wuttaweb.libver.buefy', '0.9.29') - version = util.get_libver(self.request, 'buefy', default_only=True) + version = mod.get_libver(self.request, 'buefy', default_only=True) self.assertEqual(version, 'latest') def test_buefy_css_default(self): - version = util.get_libver(self.request, 'buefy.css') + version = mod.get_libver(self.request, 'buefy.css') self.assertEqual(version, 'latest') def test_buefy_css_custom_old(self): # nb. this uses same setting as buefy (js) self.config.setdefault('wuttaweb.buefy_version', '0.9.29') - version = util.get_libver(self.request, 'buefy.css') + version = mod.get_libver(self.request, 'buefy.css') self.assertEqual(version, '0.9.29') def test_buefy_css_custom_new(self): # nb. this uses same setting as buefy (js) self.config.setdefault('wuttaweb.libver.buefy', '0.9.29') - version = util.get_libver(self.request, 'buefy.css') + version = mod.get_libver(self.request, 'buefy.css') self.assertEqual(version, '0.9.29') def test_buefy_css_configured_only(self): - version = util.get_libver(self.request, 'buefy.css', configured_only=True) + version = mod.get_libver(self.request, 'buefy.css', configured_only=True) self.assertIsNone(version) def test_buefy_css_default_only(self): self.config.setdefault('wuttaweb.libver.buefy', '0.9.29') - version = util.get_libver(self.request, 'buefy.css', default_only=True) + version = mod.get_libver(self.request, 'buefy.css', default_only=True) self.assertEqual(version, 'latest') def test_vue_default(self): - version = util.get_libver(self.request, 'vue') + version = mod.get_libver(self.request, 'vue') self.assertEqual(version, '2.6.14') def test_vue_custom_old(self): self.config.setdefault('wuttaweb.vue_version', '3.4.31') - version = util.get_libver(self.request, 'vue') + version = mod.get_libver(self.request, 'vue') self.assertEqual(version, '3.4.31') def test_vue_custom_new(self): self.config.setdefault('wuttaweb.libver.vue', '3.4.31') - version = util.get_libver(self.request, 'vue') + version = mod.get_libver(self.request, 'vue') self.assertEqual(version, '3.4.31') def test_vue_configured_only(self): - version = util.get_libver(self.request, 'vue', configured_only=True) + version = mod.get_libver(self.request, 'vue', configured_only=True) self.assertIsNone(version) def test_vue_default_only(self): self.config.setdefault('wuttaweb.libver.vue', '3.4.31') - version = util.get_libver(self.request, 'vue', default_only=True) + version = mod.get_libver(self.request, 'vue', default_only=True) self.assertEqual(version, '2.6.14') def test_vue_resource_default(self): - version = util.get_libver(self.request, 'vue_resource') + version = mod.get_libver(self.request, 'vue_resource') self.assertEqual(version, 'latest') def test_vue_resource_custom(self): self.config.setdefault('wuttaweb.libver.vue_resource', '1.5.3') - version = util.get_libver(self.request, 'vue_resource') + version = mod.get_libver(self.request, 'vue_resource') self.assertEqual(version, '1.5.3') def test_fontawesome_default(self): - version = util.get_libver(self.request, 'fontawesome') + version = mod.get_libver(self.request, 'fontawesome') self.assertEqual(version, '5.3.1') def test_fontawesome_custom(self): self.config.setdefault('wuttaweb.libver.fontawesome', '5.6.3') - version = util.get_libver(self.request, 'fontawesome') + version = mod.get_libver(self.request, 'fontawesome') self.assertEqual(version, '5.6.3') def test_bb_vue_default(self): - version = util.get_libver(self.request, 'bb_vue') + version = mod.get_libver(self.request, 'bb_vue') self.assertEqual(version, '3.4.31') def test_bb_vue_custom(self): self.config.setdefault('wuttaweb.libver.bb_vue', '3.4.30') - version = util.get_libver(self.request, 'bb_vue') + version = mod.get_libver(self.request, 'bb_vue') self.assertEqual(version, '3.4.30') def test_bb_oruga_default(self): - version = util.get_libver(self.request, 'bb_oruga') + version = mod.get_libver(self.request, 'bb_oruga') self.assertEqual(version, '0.8.12') def test_bb_oruga_custom(self): self.config.setdefault('wuttaweb.libver.bb_oruga', '0.8.11') - version = util.get_libver(self.request, 'bb_oruga') + version = mod.get_libver(self.request, 'bb_oruga') self.assertEqual(version, '0.8.11') def test_bb_oruga_bulma_default(self): - version = util.get_libver(self.request, 'bb_oruga_bulma') + version = mod.get_libver(self.request, 'bb_oruga_bulma') self.assertEqual(version, '0.3.0') - version = util.get_libver(self.request, 'bb_oruga_bulma_css') + version = mod.get_libver(self.request, 'bb_oruga_bulma_css') self.assertEqual(version, '0.3.0') def test_bb_oruga_bulma_custom(self): self.config.setdefault('wuttaweb.libver.bb_oruga_bulma', '0.2.11') - version = util.get_libver(self.request, 'bb_oruga_bulma') + version = mod.get_libver(self.request, 'bb_oruga_bulma') self.assertEqual(version, '0.2.11') def test_bb_fontawesome_svg_core_default(self): - version = util.get_libver(self.request, 'bb_fontawesome_svg_core') + version = mod.get_libver(self.request, 'bb_fontawesome_svg_core') self.assertEqual(version, '6.5.2') def test_bb_fontawesome_svg_core_custom(self): self.config.setdefault('wuttaweb.libver.bb_fontawesome_svg_core', '6.5.1') - version = util.get_libver(self.request, 'bb_fontawesome_svg_core') + version = mod.get_libver(self.request, 'bb_fontawesome_svg_core') self.assertEqual(version, '6.5.1') def test_bb_free_solid_svg_icons_default(self): - version = util.get_libver(self.request, 'bb_free_solid_svg_icons') + version = mod.get_libver(self.request, 'bb_free_solid_svg_icons') self.assertEqual(version, '6.5.2') def test_bb_free_solid_svg_icons_custom(self): self.config.setdefault('wuttaweb.libver.bb_free_solid_svg_icons', '6.5.1') - version = util.get_libver(self.request, 'bb_free_solid_svg_icons') + version = mod.get_libver(self.request, 'bb_free_solid_svg_icons') self.assertEqual(version, '6.5.1') def test_bb_vue_fontawesome_default(self): - version = util.get_libver(self.request, 'bb_vue_fontawesome') + version = mod.get_libver(self.request, 'bb_vue_fontawesome') self.assertEqual(version, '3.0.6') def test_bb_vue_fontawesome_custom(self): self.config.setdefault('wuttaweb.libver.bb_vue_fontawesome', '3.0.8') - version = util.get_libver(self.request, 'bb_vue_fontawesome') + version = mod.get_libver(self.request, 'bb_vue_fontawesome') self.assertEqual(version, '3.0.8') @@ -238,191 +246,191 @@ class TestGetLibUrl(TestCase): self.request.script_name = '/wutta' def test_buefy_default(self): - url = util.get_liburl(self.request, 'buefy') + url = mod.get_liburl(self.request, 'buefy') self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.js') def test_buefy_custom(self): self.config.setdefault('wuttaweb.liburl.buefy', '/lib/buefy.js') - url = util.get_liburl(self.request, 'buefy') + url = mod.get_liburl(self.request, 'buefy') self.assertEqual(url, '/lib/buefy.js') def test_buefy_custom_tailbone(self): self.config.setdefault('tailbone.liburl.buefy', '/tailbone/buefy.js') - url = util.get_liburl(self.request, 'buefy', prefix='tailbone') + url = mod.get_liburl(self.request, 'buefy', prefix='tailbone') self.assertEqual(url, '/tailbone/buefy.js') def test_buefy_default_only(self): self.config.setdefault('wuttaweb.liburl.buefy', '/lib/buefy.js') - url = util.get_liburl(self.request, 'buefy', default_only=True) + url = mod.get_liburl(self.request, 'buefy', default_only=True) self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.js') def test_buefy_configured_only(self): - url = util.get_liburl(self.request, 'buefy', configured_only=True) + url = mod.get_liburl(self.request, 'buefy', configured_only=True) self.assertIsNone(url) def test_buefy_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'buefy') + url = mod.get_liburl(self.request, 'buefy') self.assertEqual(url, '/wutta/fanstatic/buefy.js') def test_buefy_fanstatic_tailbone(self): self.setup_fanstatic(register=False) self.config.setdefault('tailbone.static_libcache.module', 'tests.test_util') - url = util.get_liburl(self.request, 'buefy', prefix='tailbone') + url = mod.get_liburl(self.request, 'buefy', prefix='tailbone') self.assertEqual(url, '/wutta/fanstatic/buefy.js') def test_buefy_css_default(self): - url = util.get_liburl(self.request, 'buefy.css') + url = mod.get_liburl(self.request, 'buefy.css') self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.css') def test_buefy_css_custom(self): self.config.setdefault('wuttaweb.liburl.buefy.css', '/lib/buefy.css') - url = util.get_liburl(self.request, 'buefy.css') + url = mod.get_liburl(self.request, 'buefy.css') self.assertEqual(url, '/lib/buefy.css') def test_buefy_css_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'buefy.css') + url = mod.get_liburl(self.request, 'buefy.css') self.assertEqual(url, '/wutta/fanstatic/buefy.css') def test_vue_default(self): - url = util.get_liburl(self.request, 'vue') + url = mod.get_liburl(self.request, 'vue') self.assertEqual(url, 'https://unpkg.com/vue@2.6.14/dist/vue.min.js') def test_vue_custom(self): self.config.setdefault('wuttaweb.liburl.vue', '/lib/vue.js') - url = util.get_liburl(self.request, 'vue') + url = mod.get_liburl(self.request, 'vue') self.assertEqual(url, '/lib/vue.js') def test_vue_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'vue') + url = mod.get_liburl(self.request, 'vue') self.assertEqual(url, '/wutta/fanstatic/vue.js') def test_vue_resource_default(self): - url = util.get_liburl(self.request, 'vue_resource') + url = mod.get_liburl(self.request, 'vue_resource') self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/vue-resource@latest') def test_vue_resource_custom(self): self.config.setdefault('wuttaweb.liburl.vue_resource', '/lib/vue-resource.js') - url = util.get_liburl(self.request, 'vue_resource') + url = mod.get_liburl(self.request, 'vue_resource') self.assertEqual(url, '/lib/vue-resource.js') def test_vue_resource_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'vue_resource') + url = mod.get_liburl(self.request, 'vue_resource') self.assertEqual(url, '/wutta/fanstatic/vue_resource.js') def test_fontawesome_default(self): - url = util.get_liburl(self.request, 'fontawesome') + url = mod.get_liburl(self.request, 'fontawesome') self.assertEqual(url, 'https://use.fontawesome.com/releases/v5.3.1/js/all.js') def test_fontawesome_custom(self): self.config.setdefault('wuttaweb.liburl.fontawesome', '/lib/fontawesome.js') - url = util.get_liburl(self.request, 'fontawesome') + url = mod.get_liburl(self.request, 'fontawesome') self.assertEqual(url, '/lib/fontawesome.js') def test_fontawesome_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'fontawesome') + url = mod.get_liburl(self.request, 'fontawesome') self.assertEqual(url, '/wutta/fanstatic/fontawesome.js') def test_bb_vue_default(self): - url = util.get_liburl(self.request, 'bb_vue') + url = mod.get_liburl(self.request, 'bb_vue') self.assertEqual(url, 'https://unpkg.com/vue@3.4.31/dist/vue.esm-browser.prod.js') def test_bb_vue_custom(self): self.config.setdefault('wuttaweb.liburl.bb_vue', '/lib/vue.js') - url = util.get_liburl(self.request, 'bb_vue') + url = mod.get_liburl(self.request, 'bb_vue') self.assertEqual(url, '/lib/vue.js') def test_bb_vue_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_vue') + url = mod.get_liburl(self.request, 'bb_vue') self.assertEqual(url, '/wutta/fanstatic/bb_vue.js') def test_bb_oruga_default(self): - url = util.get_liburl(self.request, 'bb_oruga') + url = mod.get_liburl(self.request, 'bb_oruga') self.assertEqual(url, 'https://unpkg.com/@oruga-ui/oruga-next@0.8.12/dist/oruga.mjs') def test_bb_oruga_custom(self): self.config.setdefault('wuttaweb.liburl.bb_oruga', '/lib/oruga.js') - url = util.get_liburl(self.request, 'bb_oruga') + url = mod.get_liburl(self.request, 'bb_oruga') self.assertEqual(url, '/lib/oruga.js') def test_bb_oruga_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_oruga') + url = mod.get_liburl(self.request, 'bb_oruga') self.assertEqual(url, '/wutta/fanstatic/bb_oruga.js') def test_bb_oruga_bulma_default(self): - url = util.get_liburl(self.request, 'bb_oruga_bulma') + url = mod.get_liburl(self.request, 'bb_oruga_bulma') self.assertEqual(url, 'https://unpkg.com/@oruga-ui/theme-bulma@0.3.0/dist/bulma.mjs') def test_bb_oruga_bulma_custom(self): self.config.setdefault('wuttaweb.liburl.bb_oruga_bulma', '/lib/oruga_bulma.js') - url = util.get_liburl(self.request, 'bb_oruga_bulma') + url = mod.get_liburl(self.request, 'bb_oruga_bulma') self.assertEqual(url, '/lib/oruga_bulma.js') def test_bb_oruga_bulma_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_oruga_bulma') + url = mod.get_liburl(self.request, 'bb_oruga_bulma') self.assertEqual(url, '/wutta/fanstatic/bb_oruga_bulma.js') def test_bb_oruga_bulma_css_default(self): - url = util.get_liburl(self.request, 'bb_oruga_bulma_css') + url = mod.get_liburl(self.request, 'bb_oruga_bulma_css') self.assertEqual(url, 'https://unpkg.com/@oruga-ui/theme-bulma@0.3.0/dist/bulma.css') def test_bb_oruga_bulma_css_custom(self): self.config.setdefault('wuttaweb.liburl.bb_oruga_bulma_css', '/lib/oruga-bulma.css') - url = util.get_liburl(self.request, 'bb_oruga_bulma_css') + url = mod.get_liburl(self.request, 'bb_oruga_bulma_css') self.assertEqual(url, '/lib/oruga-bulma.css') def test_bb_oruga_bulma_css_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_oruga_bulma_css') + url = mod.get_liburl(self.request, 'bb_oruga_bulma_css') self.assertEqual(url, '/wutta/fanstatic/bb_oruga_bulma.css') def test_bb_fontawesome_svg_core_default(self): - url = util.get_liburl(self.request, 'bb_fontawesome_svg_core') + url = mod.get_liburl(self.request, 'bb_fontawesome_svg_core') self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@6.5.2/+esm') def test_bb_fontawesome_svg_core_custom(self): self.config.setdefault('wuttaweb.liburl.bb_fontawesome_svg_core', '/lib/fontawesome-svg-core.js') - url = util.get_liburl(self.request, 'bb_fontawesome_svg_core') + url = mod.get_liburl(self.request, 'bb_fontawesome_svg_core') self.assertEqual(url, '/lib/fontawesome-svg-core.js') def test_bb_fontawesome_svg_core_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_fontawesome_svg_core') + url = mod.get_liburl(self.request, 'bb_fontawesome_svg_core') self.assertEqual(url, '/wutta/fanstatic/bb_fontawesome_svg_core.js') def test_bb_free_solid_svg_icons_default(self): - url = util.get_liburl(self.request, 'bb_free_solid_svg_icons') + url = mod.get_liburl(self.request, 'bb_free_solid_svg_icons') self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@6.5.2/+esm') def test_bb_free_solid_svg_icons_custom(self): self.config.setdefault('wuttaweb.liburl.bb_free_solid_svg_icons', '/lib/free-solid-svg-icons.js') - url = util.get_liburl(self.request, 'bb_free_solid_svg_icons') + url = mod.get_liburl(self.request, 'bb_free_solid_svg_icons') self.assertEqual(url, '/lib/free-solid-svg-icons.js') def test_bb_free_solid_svg_icons_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_free_solid_svg_icons') + url = mod.get_liburl(self.request, 'bb_free_solid_svg_icons') self.assertEqual(url, '/wutta/fanstatic/bb_free_solid_svg_icons.js') def test_bb_vue_fontawesome_default(self): - url = util.get_liburl(self.request, 'bb_vue_fontawesome') + url = mod.get_liburl(self.request, 'bb_vue_fontawesome') self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@3.0.6/+esm') def test_bb_vue_fontawesome_custom(self): self.config.setdefault('wuttaweb.liburl.bb_vue_fontawesome', '/lib/vue-fontawesome.js') - url = util.get_liburl(self.request, 'bb_vue_fontawesome') + url = mod.get_liburl(self.request, 'bb_vue_fontawesome') self.assertEqual(url, '/lib/vue-fontawesome.js') def test_bb_vue_fontawesome_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_vue_fontawesome') + url = mod.get_liburl(self.request, 'bb_vue_fontawesome') self.assertEqual(url, '/wutta/fanstatic/bb_vue_fontawesome.js') @@ -439,17 +447,17 @@ class TestGetFormData(TestCase): def test_default(self): request = self.make_request() - data = util.get_form_data(request) + data = mod.get_form_data(request) self.assertEqual(data, {'foo1': 'bar'}) def test_is_xhr(self): request = self.make_request(POST=None, is_xhr=True) - data = util.get_form_data(request) + data = mod.get_form_data(request) self.assertEqual(data, {'foo2': 'baz'}) def test_content_type(self): request = self.make_request(POST=None, content_type='application/json') - data = util.get_form_data(request) + data = mod.get_form_data(request) self.assertEqual(data, {'foo2': 'baz'}) @@ -460,16 +468,16 @@ class TestGetModelFields(TestCase): self.app = self.config.get_app() def test_empty_model_class(self): - fields = util.get_model_fields(self.config) + fields = mod.get_model_fields(self.config) self.assertIsNone(fields) def test_unknown_model_class(self): - fields = util.get_model_fields(self.config, TestCase) + fields = mod.get_model_fields(self.config, TestCase) self.assertIsNone(fields) def test_basic(self): model = self.app.model - fields = util.get_model_fields(self.config, model.Setting) + fields = mod.get_model_fields(self.config, model.Setting) self.assertEqual(fields, ['name', 'value']) @@ -484,9 +492,9 @@ class TestGetCsrfToken(TestCase): # same token returned for same request # TODO: dummy request is always returning same token! # so this isn't really testing anything.. :( - first = util.get_csrf_token(self.request) + first = mod.get_csrf_token(self.request) self.assertIsNotNone(first) - second = util.get_csrf_token(self.request) + second = mod.get_csrf_token(self.request) self.assertEqual(first, second) # TODO: ideally would make a new request here and confirm it @@ -497,7 +505,7 @@ class TestGetCsrfToken(TestCase): # nb. dummy request always returns same token, so must # trick it into thinking it doesn't have one yet with patch.object(self.request.session, 'get_csrf_token', return_value=None): - token = util.get_csrf_token(self.request) + token = mod.get_csrf_token(self.request) self.assertIsNotNone(token) @@ -508,10 +516,10 @@ class TestRenderCsrfToken(TestCase): self.request = testing.DummyRequest(wutta_config=self.config) def test_basics(self): - html = util.render_csrf_token(self.request) + html = mod.render_csrf_token(self.request) self.assertIn('type="hidden"', html) self.assertIn('name="_csrf"', html) - token = util.get_csrf_token(self.request) + token = mod.get_csrf_token(self.request) self.assertIn(f'value="{token}"', html) @@ -522,17 +530,17 @@ class TestMakeJsonSafe(TestCase): self.app = self.config.get_app() def test_null(self): - value = util.make_json_safe(colander.null) + value = mod.make_json_safe(colander.null) self.assertIsNone(value) - value = util.make_json_safe(None) + value = mod.make_json_safe(None) self.assertIsNone(value) def test_invalid(self): model = self.app.model person = model.Person(full_name="Betty Boop") self.assertRaises(TypeError, json.dumps, person) - value = util.make_json_safe(person, key='person') + value = mod.make_json_safe(person, key='person') self.assertEqual(value, "Betty Boop") def test_dict(self): @@ -545,7 +553,7 @@ class TestMakeJsonSafe(TestCase): } self.assertRaises(TypeError, json.dumps, data) - value = util.make_json_safe(data) + value = mod.make_json_safe(data) self.assertEqual(value, { 'foo': 'bar', 'person': "Betty Boop", diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 023449a..2e5f8a7 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -1,5 +1,6 @@ # -*- coding: utf-8; -*- +import datetime import decimal import functools from unittest import TestCase @@ -579,7 +580,6 @@ class TestMasterView(WebTestCase): self.assertEqual(value, "No") def test_grid_render_currency(self): - model = self.app.model view = self.make_view() obj = {'amount': None} @@ -597,6 +597,33 @@ class TestMasterView(WebTestCase): value = view.grid_render_currency(obj, 'amount', '-100.42') self.assertEqual(value, "($100.42)") + def test_grid_render_datetime(self): + view = self.make_view() + obj = {'dt': None} + + # null + value = view.grid_render_datetime(obj, 'dt', None) + self.assertIsNone(value) + + # normal + obj['dt'] = datetime.datetime(2024, 8, 24, 11) + value = view.grid_render_datetime(obj, 'dt', '2024-08-24T11:00:00') + self.assertEqual(value, '2024-08-24 11:00:00 AM') + + def test_grid_render_enum(self): + enum = self.app.enum + view = self.make_view() + obj = {'status': None} + + # null + value = view.grid_render_enum(obj, 'status', None, enum=enum.UpgradeStatus) + self.assertIsNone(value) + + # normal + obj['status'] = enum.UpgradeStatus.SUCCESS + value = view.grid_render_enum(obj, 'status', 'SUCCESS', enum=enum.UpgradeStatus) + self.assertEqual(value, 'SUCCESS') + def test_grid_render_notes(self): model = self.app.model view = self.make_view() diff --git a/tests/views/test_upgrades.py b/tests/views/test_upgrades.py new file mode 100644 index 0000000..4641029 --- /dev/null +++ b/tests/views/test_upgrades.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8; -*- + +import datetime +from unittest.mock import patch, MagicMock + +from wuttaweb.views import upgrades as mod +from tests.util import WebTestCase + + +class TestUpgradeView(WebTestCase): + + def make_view(self): + return mod.UpgradeView(self.request) + + def test_includeme(self): + self.pyramid_config.include('wuttaweb.views.upgrades') + + def test_configure_grid(self): + model = self.app.model + view = self.make_view() + + # sanity / coverage check + grid = view.make_grid(model_class=model.Upgrade) + view.configure_grid(grid) + + def test_grid_row_class(self): + model = self.app.model + enum = self.app.enum + upgrade = model.Upgrade(description="test", status=enum.UpgradeStatus.PENDING) + data = dict(upgrade) + view = self.make_view() + + self.assertIsNone(view.grid_row_class(upgrade, data, 1)) + + upgrade.status = enum.UpgradeStatus.EXECUTING + self.assertEqual(view.grid_row_class(upgrade, data, 1), 'has-background-warning') + + upgrade.status = enum.UpgradeStatus.SUCCESS + self.assertIsNone(view.grid_row_class(upgrade, data, 1)) + + upgrade.status = enum.UpgradeStatus.FAILURE + self.assertEqual(view.grid_row_class(upgrade, data, 1), 'has-background-warning') + + def test_configure_form(self): + model = self.app.model + enum = self.app.enum + user = model.User(username='barney') + self.session.add(user) + upgrade = model.Upgrade(description='test', created_by=user, + status=enum.UpgradeStatus.PENDING) + self.session.add(upgrade) + self.session.commit() + view = self.make_view() + + # some fields exist when viewing + with patch.object(view, 'viewing', new=True): + form = view.make_form(model_class=model.Upgrade, model_instance=upgrade) + self.assertIn('created', form) + view.configure_form(form) + self.assertIn('created', form) + + # but then are removed when creating + with patch.object(view, 'creating', new=True): + form = view.make_form(model_class=model.Upgrade) + self.assertIn('created', form) + view.configure_form(form) + self.assertNotIn('created', form) + + # test executed field when viewing + with patch.object(view, 'viewing', new=True): + + # executed is *not* shown by default + form = view.make_form(model_class=model.Upgrade, model_instance=upgrade) + self.assertIn('executed', form) + view.configure_form(form) + self.assertNotIn('executed', form) + + # but it *is* shown if upgrade is executed + upgrade.executed = datetime.datetime.now() + form = view.make_form(model_class=model.Upgrade, model_instance=upgrade) + self.assertIn('executed', form) + view.configure_form(form) + self.assertIn('executed', form) + + def test_objectify(self): + model = self.app.model + enum = self.app.enum + user = model.User(username='barney') + self.session.add(user) + self.session.commit() + view = self.make_view() + + # user and status are auto-set when creating + self.request.user = user + self.request.method = 'POST' + self.request.POST = {'description': "new one"} + with patch.object(view, 'creating', new=True): + form = view.make_model_form() + self.assertTrue(form.validate()) + upgrade = view.objectify(form) + self.assertEqual(upgrade.description, "new one") + self.assertIs(upgrade.created_by, user) + self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING)