3
0
Fork 0

Compare commits

...

9 commits

Author SHA1 Message Date
Lance Edgar 0910153685 bump: version 0.12.1 → 0.13.0 2024-08-26 14:27:20 -05:00
Lance Edgar a010071985 feat: use native wuttjamaican app to send feedback email 2024-08-26 14:22:23 -05:00
Lance Edgar a377061da0 fix: tweak max image size for full logo on home, login pages
as it happens these are the same dimensions for the default logo
image.  they seem standard but i don't know much about that..
2024-08-25 20:27:43 -05:00
Lance Edgar 4934ed1d93 feat: add basic user feedback email mechanism
this definitely needs some more work.  using pyramid_mailer for
testing although not ready to declare that dependency.  for now this
is "broken" without it being installed.
2024-08-25 20:25:14 -05:00
Lance Edgar 8669ca2283 feat: add "progress" page for executing upgrades
show scrolling stdout from subprocess

nb. this does *not* show stderr, although that is captured
2024-08-25 15:52:29 -05:00
Lance Edgar e5e31a7d32 feat: add basic support for execute upgrades, download stdout/stderr
upgrade progress is still not being shown yet
2024-08-25 12:20:28 -05:00
Lance Edgar 1a8900c9f4 feat: add basic progress page/indicator support
so far "delete results" (for Raw Settings) is the only use case.

user cancel is not yet supported
2024-08-24 19:28:13 -05:00
Lance Edgar 6fa8b0aeaa feat: add basic "delete results" grid tool
this is done synchronously with no progress indicator yet
2024-08-24 14:26:13 -05:00
Lance Edgar 6650ee698e feat: add initial views for upgrades
CRUD only so far, still need execute features
2024-08-24 11:29:52 -05:00
49 changed files with 3538 additions and 185 deletions

View file

@ -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

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

@ -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),
}

View file

@ -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",
]

View file

@ -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):
"""

View file

@ -0,0 +1,40 @@
## -*- coding: utf-8 -*-
<html>
<head>
<style type="text/css">
label {
display: block;
font-weight: bold;
margin-top: 1em;
}
p {
margin: 1em 0 1em 1.5em;
}
p.msg {
white-space: pre-wrap;
}
</style>
</head>
<body>
<h1>User feedback from website</h1>
<label>User Name</label>
<p>
% if user:
<a href="${user_url}">${user}</a>
% else:
${user_name}
% endif
</p>
<label>Referring URL</label>
<p><a href="${referrer}">${referrer}</a></p>
<label>Client IP</label>
<p>${client_ip}</p>
<label>Message</label>
<p class="msg">${message}</p>
</body>
</html>

View file

@ -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}

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -168,6 +168,11 @@ class MenuHandler(GenericHandler):
'route': 'settings',
'perm': 'settings.list',
},
{
'title': "Upgrades",
'route': 'upgrades',
'perm': 'upgrades.list',
},
],
}

165
src/wuttaweb/progress.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Progress Indicators
"""
from wuttjamaican.progress import ProgressBase
from beaker.session import Session as BeakerSession
def get_basic_session(request, **kwargs):
"""
Create/get a "basic" Beaker session object.
"""
kwargs['use_cookies'] = False
return BeakerSession(request, **kwargs)
def get_progress_session(request, key, **kwargs):
"""
Create/get a Beaker session object, to be used for progress.
"""
kwargs['id'] = f'{request.session.id}.progress.{key}'
return get_basic_session(request, **kwargs)
class SessionProgress(ProgressBase):
"""
Progress indicator which uses Beaker session storage to track
current status.
This is a subclass of
:class:`wuttjamaican:wuttjamaican.progress.ProgressBase`.
A view callable can create one of these, and then pass it into
:meth:`~wuttjamaican.app.AppHandler.progress_loop()` or similar.
As the loop updates progress along the way, this indicator will
update the Beaker session to match.
Separately then, the client side can send requests for the
:func:`~wuttaweb.views.progress.progress()` view, to fetch current
status out of the Beaker session.
:param request: Current :term:`request` object.
:param key: Unique key for this progress indicator. Used to
distinguish progress indicators in the Beaker session.
Note that in addition to
:meth:`~wuttjamaican:wuttjamaican.progress.ProgressBase.update()`
and
:meth:`~wuttjamaican:wuttjamaican.progress.ProgressBase.finish()`
this progres class has some extra attributes and methods:
.. attribute:: success_msg
Optional message to display to the user (via session flash)
when the operation completes successfully.
.. attribute:: success_url
URL to which user should be redirected, once the operation
completes.
.. attribute:: error_url
URL to which user should be redirected, if the operation
encounters an error. If not specified, will fall back to
:attr:`success_url`.
"""
def __init__(self, request, key, success_msg=None, success_url=None, error_url=None):
self.key = key
self.success_msg = success_msg
self.success_url = success_url
self.error_url = error_url or self.success_url
self.session = get_progress_session(request, key)
self.clear()
def __call__(self, message, maximum):
self.clear()
self.session['message'] = message
self.session['maximum'] = maximum
self.session['maximum_display'] = f'{maximum:,d}'
self.session['value'] = 0
self.session.save()
return self
def clear(self):
""" """
self.session.clear()
self.session['complete'] = False
self.session['error'] = False
self.session.save()
def update(self, value):
""" """
self.session.load()
self.session['value'] = value
self.session.save()
def handle_error(self, error, error_url=None):
"""
This should be called by the view code, within a try/catch
block upon error.
The session storage will be updated to reflect details of the
error. Next time client requests the progress status it will
learn of the error and redirect the user.
:param error: :class:`python:Exception` instance.
:param error_url: Optional redirect URL; if not specified
:attr:`error_url` is used.
"""
self.session.load()
self.session['error'] = True
self.session['error_msg'] = str(error)
self.session['error_url'] = error_url or self.error_url
self.session.save()
def handle_success(self, success_msg=None, success_url=None):
"""
This should be called by the view code, when the long-running
operation completes.
The session storage will be updated to reflect the completed
status. Next time client requests the progress status it will
discover it has completed, and redirect the user.
:param success_msg: Optional message to display to the user
(via session flash) when the operation completes
successfully. If not specified :attr:`success_msg` (or
nothing) is used
:param success_url: Optional redirect URL; if not specified
:attr:`success_url` is used.
"""
self.session.load()
self.session['complete'] = True
self.session['success_msg'] = success_msg or self.success_msg
self.session['success_url'] = success_url or self.success_url
self.session.save()

View file

@ -73,6 +73,54 @@
</div>
<h3 class="block is-size-3">Email</h3>
<div class="block" style="padding-left: 2rem; width: 50%;">
<b-field>
<b-checkbox name="${config.appname}.mail.send_emails"
v-model="simpleSettings['${config.appname}.mail.send_emails']"
native-value="true"
@input="settingsNeedSaved = true">
Enable email sending
</b-checkbox>
</b-field>
<div v-show="simpleSettings['${config.appname}.mail.send_emails']">
<b-field label="Default Sender">
<b-input name="${app.appname}.email.default.sender"
v-model="simpleSettings['${app.appname}.email.default.sender']"
@input="settingsNeedSaved = true" />
</b-field>
<b-field label="Default Recipient(s)">
<b-input name="${app.appname}.email.default.to"
v-model="simpleSettings['${app.appname}.email.default.to']"
@input="settingsNeedSaved = true" />
</b-field>
<b-field label="Default Subject (optional)">
<b-input name="${app.appname}.email.default.subject"
v-model="simpleSettings['${app.appname}.email.default.subject']"
@input="settingsNeedSaved = true" />
</b-field>
<b-field label="Feedback Recipient(s) (optional)">
<b-input name="${app.appname}.email.feedback.to"
v-model="simpleSettings['${app.appname}.email.feedback.to']"
@input="settingsNeedSaved = true" />
</b-field>
<b-field label="Feedback Subject (optional)">
<b-input name="${app.appname}.email.feedback.subject"
v-model="simpleSettings['${app.appname}.email.feedback.subject']"
@input="settingsNeedSaved = true" />
</b-field>
</div>
</div>
<h3 class="block is-size-3">Web Libraries</h3>
<div class="block" style="padding-left: 2rem;">
@ -219,6 +267,19 @@
this.editWebLibraryShowDialog = false
}
ThisPage.methods.validateEmailSettings = function() {
if (this.simpleSettings['${config.appname}.mail.send_emails']) {
if (!this.simpleSettings['${config.appname}.email.default.sender']) {
return "Default Sender is required to send email."
}
if (!this.simpleSettings['${config.appname}.email.default.to']) {
return "Default Recipient(s) are required to send email."
}
}
}
ThisPageData.validators.push(ThisPage.methods.validateEmailSettings)
</script>
</%def>

View file

@ -20,7 +20,10 @@
<span>${app.get_node_title()}</span>
</b-field>
<b-field horizontal label="Production Mode">
<span>${config.production()}</span>
<span>${"Yes" if config.production() else "No"}</span>
</b-field>
<b-field horizontal label="Email Enabled">
<span>${"Yes" if app.get_email_handler().sending_is_enabled() else "No"}</span>
</b-field>
</div>
</div>

View file

@ -21,8 +21,8 @@
justify-content: center;
}
.wutta-logo img {
max-height: 350px;
max-width: 800px;
max-height: 480px;
max-width: 640px;
}
</style>
</%def>

View file

