1
0
Fork 0

feat: add basic support for execute upgrades, download stdout/stderr

upgrade progress is still not being shown yet
This commit is contained in:
Lance Edgar 2024-08-25 12:20:28 -05:00
parent 1a8900c9f4
commit e5e31a7d32
17 changed files with 805 additions and 12 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -160,6 +160,9 @@ class CommonView(View):
'upgrades.view', 'upgrades.view',
'upgrades.edit', 'upgrades.edit',
'upgrades.delete', 'upgrades.delete',
'upgrades.execute',
'upgrades.download',
'upgrades.configure',
'users.list', 'users.list',
'users.create', 'users.create',
'users.view', 'users.view',

View file

@ -25,6 +25,7 @@ Base Logic for Master Views
""" """
import logging import logging
import os
import threading import threading
import sqlalchemy as sa import sqlalchemy as sa
@ -322,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
@ -350,6 +363,8 @@ class MasterView(View):
deletable_bulk = False deletable_bulk = False
deletable_bulk_quick = False deletable_bulk_quick = False
has_autocomplete = False has_autocomplete = False
downloadable = False
executable = False
configurable = False configurable = False
# current action # current action
@ -842,6 +857,126 @@ 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()`
"""
model_title = self.get_model_title()
obj = self.get_instance()
try:
self.execute_instance(obj)
except Exception as error:
log.exception("failed to execute %s: %s", model_title, obj)
error = str(error) or error.__class__.__name__
self.request.session.flash(error, 'error')
else:
self.request.session.flash(f"{model_title} was executed.")
return self.redirect(self.get_action_url('view', obj))
def execute_instance(self, obj):
"""
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.
"""
############################## ##############################
# configure methods # configure methods
############################## ##############################
@ -2370,6 +2505,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',

View file

@ -24,12 +24,21 @@
Upgrade Views Upgrade Views
""" """
import datetime
import logging
import os
import shutil
import subprocess
from sqlalchemy import orm from sqlalchemy import orm
from wuttjamaican.db.model import Upgrade from wuttjamaican.db.model import Upgrade
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.forms import widgets from wuttaweb.forms import widgets
from wuttaweb.forms.schema import UserRef, WuttaEnum from wuttaweb.forms.schema import UserRef, WuttaEnum, FileDownload
log = logging.getLogger(__name__)
class UpgradeView(MasterView): class UpgradeView(MasterView):
@ -47,6 +56,9 @@ class UpgradeView(MasterView):
* ``/upgrades/XXX/delete`` * ``/upgrades/XXX/delete``
""" """
model_class = Upgrade model_class = Upgrade
executable = True
downloadable = True
configurable = True
grid_columns = [ grid_columns = [
'created', 'created',
@ -81,6 +93,9 @@ class UpgradeView(MasterView):
# status # status
g.set_renderer('status', self.grid_render_enum, enum=enum.UpgradeStatus) g.set_renderer('status', self.grid_render_enum, enum=enum.UpgradeStatus)
# executed
g.set_renderer('executed', self.grid_render_datetime)
# executed_by # executed_by
g.set_link('executed_by') g.set_link('executed_by')
Executor = orm.aliased(model.User) Executor = orm.aliased(model.User)
@ -138,10 +153,6 @@ class UpgradeView(MasterView):
else: else:
f.set_node('status', WuttaEnum(self.request, enum.UpgradeStatus)) f.set_node('status', WuttaEnum(self.request, enum.UpgradeStatus))
# exit_code
if self.creating or not upgrade.executed:
f.remove('exit_code')
# executed # executed
if self.creating or self.editing or not upgrade.executed: if self.creating or self.editing or not upgrade.executed:
f.remove('executed') f.remove('executed')
@ -152,6 +163,39 @@ class UpgradeView(MasterView):
else: else:
f.set_node('executed_by', UserRef(self.request)) 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): def objectify(self, form):
""" """ """ """
upgrade = super().objectify(form) upgrade = super().objectify(form)
@ -164,6 +208,71 @@ class UpgradeView(MasterView):
return upgrade 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):
"""
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
script = self.config.require(f'{self.app.appname}.upgrades.command',
session=self.Session())
stdout_path = self.get_upgrade_filepath(upgrade, 'stdout.log')
stderr_path = self.get_upgrade_filepath(upgrade, 'stderr.log')
# 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,
stdout=stdout, stderr=stderr)
logger = log.warning if upgrade.exit_code != 0 else log.debug
logger("upgrade command had non-zero exit code: %s", upgrade.exit_code)
# declare it complete
upgrade.executed = datetime.datetime.now()
upgrade.executed_by = self.request.user
if upgrade.exit_code == 0:
upgrade.status = enum.UpgradeStatus.SUCCESS
else:
upgrade.status = enum.UpgradeStatus.FAILURE
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 @classmethod
def defaults(cls, config): def defaults(cls, config):
""" """ """ """

View file

@ -316,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')

View file

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

View file

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

View file

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

View file

@ -30,6 +30,8 @@ class TestMasterView(WebTestCase):
model_key='uuid', model_key='uuid',
deletable_bulk=True, 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)
@ -1310,6 +1312,59 @@ 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}')
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'}
# basic usage, redirects to view obj url
response = view.execute()
self.assertEqual(response.status_code, 302)
self.assertEqual(self.request.session.pop_flash(), ["Setting was executed."])
# execution error
with patch.object(view, 'execute_instance', side_effect=RuntimeError):
response = view.execute()
self.assertEqual(response.status_code, 302)
self.assertEqual(self.request.session.pop_flash(), [])
self.assertEqual(self.request.session.pop_flash('error'), ["RuntimeError"])
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')

View file

@ -1,9 +1,12 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
import datetime import datetime
import os
import sys
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from wuttaweb.views import upgrades as mod from wuttaweb.views import upgrades as mod
from wuttjamaican.exc import ConfigurationError
from tests.util import WebTestCase from tests.util import WebTestCase
@ -42,6 +45,7 @@ class TestUpgradeView(WebTestCase):
self.assertEqual(view.grid_row_class(upgrade, data, 1), 'has-background-warning') self.assertEqual(view.grid_row_class(upgrade, data, 1), 'has-background-warning')
def test_configure_form(self): def test_configure_form(self):
self.pyramid_config.add_route('upgrades.download', '/upgrades/{uuid}/download')
model = self.app.model model = self.app.model
enum = self.app.enum enum = self.app.enum
user = model.User(username='barney') user = model.User(username='barney')
@ -66,7 +70,7 @@ class TestUpgradeView(WebTestCase):
view.configure_form(form) view.configure_form(form)
self.assertNotIn('created', form) self.assertNotIn('created', form)
# test executed field when viewing # test executed, stdout/stderr when viewing
with patch.object(view, 'viewing', new=True): with patch.object(view, 'viewing', new=True):
# executed is *not* shown by default # executed is *not* shown by default
@ -74,13 +78,18 @@ class TestUpgradeView(WebTestCase):
self.assertIn('executed', form) self.assertIn('executed', form)
view.configure_form(form) view.configure_form(form)
self.assertNotIn('executed', form) self.assertNotIn('executed', form)
self.assertNotIn('stdout_file', form)
self.assertNotIn('stderr_file', form)
# but it *is* shown if upgrade is executed # but it *is* shown if upgrade is executed
upgrade.executed = datetime.datetime.now() upgrade.executed = datetime.datetime.now()
upgrade.status = enum.UpgradeStatus.SUCCESS
form = view.make_form(model_class=model.Upgrade, model_instance=upgrade) form = view.make_form(model_class=model.Upgrade, model_instance=upgrade)
self.assertIn('executed', form) self.assertIn('executed', form)
view.configure_form(form) view.configure_form(form)
self.assertIn('executed', form) self.assertIn('executed', form)
self.assertIn('stdout_file', form)
self.assertIn('stderr_file', form)
def test_objectify(self): def test_objectify(self):
model = self.app.model model = self.app.model
@ -101,3 +110,167 @@ class TestUpgradeView(WebTestCase):
self.assertEqual(upgrade.description, "new one") self.assertEqual(upgrade.description, "new one")
self.assertIs(upgrade.created_by, user) self.assertIs(upgrade.created_by, user)
self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING) 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)
# 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)
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)
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_configure_get_simple_settings(self):
# sanity/coverage check
view = self.make_view()
simple = view.configure_get_simple_settings()