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/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## v0.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)
|
## v0.12.1 (2024-08-22)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
handler
|
handler
|
||||||
helpers
|
helpers
|
||||||
menus
|
menus
|
||||||
|
progress
|
||||||
static
|
static
|
||||||
subscribers
|
subscribers
|
||||||
util
|
util
|
||||||
|
@ -30,6 +31,8 @@
|
||||||
views.essential
|
views.essential
|
||||||
views.master
|
views.master
|
||||||
views.people
|
views.people
|
||||||
|
views.progress
|
||||||
views.roles
|
views.roles
|
||||||
views.settings
|
views.settings
|
||||||
|
views.upgrades
|
||||||
views.users
|
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``
|
``wuttaweb.views.people``
|
||||||
===========================
|
=========================
|
||||||
|
|
||||||
.. automodule:: wuttaweb.views.people
|
.. automodule:: wuttaweb.views.people
|
||||||
:members:
|
: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),
|
'deform': ('https://docs.pylonsproject.org/projects/deform/en/latest/', None),
|
||||||
'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None),
|
'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None),
|
||||||
'python': ('https://docs.python.org/3/', 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),
|
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
|
||||||
'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
|
'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "WuttaWeb"
|
name = "WuttaWeb"
|
||||||
version = "0.12.1"
|
version = "0.13.0"
|
||||||
description = "Web App for Wutta Framework"
|
description = "Web App for Wutta Framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
|
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
|
||||||
|
@ -31,6 +31,7 @@ classifiers = [
|
||||||
requires-python = ">= 3.8"
|
requires-python = ">= 3.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ColanderAlchemy",
|
"ColanderAlchemy",
|
||||||
|
"humanize",
|
||||||
"paginate",
|
"paginate",
|
||||||
"paginate_sqlalchemy",
|
"paginate_sqlalchemy",
|
||||||
"pyramid>=2",
|
"pyramid>=2",
|
||||||
|
@ -41,7 +42,7 @@ dependencies = [
|
||||||
"pyramid_tm",
|
"pyramid_tm",
|
||||||
"waitress",
|
"waitress",
|
||||||
"WebHelpers2",
|
"WebHelpers2",
|
||||||
"WuttJamaican[db]>=0.12.1",
|
"WuttJamaican[db,email]>=0.13.0",
|
||||||
"zope.sqlalchemy>=1.5",
|
"zope.sqlalchemy>=1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -37,9 +37,10 @@ from wuttaweb.auth import WuttaSecurityPolicy
|
||||||
|
|
||||||
class WebAppProvider(AppProvider):
|
class WebAppProvider(AppProvider):
|
||||||
"""
|
"""
|
||||||
The :term:`app provider` for WuttaWeb. This adds some methods
|
The :term:`app provider` for WuttaWeb. This adds some methods to
|
||||||
specific to web apps.
|
the :term:`app handler`, which are specific to web apps.
|
||||||
"""
|
"""
|
||||||
|
email_templates = 'wuttaweb:email/templates'
|
||||||
|
|
||||||
def get_web_handler(self, **kwargs):
|
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()")
|
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):
|
class ObjectRef(colander.SchemaType):
|
||||||
"""
|
"""
|
||||||
Custom schema type for a model class reference field.
|
Custom schema type for a model class reference field.
|
||||||
|
@ -199,7 +246,7 @@ class ObjectRef(colander.SchemaType):
|
||||||
|
|
||||||
# fetch object from DB
|
# fetch object from DB
|
||||||
model = self.app.model
|
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
|
# raise error if not found
|
||||||
if not obj:
|
if not obj:
|
||||||
|
@ -247,14 +294,28 @@ class ObjectRef(colander.SchemaType):
|
||||||
kwargs['values'] = values
|
kwargs['values'] = values
|
||||||
|
|
||||||
if 'url' not in kwargs:
|
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)
|
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):
|
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`.
|
This is a subclass of :class:`ObjectRef`.
|
||||||
"""
|
"""
|
||||||
|
@ -269,26 +330,33 @@ class PersonRef(ObjectRef):
|
||||||
""" """
|
""" """
|
||||||
return query.order_by(self.model_class.full_name)
|
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
|
This is a subclass of :class:`ObjectRef`.
|
||||||
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):
|
@property
|
||||||
super().__init__()
|
def model_class(self):
|
||||||
self.request = request
|
""" """
|
||||||
self.config = self.request.wutta_config
|
model = self.app.model
|
||||||
self.app = self.config.get_app()
|
return model.User
|
||||||
self.session = session or Session()
|
|
||||||
|
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):
|
class RoleRefs(WuttaSet):
|
||||||
|
@ -388,3 +456,35 @@ class Permissions(WuttaSet):
|
||||||
kwargs['values'] = values
|
kwargs['values'] = values
|
||||||
|
|
||||||
return widgets.PermissionsWidget(self.request, **kwargs)
|
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`
|
* :class:`deform:deform.widget.MoneyInputWidget`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
|
import humanize
|
||||||
from deform.widget import (Widget, TextInputWidget, TextAreaWidget,
|
from deform.widget import (Widget, TextInputWidget, TextAreaWidget,
|
||||||
PasswordWidget, CheckedPasswordWidget,
|
PasswordWidget, CheckedPasswordWidget,
|
||||||
CheckboxWidget, SelectWidget, CheckboxChoiceWidget,
|
CheckboxWidget, SelectWidget, CheckboxChoiceWidget,
|
||||||
|
@ -147,6 +150,63 @@ class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
|
||||||
self.session = session or Session()
|
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):
|
class RoleRefsWidget(WuttaCheckboxChoiceWidget):
|
||||||
"""
|
"""
|
||||||
Widget for use with User
|
Widget for use with User
|
||||||
|
@ -184,7 +244,7 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget):
|
||||||
roles = []
|
roles = []
|
||||||
if cstruct:
|
if cstruct:
|
||||||
for uuid in cstruct:
|
for uuid in cstruct:
|
||||||
role = self.session.query(model.Role).get(uuid)
|
role = self.session.get(model.Role, uuid)
|
||||||
if role:
|
if role:
|
||||||
roles.append(role)
|
roles.append(role)
|
||||||
kw['roles'] = roles
|
kw['roles'] = roles
|
||||||
|
@ -228,6 +288,10 @@ class UserRefsWidget(WuttaCheckboxChoiceWidget):
|
||||||
users.append(dict([(key, getattr(user, key))
|
users.append(dict([(key, getattr(user, key))
|
||||||
for key in columns + ['uuid']]))
|
for key in columns + ['uuid']]))
|
||||||
|
|
||||||
|
# do not render if no data
|
||||||
|
if not users:
|
||||||
|
return HTML.tag('span')
|
||||||
|
|
||||||
# grid
|
# grid
|
||||||
grid = Grid(self.request, key='roles.view.users',
|
grid = Grid(self.request, key='roles.view.users',
|
||||||
columns=columns, data=users)
|
columns=columns, data=users)
|
||||||
|
|
|
@ -28,7 +28,7 @@ import functools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import warnings
|
import warnings
|
||||||
from collections import namedtuple
|
from collections import namedtuple, OrderedDict
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
@ -339,6 +339,16 @@ class Grid:
|
||||||
sorting.
|
sorting.
|
||||||
|
|
||||||
See :meth:`set_joiner()` for more info.
|
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__(
|
def __init__(
|
||||||
|
@ -369,6 +379,7 @@ class Grid:
|
||||||
filters=None,
|
filters=None,
|
||||||
filter_defaults=None,
|
filter_defaults=None,
|
||||||
joiners=None,
|
joiners=None,
|
||||||
|
tools=None,
|
||||||
):
|
):
|
||||||
self.request = request
|
self.request = request
|
||||||
self.vue_tagname = vue_tagname
|
self.vue_tagname = vue_tagname
|
||||||
|
@ -386,6 +397,7 @@ class Grid:
|
||||||
self.app = self.config.get_app()
|
self.app = self.config.get_app()
|
||||||
|
|
||||||
self.set_columns(columns or self.get_columns())
|
self.set_columns(columns or self.get_columns())
|
||||||
|
self.set_tools(tools)
|
||||||
|
|
||||||
# sorting
|
# sorting
|
||||||
self.sortable = sortable
|
self.sortable = sortable
|
||||||
|
@ -658,6 +670,33 @@ class Grid:
|
||||||
"""
|
"""
|
||||||
self.actions.append(GridAction(self.request, key, **kwargs))
|
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
|
# joining methods
|
||||||
##############################
|
##############################
|
||||||
|
@ -1078,6 +1117,7 @@ class Grid:
|
||||||
:returns: A :class:`~wuttaweb.grids.filters.GridFilter`
|
:returns: A :class:`~wuttaweb.grids.filters.GridFilter`
|
||||||
instance.
|
instance.
|
||||||
"""
|
"""
|
||||||
|
key = kwargs.pop('key', None)
|
||||||
|
|
||||||
# model_property is required
|
# model_property is required
|
||||||
model_property = None
|
model_property = None
|
||||||
|
@ -1102,7 +1142,7 @@ class Grid:
|
||||||
|
|
||||||
# make filter
|
# make filter
|
||||||
kwargs['model_property'] = model_property
|
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):
|
def set_filter(self, key, filterinfo=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -1132,6 +1172,7 @@ class Grid:
|
||||||
# filtr = filterinfo
|
# filtr = filterinfo
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
else:
|
else:
|
||||||
|
kwargs['key'] = key
|
||||||
kwargs.setdefault('label', self.get_label(key))
|
kwargs.setdefault('label', self.get_label(key))
|
||||||
filtr = self.make_filter(filterinfo or key, **kwargs)
|
filtr = self.make_filter(filterinfo or key, **kwargs)
|
||||||
|
|
||||||
|
|
|
@ -168,6 +168,11 @@ class MenuHandler(GenericHandler):
|
||||||
'route': 'settings',
|
'route': 'settings',
|
||||||
'perm': 'settings.list',
|
'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>
|
</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>
|
<h3 class="block is-size-3">Web Libraries</h3>
|
||||||
<div class="block" style="padding-left: 2rem;">
|
<div class="block" style="padding-left: 2rem;">
|
||||||
|
|
||||||
|
@ -219,6 +267,19 @@
|
||||||
this.editWebLibraryShowDialog = false
|
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>
|
</script>
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,10 @@
|
||||||
<span>${app.get_node_title()}</span>
|
<span>${app.get_node_title()}</span>
|
||||||
</b-field>
|
</b-field>
|
||||||
<b-field horizontal label="Production Mode">
|
<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>
|
</b-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -21,8 +21,8 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.wutta-logo img {
|
.wutta-logo img {
|
||||||
max-height: 350px;
|
max-height: 480px;
|
||||||
max-width: 800px;
|
max-width: 640px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</%def>
|
</%def>
|
||||||
|
|
|
@ -3,13 +3,7 @@
|
||||||
<%namespace file="/wutta-components.mako" import="make_wutta_components" />
|
<%namespace file="/wutta-components.mako" import="make_wutta_components" />
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
${self.html_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>
|
|
||||||
<body>
|
<body>
|
||||||
<div id="app" style="height: 100%;">
|
<div id="app" style="height: 100%;">
|
||||||
<whole-page />
|
<whole-page />
|
||||||
|
@ -30,7 +24,20 @@
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
|
## it also is used as default value for content_title() below
|
||||||
<%def name="title()"></%def>
|
<%def name="title()"></%def>
|
||||||
|
|
||||||
|
@ -39,9 +46,9 @@
|
||||||
<%def name="content_title()">${self.title()}</%def>
|
<%def name="content_title()">${self.title()}</%def>
|
||||||
|
|
||||||
<%def name="header_core()">
|
<%def name="header_core()">
|
||||||
${self.core_javascript()}
|
${self.base_javascript()}
|
||||||
${self.extra_javascript()}
|
${self.extra_javascript()}
|
||||||
${self.core_styles()}
|
${self.base_styles()}
|
||||||
${self.extra_styles()}
|
${self.extra_styles()}
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
@ -49,6 +56,10 @@
|
||||||
${self.vuejs()}
|
${self.vuejs()}
|
||||||
${self.buefy()}
|
${self.buefy()}
|
||||||
${self.fontawesome()}
|
${self.fontawesome()}
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
<%def name="base_javascript()">
|
||||||
|
${self.core_javascript()}
|
||||||
${self.hamburger_menu_js()}
|
${self.hamburger_menu_js()}
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
@ -99,7 +110,6 @@
|
||||||
|
|
||||||
<%def name="core_styles()">
|
<%def name="core_styles()">
|
||||||
${self.buefy_styles()}
|
${self.buefy_styles()}
|
||||||
${self.base_styles()}
|
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
<%def name="buefy_styles()">
|
<%def name="buefy_styles()">
|
||||||
|
@ -107,6 +117,7 @@
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
<%def name="base_styles()">
|
<%def name="base_styles()">
|
||||||
|
${self.core_styles()}
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
|
@ -194,16 +205,7 @@
|
||||||
|
|
||||||
<%def name="head_tags()"></%def>
|
<%def name="head_tags()"></%def>
|
||||||
|
|
||||||
<%def name="render_vue_template_whole_page()">
|
<%def name="whole_page_content()">
|
||||||
<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;">
|
|
||||||
|
|
||||||
## nb. the header-wrapper contains 2 elements:
|
## nb. the header-wrapper contains 2 elements:
|
||||||
## 1) header proper (menu + index title area)
|
## 1) header proper (menu + index title area)
|
||||||
## 2) page/content title area
|
## 2) page/content title area
|
||||||
|
@ -327,7 +329,18 @@
|
||||||
${base_meta.footer()}
|
${base_meta.footer()}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
</div>
|
||||||
</script>
|
</script>
|
||||||
</%def>
|
</%def>
|
||||||
|
@ -407,7 +420,153 @@
|
||||||
|
|
||||||
<%def name="render_theme_picker()"></%def>
|
<%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()">
|
<%def name="render_vue_script_whole_page()">
|
||||||
<script>
|
<script>
|
||||||
|
@ -418,7 +577,7 @@
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
for (let hook of this.mountedHooks) {
|
for (let hook of this.mountedHooks) {
|
||||||
hook(this)
|
hook.call(this)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -565,18 +724,33 @@
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
<%def name="render_vue_templates()">
|
<%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_template_whole_page()}
|
||||||
${self.render_vue_script_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>
|
||||||
|
|
||||||
<%def name="modify_vue_vars()"></%def>
|
<%def name="modify_vue_vars()"></%def>
|
||||||
|
|
||||||
<%def name="make_vue_components()">
|
<%def name="make_vue_components()">
|
||||||
${make_wutta_components()}
|
|
||||||
<script>
|
<script>
|
||||||
WholePage.data = function() { return WholePageData }
|
WholePage.data = function() { return WholePageData }
|
||||||
Vue.component('whole-page', WholePage)
|
Vue.component('whole-page', WholePage)
|
||||||
</script>
|
</script>
|
||||||
|
% if request.has_perm('common.feedback'):
|
||||||
|
<script>
|
||||||
|
WuttaFeedbackForm.data = function() { return WuttaFeedbackFormData }
|
||||||
|
Vue.component('wutta-feedback-form', WuttaFeedbackForm)
|
||||||
|
</script>
|
||||||
|
% endif
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
<%def name="make_vue_app()">
|
<%def name="make_vue_app()">
|
||||||
|
|
|
@ -167,7 +167,11 @@
|
||||||
for (let validator of this.validators) {
|
for (let validator of this.validators) {
|
||||||
let msg = validator.call(this)
|
let msg = validator.call(this)
|
||||||
if (msg) {
|
if (msg) {
|
||||||
alert(msg)
|
this.$buefy.toast.open({
|
||||||
|
message: msg,
|
||||||
|
type: 'is-warning',
|
||||||
|
duration: 4000, // 4 seconds
|
||||||
|
})
|
||||||
return
|
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>
|
</form>
|
||||||
% endif
|
% 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>
|
</div>
|
||||||
|
|
||||||
<${b}-table :data="data"
|
<${b}-table :data="data"
|
||||||
|
@ -290,6 +303,14 @@
|
||||||
template: '#${grid.vue_tagname}-template',
|
template: '#${grid.vue_tagname}-template',
|
||||||
computed: {
|
computed: {
|
||||||
|
|
||||||
|
recordCount() {
|
||||||
|
% if grid.paginated:
|
||||||
|
return this.pagerStats.item_count
|
||||||
|
% else:
|
||||||
|
return this.data.length
|
||||||
|
% endif
|
||||||
|
},
|
||||||
|
|
||||||
directLink() {
|
directLink() {
|
||||||
const params = new URLSearchParams(this.getAllParams())
|
const params = new URLSearchParams(this.getAllParams())
|
||||||
return `${request.path_url}?${'$'}{params}`
|
return `${request.path_url}?${'$'}{params}`
|
||||||
|
|
|
@ -21,8 +21,8 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.wutta-logo img {
|
.wutta-logo img {
|
||||||
max-height: 350px;
|
max-height: 480px;
|
||||||
max-width: 800px;
|
max-width: 640px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</%def>
|
</%def>
|
||||||
|
|
|
@ -23,6 +23,41 @@
|
||||||
% endif
|
% endif
|
||||||
</%def>
|
</%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()">
|
<%def name="make_vue_components()">
|
||||||
${parent.make_vue_components()}
|
${parent.make_vue_components()}
|
||||||
% if grid is not Undefined:
|
% 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()">
|
<%def name="make_wutta_components()">
|
||||||
|
${self.make_wutta_request_mixin()}
|
||||||
${self.make_wutta_button_component()}
|
${self.make_wutta_button_component()}
|
||||||
${self.make_wutta_filter_component()}
|
${self.make_wutta_filter_component()}
|
||||||
${self.make_wutta_filter_value_component()}
|
${self.make_wutta_filter_value_component()}
|
||||||
</%def>
|
</%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()">
|
<%def name="make_wutta_button_component()">
|
||||||
<script type="text/x-template" id="wutta-button-template">
|
<script type="text/x-template" id="wutta-button-template">
|
||||||
<b-button :type="type"
|
<b-button :type="type"
|
||||||
|
|
|
@ -83,6 +83,32 @@ class FieldList(list):
|
||||||
field, newfield)
|
field, newfield)
|
||||||
self.append(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):
|
def get_form_data(request):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -24,8 +24,11 @@
|
||||||
Base Logic for Views
|
Base Logic for Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from pyramid import httpexceptions
|
from pyramid import httpexceptions
|
||||||
from pyramid.renderers import render_to_response
|
from pyramid.renderers import render_to_response
|
||||||
|
from pyramid.response import FileResponse
|
||||||
|
|
||||||
from wuttaweb import forms, grids
|
from wuttaweb import forms, grids
|
||||||
|
|
||||||
|
@ -119,9 +122,46 @@ class View:
|
||||||
"""
|
"""
|
||||||
return httpexceptions.HTTPFound(location=url, **kwargs)
|
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):
|
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.
|
:param context: Context data to be rendered as JSON.
|
||||||
|
|
||||||
|
|
|
@ -24,13 +24,19 @@
|
||||||
Common Views
|
Common Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
|
from pyramid.renderers import render
|
||||||
|
|
||||||
from wuttaweb.views import View
|
from wuttaweb.views import View
|
||||||
from wuttaweb.forms import widgets
|
from wuttaweb.forms import widgets
|
||||||
from wuttaweb.db import Session
|
from wuttaweb.db import Session
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CommonView(View):
|
class CommonView(View):
|
||||||
"""
|
"""
|
||||||
Common views shared by all apps.
|
Common views shared by all apps.
|
||||||
|
@ -78,6 +84,58 @@ class CommonView(View):
|
||||||
"""
|
"""
|
||||||
return {'index_title': self.app.get_title()}
|
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):
|
def setup(self, session=None):
|
||||||
"""
|
"""
|
||||||
View for first-time app setup, to create admin user.
|
View for first-time app setup, to create admin user.
|
||||||
|
@ -154,6 +212,15 @@ class CommonView(View):
|
||||||
'settings.view',
|
'settings.view',
|
||||||
'settings.edit',
|
'settings.edit',
|
||||||
'settings.delete',
|
'settings.delete',
|
||||||
|
'settings.delete_bulk',
|
||||||
|
'upgrades.list',
|
||||||
|
'upgrades.create',
|
||||||
|
'upgrades.view',
|
||||||
|
'upgrades.edit',
|
||||||
|
'upgrades.delete',
|
||||||
|
'upgrades.execute',
|
||||||
|
'upgrades.download',
|
||||||
|
'upgrades.configure',
|
||||||
'users.list',
|
'users.list',
|
||||||
'users.create',
|
'users.create',
|
||||||
'users.view',
|
'users.view',
|
||||||
|
@ -194,6 +261,8 @@ class CommonView(View):
|
||||||
@classmethod
|
@classmethod
|
||||||
def _defaults(cls, config):
|
def _defaults(cls, config):
|
||||||
|
|
||||||
|
config.add_wutta_permission_group('common', "(common)", overwrite=False)
|
||||||
|
|
||||||
# home page
|
# home page
|
||||||
config.add_route('home', '/')
|
config.add_route('home', '/')
|
||||||
config.add_view(cls, attr='home',
|
config.add_view(cls, attr='home',
|
||||||
|
@ -210,6 +279,16 @@ class CommonView(View):
|
||||||
append_slash=True,
|
append_slash=True,
|
||||||
renderer='/notfound.mako')
|
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
|
# setup
|
||||||
config.add_route('setup', '/setup')
|
config.add_route('setup', '/setup')
|
||||||
config.add_view(cls, attr='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.auth`
|
||||||
* :mod:`wuttaweb.views.common`
|
* :mod:`wuttaweb.views.common`
|
||||||
* :mod:`wuttaweb.views.settings`
|
* :mod:`wuttaweb.views.settings`
|
||||||
|
* :mod:`wuttaweb.views.progress`
|
||||||
* :mod:`wuttaweb.views.people`
|
* :mod:`wuttaweb.views.people`
|
||||||
* :mod:`wuttaweb.views.roles`
|
* :mod:`wuttaweb.views.roles`
|
||||||
* :mod:`wuttaweb.views.users`
|
* :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.auth'))
|
||||||
config.include(mod('wuttaweb.views.common'))
|
config.include(mod('wuttaweb.views.common'))
|
||||||
config.include(mod('wuttaweb.views.settings'))
|
config.include(mod('wuttaweb.views.settings'))
|
||||||
|
config.include(mod('wuttaweb.views.progress'))
|
||||||
config.include(mod('wuttaweb.views.people'))
|
config.include(mod('wuttaweb.views.people'))
|
||||||
config.include(mod('wuttaweb.views.roles'))
|
config.include(mod('wuttaweb.views.roles'))
|
||||||
config.include(mod('wuttaweb.views.users'))
|
config.include(mod('wuttaweb.views.users'))
|
||||||
|
config.include(mod('wuttaweb.views.upgrades'))
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
def includeme(config):
|
||||||
|
|
|
@ -24,6 +24,10 @@
|
||||||
Base Logic for Master Views
|
Base Logic for Master Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
@ -31,11 +35,15 @@ from pyramid.renderers import render_to_response
|
||||||
from webhelpers2.html import HTML
|
from webhelpers2.html import HTML
|
||||||
|
|
||||||
from wuttaweb.views import View
|
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.db import Session
|
||||||
|
from wuttaweb.progress import SessionProgress
|
||||||
from wuttjamaican.util import get_class_hierarchy
|
from wuttjamaican.util import get_class_hierarchy
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MasterView(View):
|
class MasterView(View):
|
||||||
"""
|
"""
|
||||||
Base class for "master" views.
|
Base class for "master" views.
|
||||||
|
@ -68,7 +76,7 @@ class MasterView(View):
|
||||||
Optional reference to a data model class. While not strictly
|
Optional reference to a data model class. While not strictly
|
||||||
required, most views will set this to a SQLAlchemy mapped
|
required, most views will set this to a SQLAlchemy mapped
|
||||||
class,
|
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
|
Code should not access this directly but instead call
|
||||||
:meth:`get_model_class()`.
|
:meth:`get_model_class()`.
|
||||||
|
@ -284,6 +292,25 @@ class MasterView(View):
|
||||||
|
|
||||||
See also :meth:`is_deletable()`.
|
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
|
.. attribute:: form_fields
|
||||||
|
|
||||||
List of fields for the model form.
|
List of fields for the model form.
|
||||||
|
@ -296,6 +323,18 @@ class MasterView(View):
|
||||||
"autocomplete" - i.e. it should have an :meth:`autocomplete()`
|
"autocomplete" - i.e. it should have an :meth:`autocomplete()`
|
||||||
view. Default is ``False``.
|
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
|
.. attribute:: configurable
|
||||||
|
|
||||||
Boolean indicating whether the master view supports
|
Boolean indicating whether the master view supports
|
||||||
|
@ -321,7 +360,12 @@ class MasterView(View):
|
||||||
viewable = True
|
viewable = True
|
||||||
editable = True
|
editable = True
|
||||||
deletable = True
|
deletable = True
|
||||||
|
deletable_bulk = False
|
||||||
|
deletable_bulk_quick = False
|
||||||
has_autocomplete = False
|
has_autocomplete = False
|
||||||
|
downloadable = False
|
||||||
|
executable = False
|
||||||
|
execute_progress_template = None
|
||||||
configurable = False
|
configurable = False
|
||||||
|
|
||||||
# current action
|
# current action
|
||||||
|
@ -622,6 +666,120 @@ class MasterView(View):
|
||||||
session = self.app.get_session(obj)
|
session = self.app.get_session(obj)
|
||||||
session.delete(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
|
# autocomplete methods
|
||||||
##############################
|
##############################
|
||||||
|
@ -700,6 +858,166 @@ class MasterView(View):
|
||||||
'label': str(obj),
|
'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
|
# configure methods
|
||||||
##############################
|
##############################
|
||||||
|
@ -1040,6 +1358,56 @@ class MasterView(View):
|
||||||
fmt = f"${{:0,.{scale}f}}"
|
fmt = f"${{:0,.{scale}f}}"
|
||||||
return fmt.format(value)
|
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):
|
def grid_render_notes(self, record, key, value, maxlen=100):
|
||||||
"""
|
"""
|
||||||
Custom grid value renderer for "notes" fields.
|
Custom grid value renderer for "notes" fields.
|
||||||
|
@ -1118,6 +1486,97 @@ class MasterView(View):
|
||||||
return True
|
return True
|
||||||
return False
|
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):
|
def render_to_response(self, template, context):
|
||||||
"""
|
"""
|
||||||
Locate and render an appropriate template, with the given
|
Locate and render an appropriate template, with the given
|
||||||
|
@ -1328,6 +1787,14 @@ class MasterView(View):
|
||||||
|
|
||||||
kwargs['actions'] = actions
|
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'):
|
if hasattr(self, 'grid_row_class'):
|
||||||
kwargs.setdefault('row_class', self.grid_row_class)
|
kwargs.setdefault('row_class', self.grid_row_class)
|
||||||
kwargs.setdefault('filterable', self.filterable)
|
kwargs.setdefault('filterable', self.filterable)
|
||||||
|
@ -1421,23 +1888,69 @@ class MasterView(View):
|
||||||
# for key in self.get_model_key():
|
# for key in self.get_model_key():
|
||||||
# grid.set_link(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
|
This should return the appropriate model instance, based on
|
||||||
request details (e.g. route kwargs).
|
the ``matchdict`` of model keys.
|
||||||
|
|
||||||
If the instance cannot be found, this should raise a HTTP 404
|
Normally this is called with no arguments, in which case the
|
||||||
exception, i.e. :meth:`~wuttaweb.views.base.View.notfound()`.
|
: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*
|
If a ``matchdict`` is provided then that is used instead, to
|
||||||
override or else a ``NotImplementedError`` is raised.
|
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()
|
model_class = self.get_model_class()
|
||||||
if model_class:
|
if model_class:
|
||||||
session = session or self.Session()
|
session = session or self.Session()
|
||||||
|
matchdict = matchdict or self.request.matchdict
|
||||||
|
|
||||||
def filtr(query, model_key):
|
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)
|
query = query.filter(getattr(self.model_class, model_key) == key)
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
@ -1700,7 +2213,7 @@ class MasterView(View):
|
||||||
Returns the model class for the view (if defined).
|
Returns the model class for the view (if defined).
|
||||||
|
|
||||||
A model class will *usually* be a SQLAlchemy mapped class,
|
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
|
There is no default value here, but a subclass may override by
|
||||||
assigning :attr:`model_class`.
|
assigning :attr:`model_class`.
|
||||||
|
@ -2034,17 +2547,6 @@ class MasterView(View):
|
||||||
f'{permission_prefix}.create',
|
f'{permission_prefix}.create',
|
||||||
f"Create new {model_title}")
|
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
|
# edit
|
||||||
if cls.editable:
|
if cls.editable:
|
||||||
instance_url_prefix = cls.get_instance_url_prefix()
|
instance_url_prefix = cls.get_instance_url_prefix()
|
||||||
|
@ -2069,6 +2571,18 @@ class MasterView(View):
|
||||||
f'{permission_prefix}.delete',
|
f'{permission_prefix}.delete',
|
||||||
f"Delete {model_title}")
|
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
|
# autocomplete
|
||||||
if cls.has_autocomplete:
|
if cls.has_autocomplete:
|
||||||
config.add_route(f'{route_prefix}.autocomplete',
|
config.add_route(f'{route_prefix}.autocomplete',
|
||||||
|
@ -2078,6 +2592,29 @@ class MasterView(View):
|
||||||
renderer='json',
|
renderer='json',
|
||||||
permission=f'{route_prefix}.list')
|
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
|
# configure
|
||||||
if cls.configurable:
|
if cls.configurable:
|
||||||
config.add_route(f'{route_prefix}.configure',
|
config.add_route(f'{route_prefix}.configure',
|
||||||
|
@ -2088,3 +2625,16 @@ class MasterView(View):
|
||||||
config.add_wutta_permission(permission_prefix,
|
config.add_wutta_permission(permission_prefix,
|
||||||
f'{permission_prefix}.configure',
|
f'{permission_prefix}.configure',
|
||||||
f"Configure {model_title_plural}")
|
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 = [
|
simple_settings = [
|
||||||
|
|
||||||
# basics
|
# basics
|
||||||
{'name': f'{self.app.appname}.app_title'},
|
{'name': f'{self.config.appname}.app_title'},
|
||||||
{'name': f'{self.app.appname}.node_type'},
|
{'name': f'{self.config.appname}.node_type'},
|
||||||
{'name': f'{self.app.appname}.node_title'},
|
{'name': f'{self.config.appname}.node_title'},
|
||||||
{'name': f'{self.app.appname}.production',
|
{'name': f'{self.config.appname}.production',
|
||||||
'type': bool},
|
'type': bool},
|
||||||
|
|
||||||
# user/auth
|
# user/auth
|
||||||
{'name': 'wuttaweb.home_redirect_to_login',
|
{'name': 'wuttaweb.home_redirect_to_login',
|
||||||
'type': bool, 'default': False},
|
'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):
|
def getval(key):
|
||||||
|
@ -202,6 +211,7 @@ class SettingView(MasterView):
|
||||||
"""
|
"""
|
||||||
model_class = Setting
|
model_class = Setting
|
||||||
model_title = "Raw Setting"
|
model_title = "Raw Setting"
|
||||||
|
deletable_bulk = True
|
||||||
filter_defaults = {
|
filter_defaults = {
|
||||||
'name': {'active': True},
|
'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 wuttjamaican.conf import WuttaConfig
|
||||||
from wuttaweb.forms import schema as mod
|
from wuttaweb.forms import schema as mod
|
||||||
from wuttaweb.forms import widgets
|
from wuttaweb.forms import widgets
|
||||||
from tests.util import DataTestCase
|
from tests.util import DataTestCase, WebTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestObjectNode(DataTestCase):
|
class TestObjectNode(DataTestCase):
|
||||||
|
@ -47,6 +47,15 @@ class TestObjectNode(DataTestCase):
|
||||||
self.assertIs(value, person)
|
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):
|
class TestObjectRef(DataTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
@ -140,10 +149,17 @@ class TestObjectRef(DataTestCase):
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.assertIsNotNone(person.uuid)
|
self.assertIsNotNone(person.uuid)
|
||||||
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
|
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
|
||||||
|
|
||||||
|
# can specify as uuid
|
||||||
typ = mod.ObjectRef(self.request, session=self.session)
|
typ = mod.ObjectRef(self.request, session=self.session)
|
||||||
value = typ.objectify(person.uuid)
|
value = typ.objectify(person.uuid)
|
||||||
self.assertIs(value, person)
|
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
|
# error if not found
|
||||||
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
|
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
|
||||||
typ = mod.ObjectRef(self.request, session=self.session)
|
typ = mod.ObjectRef(self.request, session=self.session)
|
||||||
|
@ -186,11 +202,7 @@ class TestObjectRef(DataTestCase):
|
||||||
self.assertEqual(widget.values[1][1], "Betty Boop")
|
self.assertEqual(widget.values[1][1], "Betty Boop")
|
||||||
|
|
||||||
|
|
||||||
class TestPersonRef(DataTestCase):
|
class TestPersonRef(WebTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.setup_db()
|
|
||||||
self.request = testing.DummyRequest(wutta_config=self.config)
|
|
||||||
|
|
||||||
def test_sort_query(self):
|
def test_sort_query(self):
|
||||||
typ = mod.PersonRef(self.request, session=self.session)
|
typ = mod.PersonRef(self.request, session=self.session)
|
||||||
|
@ -200,6 +212,43 @@ class TestPersonRef(DataTestCase):
|
||||||
self.assertIsInstance(sorted_query, orm.Query)
|
self.assertIsInstance(sorted_query, orm.Query)
|
||||||
self.assertIsNot(sorted_query, 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):
|
class TestUserRefs(DataTestCase):
|
||||||
|
|
||||||
|
@ -267,3 +316,18 @@ class TestPermissions(DataTestCase):
|
||||||
widget = typ.widget_maker()
|
widget = typ.widget_maker()
|
||||||
self.assertEqual(len(widget.values), 1)
|
self.assertEqual(len(widget.values), 1)
|
||||||
self.assertEqual(widget.values[0], ('widgets.polish', "Polish the widgets"))
|
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 pyramid import testing
|
||||||
|
|
||||||
from wuttaweb.forms import widgets as mod
|
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
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
|
|
||||||
|
@ -52,6 +52,55 @@ class TestObjectRefWidget(WebTestCase):
|
||||||
self.assertIn('href="/foo"', html)
|
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):
|
class TestRoleRefsWidget(WebTestCase):
|
||||||
|
|
||||||
def make_field(self, node, **kwargs):
|
def make_field(self, node, **kwargs):
|
||||||
|
@ -113,7 +162,7 @@ class TestUserRefsWidget(WebTestCase):
|
||||||
|
|
||||||
# empty
|
# empty
|
||||||
html = widget.serialize(field, set(), readonly=True)
|
html = widget.serialize(field, set(), readonly=True)
|
||||||
self.assertIn('<b-table ', html)
|
self.assertEqual(html, '<span></span>')
|
||||||
|
|
||||||
# with data, no actions
|
# with data, no actions
|
||||||
user = model.User(username='barney')
|
user = model.User(username='barney')
|
||||||
|
|
|
@ -254,6 +254,42 @@ class TestGrid(WebTestCase):
|
||||||
self.assertEqual(len(grid.actions), 1)
|
self.assertEqual(len(grid.actions), 1)
|
||||||
self.assertIsInstance(grid.actions[0], mod.GridAction)
|
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):
|
def test_get_pagesize_options(self):
|
||||||
grid = self.make_grid()
|
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 pyramid import testing
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfig
|
from wuttjamaican.conf import WuttaConfig
|
||||||
from wuttaweb import util
|
from wuttaweb import util as mod
|
||||||
|
|
||||||
|
|
||||||
class TestFieldList(TestCase):
|
class TestFieldList(TestCase):
|
||||||
|
|
||||||
def test_insert_before(self):
|
def test_insert_before(self):
|
||||||
fields = util.FieldList(['f1', 'f2'])
|
fields = mod.FieldList(['f1', 'f2'])
|
||||||
self.assertEqual(fields, ['f1', 'f2'])
|
self.assertEqual(fields, ['f1', 'f2'])
|
||||||
|
|
||||||
# typical
|
# typical
|
||||||
|
@ -29,7 +29,7 @@ class TestFieldList(TestCase):
|
||||||
self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ'])
|
self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ'])
|
||||||
|
|
||||||
def test_insert_after(self):
|
def test_insert_after(self):
|
||||||
fields = util.FieldList(['f1', 'f2'])
|
fields = mod.FieldList(['f1', 'f2'])
|
||||||
self.assertEqual(fields, ['f1', 'f2'])
|
self.assertEqual(fields, ['f1', 'f2'])
|
||||||
|
|
||||||
# typical
|
# typical
|
||||||
|
@ -42,6 +42,14 @@ class TestFieldList(TestCase):
|
||||||
fields.insert_after('f3', 'ZZZ')
|
fields.insert_after('f3', 'ZZZ')
|
||||||
self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2', '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):
|
class TestGetLibVer(TestCase):
|
||||||
|
|
||||||
|
@ -51,153 +59,153 @@ class TestGetLibVer(TestCase):
|
||||||
self.request.wutta_config = self.config
|
self.request.wutta_config = self.config
|
||||||
|
|
||||||
def test_buefy_default(self):
|
def test_buefy_default(self):
|
||||||
version = util.get_libver(self.request, 'buefy')
|
version = mod.get_libver(self.request, 'buefy')
|
||||||
self.assertEqual(version, 'latest')
|
self.assertEqual(version, 'latest')
|
||||||
|
|
||||||
def test_buefy_custom_old(self):
|
def test_buefy_custom_old(self):
|
||||||
self.config.setdefault('wuttaweb.buefy_version', '0.9.29')
|
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')
|
self.assertEqual(version, '0.9.29')
|
||||||
|
|
||||||
def test_buefy_custom_old_tailbone(self):
|
def test_buefy_custom_old_tailbone(self):
|
||||||
self.config.setdefault('tailbone.libver.buefy', '0.9.28')
|
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')
|
self.assertEqual(version, '0.9.28')
|
||||||
|
|
||||||
def test_buefy_custom_new(self):
|
def test_buefy_custom_new(self):
|
||||||
self.config.setdefault('wuttaweb.libver.buefy', '0.9.29')
|
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')
|
self.assertEqual(version, '0.9.29')
|
||||||
|
|
||||||
def test_buefy_configured_only(self):
|
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)
|
self.assertIsNone(version)
|
||||||
|
|
||||||
def test_buefy_default_only(self):
|
def test_buefy_default_only(self):
|
||||||
self.config.setdefault('wuttaweb.libver.buefy', '0.9.29')
|
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')
|
self.assertEqual(version, 'latest')
|
||||||
|
|
||||||
def test_buefy_css_default(self):
|
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')
|
self.assertEqual(version, 'latest')
|
||||||
|
|
||||||
def test_buefy_css_custom_old(self):
|
def test_buefy_css_custom_old(self):
|
||||||
# nb. this uses same setting as buefy (js)
|
# nb. this uses same setting as buefy (js)
|
||||||
self.config.setdefault('wuttaweb.buefy_version', '0.9.29')
|
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')
|
self.assertEqual(version, '0.9.29')
|
||||||
|
|
||||||
def test_buefy_css_custom_new(self):
|
def test_buefy_css_custom_new(self):
|
||||||
# nb. this uses same setting as buefy (js)
|
# nb. this uses same setting as buefy (js)
|
||||||
self.config.setdefault('wuttaweb.libver.buefy', '0.9.29')
|
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')
|
self.assertEqual(version, '0.9.29')
|
||||||
|
|
||||||
def test_buefy_css_configured_only(self):
|
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)
|
self.assertIsNone(version)
|
||||||
|
|
||||||
def test_buefy_css_default_only(self):
|
def test_buefy_css_default_only(self):
|
||||||
self.config.setdefault('wuttaweb.libver.buefy', '0.9.29')
|
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')
|
self.assertEqual(version, 'latest')
|
||||||
|
|
||||||
def test_vue_default(self):
|
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')
|
self.assertEqual(version, '2.6.14')
|
||||||
|
|
||||||
def test_vue_custom_old(self):
|
def test_vue_custom_old(self):
|
||||||
self.config.setdefault('wuttaweb.vue_version', '3.4.31')
|
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')
|
self.assertEqual(version, '3.4.31')
|
||||||
|
|
||||||
def test_vue_custom_new(self):
|
def test_vue_custom_new(self):
|
||||||
self.config.setdefault('wuttaweb.libver.vue', '3.4.31')
|
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')
|
self.assertEqual(version, '3.4.31')
|
||||||
|
|
||||||
def test_vue_configured_only(self):
|
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)
|
self.assertIsNone(version)
|
||||||
|
|
||||||
def test_vue_default_only(self):
|
def test_vue_default_only(self):
|
||||||
self.config.setdefault('wuttaweb.libver.vue', '3.4.31')
|
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')
|
self.assertEqual(version, '2.6.14')
|
||||||
|
|
||||||
def test_vue_resource_default(self):
|
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')
|
self.assertEqual(version, 'latest')
|
||||||
|
|
||||||
def test_vue_resource_custom(self):
|
def test_vue_resource_custom(self):
|
||||||
self.config.setdefault('wuttaweb.libver.vue_resource', '1.5.3')
|
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')
|
self.assertEqual(version, '1.5.3')
|
||||||
|
|
||||||
def test_fontawesome_default(self):
|
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')
|
self.assertEqual(version, '5.3.1')
|
||||||
|
|
||||||
def test_fontawesome_custom(self):
|
def test_fontawesome_custom(self):
|
||||||
self.config.setdefault('wuttaweb.libver.fontawesome', '5.6.3')
|
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')
|
self.assertEqual(version, '5.6.3')
|
||||||
|
|
||||||
def test_bb_vue_default(self):
|
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')
|
self.assertEqual(version, '3.4.31')
|
||||||
|
|
||||||
def test_bb_vue_custom(self):
|
def test_bb_vue_custom(self):
|
||||||
self.config.setdefault('wuttaweb.libver.bb_vue', '3.4.30')
|
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')
|
self.assertEqual(version, '3.4.30')
|
||||||
|
|
||||||
def test_bb_oruga_default(self):
|
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')
|
self.assertEqual(version, '0.8.12')
|
||||||
|
|
||||||
def test_bb_oruga_custom(self):
|
def test_bb_oruga_custom(self):
|
||||||
self.config.setdefault('wuttaweb.libver.bb_oruga', '0.8.11')
|
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')
|
self.assertEqual(version, '0.8.11')
|
||||||
|
|
||||||
def test_bb_oruga_bulma_default(self):
|
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')
|
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')
|
self.assertEqual(version, '0.3.0')
|
||||||
|
|
||||||
def test_bb_oruga_bulma_custom(self):
|
def test_bb_oruga_bulma_custom(self):
|
||||||
self.config.setdefault('wuttaweb.libver.bb_oruga_bulma', '0.2.11')
|
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')
|
self.assertEqual(version, '0.2.11')
|
||||||
|
|
||||||
def test_bb_fontawesome_svg_core_default(self):
|
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')
|
self.assertEqual(version, '6.5.2')
|
||||||
|
|
||||||
def test_bb_fontawesome_svg_core_custom(self):
|
def test_bb_fontawesome_svg_core_custom(self):
|
||||||
self.config.setdefault('wuttaweb.libver.bb_fontawesome_svg_core', '6.5.1')
|
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')
|
self.assertEqual(version, '6.5.1')
|
||||||
|
|
||||||
def test_bb_free_solid_svg_icons_default(self):
|
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')
|
self.assertEqual(version, '6.5.2')
|
||||||
|
|
||||||
def test_bb_free_solid_svg_icons_custom(self):
|
def test_bb_free_solid_svg_icons_custom(self):
|
||||||
self.config.setdefault('wuttaweb.libver.bb_free_solid_svg_icons', '6.5.1')
|
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')
|
self.assertEqual(version, '6.5.1')
|
||||||
|
|
||||||
def test_bb_vue_fontawesome_default(self):
|
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')
|
self.assertEqual(version, '3.0.6')
|
||||||
|
|
||||||
def test_bb_vue_fontawesome_custom(self):
|
def test_bb_vue_fontawesome_custom(self):
|
||||||
self.config.setdefault('wuttaweb.libver.bb_vue_fontawesome', '3.0.8')
|
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')
|
self.assertEqual(version, '3.0.8')
|
||||||
|
|
||||||
|
|
||||||
|
@ -238,191 +246,191 @@ class TestGetLibUrl(TestCase):
|
||||||
self.request.script_name = '/wutta'
|
self.request.script_name = '/wutta'
|
||||||
|
|
||||||
def test_buefy_default(self):
|
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')
|
self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.js')
|
||||||
|
|
||||||
def test_buefy_custom(self):
|
def test_buefy_custom(self):
|
||||||
self.config.setdefault('wuttaweb.liburl.buefy', '/lib/buefy.js')
|
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')
|
self.assertEqual(url, '/lib/buefy.js')
|
||||||
|
|
||||||
def test_buefy_custom_tailbone(self):
|
def test_buefy_custom_tailbone(self):
|
||||||
self.config.setdefault('tailbone.liburl.buefy', '/tailbone/buefy.js')
|
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')
|
self.assertEqual(url, '/tailbone/buefy.js')
|
||||||
|
|
||||||
def test_buefy_default_only(self):
|
def test_buefy_default_only(self):
|
||||||
self.config.setdefault('wuttaweb.liburl.buefy', '/lib/buefy.js')
|
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')
|
self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.js')
|
||||||
|
|
||||||
def test_buefy_configured_only(self):
|
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)
|
self.assertIsNone(url)
|
||||||
|
|
||||||
def test_buefy_fanstatic(self):
|
def test_buefy_fanstatic(self):
|
||||||
self.setup_fanstatic()
|
self.setup_fanstatic()
|
||||||
url = util.get_liburl(self.request, 'buefy')
|
url = mod.get_liburl(self.request, 'buefy')
|
||||||
self.assertEqual(url, '/wutta/fanstatic/buefy.js')
|
self.assertEqual(url, '/wutta/fanstatic/buefy.js')
|
||||||
|
|
||||||
def test_buefy_fanstatic_tailbone(self):
|
def test_buefy_fanstatic_tailbone(self):
|
||||||
self.setup_fanstatic(register=False)
|
self.setup_fanstatic(register=False)
|
||||||
self.config.setdefault('tailbone.static_libcache.module', 'tests.test_util')
|
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')
|
self.assertEqual(url, '/wutta/fanstatic/buefy.js')
|
||||||
|
|
||||||
def test_buefy_css_default(self):
|
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')
|
self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.css')
|
||||||
|
|
||||||
def test_buefy_css_custom(self):
|
def test_buefy_css_custom(self):
|
||||||
self.config.setdefault('wuttaweb.liburl.buefy.css', '/lib/buefy.css')
|
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')
|
self.assertEqual(url, '/lib/buefy.css')
|
||||||
|
|
||||||
def test_buefy_css_fanstatic(self):
|
def test_buefy_css_fanstatic(self):
|
||||||
self.setup_fanstatic()
|
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')
|
self.assertEqual(url, '/wutta/fanstatic/buefy.css')
|
||||||
|
|
||||||
def test_vue_default(self):
|
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')
|
self.assertEqual(url, 'https://unpkg.com/vue@2.6.14/dist/vue.min.js')
|
||||||
|
|
||||||
def test_vue_custom(self):
|
def test_vue_custom(self):
|
||||||
self.config.setdefault('wuttaweb.liburl.vue', '/lib/vue.js')
|
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')
|
self.assertEqual(url, '/lib/vue.js')
|
||||||
|
|
||||||
def test_vue_fanstatic(self):
|
def test_vue_fanstatic(self):
|
||||||
self.setup_fanstatic()
|
self.setup_fanstatic()
|
||||||
url = util.get_liburl(self.request, 'vue')
|
url = mod.get_liburl(self.request, 'vue')
|
||||||
self.assertEqual(url, '/wutta/fanstatic/vue.js')
|
self.assertEqual(url, '/wutta/fanstatic/vue.js')
|
||||||
|
|
||||||
def test_vue_resource_default(self):
|
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')
|
self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/vue-resource@latest')
|
||||||
|
|
||||||
def test_vue_resource_custom(self):
|
def test_vue_resource_custom(self):
|
||||||
self.config.setdefault('wuttaweb.liburl.vue_resource', '/lib/vue-resource.js')
|
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')
|
self.assertEqual(url, '/lib/vue-resource.js')
|
||||||
|
|
||||||
def test_vue_resource_fanstatic(self):
|
def test_vue_resource_fanstatic(self):
|
||||||
self.setup_fanstatic()
|
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')
|
self.assertEqual(url, '/wutta/fanstatic/vue_resource.js')
|
||||||
|
|
||||||
def test_fontawesome_default(self):
|
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')
|
self.assertEqual(url, 'https://use.fontawesome.com/releases/v5.3.1/js/all.js')
|
||||||
|
|
||||||
def test_fontawesome_custom(self):
|
def test_fontawesome_custom(self):
|
||||||
self.config.setdefault('wuttaweb.liburl.fontawesome', '/lib/fontawesome.js')
|
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')
|
self.assertEqual(url, '/lib/fontawesome.js')
|
||||||
|
|
||||||
def test_fontawesome_fanstatic(self):
|
def test_fontawesome_fanstatic(self):
|
||||||
self.setup_fanstatic()
|
self.setup_fanstatic()
|
||||||
url = util.get_liburl(self.request, 'fontawesome')
|
url = mod.get_liburl(self.request, 'fontawesome')
|
||||||
self.assertEqual(url, '/wutta/fanstatic/fontawesome.js')
|
self.assertEqual(url, '/wutta/fanstatic/fontawesome.js')
|
||||||
|
|
||||||
def test_bb_vue_default(self):
|
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')
|
self.assertEqual(url, 'https://unpkg.com/vue@3.4.31/dist/vue.esm-browser.prod.js')
|
||||||
|
|
||||||
def test_bb_vue_custom(self):
|
def test_bb_vue_custom(self):
|
||||||
self.config.setdefault('wuttaweb.liburl.bb_vue', '/lib/vue.js')
|
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')
|
self.assertEqual(url, '/lib/vue.js')
|
||||||
|
|
||||||
def test_bb_vue_fanstatic(self):
|
def test_bb_vue_fanstatic(self):
|
||||||
self.setup_fanstatic()
|
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')
|
self.assertEqual(url, '/wutta/fanstatic/bb_vue.js')
|
||||||
|
|
||||||
def test_bb_oruga_default(self):
|
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')
|
self.assertEqual(url, 'https://unpkg.com/@oruga-ui/oruga-next@0.8.12/dist/oruga.mjs')
|
||||||
|
|
||||||
def test_bb_oruga_custom(self):
|
def test_bb_oruga_custom(self):
|
||||||
self.config.setdefault('wuttaweb.liburl.bb_oruga', '/lib/oruga.js')
|
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')
|
self.assertEqual(url, '/lib/oruga.js')
|
||||||
|
|
||||||
def test_bb_oruga_fanstatic(self):
|
def test_bb_oruga_fanstatic(self):
|
||||||
self.setup_fanstatic()
|
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')
|
self.assertEqual(url, '/wutta/fanstatic/bb_oruga.js')
|
||||||
|
|
||||||
def test_bb_oruga_bulma_default(self):
|
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')
|
self.assertEqual(url, 'https://unpkg.com/@oruga-ui/theme-bulma@0.3.0/dist/bulma.mjs')
|
||||||
|
|
||||||
def test_bb_oruga_bulma_custom(self):
|
def test_bb_oruga_bulma_custom(self):
|
||||||
self.config.setdefault('wuttaweb.liburl.bb_oruga_bulma', '/lib/oruga_bulma.js')
|
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')
|
self.assertEqual(url, '/lib/oruga_bulma.js')
|
||||||
|
|
||||||
def test_bb_oruga_bulma_fanstatic(self):
|
def test_bb_oruga_bulma_fanstatic(self):
|
||||||
self.setup_fanstatic()
|
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')
|
self.assertEqual(url, '/wutta/fanstatic/bb_oruga_bulma.js')
|
||||||
|
|
||||||
def test_bb_oruga_bulma_css_default(self):
|
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')
|
self.assertEqual(url, 'https://unpkg.com/@oruga-ui/theme-bulma@0.3.0/dist/bulma.css')
|
||||||
|
|
||||||
def test_bb_oruga_bulma_css_custom(self):
|
def test_bb_oruga_bulma_css_custom(self):
|
||||||
self.config.setdefault('wuttaweb.liburl.bb_oruga_bulma_css', '/lib/oruga-bulma.css')
|
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')
|
self.assertEqual(url, '/lib/oruga-bulma.css')
|
||||||
|
|
||||||
def test_bb_oruga_bulma_css_fanstatic(self):
|
def test_bb_oruga_bulma_css_fanstatic(self):
|
||||||
self.setup_fanstatic()
|
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')
|
self.assertEqual(url, '/wutta/fanstatic/bb_oruga_bulma.css')
|
||||||
|
|
||||||
def test_bb_fontawesome_svg_core_default(self):
|
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')
|
self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@6.5.2/+esm')
|
||||||
|
|
||||||
def test_bb_fontawesome_svg_core_custom(self):
|
def test_bb_fontawesome_svg_core_custom(self):
|
||||||
self.config.setdefault('wuttaweb.liburl.bb_fontawesome_svg_core', '/lib/fontawesome-svg-core.js')
|
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')
|
self.assertEqual(url, '/lib/fontawesome-svg-core.js')
|
||||||
|
|
||||||
def test_bb_fontawesome_svg_core_fanstatic(self):
|
def test_bb_fontawesome_svg_core_fanstatic(self):
|
||||||
self.setup_fanstatic()
|
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')
|
self.assertEqual(url, '/wutta/fanstatic/bb_fontawesome_svg_core.js')
|
||||||
|
|
||||||
def test_bb_free_solid_svg_icons_default(self):
|
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')
|
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):
|
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')
|
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')
|
self.assertEqual(url, '/lib/free-solid-svg-icons.js')
|
||||||
|
|
||||||
def test_bb_free_solid_svg_icons_fanstatic(self):
|
def test_bb_free_solid_svg_icons_fanstatic(self):
|
||||||
self.setup_fanstatic()
|
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')
|
self.assertEqual(url, '/wutta/fanstatic/bb_free_solid_svg_icons.js')
|
||||||
|
|
||||||
def test_bb_vue_fontawesome_default(self):
|
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')
|
self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@3.0.6/+esm')
|
||||||
|
|
||||||
def test_bb_vue_fontawesome_custom(self):
|
def test_bb_vue_fontawesome_custom(self):
|
||||||
self.config.setdefault('wuttaweb.liburl.bb_vue_fontawesome', '/lib/vue-fontawesome.js')
|
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')
|
self.assertEqual(url, '/lib/vue-fontawesome.js')
|
||||||
|
|
||||||
def test_bb_vue_fontawesome_fanstatic(self):
|
def test_bb_vue_fontawesome_fanstatic(self):
|
||||||
self.setup_fanstatic()
|
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')
|
self.assertEqual(url, '/wutta/fanstatic/bb_vue_fontawesome.js')
|
||||||
|
|
||||||
|
|
||||||
|
@ -439,17 +447,17 @@ class TestGetFormData(TestCase):
|
||||||
|
|
||||||
def test_default(self):
|
def test_default(self):
|
||||||
request = self.make_request()
|
request = self.make_request()
|
||||||
data = util.get_form_data(request)
|
data = mod.get_form_data(request)
|
||||||
self.assertEqual(data, {'foo1': 'bar'})
|
self.assertEqual(data, {'foo1': 'bar'})
|
||||||
|
|
||||||
def test_is_xhr(self):
|
def test_is_xhr(self):
|
||||||
request = self.make_request(POST=None, is_xhr=True)
|
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'})
|
self.assertEqual(data, {'foo2': 'baz'})
|
||||||
|
|
||||||
def test_content_type(self):
|
def test_content_type(self):
|
||||||
request = self.make_request(POST=None, content_type='application/json')
|
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'})
|
self.assertEqual(data, {'foo2': 'baz'})
|
||||||
|
|
||||||
|
|
||||||
|
@ -460,16 +468,16 @@ class TestGetModelFields(TestCase):
|
||||||
self.app = self.config.get_app()
|
self.app = self.config.get_app()
|
||||||
|
|
||||||
def test_empty_model_class(self):
|
def test_empty_model_class(self):
|
||||||
fields = util.get_model_fields(self.config)
|
fields = mod.get_model_fields(self.config)
|
||||||
self.assertIsNone(fields)
|
self.assertIsNone(fields)
|
||||||
|
|
||||||
def test_unknown_model_class(self):
|
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)
|
self.assertIsNone(fields)
|
||||||
|
|
||||||
def test_basic(self):
|
def test_basic(self):
|
||||||
model = self.app.model
|
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'])
|
self.assertEqual(fields, ['name', 'value'])
|
||||||
|
|
||||||
|
|
||||||
|
@ -484,9 +492,9 @@ class TestGetCsrfToken(TestCase):
|
||||||
# same token returned for same request
|
# same token returned for same request
|
||||||
# TODO: dummy request is always returning same token!
|
# TODO: dummy request is always returning same token!
|
||||||
# so this isn't really testing anything.. :(
|
# so this isn't really testing anything.. :(
|
||||||
first = util.get_csrf_token(self.request)
|
first = mod.get_csrf_token(self.request)
|
||||||
self.assertIsNotNone(first)
|
self.assertIsNotNone(first)
|
||||||
second = util.get_csrf_token(self.request)
|
second = mod.get_csrf_token(self.request)
|
||||||
self.assertEqual(first, second)
|
self.assertEqual(first, second)
|
||||||
|
|
||||||
# TODO: ideally would make a new request here and confirm it
|
# 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
|
# nb. dummy request always returns same token, so must
|
||||||
# trick it into thinking it doesn't have one yet
|
# trick it into thinking it doesn't have one yet
|
||||||
with patch.object(self.request.session, 'get_csrf_token', return_value=None):
|
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)
|
self.assertIsNotNone(token)
|
||||||
|
|
||||||
|
|
||||||
|
@ -508,10 +516,10 @@ class TestRenderCsrfToken(TestCase):
|
||||||
self.request = testing.DummyRequest(wutta_config=self.config)
|
self.request = testing.DummyRequest(wutta_config=self.config)
|
||||||
|
|
||||||
def test_basics(self):
|
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('type="hidden"', html)
|
||||||
self.assertIn('name="_csrf"', 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)
|
self.assertIn(f'value="{token}"', html)
|
||||||
|
|
||||||
|
|
||||||
|
@ -522,17 +530,17 @@ class TestMakeJsonSafe(TestCase):
|
||||||
self.app = self.config.get_app()
|
self.app = self.config.get_app()
|
||||||
|
|
||||||
def test_null(self):
|
def test_null(self):
|
||||||
value = util.make_json_safe(colander.null)
|
value = mod.make_json_safe(colander.null)
|
||||||
self.assertIsNone(value)
|
self.assertIsNone(value)
|
||||||
|
|
||||||
value = util.make_json_safe(None)
|
value = mod.make_json_safe(None)
|
||||||
self.assertIsNone(value)
|
self.assertIsNone(value)
|
||||||
|
|
||||||
def test_invalid(self):
|
def test_invalid(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
person = model.Person(full_name="Betty Boop")
|
person = model.Person(full_name="Betty Boop")
|
||||||
self.assertRaises(TypeError, json.dumps, person)
|
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")
|
self.assertEqual(value, "Betty Boop")
|
||||||
|
|
||||||
def test_dict(self):
|
def test_dict(self):
|
||||||
|
@ -545,7 +553,7 @@ class TestMakeJsonSafe(TestCase):
|
||||||
}
|
}
|
||||||
|
|
||||||
self.assertRaises(TypeError, json.dumps, data)
|
self.assertRaises(TypeError, json.dumps, data)
|
||||||
value = util.make_json_safe(data)
|
value = mod.make_json_safe(data)
|
||||||
self.assertEqual(value, {
|
self.assertEqual(value, {
|
||||||
'foo': 'bar',
|
'foo': 'bar',
|
||||||
'person': "Betty Boop",
|
'person': "Betty Boop",
|
||||||
|
|
|
@ -6,11 +6,12 @@ from unittest.mock import MagicMock
|
||||||
from pyramid import testing
|
from pyramid import testing
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfig
|
from wuttjamaican.conf import WuttaConfig
|
||||||
|
from wuttjamaican.testing import FileConfigTestCase
|
||||||
from wuttaweb import subscribers
|
from wuttaweb import subscribers
|
||||||
from wuttaweb.menus import MenuHandler
|
from wuttaweb.menus import MenuHandler
|
||||||
|
|
||||||
|
|
||||||
class DataTestCase(TestCase):
|
class DataTestCase(FileConfigTestCase):
|
||||||
"""
|
"""
|
||||||
Base class for test suites requiring a full (typical) database.
|
Base class for test suites requiring a full (typical) database.
|
||||||
"""
|
"""
|
||||||
|
@ -19,6 +20,7 @@ class DataTestCase(TestCase):
|
||||||
self.setup_db()
|
self.setup_db()
|
||||||
|
|
||||||
def setup_db(self):
|
def setup_db(self):
|
||||||
|
self.setup_files()
|
||||||
self.config = WuttaConfig(defaults={
|
self.config = WuttaConfig(defaults={
|
||||||
'wutta.db.default.url': 'sqlite://',
|
'wutta.db.default.url': 'sqlite://',
|
||||||
})
|
})
|
||||||
|
@ -33,7 +35,7 @@ class DataTestCase(TestCase):
|
||||||
self.teardown_db()
|
self.teardown_db()
|
||||||
|
|
||||||
def teardown_db(self):
|
def teardown_db(self):
|
||||||
pass
|
self.teardown_files()
|
||||||
|
|
||||||
|
|
||||||
class WebTestCase(DataTestCase):
|
class WebTestCase(DataTestCase):
|
||||||
|
|
|
@ -50,6 +50,26 @@ class TestView(WebTestCase):
|
||||||
self.assertIsInstance(error, HTTPFound)
|
self.assertIsInstance(error, HTTPFound)
|
||||||
self.assertEqual(error.location, '/')
|
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):
|
def test_json_response(self):
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
response = view.json_response({'foo': 'bar'})
|
response = view.json_response({'foo': 'bar'})
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import colander
|
||||||
|
|
||||||
from wuttaweb.views import common as mod
|
from wuttaweb.views import common as mod
|
||||||
from tests.util import WebTestCase
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
|
@ -51,6 +55,78 @@ class TestCommonView(WebTestCase):
|
||||||
context = view.home(session=self.session)
|
context = view.home(session=self.session)
|
||||||
self.assertEqual(context['index_title'], self.app.get_title())
|
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):
|
def test_setup(self):
|
||||||
self.pyramid_config.add_route('home', '/')
|
self.pyramid_config.add_route('home', '/')
|
||||||
self.pyramid_config.add_route('login', '/login')
|
self.pyramid_config.add_route('login', '/login')
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
import datetime
|
||||||
import decimal
|
import decimal
|
||||||
import functools
|
import functools
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
@ -13,6 +14,7 @@ from pyramid.httpexceptions import HTTPNotFound
|
||||||
from wuttjamaican.conf import WuttaConfig
|
from wuttjamaican.conf import WuttaConfig
|
||||||
from wuttaweb.views import master as mod
|
from wuttaweb.views import master as mod
|
||||||
from wuttaweb.views import View
|
from wuttaweb.views import View
|
||||||
|
from wuttaweb.progress import SessionProgress
|
||||||
from wuttaweb.subscribers import new_request_set_user
|
from wuttaweb.subscribers import new_request_set_user
|
||||||
from tests.util import WebTestCase
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
|
@ -26,7 +28,10 @@ class TestMasterView(WebTestCase):
|
||||||
with patch.multiple(mod.MasterView, create=True,
|
with patch.multiple(mod.MasterView, create=True,
|
||||||
model_name='Widget',
|
model_name='Widget',
|
||||||
model_key='uuid',
|
model_key='uuid',
|
||||||
|
deletable_bulk=True,
|
||||||
has_autocomplete=True,
|
has_autocomplete=True,
|
||||||
|
downloadable=True,
|
||||||
|
executable=True,
|
||||||
configurable=True):
|
configurable=True):
|
||||||
mod.MasterView.defaults(self.pyramid_config)
|
mod.MasterView.defaults(self.pyramid_config)
|
||||||
|
|
||||||
|
@ -399,6 +404,49 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertTrue(view.has_any_perm('list', 'view'))
|
self.assertTrue(view.has_any_perm('list', 'view'))
|
||||||
self.assertTrue(self.request.has_any_perm('settings.list', 'settings.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):
|
def test_render_to_response(self):
|
||||||
self.pyramid_config.include('wuttaweb.views.common')
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
self.pyramid_config.include('wuttaweb.views.auth')
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
|
@ -472,6 +520,7 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertEqual(grid.labels, {'name': "SETTING NAME"})
|
self.assertEqual(grid.labels, {'name': "SETTING NAME"})
|
||||||
|
|
||||||
def test_make_model_grid(self):
|
def test_make_model_grid(self):
|
||||||
|
self.pyramid_config.add_route('settings.delete_bulk', '/settings/delete-bulk')
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
|
||||||
# no model class
|
# no model class
|
||||||
|
@ -524,6 +573,20 @@ class TestMasterView(WebTestCase):
|
||||||
grid = view.make_model_grid(session=self.session)
|
grid = view.make_model_grid(session=self.session)
|
||||||
self.assertEqual(len(grid.actions), 3)
|
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):
|
def test_get_grid_data(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
self.app.save_setting(self.session, 'foo', 'bar')
|
self.app.save_setting(self.session, 'foo', 'bar')
|
||||||
|
@ -579,7 +642,6 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertEqual(value, "No")
|
self.assertEqual(value, "No")
|
||||||
|
|
||||||
def test_grid_render_currency(self):
|
def test_grid_render_currency(self):
|
||||||
model = self.app.model
|
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
obj = {'amount': None}
|
obj = {'amount': None}
|
||||||
|
|
||||||
|
@ -597,6 +659,33 @@ class TestMasterView(WebTestCase):
|
||||||
value = view.grid_render_currency(obj, 'amount', '-100.42')
|
value = view.grid_render_currency(obj, 'amount', '-100.42')
|
||||||
self.assertEqual(value, "($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):
|
def test_grid_render_notes(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
|
@ -1026,6 +1115,162 @@ class TestMasterView(WebTestCase):
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.assertEqual(self.session.query(model.Setting).count(), 0)
|
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):
|
def test_autocomplete(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
|
||||||
|
@ -1067,6 +1312,98 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertEqual(normal, {'value': 'bogus',
|
self.assertEqual(normal, {'value': 'bogus',
|
||||||
'label': "Betty Boop"})
|
'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):
|
def test_configure(self):
|
||||||
self.pyramid_config.include('wuttaweb.views.common')
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
self.pyramid_config.include('wuttaweb.views.auth')
|
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