@ -3,13 +3,7 @@
<%namespace file="/wutta-components.mako" import="make_wutta_components" />
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<title>${base_meta.global_title()} &raquo; ${capture(self.title)|n}</title>
${base_meta.favicon()}
${self.header_core()}
${self.head_tags()}
</head>
${self.html_head()}
<body>
<div id="app" style="height: 100%;">
<whole-page />
@ -30,7 +24,20 @@
</body>
</html>
## nb. this becomes part of the page <title> tag within <head>
<%def name="html_head()">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<title>${self.head_title()}</title>
${base_meta.favicon()}
${self.header_core()}
${self.head_tags()}
</head>
</%def>
## nb. this is the full <title> within html <head>
<%def name="head_title()">${base_meta.global_title()} &raquo; ${self.title()}</%def>
## nb. this becomes part of head_title() above
## it also is used as default value for content_title() below
<%def name="title()"></%def>
@ -39,9 +46,9 @@
<%def name="content_title()">${self.title()}</%def>
<%def name="header_core()">
${self.core_javascript()}
${self.base_javascript()}
${self.extra_javascript()}
${self.core_styles()}
${self.base_styles()}
${self.extra_styles()}
</%def>
@ -49,6 +56,10 @@
${self.vuejs()}
${self.buefy()}
${self.fontawesome()}
</%def>
<%def name="base_javascript()">
${self.core_javascript()}
${self.hamburger_menu_js()}
</%def>
@ -99,7 +110,6 @@
<%def name="core_styles()">
${self.buefy_styles()}
${self.base_styles()}
</%def>
<%def name="buefy_styles()">
@ -107,6 +117,7 @@
</%def>
<%def name="base_styles()">
${self.core_styles()}
<style>
##############################
@ -194,16 +205,7 @@
<%def name="head_tags()"></%def>
<%def name="render_vue_template_whole_page()">
<script type="text/x-template" id="whole-page-template">
## nb. the whole-page contains 3 elements:
## 1) header-wrapper
## 2) content-wrapper
## 3) footer
<div id="whole-page"
style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
<%def name="whole_page_content()">
## nb. the header-wrapper contains 2 elements:
## 1) header proper (menu + index title area)
## 2) page/content title area
@ -327,7 +329,18 @@
${base_meta.footer()}
</div>
</footer>
</%def>
<%def name="render_vue_template_whole_page()">
<script type="text/x-template" id="whole-page-template">
## nb. the whole-page normally contains 3 elements:
## 1) header-wrapper
## 2) content-wrapper
## 3) footer
<div id="whole-page"
style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
${self.whole_page_content()}
</div>
</script>
</%def>
@ -407,7 +420,153 @@
<%def name="render_theme_picker()"></%def>
<%def name="render_feedback_button()"></%def>
<%def name="render_feedback_button()">
% if request.has_perm('common.feedback'):
<wutta-feedback-form action="${url('feedback')}" />
% endif
</%def>
<%def name="render_vue_template_feedback()">
<script type="text/x-template" id="wutta-feedback-template">
<div>
<b-button type="is-primary"
@click="showFeedback()"
icon-pack="fas"
icon-left="comment">
Feedback
</b-button>
<b-modal has-modal-card
:active.sync="showDialog">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">User Feedback</p>
</header>
<section class="modal-card-body">
<p class="block">
Feedback regarding this website may be submitted below.
</p>
<b-field label="User Name"
:type="userName && userName.trim() ? null : 'is-danger'">
<b-input v-model.trim="userName"
% if request.user:
disabled
% endif
/>
</b-field>
<b-field label="Referring URL">
<b-input v-model="referrer"
disabled="true" />
</b-field>
<b-field label="Message"
:type="message && message.trim() ? null : 'is-danger'">
<b-input type="textarea"
v-model.trim="message"
ref="textarea" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button @click="showDialog = false">
Cancel
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="paper-plane"
@click="sendFeedback()"
:disabled="submitDisabled">
{{ sendingFeedback ? "Working, please wait..." : "Send Message" }}
</b-button>
</footer>
</div>
</b-modal>
</div>
</script>
</%def>
<%def name="render_vue_script_feedback()">
<script>
const WuttaFeedbackForm = {
template: '#wutta-feedback-template',
mixins: [WuttaRequestMixin],
props: {
action: String,
},
computed: {
submitDisabled() {
if (this.sendingFeedback) {
return true
}
if (!this.userName || !this.userName.trim()) {
return true
}
if (!this.message || !this.message.trim()) {
return true
}
return false
},
},
methods: {
showFeedback() {
// nb. update referrer to include anchor hash if any
this.referrer = location.href
this.showDialog = true
this.$nextTick(function() {
this.$refs.textarea.focus()
})
},
sendFeedback() {
this.sendingFeedback = true
const params = {
referrer: this.referrer,
user_uuid: this.userUUID,
user_name: this.userName,
message: this.message.trim(),
}
this.wuttaPOST(this.action, params, response => {
this.$buefy.toast.open({
message: "Message sent! Thank you for your feedback.",
type: 'is-info',
duration: 4000, // 4 seconds
})
this.showDialog = false
// clear out message, in case they need to send another
this.message = ""
this.sendingFeedback = false
}, response => { // failure
this.sendingFeedback = false
})
},
}
}
const WuttaFeedbackFormData = {
referrer: null,
userUUID: ${json.dumps(request.user.uuid if request.user else None)|n},
userName: ${json.dumps(str(request.user) if request.user else None)|n},
showDialog: false,
sendingFeedback: false,
message: '',
}
</script>
</%def>
<%def name="render_vue_script_whole_page()">
<script>
@ -418,7 +577,7 @@
mounted() {
for (let hook of this.mountedHooks) {
hook(this)
hook.call(this)
}
},
@ -565,18 +724,33 @@
##############################
<%def name="render_vue_templates()">
## nb. must make wutta components first; they are stable so
## intermediate pages do not need to modify them. and some pages
## may need the request mixin to be defined.
${make_wutta_components()}
${self.render_vue_template_whole_page()}
${self.render_vue_script_whole_page()}
% if request.has_perm('common.feedback'):
${self.render_vue_template_feedback()}
${self.render_vue_script_feedback()}
% endif
</%def>
<%def name="modify_vue_vars()"></%def>
<%def name="make_vue_components()">
${make_wutta_components()}
<script>
WholePage.data = function() { return WholePageData }
Vue.component('whole-page', WholePage)
</script>
% if request.has_perm('common.feedback'):
<script>
WuttaFeedbackForm.data = function() { return WuttaFeedbackFormData }
Vue.component('wutta-feedback-form', WuttaFeedbackForm)
</script>
% endif
</%def>
<%def name="make_vue_app()">

View file

@ -167,7 +167,11 @@
for (let validator of this.validators) {
let msg = validator.call(this)
if (msg) {
alert(msg)
this.$buefy.toast.open({
message: msg,
type: 'is-warning',
duration: 4000, // 4 seconds
})
return
}
}

View file

@ -0,0 +1,14 @@
<tal:omit>
<a tal:condition="url" href="${url}">
${filename}
<tal:omit tal:condition="filesize">
(${filesize})
</tal:omit>
</a>
<span tal:condition="not url">
${filename}
<tal:omit tal:condition="filesize">
(${filesize})
</tal:omit>
</span>
</tal:omit>

View file

@ -90,6 +90,19 @@
</form>
% endif
<div style="display: flex; flex-direction: column; justify-content: space-between;">
## nb. this is needed to force tools to bottom
## TODO: should we put a context menu here?
<div></div>
<div class="wutta-grid-tools-wrapper">
% for html in grid.tools.values():
${html}
% endfor
</div>
</div>
</div>
<${b}-table :data="data"
@ -290,6 +303,14 @@
template: '#${grid.vue_tagname}-template',
computed: {
recordCount() {
% if grid.paginated:
return this.pagerStats.item_count
% else:
return this.data.length
% endif
},
directLink() {
const params = new URLSearchParams(this.getAllParams())
return `${request.path_url}?${'$'}{params}`

View file

@ -21,8 +21,8 @@
justify-content: center;
}
.wutta-logo img {
max-height: 350px;
max-width: 800px;
max-height: 480px;
max-width: 640px;
}
</style>
</%def>

View file

@ -23,6 +23,41 @@
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
% if master.deletable_bulk and master.has_perm('delete_bulk'):
<script>
${grid.vue_component}Data.deleteResultsSubmitting = false
${grid.vue_component}.computed.deleteResultsDisabled = function() {
if (this.deleteResultsSubmitting) {
return true
}
if (!this.recordCount) {
return true
}
return false
}
${grid.vue_component}.methods.deleteResultsSubmit = function() {
## TODO: should give a better dialog here
const msg = "You are about to delete "
+ this.recordCount.toLocaleString('en')
+ " records.\n\nAre you sure?"
if (!confirm(msg)) {
return
}
this.deleteResultsSubmitting = true
this.$refs.deleteResultsForm.submit()
}
</script>
% endif
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
% if grid is not Undefined:

View file

@ -0,0 +1,127 @@
## -*- coding: utf-8; -*-
<%inherit file="/base.mako" />
<%def name="head_title()">${initial_msg or "Working"}...</%def>
<%def name="base_javascript()">
${self.core_javascript()}
</%def>
<%def name="base_styles()">
${self.core_styles()}
</%def>
<%def name="whole_page_content()">
<section class="hero is-fullheight">
<div class="hero-body">
<div class="container">
<div style="display: flex;">
<div style="flex-grow: 1;"></div>
<div>
<p class="block">
{{ progressMessage }} ... {{ totalDisplay }}
</p>
<div class="level">
<div class="level-item">
<b-progress size="is-large"
style="width: 400px;"
:max="progressMax"
:value="progressValue"
show-value
format="percent"
precision="0">
</b-progress>
</div>
</div>
</div>
<div style="flex-grow: 1;"></div>
</div>
${self.after_progress()}
</div>
</div>
</section>
</%def>
<%def name="after_progress()"></%def>
<%def name="modify_vue_vars()">
<script>
WholePageData.progressURL = '${url('progress', key=progress.key)}'
WholePageData.progressMessage = "${(initial_msg or "Working").replace('"', '\\"')} (please wait)"
WholePageData.progressMax = null
WholePageData.progressMaxDisplay = null
WholePageData.progressValue = null
WholePageData.stillInProgress = true
WholePage.computed.totalDisplay = function() {
if (!this.stillInProgress) {
return "done!"
}
if (this.progressMaxDisplay) {
return `(${'$'}{this.progressMaxDisplay} total)`
}
}
WholePageData.mountedHooks.push(function() {
// fetch first progress data, one second from now
setTimeout(() => {
this.updateProgress()
}, 1000)
})
WholePage.methods.updateProgress = function() {
this.$http.get(this.progressURL).then(response => {
if (response.data.error) {
// errors stop the show; redirect
location.href = response.data.error_url
} else {
if (response.data.complete || response.data.maximum) {
this.progressMessage = response.data.message
this.progressMaxDisplay = response.data.maximum_display
if (response.data.complete) {
this.progressValue = this.progressMax
this.stillInProgress = false
location.href = response.data.success_url
} else {
this.progressValue = response.data.value
this.progressMax = response.data.maximum
}
}
// custom logic if applicable
this.updateProgressCustom(response)
if (this.stillInProgress) {
// fetch progress data again, in one second from now
setTimeout(() => {
this.updateProgress()
}, 1000)
}
}
})
}
WholePage.methods.updateProgressCustom = function(response) {}
</script>
</%def>

View file

@ -0,0 +1,64 @@
## -*- coding: utf-8; -*-
<%inherit file="/progress.mako" />
<%def name="extra_styles()">
${parent.extra_styles()}
<style>
.upgrade-textout {
border: 1px solid Black;
line-height: 1.2;
margin-top: 1rem;
overflow: auto;
padding: 1rem;
}
</style>
</%def>
<%def name="after_progress()">
<div ref="textout"
class="upgrade-textout is-family-monospace is-size-7">
<span v-for="line in progressOutput"
:key="line.key"
v-html="line.text">
</span>
## nb. we auto-scroll down to "see" this element
<div ref="seeme"></div>
</div>
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
WholePageData.progressURL = '${url('upgrades.execute_progress', uuid=instance.uuid)}'
WholePageData.progressOutput = []
WholePageData.progressOutputCounter = 0
WholePageData.mountedHooks.push(function() {
// grow the textout area to fill most of screen
const textout = this.$refs.textout
const height = window.innerHeight - textout.offsetTop - 100
textout.style.height = height + 'px'
})
WholePage.methods.updateProgressCustom = function(response) {
if (response.data.stdout) {
// add lines to textout area
this.progressOutput.push({
key: ++this.progressOutputCounter,
text: response.data.stdout})
// scroll down to end of textout area
this.$nextTick(() => {
this.$refs.seeme.scrollIntoView({behavior: 'smooth'})
})
}
}
</script>
</%def>

View file

@ -0,0 +1,20 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/configure.mako" />
<%def name="form_content()">
<h3 class="is-size-3">Basics</h3>
<div class="block" style="padding-left: 2rem; width: 50%;">
<b-field label="Upgrade Script (for Execute)"
message="The command + args will be interpreted by the shell.">
<b-input name="${app.appname}.upgrades.command"
v-model="simpleSettings['${app.appname}.upgrades.command']"
@input="settingsNeedSaved = true"
## ref="upgradeSystemCommand"
## expanded
/>
</b-field>
</div>
</%def>

View file

@ -0,0 +1,37 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" />
<%def name="page_content()">
${parent.page_content()}
% if instance.status == app.enum.UpgradeStatus.PENDING and master.has_perm('execute'):
<div class="buttons"
style="margin: 2rem 5rem;">
${h.form(master.get_action_url('execute', instance), **{'@submit': 'executeFormSubmit'})}
${h.csrf_token(request)}
<b-button type="is-primary"
native-type="submit"
icon-pack="fas"
icon-left="arrow-circle-right"
:disabled="executeFormSubmitting">
{{ executeFormSubmitting ? "Working, please wait..." : "Execute this upgrade" }}
</b-button>
${h.end_form()}
</div>
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
% if instance.status == app.enum.UpgradeStatus.PENDING and master.has_perm('execute'):
<script>
ThisPageData.executeFormSubmitting = false
ThisPage.methods.executeFormSubmit = function() {
this.executeFormSubmitting = true
}
</script>
% endif
</%def>

View file

@ -1,10 +1,87 @@
<%def name="make_wutta_components()">
${self.make_wutta_request_mixin()}
${self.make_wutta_button_component()}
${self.make_wutta_filter_component()}
${self.make_wutta_filter_value_component()}
</%def>
<%def name="make_wutta_request_mixin()">
<script>
const WuttaRequestMixin = {
methods: {
wuttaGET(url, params, success, failure) {
this.$http.get(url, {params: params}).then(response => {
if (response.data.error) {
this.$buefy.toast.open({
message: `Request failed: ${'$'}{response.data.error}`,
type: 'is-danger',
duration: 4000, // 4 seconds
})
if (failure) {
failure(response)
}
} else {
success(response)
}
}, response => {
this.$buefy.toast.open({
message: "Request failed: (unknown server error)",
type: 'is-danger',
duration: 4000, // 4 seconds
})
if (failure) {
failure(response)
}
})
},
wuttaPOST(action, params, success, failure) {
const csrftoken = ${json.dumps(h.get_csrf_token(request))|n}
const headers = {'X-CSRF-TOKEN': csrftoken}
this.$http.post(action, params, {headers: headers}).then(response => {
if (response.data.error) {
this.$buefy.toast.open({
message: "Submit failed: " + (response.data.error ||
"(unknown error)"),
type: 'is-danger',
duration: 4000, // 4 seconds
})
if (failure) {
failure(response)
}
} else {
success(response)
}
}, response => {
this.$buefy.toast.open({
message: "Submit failed! (unknown server error)",
type: 'is-danger',
duration: 4000, // 4 seconds
})
if (failure) {
failure(response)
}
})
},
},
}
</script>
</%def>
<%def name="make_wutta_button_component()">
<script type="text/x-template" id="wutta-button-template">
<b-button :type="type"

View file

@ -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):
"""

View file

@ -24,8 +24,11 @@
Base Logic for Views
"""
import os
from pyramid import httpexceptions
from pyramid.renderers import render_to_response
from pyramid.response import FileResponse
from wuttaweb import forms, grids
@ -119,9 +122,46 @@ class View:
"""
return httpexceptions.HTTPFound(location=url, **kwargs)
def file_response(self, path, attachment=True, filename=None):
"""
Returns a generic file response for the given path.
:param path: Path to a file on local disk; must be accessible
by the web app.
:param attachment: Whether the file should come down as an
"attachment" instead of main payload.
The attachment behavior is the default here, and will cause
the user to be prompted for where to save the file.
Set ``attachment=False`` in order to cause the browser to
render the file as if it were the page being navigated to.
:param filename: Optional filename to use for attachment
behavior. This will be the "suggested filename" when user
is prompted to save the download. If not specified, the
filename is derived from ``path``.
:returns: A :class:`~pyramid:pyramid.response.FileResponse`
object with file content.
"""
if not os.path.exists(path):
return self.notfound()
response = FileResponse(path, request=self.request)
response.content_length = os.path.getsize(path)
if attachment:
if not filename:
filename = os.path.basename(path)
response.content_disposition = f'attachment; filename="{filename}"'
return response
def json_response(self, context):
"""
Convenience method to return a JSON response.
Returns a JSON response with the given context data.
:param context: Context data to be rendered as JSON.

