diff --git a/CHANGELOG.md b/CHANGELOG.md index c7b0850..34f59cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ 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/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.13.0 (2024-08-26) + +### Feat + +- use native wuttjamaican app to send feedback email +- add basic user feedback email mechanism +- add "progress" page for executing upgrades +- add basic support for execute upgrades, download stdout/stderr +- add basic progress page/indicator support +- add basic "delete results" grid tool +- add initial views for upgrades +- allow app db to be rattail-native instead of wutta-native +- add per-row css class support for grids +- improve grid filter API a bit, support string/bool filters + +### Fix + +- tweak max image size for full logo on home, login pages +- improve handling of boolean form fields +- misc. improvements for display of grids, form errors +- use autocomplete for grid filter verb choices +- small cleanup for grid filters template +- add once-button action for grid Reset View +- set sort defaults for users, roles +- add override hook for base form template + ## v0.12.1 (2024-08-22) ### Fix diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 9749cae..7299034 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -20,6 +20,7 @@ handler helpers menus + progress static subscribers util @@ -30,6 +31,8 @@ views.essential views.master views.people + views.progress views.roles views.settings + views.upgrades views.users diff --git a/docs/api/wuttaweb/progress.rst b/docs/api/wuttaweb/progress.rst new file mode 100644 index 0000000..498d641 --- /dev/null +++ b/docs/api/wuttaweb/progress.rst @@ -0,0 +1,6 @@ + +``wuttaweb.progress`` +===================== + +.. automodule:: wuttaweb.progress + :members: diff --git a/docs/api/wuttaweb/views.people.rst b/docs/api/wuttaweb/views.people.rst index 89c6883..2dc919b 100644 --- a/docs/api/wuttaweb/views.people.rst +++ b/docs/api/wuttaweb/views.people.rst @@ -1,6 +1,6 @@ ``wuttaweb.views.people`` -=========================== +========================= .. automodule:: wuttaweb.views.people :members: diff --git a/docs/api/wuttaweb/views.progress.rst b/docs/api/wuttaweb/views.progress.rst new file mode 100644 index 0000000..34e2661 --- /dev/null +++ b/docs/api/wuttaweb/views.progress.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.progress`` +=========================== + +.. automodule:: wuttaweb.views.progress + :members: 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/docs/conf.py b/docs/conf.py index 3d568ef..0f73d82 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,6 +31,7 @@ intersphinx_mapping = { 'deform': ('https://docs.pylonsproject.org/projects/deform/en/latest/', None), 'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None), 'python': ('https://docs.python.org/3/', None), + 'rattail-manual': ('https://rattailproject.org/docs/rattail-manual/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), } diff --git a/pyproject.toml b/pyproject.toml index 41fda6b..fac1e9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.12.1" +version = "0.13.0" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -31,6 +31,7 @@ classifiers = [ requires-python = ">= 3.8" dependencies = [ "ColanderAlchemy", + "humanize", "paginate", "paginate_sqlalchemy", "pyramid>=2", @@ -41,7 +42,7 @@ dependencies = [ "pyramid_tm", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.12.1", + "WuttJamaican[db,email]>=0.13.0", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index 88318b4..c263b60 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -37,9 +37,10 @@ from wuttaweb.auth import WuttaSecurityPolicy class WebAppProvider(AppProvider): """ - The :term:`app provider` for WuttaWeb. This adds some methods - specific to web apps. + The :term:`app provider` for WuttaWeb. This adds some methods to + the :term:`app handler`, which are specific to web apps. """ + email_templates = 'wuttaweb:email/templates' def get_web_handler(self, **kwargs): """ diff --git a/src/wuttaweb/email/templates/feedback.html.mako b/src/wuttaweb/email/templates/feedback.html.mako new file mode 100644 index 0000000..c483f1a --- /dev/null +++ b/src/wuttaweb/email/templates/feedback.html.mako @@ -0,0 +1,40 @@ +## -*- coding: utf-8 -*- + +
+ + + ++ % if user: + ${user} + % else: + ${user_name} + % endif +
+ + + + + +${client_ip}
+ + +${message}
+ + + diff --git a/src/wuttaweb/email/templates/feedback.txt.mako b/src/wuttaweb/email/templates/feedback.txt.mako new file mode 100644 index 0000000..a73a55e --- /dev/null +++ b/src/wuttaweb/email/templates/feedback.txt.mako @@ -0,0 +1,23 @@ +## -*- coding: utf-8; -*- + +# User feedback from website + +**User Name** + +% if user: +${user} +% else: +${user_name} +% endif + +**Referring URL** + +${referrer} + +**Client IP** + +${client_ip} + +**Message** + +${message} diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 4402fde..74839a7 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): @@ -388,3 +456,35 @@ class Permissions(WuttaSet): kwargs['values'] = values return widgets.PermissionsWidget(self.request, **kwargs) + + +class FileDownload(colander.String): + """ + Custom schema type for a file download field. + + This field is only meant for readonly use, it does not handle file + uploads. + + It expects the incoming ``appstruct`` to be the path to a file on + disk (or null). + + Uses the :class:`~wuttaweb.forms.widgets.FileDownloadWidget` by + default. + + :param request: Current :term:`request` object. + + :param url: Optional URL for hyperlink. If not specified, file + name/size is shown with no hyperlink. + """ + + def __init__(self, request, *args, **kwargs): + self.url = kwargs.pop('url', None) + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + + def widget_maker(self, **kwargs): + """ """ + kwargs.setdefault('url', self.url) + return widgets.FileDownloadWidget(self.request, **kwargs) diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index 837b6f1..4db861a 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -39,7 +39,10 @@ in the namespace: * :class:`deform:deform.widget.MoneyInputWidget` """ +import os + import colander +import humanize from deform.widget import (Widget, TextInputWidget, TextAreaWidget, PasswordWidget, CheckedPasswordWidget, CheckboxWidget, SelectWidget, CheckboxChoiceWidget, @@ -147,6 +150,63 @@ class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget): self.session = session or Session() +class FileDownloadWidget(Widget): + """ + Widget for use with :class:`~wuttaweb.forms.schema.FileDownload` + fields. + + This only supports readonly, and shows a hyperlink to download the + file. Link text is the filename plus file size. + + This is a subclass of :class:`deform:deform.widget.Widget` and + uses these Deform templates: + + * ``readonly/filedownload`` + + :param request: Current :term:`request` object. + + :param url: Optional URL for hyperlink. If not specified, file + name/size is shown with no hyperlink. + """ + readonly_template = 'readonly/filedownload' + + def __init__(self, request, *args, **kwargs): + self.url = kwargs.pop('url', None) + 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): + """ """ + # nb. readonly is the only way this rolls + kw['readonly'] = True + template = self.readonly_template + + path = cstruct or None + if path: + kw.setdefault('filename', os.path.basename(path)) + kw.setdefault('filesize', self.readable_size(path)) + if self.url: + kw.setdefault('url', self.url) + + else: + kw.setdefault('filename', None) + kw.setdefault('filesize', None) + + kw.setdefault('url', None) + values = self.get_template_values(field, cstruct, kw) + return field.renderer(template, **values) + + def readable_size(self, path): + """ """ + try: + size = os.path.getsize(path) + except os.error: + size = 0 + return humanize.naturalsize(size) + + class RoleRefsWidget(WuttaCheckboxChoiceWidget): """ Widget for use with User @@ -184,7 +244,7 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget): roles = [] if cstruct: for uuid in cstruct: - role = self.session.query(model.Role).get(uuid) + role = self.session.get(model.Role, uuid) if role: roles.append(role) kw['roles'] = roles @@ -228,6 +288,10 @@ class UserRefsWidget(WuttaCheckboxChoiceWidget): users.append(dict([(key, getattr(user, key)) for key in columns + ['uuid']])) + # do not render if no data + if not users: + return HTML.tag('span') + # grid grid = Grid(self.request, key='roles.view.users', columns=columns, data=users) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 0f2c812..4ff990e 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -28,7 +28,7 @@ import functools import json import logging import warnings -from collections import namedtuple +from collections import namedtuple, OrderedDict import sqlalchemy as sa from sqlalchemy import orm @@ -339,6 +339,16 @@ class Grid: sorting. See :meth:`set_joiner()` for more info. + + .. attribute:: tools + + Dict of "tool" elements for the grid. Tools are usually buttons + (e.g. "Delete Results"), shown on top right of the grid. + + The keys for this dict are somewhat arbitrary, defined by the + caller. Values should be HTML literal elements. + + See also :meth:`add_tool()` and :meth:`set_tools()`. """ def __init__( @@ -369,6 +379,7 @@ class Grid: filters=None, filter_defaults=None, joiners=None, + tools=None, ): self.request = request self.vue_tagname = vue_tagname @@ -386,6 +397,7 @@ class Grid: self.app = self.config.get_app() self.set_columns(columns or self.get_columns()) + self.set_tools(tools) # sorting self.sortable = sortable @@ -658,6 +670,33 @@ class Grid: """ self.actions.append(GridAction(self.request, key, **kwargs)) + def set_tools(self, tools): + """ + Set the :attr:`tools` attribute using the given tools collection. + + This will normalize the list/dict to desired internal format. + """ + if tools and isinstance(tools, list): + if not any([isinstance(t, (tuple, list)) for t in tools]): + tools = [(self.app.make_uuid(), t) for t in tools] + self.tools = OrderedDict(tools or []) + + def add_tool(self, html, key=None): + """ + Add a new HTML snippet to the :attr:`tools` dict. + + :param html: HTML literal for the tool element. + + :param key: Optional key to use when adding to the + :attr:`tools` dict. If not specified, a random string is + generated. + + See also :meth:`set_tools()`. + """ + if not key: + key = self.app.make_uuid() + self.tools[key] = html + ############################## # joining methods ############################## @@ -1078,6 +1117,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 +1142,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 +1172,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/progress.py b/src/wuttaweb/progress.py new file mode 100644 index 0000000..759c2da --- /dev/null +++ b/src/wuttaweb/progress.py @@ -0,0 +1,165 @@ +# -*- 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+ {{ progressMessage }} ... {{ totalDisplay }} +
+ +