Compare commits
9 commits
1804e74d13
...
0910153685
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0910153685 | ||
![]() |
a010071985 | ||
![]() |
a377061da0 | ||
![]() |
4934ed1d93 | ||
![]() |
8669ca2283 | ||
![]() |
e5e31a7d32 | ||
![]() |
1a8900c9f4 | ||
![]() |
6fa8b0aeaa | ||
![]() |
6650ee698e |
26
CHANGELOG.md
26
CHANGELOG.md
|
@ -5,6 +5,32 @@ All notable changes to wuttaweb will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## v0.13.0 (2024-08-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- use native wuttjamaican app to send feedback email
|
||||
- add basic user feedback email mechanism
|
||||
- add "progress" page for executing upgrades
|
||||
- add basic support for execute upgrades, download stdout/stderr
|
||||
- add basic progress page/indicator support
|
||||
- add basic "delete results" grid tool
|
||||
- add initial views for upgrades
|
||||
- allow app db to be rattail-native instead of wutta-native
|
||||
- add per-row css class support for grids
|
||||
- improve grid filter API a bit, support string/bool filters
|
||||
|
||||
### Fix
|
||||
|
||||
- tweak max image size for full logo on home, login pages
|
||||
- improve handling of boolean form fields
|
||||
- misc. improvements for display of grids, form errors
|
||||
- use autocomplete for grid filter verb choices
|
||||
- small cleanup for grid filters template
|
||||
- add once-button action for grid Reset View
|
||||
- set sort defaults for users, roles
|
||||
- add override hook for base form template
|
||||
|
||||
## v0.12.1 (2024-08-22)
|
||||
|
||||
### Fix
|
||||
|
|
|
@ -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
|
||||
|
|
6
docs/api/wuttaweb/progress.rst
Normal file
6
docs/api/wuttaweb/progress.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttaweb.progress``
|
||||
=====================
|
||||
|
||||
.. automodule:: wuttaweb.progress
|
||||
:members:
|
|
@ -1,6 +1,6 @@
|
|||
|
||||
``wuttaweb.views.people``
|
||||
===========================
|
||||
=========================
|
||||
|
||||
.. automodule:: wuttaweb.views.people
|
||||
:members:
|
||||
|
|
6
docs/api/wuttaweb/views.progress.rst
Normal file
6
docs/api/wuttaweb/views.progress.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttaweb.views.progress``
|
||||
===========================
|
||||
|
||||
.. automodule:: wuttaweb.views.progress
|
||||
:members:
|
6
docs/api/wuttaweb/views.upgrades.rst
Normal file
6
docs/api/wuttaweb/views.upgrades.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttaweb.views.upgrades``
|
||||
===========================
|
||||
|
||||
.. automodule:: wuttaweb.views.upgrades
|
||||
:members:
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
40
src/wuttaweb/email/templates/feedback.html.mako
Normal file
40
src/wuttaweb/email/templates/feedback.html.mako
Normal 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>
|
23
src/wuttaweb/email/templates/feedback.txt.mako
Normal file
23
src/wuttaweb/email/templates/feedback.txt.mako
Normal 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}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
165
src/wuttaweb/progress.py
Normal 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()
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -21,8 +21,8 @@
|
|||
justify-content: center;
|
||||
}
|
||||
.wutta-logo img {
|
||||
max-height: 350px;
|
||||
max-width: 800px;
|
||||
max-height: 480px;
|
||||
max-width: 640px;
|
||||
}
|
||||
</style>
|
||||
</%def>
|
||||
|
|
|
@ -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()} » ${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()} » ${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()">
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
14
src/wuttaweb/templates/deform/readonly/filedownload.pt
Normal file
14
src/wuttaweb/templates/deform/readonly/filedownload.pt
Normal 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>
|
|
@ -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}`
|
||||
|
|
|
@ -21,8 +21,8 @@
|
|||
justify-content: center;
|
||||
}
|
||||
.wutta-logo img {
|
||||
max-height: 350px;
|
||||
max-width: 800px;
|
||||
max-height: 480px;
|
||||
max-width: 640px;
|
||||
}
|
||||
</style>
|
||||
</%def>
|
||||
|
|
|
@ -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:
|
||||
|
|
127
src/wuttaweb/templates/progress.mako
Normal file
127
src/wuttaweb/templates/progress.mako
Normal 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>
|
64
src/wuttaweb/templates/upgrade.mako
Normal file
64
src/wuttaweb/templates/upgrade.mako
Normal 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>
|
20
src/wuttaweb/templates/upgrades/configure.mako
Normal file
20
src/wuttaweb/templates/upgrades/configure.mako
Normal 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>
|
37
src/wuttaweb/templates/upgrades/view.mako
Normal file
37
src/wuttaweb/templates/upgrades/view.mako
Normal 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>
|
|
@ -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"
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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}")
|
||||
|
|
75
src/wuttaweb/views/progress.py
Normal file
75
src/wuttaweb/views/progress.py
Normal 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)
|
|
@ -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},
|
||||
}
|
||||
|
|
364
src/wuttaweb/views/upgrades.py
Normal file
364
src/wuttaweb/views/upgrades.py
Normal 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)
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
62
tests/test_progress.py
Normal 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)
|
|
@ -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",
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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'})
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
62
tests/views/test_progress.py
Normal file
62
tests/views/test_progress.py
Normal 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!")
|
364
tests/views/test_upgrades.py
Normal file
364
tests/views/test_upgrades.py
Normal 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()
|
Loading…
Reference in a new issue