View file

@ -24,13 +24,19 @@
Common Views
"""
import logging
import colander
from pyramid.renderers import render
from wuttaweb.views import View
from wuttaweb.forms import widgets
from wuttaweb.db import Session
log = logging.getLogger(__name__)
class CommonView(View):
"""
Common views shared by all apps.
@ -78,6 +84,58 @@ class CommonView(View):
"""
return {'index_title': self.app.get_title()}
def feedback(self):
""" """
model = self.app.model
session = Session()
# validate form
schema = self.feedback_make_schema()
form = self.make_form(schema=schema)
if not form.validate():
# TODO: native Form class should better expose error(s)
dform = form.get_deform()
return {'error': str(dform.error)}
# build email template context
context = dict(form.validated)
if context['user_uuid']:
context['user'] = session.get(model.User, context['user_uuid'])
context['user_url'] = self.request.route_url('users.view', uuid=context['user_uuid'])
context['client_ip'] = self.request.client_addr
# send email
try:
self.feedback_send(context)
except Exception as error:
log.warning("failed to send feedback email", exc_info=True)
return {'error': str(error) or error.__class__.__name__}
return {'ok': True}
def feedback_make_schema(self):
""" """
schema = colander.Schema()
schema.add(colander.SchemaNode(colander.String(),
name='referrer'))
schema.add(colander.SchemaNode(colander.String(),
name='user_uuid',
missing=None))
schema.add(colander.SchemaNode(colander.String(),
name='user_name'))
schema.add(colander.SchemaNode(colander.String(),
name='message'))
return schema
def feedback_send(self, context):
""" """
self.app.send_email('feedback', context)
def setup(self, session=None):
"""
View for first-time app setup, to create admin user.
@ -154,6 +212,15 @@ class CommonView(View):
'settings.view',
'settings.edit',
'settings.delete',
'settings.delete_bulk',
'upgrades.list',
'upgrades.create',
'upgrades.view',
'upgrades.edit',
'upgrades.delete',
'upgrades.execute',
'upgrades.download',
'upgrades.configure',
'users.list',
'users.create',
'users.view',
@ -194,6 +261,8 @@ class CommonView(View):
@classmethod
def _defaults(cls, config):
config.add_wutta_permission_group('common', "(common)", overwrite=False)
# home page
config.add_route('home', '/')
config.add_view(cls, attr='home',
@ -210,6 +279,16 @@ class CommonView(View):
append_slash=True,
renderer='/notfound.mako')
# feedback
config.add_route('feedback', '/feedback',
request_method='POST')
config.add_view(cls, attr='feedback',
route_name='feedback',
permission='common.feedback',
renderer='json')
config.add_wutta_permission('common', 'common.feedback',
"Send user feedback about the app")
# setup
config.add_route('setup', '/setup')
config.add_view(cls, attr='setup',

View file

@ -32,9 +32,11 @@ That will in turn include the following modules:
* :mod:`wuttaweb.views.auth`
* :mod:`wuttaweb.views.common`
* :mod:`wuttaweb.views.settings`
* :mod:`wuttaweb.views.progress`
* :mod:`wuttaweb.views.people`
* :mod:`wuttaweb.views.roles`
* :mod:`wuttaweb.views.users`
* :mod:`wuttaweb.views.upgrades`
"""
@ -44,9 +46,11 @@ def defaults(config, **kwargs):
config.include(mod('wuttaweb.views.auth'))
config.include(mod('wuttaweb.views.common'))
config.include(mod('wuttaweb.views.settings'))
config.include(mod('wuttaweb.views.progress'))
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):

View file

@ -24,6 +24,10 @@
Base Logic for Master Views
"""
import logging
import os
import threading
import sqlalchemy as sa
from sqlalchemy import orm
@ -31,11 +35,15 @@ from pyramid.renderers import render_to_response
from webhelpers2.html import HTML
from wuttaweb.views import View
from wuttaweb.util import get_form_data, get_model_fields
from wuttaweb.util import get_form_data, get_model_fields, render_csrf_token
from wuttaweb.db import Session
from wuttaweb.progress import SessionProgress
from wuttjamaican.util import get_class_hierarchy
log = logging.getLogger(__name__)
class MasterView(View):
"""
Base class for "master" views.
@ -68,7 +76,7 @@ class MasterView(View):
Optional reference to a data model class. While not strictly
required, most views will set this to a SQLAlchemy mapped
class,
e.g. :class:`wuttjamaican:wuttjamaican.db.model.auth.User`.
e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
Code should not access this directly but instead call
:meth:`get_model_class()`.
@ -284,6 +292,25 @@ class MasterView(View):
See also :meth:`is_deletable()`.
.. attribute:: deletable_bulk
Boolean indicating whether the view model supports "bulk
deleting" - i.e. it should have a :meth:`delete_bulk()` view.
Default value is ``False``.
See also :attr:`deletable_bulk_quick`.
.. attribute:: deletable_bulk_quick
Boolean indicating whether the view model supports "quick" bulk
deleting, i.e. the operation is reliably quick enough that it
should happen *synchronously* with no progress indicator.
Default is ``False`` in which case a progress indicator is
shown while the bulk deletion is performed.
Only relevant if :attr:`deletable_bulk` is true.
.. attribute:: form_fields
List of fields for the model form.
@ -296,6 +323,18 @@ class MasterView(View):
"autocomplete" - i.e. it should have an :meth:`autocomplete()`
view. Default is ``False``.
.. attribute:: downloadable
Boolean indicating whether the view model supports
"downloading" - i.e. it should have a :meth:`download()` view.
Default is ``False``.
.. attribute:: executable
Boolean indicating whether the view model supports "executing"
- i.e. it should have an :meth:`execute()` view. Default is
``False``.
.. attribute:: configurable
Boolean indicating whether the master view supports
@ -321,7 +360,12 @@ class MasterView(View):
viewable = True
editable = True
deletable = True
deletable_bulk = False
deletable_bulk_quick = False
has_autocomplete = False
downloadable = False
executable = False
execute_progress_template = None
configurable = False
# current action
@ -622,6 +666,120 @@ class MasterView(View):
session = self.app.get_session(obj)
session.delete(obj)
def delete_bulk(self):
"""
View to delete all records in the current :meth:`index()` grid
data set, i.e. those matching current query.
This usually corresponds to a URL like
``/widgets/delete-bulk``.
By default, this view is included only if
:attr:`deletable_bulk` is true.
This view requires POST method. When it is finished deleting,
user is redirected back to :meth:`index()` view.
Subclass normally should not override this method, but rather
one of the related methods which are called (in)directly by
this one:
* :meth:`delete_bulk_action()`
"""
# get current data set from grid
# nb. this must *not* be paginated, we need it all
grid = self.make_model_grid(paginated=False)
data = grid.get_visible_data()
if self.deletable_bulk_quick:
# delete it all and go back to listing
self.delete_bulk_action(data)
return self.redirect(self.get_index_url())
else:
# start thread for delete; show progress page
route_prefix = self.get_route_prefix()
key = f'{route_prefix}.delete_bulk'
progress = self.make_progress(key, success_url=self.get_index_url())
thread = threading.Thread(target=self.delete_bulk_thread,
args=(data,), kwargs={'progress': progress})
thread.start()
return self.render_progress(progress)
def delete_bulk_thread(self, query, success_url=None, progress=None):
""" """
model_title_plural = self.get_model_title_plural()
# nb. use new session, separate from web transaction
session = self.app.make_session()
records = query.with_session(session).all()
try:
self.delete_bulk_action(records, progress=progress)
except Exception as error:
session.rollback()
log.warning("failed to delete %s results for %s",
len(records), model_title_plural,
exc_info=True)
if progress:
progress.handle_error(error)
else:
session.commit()
if progress:
progress.handle_success()
finally:
session.close()
def delete_bulk_action(self, data, progress=None):
"""
This method performs the actual bulk deletion, for the given
data set. This is called via :meth:`delete_bulk()`.
Default logic will call :meth:`is_deletable()` for every data
record, and if that returns true then it calls
:meth:`delete_instance()`. A progress indicator will be
updated if one is provided.
Subclass should override if needed.
"""
model_title_plural = self.get_model_title_plural()
def delete(obj, i):
if self.is_deletable(obj):
self.delete_instance(obj)
self.app.progress_loop(delete, data, progress,
message=f"Deleting {model_title_plural}")
def delete_bulk_make_button(self):
""" """
route_prefix = self.get_route_prefix()
label = HTML.literal(
'{{ deleteResultsSubmitting ? "Working, please wait..." : "Delete Results" }}')
button = self.make_button(label,
variant='is-danger',
icon_left='trash',
**{'@click': 'deleteResultsSubmit()',
':disabled': 'deleteResultsDisabled'})
form = HTML.tag('form',
method='post',
action=self.request.route_url(f'{route_prefix}.delete_bulk'),
ref='deleteResultsForm',
class_='control',
c=[
render_csrf_token(self.request),
button,
])
return form
##############################
# autocomplete methods
##############################
@ -700,6 +858,166 @@ class MasterView(View):
'label': str(obj),
}
##############################
# download methods
##############################
def download(self):
"""
View to download a file associated with a model record.
This usually corresponds to a URL like
``/widgets/XXX/download`` where ``XXX`` represents the key/ID
for the record.
By default, this view is included only if :attr:`downloadable`
is true.
This method will (try to) locate the file on disk, and return
it as a file download response to the client.
The GET request for this view may contain a ``filename`` query
string parameter, which can be used to locate one of various
files associated with the model record. This filename is
passed to :meth:`download_path()` for locating the file.
For instance: ``/widgets/XXX/download?filename=widget-specs.txt``
Subclass normally should not override this method, but rather
one of the related methods which are called (in)directly by
this one:
* :meth:`download_path()`
"""
obj = self.get_instance()
filename = self.request.GET.get('filename', None)
path = self.download_path(obj, filename)
if not path or not os.path.exists(path):
return self.notfound()
return self.file_response(path)
def download_path(self, obj, filename):
"""
Should return absolute path on disk, for the given object and
filename. Result will be used to return a file response to
client. This is called by :meth:`download()`.
Default logic always returns ``None``; subclass must override.
:param obj: Refefence to the model instance.
:param filename: Name of file for which to retrieve the path.
:returns: Path to file, or ``None`` if not found.
Note that ``filename`` may be ``None`` in which case the "default"
file path should be returned, if applicable.
If this method returns ``None`` (as it does by default) then
the :meth:`download()` view will return a 404 not found
response.
"""
##############################
# execute methods
##############################
def execute(self):
"""
View to "execute" a model record. Requires a POST request.
This usually corresponds to a URL like
``/widgets/XXX/execute`` where ``XXX`` represents the key/ID
for the record.
By default, this view is included only if :attr:`executable` is
true.
Probably this is a "rare" view to implement for a model. But
there are two notable use cases so far, namely:
* upgrades (cf. :class:`~wuttaweb.views.upgrades.UpgradeView`)
* batches (not yet implemented;
cf. :doc:`rattail-manual:data/batch/index` in Rattail
Manual)
The general idea is to take some "irrevocable" action
associated with the model record. In the case of upgrades, it
is to run the upgrade script. For batches it is to "push
live" the data held within the batch.
Subclass normally should not override this method, but rather
one of the related methods which are called (in)directly by
this one:
* :meth:`execute_instance()`
"""
route_prefix = self.get_route_prefix()
model_title = self.get_model_title()
obj = self.get_instance()
# make the progress tracker
progress = self.make_progress(f'{route_prefix}.execute',
success_msg=f"{model_title} was executed.",
success_url=self.get_action_url('view', obj))
# start thread for execute; show progress page
key = self.request.matchdict
thread = threading.Thread(target=self.execute_thread,
args=(key, self.request.user.uuid),
kwargs={'progress': progress})
thread.start()
return self.render_progress(progress, context={
'instance': obj,
}, template=self.execute_progress_template)
def execute_instance(self, obj, user, progress=None):
"""
Perform the actual "execution" logic for a model record.
Called by :meth:`execute()`.
This method does nothing by default; subclass must override.
:param obj: Reference to the model instance.
:param user: Reference to the
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is doing the execute.
:param progress: Optional progress indicator factory.
"""
def execute_thread(self, key, user_uuid, progress=None):
""" """
model = self.app.model
model_title = self.get_model_title()
# nb. use new session, separate from web transaction
session = self.app.make_session()
# fetch model instance and user for this session
obj = self.get_instance(session=session, matchdict=key)
user = session.get(model.User, user_uuid)
try:
self.execute_instance(obj, user, progress=progress)
except Exception as error:
session.rollback()
log.warning("%s failed to execute: %s", model_title, obj, exc_info=True)
if progress:
progress.handle_error(error)
else:
session.commit()
if progress:
progress.handle_success()
finally:
session.close()
##############################
# configure methods
##############################
@ -1040,6 +1358,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.
@ -1118,6 +1486,97 @@ class MasterView(View):
return True
return False
def make_button(
self,
label,
variant=None,
primary=False,
**kwargs,
):
"""
Make and return a HTML ``<b-button>`` literal.
:param label: Text label for the button.
:param variant: This is the "Buefy type" (or "Oruga variant")
for the button. Buefy and Oruga represent this differently
but this logic expects the Buefy format
(e.g. ``is-danger``) and *not* the Oruga format
(e.g. ``danger``), despite the param name matching Oruga's
terminology.
:param type: This param is not advertised in the method
signature, but if caller specifies ``type`` instead of
``variant`` it should work the same.
:param primary: If neither ``variant`` nor ``type`` are
specified, this flag may be used to automatically set the
Buefy type to ``is-primary``.
This is the preferred method where applicable, since it
avoids the Buefy vs. Oruga confusion, and the
implementation can change in the future.
:param \**kwargs: All remaining kwargs are passed to the
underlying ``HTML.tag()`` call, so will be rendered as
attributes on the button tag.
:returns: HTML literal for the button element. Will be something
along the lines of:
.. code-block::
<b-button type="is-primary"
icon-pack="fas"
icon-left="hand-pointer">
Click Me
</b-button>
"""
btn_kw = kwargs
btn_kw.setdefault('c', label)
btn_kw.setdefault('icon_pack', 'fas')
if 'type' not in btn_kw:
if variant:
btn_kw['type'] = variant
elif primary:
btn_kw['type'] = 'is-primary'
return HTML.tag('b-button', **btn_kw)
def make_progress(self, key, **kwargs):
"""
Create and return a
:class:`~wuttaweb.progress.SessionProgress` instance, with the
given key.
This is normally done just before calling
:meth:`render_progress()`.
"""
return SessionProgress(self.request, key, **kwargs)
def render_progress(self, progress, context=None, template=None):
"""
Render the progress page, with given template/context.
When a view method needs to start a long-running operation, it
first starts a thread to do the work, and then it renders the
"progress" page. As the operation continues the progress page
is updated. When the operation completes (or fails) the user
is redirected to the final destination.
TODO: should document more about how to do this..
:param progress: Progress indicator instance as returned by
:meth:`make_progress()`.
:returns: A :term:`response` with rendered progress page.
"""
template = template or '/progress.mako'
context = context or {}
context['progress'] = progress
return render_to_response(template, context, request=self.request)
def render_to_response(self, template, context):
"""
Locate and render an appropriate template, with the given
@ -1328,6 +1787,14 @@ class MasterView(View):
kwargs['actions'] = actions
if 'tools' not in kwargs:
tools = []
if self.deletable_bulk and self.has_perm('delete_bulk'):
tools.append(('delete-results', self.delete_bulk_make_button()))
kwargs['tools'] = tools
if hasattr(self, 'grid_row_class'):
kwargs.setdefault('row_class', self.grid_row_class)
kwargs.setdefault('filterable', self.filterable)
@ -1421,23 +1888,69 @@ class MasterView(View):
# for key in self.get_model_key():
# grid.set_link(key)
def get_instance(self, session=None):
def get_instance(self, session=None, matchdict=None):
"""
This should return the "current" model instance based on the
request details (e.g. route kwargs).
This should return the appropriate model instance, based on
the ``matchdict`` of model keys.
If the instance cannot be found, this should raise a HTTP 404
exception, i.e. :meth:`~wuttaweb.views.base.View.notfound()`.
Normally this is called with no arguments, in which case the
:attr:`pyramid:pyramid.request.Request.matchdict` is used, and
will return the "current" model instance based on the request
(route/params).
There is no "sane" default logic here; subclass *must*
override or else a ``NotImplementedError`` is raised.
If a ``matchdict`` is provided then that is used instead, to
obtain the model keys. In the simple/common example of a
"native" model in WuttaWeb, this would look like::
keys = {'uuid': '38905440630d11ef9228743af49773a4'}
obj = self.get_instance(matchdict=keys)
Although some models may have different, possibly composite
key names to use instead. The specific keys this logic is
expecting are the same as returned by :meth:`get_model_key()`.
If this method is unable to locate the instance, it should
raise a 404 error,
i.e. :meth:`~wuttaweb.views.base.View.notfound()`.
Default implementation of this method should work okay for
views which define a :attr:`model_class`. For other views
however it will raise ``NotImplementedError``, so subclass
may need to define.
.. warning::
If you are defining this method for a subclass, please note
this point regarding the 404 "not found" logic.
It is *not* enough to simply *return* this 404 response,
you must explicitly *raise* the error. For instance::
def get_instance(self, **kwargs):
# ..try to locate instance..
obj = self.locate_instance_somehow()
if not obj:
# NB. THIS MAY NOT WORK AS EXPECTED
#return self.notfound()
# nb. should always do this in get_instance()
raise self.notfound()
This lets calling code not have to worry about whether or
not this method might return ``None``. It can safely
assume it will get back a model instance, or else a 404
will kick in and control flow goes elsewhere.
"""
model_class = self.get_model_class()
if model_class:
session = session or self.Session()
matchdict = matchdict or self.request.matchdict
def filtr(query, model_key):
key = self.request.matchdict[model_key]
key = matchdict[model_key]
query = query.filter(getattr(self.model_class, model_key) == key)
return query
@ -1700,7 +2213,7 @@ class MasterView(View):
Returns the model class for the view (if defined).
A model class will *usually* be a SQLAlchemy mapped class,
e.g. :class:`wuttjamaican:wuttjamaican.db.model.base.Person`.
e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
There is no default value here, but a subclass may override by
assigning :attr:`model_class`.
@ -2034,17 +2547,6 @@ class MasterView(View):
f'{permission_prefix}.create',
f"Create new {model_title}")
# view
if cls.viewable:
instance_url_prefix = cls.get_instance_url_prefix()
config.add_route(f'{route_prefix}.view', instance_url_prefix)
config.add_view(cls, attr='view',
route_name=f'{route_prefix}.view',
permission=f'{permission_prefix}.view')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.view',
f"View {model_title}")
# edit
if cls.editable:
instance_url_prefix = cls.get_instance_url_prefix()
@ -2069,6 +2571,18 @@ class MasterView(View):
f'{permission_prefix}.delete',
f"Delete {model_title}")
# bulk delete
if cls.deletable_bulk:
config.add_route(f'{route_prefix}.delete_bulk',
f'{url_prefix}/delete-bulk',
request_method='POST')
config.add_view(cls, attr='delete_bulk',
route_name=f'{route_prefix}.delete_bulk',
permission=f'{permission_prefix}.delete_bulk')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.delete_bulk',
f"Delete {model_title_plural} in bulk")
# autocomplete
if cls.has_autocomplete:
config.add_route(f'{route_prefix}.autocomplete',
@ -2078,6 +2592,29 @@ class MasterView(View):
renderer='json',
permission=f'{route_prefix}.list')
# download
if cls.downloadable:
config.add_route(f'{route_prefix}.download',
f'{instance_url_prefix}/download')
config.add_view(cls, attr='download',
route_name=f'{route_prefix}.download',
permission=f'{permission_prefix}.download')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.download',
f"Download file(s) for {model_title}")
# execute
if cls.executable:
config.add_route(f'{route_prefix}.execute',
f'{instance_url_prefix}/execute',
request_method='POST')
config.add_view(cls, attr='execute',
route_name=f'{route_prefix}.execute',
permission=f'{permission_prefix}.execute')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.execute',
f"Execute {model_title}")
# configure
if cls.configurable:
config.add_route(f'{route_prefix}.configure',
@ -2088,3 +2625,16 @@ class MasterView(View):
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.configure',
f"Configure {model_title_plural}")
# view
# nb. always register this one last, so it does not take
# priority over model-wide action routes, e.g. delete_bulk
if cls.viewable:
instance_url_prefix = cls.get_instance_url_prefix()
config.add_route(f'{route_prefix}.view', instance_url_prefix)
config.add_view(cls, attr='view',
route_name=f'{route_prefix}.view',
permission=f'{permission_prefix}.view')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.view',
f"View {model_title}")

View file

@ -0,0 +1,75 @@
# -*- 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/>.
#
################################################################################
"""
Progress Views
"""
from wuttaweb.progress import get_progress_session
def progress(request):
"""
View which returns JSON with current progress status.
The URL is like ``/progress/XXX`` where ``XXX`` is the "key" to a
particular progress indicator, tied to a long-running operation.
This key is used to lookup the progress status within the Beaker
session storage. See also
:class:`~wuttaweb.progress.SessionProgress`.
"""
key = request.matchdict['key']
session = get_progress_session(request, key)
# session has 'complete' flag set when operation is over
if session.get('complete'):
# set a flash msg for user if one is defined. this is the
# time to do it since user is about to get redirected.
msg = session.get('success_msg')
if msg:
request.session.flash(msg)
elif session.get('error'): # uh-oh
# set an error flash msg for user. this is the time to do it
# since user is about to get redirected.
msg = session.get('error_msg', "An unspecified error occurred.")
request.session.flash(msg, 'error')
# nb. we return the session as-is; since it is dict-like (and only
# contains relevant progress data) it can be used directly for the
# JSON response context
return session
def defaults(config, **kwargs):
base = globals()
progress = kwargs.get('progress', base['progress'])
config.add_route('progress', '/progress/{key}')
config.add_view(progress, route_name='progress', renderer='json')
def includeme(config):
defaults(config)

View file

@ -124,16 +124,25 @@ class AppInfoView(MasterView):
simple_settings = [
# basics
{'name': f'{self.app.appname}.app_title'},
{'name': f'{self.app.appname}.node_type'},
{'name': f'{self.app.appname}.node_title'},
{'name': f'{self.app.appname}.production',
{'name': f'{self.config.appname}.app_title'},
{'name': f'{self.config.appname}.node_type'},
{'name': f'{self.config.appname}.node_title'},
{'name': f'{self.config.appname}.production',
'type': bool},
# user/auth
{'name': 'wuttaweb.home_redirect_to_login',
'type': bool, 'default': False},
# email
{'name': f'{self.config.appname}.mail.send_emails',
'type': bool, 'default': False},
{'name': f'{self.config.appname}.email.default.sender'},
{'name': f'{self.config.appname}.email.default.subject'},
{'name': f'{self.config.appname}.email.default.to'},
{'name': f'{self.config.appname}.email.feedback.subject'},
{'name': f'{self.config.appname}.email.feedback.to'},
]
def getval(key):
@ -202,6 +211,7 @@ class SettingView(MasterView):
"""
model_class = Setting
model_title = "Raw Setting"
deletable_bulk = True
filter_defaults = {
'name': {'active': True},
}

View file

@ -0,0 +1,364 @@
# -*- 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/>.
#
################################################################################
"""
Upgrade Views
"""
import datetime
import logging
import os
import shutil
import subprocess
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, FileDownload
from wuttaweb.progress import get_progress_session
log = logging.getLogger(__name__)
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
executable = True
execute_progress_template = '/upgrade.mako'
downloadable = True
configurable = True
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
g.set_renderer('executed', self.grid_render_datetime)
# 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))
# 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))
# exit_code
if self.creating or self.editing or not upgrade.executed:
f.remove('exit_code')
# stdout / stderr
if not (self.creating or self.editing) and upgrade.status in (
enum.UpgradeStatus.SUCCESS, enum.UpgradeStatus.FAILURE):
# stdout_file
f.append('stdout_file')
f.set_label('stdout_file', "STDOUT")
url = self.get_action_url('download', upgrade, _query={'filename': 'stdout.log'})
f.set_node('stdout_file', FileDownload(self.request, url=url))
f.set_default('stdout_file', self.get_upgrade_filepath(upgrade, 'stdout.log'))
# stderr_file
f.append('stderr_file')
f.set_label('stderr_file', "STDERR")
url = self.get_action_url('download', upgrade, _query={'filename': 'stderr.log'})
f.set_node('stderr_file', FileDownload(self.request, url=url))
f.set_default('stderr_file', self.get_upgrade_filepath(upgrade, 'stderr.log'))
def delete_instance(self, upgrade):
"""
We override this method to delete any files associated with
the upgrade, in addition to deleting the upgrade proper.
"""
path = self.get_upgrade_filepath(upgrade, create=False)
if os.path.exists(path):
shutil.rmtree(path)
super().delete_instance(upgrade)
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
def download_path(self, upgrade, filename):
""" """
if filename:
return self.get_upgrade_filepath(upgrade, filename)
def get_upgrade_filepath(self, upgrade, filename=None, create=True):
""" """
uuid = upgrade.uuid
path = self.app.get_appdir('data', 'upgrades', uuid[:2], uuid[2:],
create=create)
if filename:
path = os.path.join(path, filename)
return path
def execute_instance(self, upgrade, user, progress=None):
"""
This method runs the actual upgrade.
Default logic will get the script command from config, and run
it via shell in a subprocess.
The ``stdout`` and ``stderr`` streams are captured to separate
log files which are then available to download.
The upgrade itself is marked as "executed" with status of
either ``SUCCESS`` or ``FAILURE``.
"""
enum = self.app.enum
# locate file paths
script = self.config.require(f'{self.app.appname}.upgrades.command')
stdout_path = self.get_upgrade_filepath(upgrade, 'stdout.log')
stderr_path = self.get_upgrade_filepath(upgrade, 'stderr.log')
# record the fact that execution has begun for this upgrade
# nb. this is done in separate session to ensure it sticks,
# but also update local object to reflect the change
with self.app.short_session(commit=True) as s:
alt = s.merge(upgrade)
alt.status = enum.UpgradeStatus.EXECUTING
upgrade.status = enum.UpgradeStatus.EXECUTING
# run the command
log.debug("running upgrade command: %s", script)
with open(stdout_path, 'wb') as stdout:
with open(stderr_path, 'wb') as stderr:
upgrade.exit_code = subprocess.call(script, shell=True, text=True,
stdout=stdout, stderr=stderr)
logger = log.warning if upgrade.exit_code != 0 else log.debug
logger("upgrade command had exit code: %s", upgrade.exit_code)
# declare it complete
upgrade.executed = datetime.datetime.now()
upgrade.executed_by = user
if upgrade.exit_code == 0:
upgrade.status = enum.UpgradeStatus.SUCCESS
else:
upgrade.status = enum.UpgradeStatus.FAILURE
def execute_progress(self):
""" """
route_prefix = self.get_route_prefix()
upgrade = self.get_instance()
session = get_progress_session(self.request, f'{route_prefix}.execute')
# session has 'complete' flag set when operation is over
if session.get('complete'):
# set a flash msg for user if one is defined. this is the
# time to do it since user is about to get redirected.
msg = session.get('success_msg')
if msg:
self.request.session.flash(msg)
elif session.get('error'): # uh-oh
# set an error flash msg for user. this is the time to do it
# since user is about to get redirected.
msg = session.get('error_msg', "An unspecified error occurred.")
self.request.session.flash(msg, 'error')
# our return value will include all from progress session
data = dict(session)
# add whatever might be new from upgrade process STDOUT
path = self.get_upgrade_filepath(upgrade, filename='stdout.log')
offset = session.get('stdout.offset', 0)
if os.path.exists(path):
size = os.path.getsize(path) - offset
if size > 0:
# with open(path, 'rb') as f:
with open(path) as f:
f.seek(offset)
chunk = f.read(size)
# data['stdout'] = chunk.decode('utf8').replace('\n', '<br />')
data['stdout'] = chunk.replace('\n', '<br />')
session['stdout.offset'] = offset + size
session.save()
return data
def configure_get_simple_settings(self):
""" """
script = self.config.get(f'{self.app.appname}.upgrades.command')
if not script:
pass
return [
# basics
{'name': f'{self.app.appname}.upgrades.command',
'default': script},
]
@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)
cls._upgrade_defaults(config)
@classmethod
def _upgrade_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
instance_url_prefix = cls.get_instance_url_prefix()
# execution progress
config.add_route(f'{route_prefix}.execute_progress',
f'{instance_url_prefix}/execute/progress')
config.add_view(cls, attr='execute_progress',
route_name=f'{route_prefix}.execute_progress',
permission=f'{permission_prefix}.execute',
renderer='json')
def defaults(config, **kwargs):
base = globals()
UpgradeView = kwargs.get('UpgradeView', base['UpgradeView'])
UpgradeView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -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):
@ -267,3 +316,18 @@ class TestPermissions(DataTestCase):
widget = typ.widget_maker()
self.assertEqual(len(widget.values), 1)
self.assertEqual(widget.values[0], ('widgets.polish', "Polish the widgets"))
class TestFileDownload(DataTestCase):
def setUp(self):
self.setup_db()
self.request = testing.DummyRequest(wutta_config=self.config)
def test_widget_maker(self):
# sanity / coverage check
typ = mod.FileDownload(self.request, url='/foo')
widget = typ.widget_maker()
self.assertIsInstance(widget, widgets.FileDownloadWidget)
self.assertEqual(widget.url, '/foo')

View file

@ -7,7 +7,7 @@ import deform
from pyramid import testing
from wuttaweb.forms import widgets as mod
from wuttaweb.forms.schema import PersonRef, RoleRefs, UserRefs, Permissions
from wuttaweb.forms.schema import FileDownload, PersonRef, RoleRefs, UserRefs, Permissions
from tests.util import WebTestCase
@ -52,6 +52,55 @@ class TestObjectRefWidget(WebTestCase):
self.assertIn('href="/foo"', html)
class TestFileDownloadWidget(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 test_serialize(self):
# nb. we let the field construct the widget via our type
# (nb. at first we do not provide a url)
node = colander.SchemaNode(FileDownload(self.request))
field = self.make_field(node)
widget = field.widget
# null value
html = widget.serialize(field, None, readonly=True)
self.assertNotIn('<a ', html)
self.assertIn('<span>', html)
# path to nonexistent file
html = widget.serialize(field, '/this/path/does/not/exist', readonly=True)
self.assertNotIn('<a ', html)
self.assertIn('<span>', html)
# path to actual file
datfile = self.write_file('data.txt', "hello\n" * 1000)
html = widget.serialize(field, datfile, readonly=True)
self.assertNotIn('<a ', html)
self.assertIn('<span>', html)
self.assertIn('data.txt', html)
self.assertIn('kB)', html)
# path to file, w/ url
node = colander.SchemaNode(FileDownload(self.request, url='/download/blarg'))
field = self.make_field(node)
widget = field.widget
html = widget.serialize(field, datfile, readonly=True)
self.assertNotIn('<span>', html)
self.assertIn('<a href="/download/blarg">', html)
self.assertIn('data.txt', html)
self.assertIn('kB)', html)
# nb. same readonly output even if we ask for editable
html2 = widget.serialize(field, datfile, readonly=False)
self.assertEqual(html2, html)
class TestRoleRefsWidget(WebTestCase):
def make_field(self, node, **kwargs):
@ -113,7 +162,7 @@ class TestUserRefsWidget(WebTestCase):
# empty
html = widget.serialize(field, set(), readonly=True)
self.assertIn('<b-table ', html)
self.assertEqual(html, '<span></span>')
# with data, no actions
user = model.User(username='barney')

View file

@ -254,6 +254,42 @@ class TestGrid(WebTestCase):
self.assertEqual(len(grid.actions), 1)
self.assertIsInstance(grid.actions[0], mod.GridAction)
def test_set_tools(self):
grid = self.make_grid()
self.assertEqual(grid.tools, {})
# null
grid.set_tools(None)
self.assertEqual(grid.tools, {})
# empty
grid.set_tools({})
self.assertEqual(grid.tools, {})
# full dict is replaced
grid.tools = {'foo': 'bar'}
self.assertEqual(grid.tools, {'foo': 'bar'})
grid.set_tools({'bar': 'baz'})
self.assertEqual(grid.tools, {'bar': 'baz'})
# can specify as list of html elements
grid.set_tools(['foo', 'bar'])
self.assertEqual(len(grid.tools), 2)
self.assertEqual(list(grid.tools.values()), ['foo', 'bar'])
def test_add_tool(self):
grid = self.make_grid()
self.assertEqual(grid.tools, {})
# with key
grid.add_tool('foo', key='foo')
self.assertEqual(grid.tools, {'foo': 'foo'})
# without key
grid.add_tool('bar')
self.assertEqual(len(grid.tools), 2)
self.assertEqual(list(grid.tools.values()), ['foo', 'bar'])
def test_get_pagesize_options(self):
grid = self.make_grid()

62
tests/test_progress.py Normal file
View file

@ -0,0 +1,62 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
from pyramid import testing
from beaker.session import Session as BeakerSession
from wuttaweb import progress as mod
class TestGetBasicSession(TestCase):
def setUp(self):
self.request = testing.DummyRequest()
def test_basic(self):
session = mod.get_basic_session(self.request)
self.assertIsInstance(session, BeakerSession)
self.assertFalse(session.use_cookies)
class TestGetProgressSession(TestCase):
def setUp(self):
self.request = testing.DummyRequest()
def test_basic(self):
self.request.session.id = 'mockid'
session = mod.get_progress_session(self.request, 'foo')
self.assertIsInstance(session, BeakerSession)
self.assertEqual(session.id, 'mockid.progress.foo')
class TestSessionProgress(TestCase):
def setUp(self):
self.request = testing.DummyRequest()
self.request.session.id = 'mockid'
def test_error_url(self):
factory = mod.SessionProgress(self.request, 'foo', success_url='/blart')
self.assertEqual(factory.error_url, '/blart')
def test_basic(self):
# sanity / coverage check
factory = mod.SessionProgress(self.request, 'foo')
prog = factory("doing things", 2)
prog.update(1)
prog.update(2)
prog.handle_success()
def test_error(self):
# sanity / coverage check
factory = mod.SessionProgress(self.request, 'foo')
prog = factory("doing things", 2)
prog.update(1)
try:
raise RuntimeError('omg')
except Exception as error:
prog.handle_error(error)

View file

@ -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",

View file

@ -6,11 +6,12 @@ from unittest.mock import MagicMock
from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttjamaican.testing import FileConfigTestCase
from wuttaweb import subscribers
from wuttaweb.menus import MenuHandler
class DataTestCase(TestCase):
class DataTestCase(FileConfigTestCase):
"""
Base class for test suites requiring a full (typical) database.
"""
@ -19,6 +20,7 @@ class DataTestCase(TestCase):
self.setup_db()
def setup_db(self):
self.setup_files()
self.config = WuttaConfig(defaults={
'wutta.db.default.url': 'sqlite://',
})
@ -33,7 +35,7 @@ class DataTestCase(TestCase):
self.teardown_db()
def teardown_db(self):
pass
self.teardown_files()
class WebTestCase(DataTestCase):

View file

@ -50,6 +50,26 @@ class TestView(WebTestCase):
self.assertIsInstance(error, HTTPFound)
self.assertEqual(error.location, '/')
def test_file_response(self):
view = self.make_view()
# default uses attachment behavior
datfile = self.write_file('dat.txt', 'hello')
response = view.file_response(datfile)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content_disposition, 'attachment; filename="dat.txt"')
# but can disable attachment behavior
datfile = self.write_file('dat.txt', 'hello')
response = view.file_response(datfile, attachment=False)
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.content_disposition)
# path not found
crapfile = '/does/not/exist'
response = view.file_response(crapfile)
self.assertEqual(response.status_code, 404)
def test_json_response(self):
view = self.make_view()
response = view.json_response({'foo': 'bar'})

View file

@ -1,5 +1,9 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
import colander
from wuttaweb.views import common as mod
from tests.util import WebTestCase
@ -51,6 +55,78 @@ class TestCommonView(WebTestCase):
context = view.home(session=self.session)
self.assertEqual(context['index_title'], self.app.get_title())
def test_feedback_make_schema(self):
view = self.make_view()
schema = view.feedback_make_schema()
self.assertIsInstance(schema, colander.Schema)
self.assertIn('message', schema)
def test_feedback(self):
self.pyramid_config.add_route('users.view', '/users/{uuid}')
model = self.app.model
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
view = self.make_view()
with patch.object(view, 'feedback_send') as feedback_send:
# basic send, no user
self.request.client_addr = '127.0.0.1'
self.request.method = 'POST'
self.request.POST = {
'referrer': '/foo',
'user_name': "Barney Rubble",
'message': "hello world",
}
context = view.feedback()
self.assertEqual(context, {'ok': True})
feedback_send.assert_called_once()
# reset
feedback_send.reset_mock()
# basic send, with user
self.request.user = user
self.request.POST['user_uuid'] = user.uuid
with patch.object(mod, 'Session', return_value=self.session):
context = view.feedback()
self.assertEqual(context, {'ok': True})
feedback_send.assert_called_once()
# reset
self.request.user = None
feedback_send.reset_mock()
# invalid form data
self.request.POST = {'message': 'hello world'}
context = view.feedback()
self.assertEqual(list(context), ['error'])
self.assertIn('Required', context['error'])
feedback_send.assert_not_called()
# error on send
self.request.POST = {
'referrer': '/foo',
'user_name': "Barney Rubble",
'message': "hello world",
}
feedback_send.side_effect = RuntimeError
context = view.feedback()
feedback_send.assert_called_once()
self.assertEqual(list(context), ['error'])
self.assertIn('RuntimeError', context['error'])
def test_feedback_send(self):
view = self.make_view()
with patch.object(self.app, 'send_email') as send_email:
view.feedback_send({'user_name': "Barney",
'message': "hello world"})
send_email.assert_called_once_with('feedback', {
'user_name': "Barney",
'message': "hello world"
})
def test_setup(self):
self.pyramid_config.add_route('home', '/')
self.pyramid_config.add_route('login', '/login')

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8; -*-
import datetime
import decimal
import functools
from unittest import TestCase
@ -13,6 +14,7 @@ from pyramid.httpexceptions import HTTPNotFound
from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import master as mod
from wuttaweb.views import View
from wuttaweb.progress import SessionProgress
from wuttaweb.subscribers import new_request_set_user
from tests.util import WebTestCase
@ -26,7 +28,10 @@ class TestMasterView(WebTestCase):
with patch.multiple(mod.MasterView, create=True,
model_name='Widget',
model_key='uuid',
deletable_bulk=True,
has_autocomplete=True,
downloadable=True,
executable=True,
configurable=True):
mod.MasterView.defaults(self.pyramid_config)
@ -399,6 +404,49 @@ class TestMasterView(WebTestCase):
self.assertTrue(view.has_any_perm('list', 'view'))
self.assertTrue(self.request.has_any_perm('settings.list', 'settings.view'))
def test_make_button(self):
view = self.make_view()
# normal
html = view.make_button('click me')
self.assertIn('<b-button ', html)
self.assertIn('click me', html)
self.assertNotIn('is-primary', html)
# primary as primary
html = view.make_button('click me', primary=True)
self.assertIn('<b-button ', html)
self.assertIn('click me', html)
self.assertIn('is-primary', html)
# primary as variant
html = view.make_button('click me', variant='is-primary')
self.assertIn('<b-button ', html)
self.assertIn('click me', html)
self.assertIn('is-primary', html)
# primary as type
html = view.make_button('click me', type='is-primary')
self.assertIn('<b-button ', html)
self.assertIn('click me', html)
self.assertIn('is-primary', html)
def test_make_progress(self):
# basic
view = self.make_view()
self.request.session.id = 'mockid'
progress = view.make_progress('foo')
self.assertIsInstance(progress, SessionProgress)
def test_render_progress(self):
self.pyramid_config.add_route('progress', '/progress/{key}')
# sanity / coverage check
view = self.make_view()
progress = MagicMock()
response = view.render_progress(progress)
def test_render_to_response(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
@ -472,6 +520,7 @@ class TestMasterView(WebTestCase):
self.assertEqual(grid.labels, {'name': "SETTING NAME"})
def test_make_model_grid(self):
self.pyramid_config.add_route('settings.delete_bulk', '/settings/delete-bulk')
model = self.app.model
# no model class
@ -524,6 +573,20 @@ class TestMasterView(WebTestCase):
grid = view.make_model_grid(session=self.session)
self.assertEqual(len(grid.actions), 3)
# no tools by default
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
grid = view.make_model_grid(session=self.session)
self.assertEqual(grid.tools, {})
# delete-results tool added if master/perms allow
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting,
deletable_bulk=True):
with patch.object(self.request, 'is_root', new=True):
grid = view.make_model_grid(session=self.session)
self.assertIn('delete-results', grid.tools)
def test_get_grid_data(self):
model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar')
@ -579,7 +642,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 +659,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()
@ -1026,6 +1115,162 @@ class TestMasterView(WebTestCase):
self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 0)
def test_delete_bulk(self):
self.pyramid_config.add_route('settings', '/settings/')
self.pyramid_config.add_route('progress', '/progress/{key}')
model = self.app.model
sample_data = [
{'name': 'foo1', 'value': 'ONE'},
{'name': 'foo2', 'value': 'two'},
{'name': 'foo3', 'value': 'three'},
{'name': 'foo4', 'value': 'four'},
{'name': 'foo5', 'value': 'five'},
{'name': 'foo6', 'value': 'six'},
{'name': 'foo7', 'value': 'seven'},
{'name': 'foo8', 'value': 'eight'},
{'name': 'foo9', 'value': 'nine'},
]
for setting in sample_data:
self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
sample_query = self.session.query(model.Setting)
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
view = self.make_view()
# sanity check on sample data
grid = view.make_model_grid(session=self.session)
data = grid.get_visible_data()
self.assertEqual(len(data), 9)
# and then let's filter it a little
self.request.GET = {'value': 's', 'value.verb': 'contains'}
grid = view.make_model_grid(session=self.session)
self.assertEqual(len(grid.filters), 2)
self.assertEqual(len(grid.active_filters), 1)
data = grid.get_visible_data()
self.assertEqual(len(data), 2)
# okay now let's delete those via quick method
# (user should be redirected back to index)
with patch.multiple(view,
deletable_bulk_quick=True,
make_model_grid=MagicMock(return_value=grid)):
response = view.delete_bulk()
self.assertEqual(response.status_code, 302)
self.assertEqual(self.session.query(model.Setting).count(), 7)
# now use another filter since those records are gone
self.request.GET = {'name': 'foo2', 'name.verb': 'equal'}
grid = view.make_model_grid(session=self.session)
self.assertEqual(len(grid.filters), 2)
self.assertEqual(len(grid.active_filters), 1)
data = grid.get_visible_data()
self.assertEqual(len(data), 1)
# this time we delete "slowly" with progress
self.request.session.id = 'ignorethis'
with patch.multiple(view,
deletable_bulk_quick=False,
make_model_grid=MagicMock(return_value=grid)):
with patch.object(mod, 'threading') as threading:
response = view.delete_bulk()
threading.Thread.return_value.start.assert_called_once_with()
# nb. user is shown progress page
self.assertEqual(response.status_code, 200)
def test_delete_bulk_action(self):
self.pyramid_config.add_route('settings', '/settings/')
model = self.app.model
sample_data = [
{'name': 'foo1', 'value': 'ONE'},
{'name': 'foo2', 'value': 'two'},
{'name': 'foo3', 'value': 'three'},
{'name': 'foo4', 'value': 'four'},
{'name': 'foo5', 'value': 'five'},
{'name': 'foo6', 'value': 'six'},
{'name': 'foo7', 'value': 'seven'},
{'name': 'foo8', 'value': 'eight'},
{'name': 'foo9', 'value': 'nine'},
]
for setting in sample_data:
self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
sample_query = self.session.query(model.Setting)
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
view = self.make_view()
# basic bulk delete
self.assertEqual(self.session.query(model.Setting).count(), 9)
settings = self.session.query(model.Setting)\
.filter(model.Setting.value.ilike('%s%'))\
.all()
self.assertEqual(len(settings), 2)
view.delete_bulk_action(settings)
self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 7)
def test_delete_bulk_thread(self):
self.pyramid_config.add_route('settings', '/settings/')
model = self.app.model
sample_data = [
{'name': 'foo1', 'value': 'ONE'},
{'name': 'foo2', 'value': 'two'},
{'name': 'foo3', 'value': 'three'},
{'name': 'foo4', 'value': 'four'},
{'name': 'foo5', 'value': 'five'},
{'name': 'foo6', 'value': 'six'},
{'name': 'foo7', 'value': 'seven'},
{'name': 'foo8', 'value': 'eight'},
{'name': 'foo9', 'value': 'nine'},
]
for setting in sample_data:
self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
sample_query = self.session.query(model.Setting)
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
view = self.make_view()
# basic delete, no progress
self.assertEqual(self.session.query(model.Setting).count(), 9)
settings = self.session.query(model.Setting)\
.filter(model.Setting.value.ilike('%s%'))
self.assertEqual(settings.count(), 2)
with patch.object(self.app, 'make_session', return_value=self.session):
view.delete_bulk_thread(settings)
self.assertEqual(self.session.query(model.Setting).count(), 7)
# basic delete, with progress
settings = self.session.query(model.Setting)\
.filter(model.Setting.name == 'foo1')
self.assertEqual(settings.count(), 1)
with patch.object(self.app, 'make_session', return_value=self.session):
view.delete_bulk_thread(settings, progress=MagicMock())
self.assertEqual(self.session.query(model.Setting).count(), 6)
# error, no progress
settings = self.session.query(model.Setting)\
.filter(model.Setting.name == 'foo2')
self.assertEqual(settings.count(), 1)
with patch.object(self.app, 'make_session', return_value=self.session):
with patch.object(view, 'delete_bulk_action', side_effect=RuntimeError):
view.delete_bulk_thread(settings)
# nb. nothing was deleted
self.assertEqual(self.session.query(model.Setting).count(), 6)
# error, with progress
self.assertEqual(settings.count(), 1)
with patch.object(self.app, 'make_session', return_value=self.session):
with patch.object(view, 'delete_bulk_action', side_effect=RuntimeError):
view.delete_bulk_thread(settings, progress=MagicMock())
# nb. nothing was deleted
self.assertEqual(self.session.query(model.Setting).count(), 6)
def test_autocomplete(self):
model = self.app.model
@ -1067,6 +1312,98 @@ class TestMasterView(WebTestCase):
self.assertEqual(normal, {'value': 'bogus',
'label': "Betty Boop"})
def test_download(self):
model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar')
self.session.commit()
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting,
model_key='name',
Session=MagicMock(return_value=self.session)):
view = self.make_view()
self.request.matchdict = {'name': 'foo'}
# 404 if no filename
response = view.download()
self.assertEqual(response.status_code, 404)
# 404 if bad filename
self.request.GET = {'filename': 'doesnotexist'}
response = view.download()
self.assertEqual(response.status_code, 404)
# 200 if good filename
foofile = self.write_file('foo.txt', 'foo')
with patch.object(view, 'download_path', return_value=foofile):
response = view.download()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content_disposition, 'attachment; filename="foo.txt"')
def test_execute(self):
self.pyramid_config.add_route('settings.view', '/settings/{name}')
self.pyramid_config.add_route('progress', '/progress/{key}')
model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar')
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting,
model_key='name',
Session=MagicMock(return_value=self.session)):
view = self.make_view()
self.request.matchdict = {'name': 'foo'}
self.request.session.id = 'mockid'
self.request.user = user
# basic usage; user is shown progress page
with patch.object(mod, 'threading') as threading:
response = view.execute()
threading.Thread.return_value.start.assert_called_once_with()
self.assertEqual(response.status_code, 200)
def test_execute_thread(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()
with patch.multiple(mod.MasterView, create=True,
model_class=model.Upgrade):
view = self.make_view()
# basic execute, no progress
with patch.object(view, 'execute_instance') as execute_instance:
view.execute_thread({'uuid': upgrade.uuid}, user.uuid)
execute_instance.assert_called_once()
# basic execute, with progress
with patch.object(view, 'execute_instance') as execute_instance:
progress = MagicMock()
view.execute_thread({'uuid': upgrade.uuid}, user.uuid, progress=progress)
execute_instance.assert_called_once()
progress.handle_success.assert_called_once_with()
# error, no progress
with patch.object(view, 'execute_instance') as execute_instance:
execute_instance.side_effect = RuntimeError
view.execute_thread({'uuid': upgrade.uuid}, user.uuid)
execute_instance.assert_called_once()
# error, with progress
with patch.object(view, 'execute_instance') as execute_instance:
progress = MagicMock()
execute_instance.side_effect = RuntimeError
view.execute_thread({'uuid': upgrade.uuid}, user.uuid, progress=progress)
execute_instance.assert_called_once()
progress.handle_error.assert_called_once()
def test_configure(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')

View file

@ -0,0 +1,62 @@
# -*- coding: utf-8; -*-
from pyramid import testing
from wuttaweb.views import progress as mod
from wuttaweb.progress import get_progress_session
from tests.util import WebTestCase
class TestProgressView(WebTestCase):
def test_includeme(self):
self.pyramid_config.include('wuttaweb.views.progress')
def test_basic(self):
self.request.session.id = 'mockid'
self.request.matchdict = {'key': 'foo'}
# first call with no setup, will create the progress session
# but it should be "empty" - except not really since beaker
# adds some keys by default
context = mod.progress(self.request)
self.assertIsInstance(context, dict)
# now let's establish a progress session of our own
progsess = get_progress_session(self.request, 'bar')
progsess['maximum'] = 2
progsess['value'] = 1
progsess.save()
# then call view, check results
self.request.matchdict = {'key': 'bar'}
context = mod.progress(self.request)
self.assertEqual(context['maximum'], 2)
self.assertEqual(context['value'], 1)
self.assertNotIn('complete', context)
# now mark it as complete, check results
progsess['complete'] = True
progsess['success_msg'] = "yay!"
progsess.save()
context = mod.progress(self.request)
self.assertTrue(context['complete'])
self.assertEqual(context['success_msg'], "yay!")
# now do that all again, with error
progsess = get_progress_session(self.request, 'baz')
progsess['maximum'] = 2
progsess['value'] = 1
progsess.save()
self.request.matchdict = {'key': 'baz'}
context = mod.progress(self.request)
self.assertEqual(context['maximum'], 2)
self.assertEqual(context['value'], 1)
self.assertNotIn('complete', context)
self.assertNotIn('error', context)
progsess['error'] = True
progsess['error_msg'] = "omg!"
progsess.save()
context = mod.progress(self.request)
self.assertTrue(context['error'])
self.assertEqual(context['error_msg'], "omg!")

View file

@ -0,0 +1,364 @@
# -*- coding: utf-8; -*-
import datetime
import os
import sys
from unittest.mock import patch, MagicMock
from wuttaweb.views import upgrades as mod
from wuttjamaican.exc import ConfigurationError
from wuttaweb.progress import get_progress_session
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):
self.pyramid_config.add_route('upgrades.download', '/upgrades/{uuid}/download')
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, stdout/stderr 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)
self.assertNotIn('stdout_file', form)
self.assertNotIn('stderr_file', form)
# but it *is* shown if upgrade is executed
upgrade.executed = datetime.datetime.now()
upgrade.status = enum.UpgradeStatus.SUCCESS
form = view.make_form(model_class=model.Upgrade, model_instance=upgrade)
self.assertIn('executed', form)
view.configure_form(form)
self.assertIn('executed', form)
self.assertIn('stdout_file', form)
self.assertIn('stderr_file', 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)
def test_download_path(self):
model = self.app.model
enum = self.app.enum
appdir = self.mkdir('app')
self.config.setdefault('wutta.appdir', appdir)
self.assertEqual(self.app.get_appdir(), appdir)
user = model.User(username='barney')
upgrade = model.Upgrade(description='test', created_by=user,
status=enum.UpgradeStatus.PENDING)
self.session.add(upgrade)
self.session.commit()
view = self.make_view()
uuid = upgrade.uuid
# no filename
path = view.download_path(upgrade, None)
self.assertIsNone(path)
# with filename
path = view.download_path(upgrade, 'foo.txt')
self.assertEqual(path, os.path.join(appdir, 'data', 'upgrades',
uuid[:2], uuid[2:], 'foo.txt'))
def test_get_upgrade_filepath(self):
model = self.app.model
enum = self.app.enum
appdir = self.mkdir('app')
self.config.setdefault('wutta.appdir', appdir)
self.assertEqual(self.app.get_appdir(), appdir)
user = model.User(username='barney')
upgrade = model.Upgrade(description='test', created_by=user,
status=enum.UpgradeStatus.PENDING)
self.session.add(upgrade)
self.session.commit()
view = self.make_view()
uuid = upgrade.uuid
# no filename
path = view.get_upgrade_filepath(upgrade)
self.assertEqual(path, os.path.join(appdir, 'data', 'upgrades',
uuid[:2], uuid[2:]))
# with filename
path = view.get_upgrade_filepath(upgrade, 'foo.txt')
self.assertEqual(path, os.path.join(appdir, 'data', 'upgrades',
uuid[:2], uuid[2:], 'foo.txt'))
def test_delete_instance(self):
model = self.app.model
enum = self.app.enum
appdir = self.mkdir('app')
self.config.setdefault('wutta.appdir', appdir)
self.assertEqual(self.app.get_appdir(), appdir)
user = model.User(username='barney')
upgrade = model.Upgrade(description='test', created_by=user,
status=enum.UpgradeStatus.PENDING)
self.session.add(upgrade)
self.session.commit()
view = self.make_view()
# mock stdout/stderr files
upgrade_dir = view.get_upgrade_filepath(upgrade)
stdout = view.get_upgrade_filepath(upgrade, 'stdout.log')
with open(stdout, 'w') as f:
f.write('stdout')
stderr = view.get_upgrade_filepath(upgrade, 'stderr.log')
with open(stderr, 'w') as f:
f.write('stderr')
# both upgrade and files are deleted
self.assertTrue(os.path.exists(upgrade_dir))
self.assertTrue(os.path.exists(stdout))
self.assertTrue(os.path.exists(stderr))
self.assertEqual(self.session.query(model.Upgrade).count(), 1)
with patch.object(view, 'Session', return_value=self.session):
view.delete_instance(upgrade)
self.assertFalse(os.path.exists(upgrade_dir))
self.assertFalse(os.path.exists(stdout))
self.assertFalse(os.path.exists(stderr))
self.assertEqual(self.session.query(model.Upgrade).count(), 0)
def test_execute_instance(self):
model = self.app.model
enum = self.app.enum
appdir = self.mkdir('app')
self.config.setdefault('wutta.appdir', appdir)
self.assertEqual(self.app.get_appdir(), appdir)
user = model.User(username='barney')
upgrade = model.Upgrade(description='test', created_by=user,
status=enum.UpgradeStatus.PENDING)
self.session.add(upgrade)
self.session.commit()
view = self.make_view()
self.request.user = user
python = sys.executable
# script not yet confiugred
self.assertRaises(ConfigurationError, view.execute_instance, upgrade, user)
# script w/ success
goodpy = self.write_file('good.py', """
import sys
sys.stdout.write('hello from good.py')
sys.exit(0)
""")
self.app.save_setting(self.session, 'wutta.upgrades.command', f'{python} {goodpy}')
self.assertIsNone(upgrade.executed)
self.assertIsNone(upgrade.executed_by)
self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING)
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.config, 'usedb', new=True):
view.execute_instance(upgrade, user)
self.assertIsNotNone(upgrade.executed)
self.assertIs(upgrade.executed_by, user)
self.assertEqual(upgrade.status, enum.UpgradeStatus.SUCCESS)
with open(view.get_upgrade_filepath(upgrade, 'stdout.log')) as f:
self.assertEqual(f.read(), 'hello from good.py')
with open(view.get_upgrade_filepath(upgrade, 'stderr.log')) as f:
self.assertEqual(f.read(), '')
# need a new record for next test
upgrade = model.Upgrade(description='test', created_by=user,
status=enum.UpgradeStatus.PENDING)
self.session.add(upgrade)
self.session.commit()
# script w/ failure
badpy = self.write_file('bad.py', """
import sys
sys.stderr.write('hello from bad.py')
sys.exit(42)
""")
self.app.save_setting(self.session, 'wutta.upgrades.command', f'{python} {badpy}')
self.assertIsNone(upgrade.executed)
self.assertIsNone(upgrade.executed_by)
self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING)
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.config, 'usedb', new=True):
view.execute_instance(upgrade, user)
self.assertIsNotNone(upgrade.executed)
self.assertIs(upgrade.executed_by, user)
self.assertEqual(upgrade.status, enum.UpgradeStatus.FAILURE)
with open(view.get_upgrade_filepath(upgrade, 'stdout.log')) as f:
self.assertEqual(f.read(), '')
with open(view.get_upgrade_filepath(upgrade, 'stderr.log')) as f:
self.assertEqual(f.read(), 'hello from bad.py')
def test_execute_progress(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
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()
stdout = self.write_file('stdout.log', 'hello 001\n')
self.request.matchdict = {'uuid': upgrade.uuid}
with patch.multiple(mod.UpgradeView,
Session=MagicMock(return_value=self.session),
get_upgrade_filepath=MagicMock(return_value=stdout)):
# nb. this is used to identify progress tracker
self.request.session.id = 'mockid#1'
# first call should get the full contents
context = view.execute_progress()
self.assertFalse(context.get('complete'))
self.assertFalse(context.get('error'))
# nb. newline is converted to <br>
self.assertEqual(context['stdout'], 'hello 001<br />')
# next call should get any new contents
with open(stdout, 'a') as f:
f.write('hello 002\n')
context = view.execute_progress()
self.assertFalse(context.get('complete'))
self.assertFalse(context.get('error'))
self.assertEqual(context['stdout'], 'hello 002<br />')
# nb. switch to a different progress tracker
self.request.session.id = 'mockid#2'
# first call should get the full contents
context = view.execute_progress()
self.assertFalse(context.get('complete'))
self.assertFalse(context.get('error'))
self.assertEqual(context['stdout'], 'hello 001<br />hello 002<br />')
# mark progress complete
session = get_progress_session(self.request, 'upgrades.execute')
session.load()
session['complete'] = True
session['success_msg'] = 'yay!'
session.save()
# next call should reflect that
self.assertEqual(self.request.session.pop_flash(), [])
context = view.execute_progress()
self.assertTrue(context.get('complete'))
self.assertFalse(context.get('error'))
# nb. this is missing b/c we already got all contents
self.assertNotIn('stdout', context)
self.assertEqual(self.request.session.pop_flash(), ['yay!'])
# nb. switch to a different progress tracker
self.request.session.id = 'mockid#3'
# first call should get the full contents
context = view.execute_progress()
self.assertFalse(context.get('complete'))
self.assertFalse(context.get('error'))
self.assertEqual(context['stdout'], 'hello 001<br />hello 002<br />')
# mark progress error
session = get_progress_session(self.request, 'upgrades.execute')
session.load()
session['error'] = True
session['error_msg'] = 'omg!'
session.save()
# next call should reflect that
self.assertEqual(self.request.session.pop_flash('error'), [])
context = view.execute_progress()
self.assertFalse(context.get('complete'))
self.assertTrue(context.get('error'))
# nb. this is missing b/c we already got all contents
self.assertNotIn('stdout', context)
self.assertEqual(self.request.session.pop_flash('error'), ['omg!'])
def test_configure_get_simple_settings(self):
# sanity/coverage check
view = self.make_view()
simple = view.configure_get_simple_settings()