1
0
Fork 0

feat: add basic progress page/indicator support

so far "delete results" (for Raw Settings) is the only use case.

user cancel is not yet supported
This commit is contained in:
Lance Edgar 2024-08-24 19:28:13 -05:00
parent 6fa8b0aeaa
commit 1a8900c9f4
13 changed files with 746 additions and 40 deletions

View file

@ -20,6 +20,7 @@
handler
helpers
menus
progress
static
subscribers
util
@ -30,6 +31,7 @@
views.essential
views.master
views.people
views.progress
views.roles
views.settings
views.upgrades

View file

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

View file

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

View file

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

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

@ -0,0 +1,165 @@
# -*- coding: utf-8; -*-
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Progress Indicators
"""
from wuttjamaican.progress import ProgressBase
from beaker.session import Session as BeakerSession
def get_basic_session(request, **kwargs):
"""
Create/get a "basic" Beaker session object.
"""
kwargs['use_cookies'] = False
return BeakerSession(request, **kwargs)
def get_progress_session(request, key, **kwargs):
"""
Create/get a Beaker session object, to be used for progress.
"""
kwargs['id'] = f'{request.session.id}.progress.{key}'
return get_basic_session(request, **kwargs)
class SessionProgress(ProgressBase):
"""
Progress indicator which uses Beaker session storage to track
current status.
This is a subclass of
:class:`wuttjamaican:wuttjamaican.progress.ProgressBase`.
A view callable can create one of these, and then pass it into
:meth:`~wuttjamaican.app.AppHandler.progress_loop()` or similar.
As the loop updates progress along the way, this indicator will
update the Beaker session to match.
Separately then, the client side can send requests for the
:func:`~wuttaweb.views.progress.progress()` view, to fetch current
status out of the Beaker session.
:param request: Current :term:`request` object.
:param key: Unique key for this progress indicator. Used to
distinguish progress indicators in the Beaker session.
Note that in addition to
:meth:`~wuttjamaican:wuttjamaican.progress.ProgressBase.update()`
and
:meth:`~wuttjamaican:wuttjamaican.progress.ProgressBase.finish()`
this progres class has some extra attributes and methods:
.. attribute:: success_msg
Optional message to display to the user (via session flash)
when the operation completes successfully.
.. attribute:: success_url
URL to which user should be redirected, once the operation
completes.
.. attribute:: error_url
URL to which user should be redirected, if the operation
encounters an error. If not specified, will fall back to
:attr:`success_url`.
"""
def __init__(self, request, key, success_msg=None, success_url=None, error_url=None):
self.key = key
self.success_msg = success_msg
self.success_url = success_url
self.error_url = error_url or self.success_url
self.session = get_progress_session(request, key)
self.clear()
def __call__(self, message, maximum):
self.clear()
self.session['message'] = message
self.session['maximum'] = maximum
self.session['maximum_display'] = f'{maximum:,d}'
self.session['value'] = 0
self.session.save()
return self
def clear(self):
""" """
self.session.clear()
self.session['complete'] = False
self.session['error'] = False
self.session.save()
def update(self, value):
""" """
self.session.load()
self.session['value'] = value
self.session.save()
def handle_error(self, error, error_url=None):
"""
This should be called by the view code, within a try/catch
block upon error.
The session storage will be updated to reflect details of the
error. Next time client requests the progress status it will
learn of the error and redirect the user.
:param error: :class:`python:Exception` instance.
:param error_url: Optional redirect URL; if not specified
:attr:`error_url` is used.
"""
self.session.load()
self.session['error'] = True
self.session['error_msg'] = str(error)
self.session['error_url'] = error_url or self.error_url
self.session.save()
def handle_success(self, success_msg=None, success_url=None):
"""
This should be called by the view code, when the long-running
operation completes.
The session storage will be updated to reflect the completed
status. Next time client requests the progress status it will
discover it has completed, and redirect the user.
:param success_msg: Optional message to display to the user
(via session flash) when the operation completes
successfully. If not specified :attr:`success_msg` (or
nothing) is used
:param success_url: Optional redirect URL; if not specified
:attr:`success_url` is used.
"""
self.session.load()
self.session['complete'] = True
self.session['success_msg'] = success_msg or self.success_msg
self.session['success_url'] = success_url or self.success_url
self.session.save()

View file

@ -3,13 +3,7 @@
<%namespace file="/wutta-components.mako" import="make_wutta_components" />
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<title>${base_meta.global_title()} &raquo; ${capture(self.title)|n}</title>
${base_meta.favicon()}
${self.header_core()}
${self.head_tags()}
</head>
${self.html_head()}
<body>
<div id="app" style="height: 100%;">
<whole-page />
@ -30,7 +24,20 @@
</body>
</html>
## nb. this becomes part of the page <title> tag within <head>
<%def name="html_head()">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<title>${self.head_title()}</title>
${base_meta.favicon()}
${self.header_core()}
${self.head_tags()}
</head>
</%def>
## nb. this is the full <title> within html <head>
<%def name="head_title()">${base_meta.global_title()} &raquo; ${self.title()}</%def>
## nb. this becomes part of head_title() above
## it also is used as default value for content_title() below
<%def name="title()"></%def>
@ -39,9 +46,9 @@
<%def name="content_title()">${self.title()}</%def>
<%def name="header_core()">
${self.core_javascript()}
${self.base_javascript()}
${self.extra_javascript()}
${self.core_styles()}
${self.base_styles()}
${self.extra_styles()}
</%def>
@ -49,6 +56,10 @@
${self.vuejs()}
${self.buefy()}
${self.fontawesome()}
</%def>
<%def name="base_javascript()">
${self.core_javascript()}
${self.hamburger_menu_js()}
</%def>
@ -99,7 +110,6 @@
<%def name="core_styles()">
${self.buefy_styles()}
${self.base_styles()}
</%def>
<%def name="buefy_styles()">
@ -107,6 +117,7 @@
</%def>
<%def name="base_styles()">
${self.core_styles()}
<style>
##############################
@ -194,16 +205,7 @@
<%def name="head_tags()"></%def>
<%def name="render_vue_template_whole_page()">
<script type="text/x-template" id="whole-page-template">
## nb. the whole-page contains 3 elements:
## 1) header-wrapper
## 2) content-wrapper
## 3) footer
<div id="whole-page"
style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
<%def name="whole_page_content()">
## nb. the header-wrapper contains 2 elements:
## 1) header proper (menu + index title area)
## 2) page/content title area
@ -327,7 +329,18 @@
${base_meta.footer()}
</div>
</footer>
</%def>
<%def name="render_vue_template_whole_page()">
<script type="text/x-template" id="whole-page-template">
## nb. the whole-page normally contains 3 elements:
## 1) header-wrapper
## 2) content-wrapper
## 3) footer
<div id="whole-page"
style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
${self.whole_page_content()}
</div>
</script>
</%def>
@ -418,7 +431,7 @@
mounted() {
for (let hook of this.mountedHooks) {
hook(this)
hook.call(this)
}
},

View file

@ -0,0 +1,122 @@
## -*- 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, _query={'sessiontype': progress.session.type})}'
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
}
}
if (this.stillInProgress) {
// fetch progress data again, in one second from now
setTimeout(() => {
this.updateProgress()
}, 1000)
}
}
})
}
</script>
</%def>

View file

@ -32,6 +32,7 @@ That will in turn include the following modules:
* :mod:`wuttaweb.views.auth`
* :mod:`wuttaweb.views.common`
* :mod:`wuttaweb.views.settings`
* :mod:`wuttaweb.views.progress`
* :mod:`wuttaweb.views.people`
* :mod:`wuttaweb.views.roles`
* :mod:`wuttaweb.views.users`
@ -45,6 +46,7 @@ def defaults(config, **kwargs):
config.include(mod('wuttaweb.views.auth'))
config.include(mod('wuttaweb.views.common'))
config.include(mod('wuttaweb.views.settings'))
config.include(mod('wuttaweb.views.progress'))
config.include(mod('wuttaweb.views.people'))
config.include(mod('wuttaweb.views.roles'))
config.include(mod('wuttaweb.views.users'))

View file

@ -25,6 +25,7 @@ Base Logic for Master Views
"""
import logging
import threading
import sqlalchemy as sa
from sqlalchemy import orm
@ -35,6 +36,7 @@ from webhelpers2.html import HTML
from wuttaweb.views import View
from wuttaweb.util import get_form_data, get_model_fields, render_csrf_token
from wuttaweb.db import Session
from wuttaweb.progress import SessionProgress
from wuttjamaican.util import get_class_hierarchy
@ -295,6 +297,19 @@ class MasterView(View):
deleting" - i.e. it should have a :meth:`delete_bulk()` view.
Default value is ``False``.
See also :attr:`deletable_bulk_quick`.
.. attribute:: deletable_bulk_quick
Boolean indicating whether the view model supports "quick" bulk
deleting, i.e. the operation is reliably quick enough that it
should happen *synchronously* with no progress indicator.
Default is ``False`` in which case a progress indicator is
shown while the bulk deletion is performed.
Only relevant if :attr:`deletable_bulk` is true.
.. attribute:: form_fields
List of fields for the model form.
@ -333,6 +348,7 @@ class MasterView(View):
editable = True
deletable = True
deletable_bulk = False
deletable_bulk_quick = False
has_autocomplete = False
configurable = False
@ -634,7 +650,7 @@ class MasterView(View):
session = self.app.get_session(obj)
session.delete(obj)
def delete_bulk(self, session=None):
def delete_bulk(self):
"""
View to delete all records in the current :meth:`index()` grid
data set, i.e. those matching current query.
@ -652,35 +668,79 @@ class MasterView(View):
one of the related methods which are called (in)directly by
this one:
* :meth:`delete_bulk_data()`
* :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_data(data, session=session)
self.delete_bulk_action(data)
return self.redirect(self.get_index_url())
def delete_bulk_data(self, data, session=None):
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, "Bulk deletion failed")
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.
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()`.
:meth:`delete_instance()`. A progress indicator will be
updated if one is provided.
As of now there is no progress indicator or async; caller must
simply wait until delete is finished.
Subclass should override if needed.
"""
session = session or self.Session()
model_title_plural = self.get_model_title_plural()
for obj in data:
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()
@ -1308,6 +1368,39 @@ class MasterView(View):
return HTML.tag('b-button', **btn_kw)
def make_progress(self, key, **kwargs):
"""
Create and return a
:class:`~wuttaweb.progress.SessionProgress` instance, with the
given key.
This is normally done just before calling
:meth:`render_progress()`.
"""
return SessionProgress(self.request, key, **kwargs)
def render_progress(self, progress, context=None, template=None):
"""
Render the progress page, with given template/context.
When a view method needs to start a long-running operation, it
first starts a thread to do the work, and then it renders the
"progress" page. As the operation continues the progress page
is updated. When the operation completes (or fails) the user
is redirected to the final destination.
TODO: should document more about how to do this..
:param progress: Progress indicator instance as returned by
:meth:`make_progress()`.
:returns: A :term:`response` with rendered progress page.
"""
template = template or '/progress.mako'
context = context or {}
context['progress'] = progress
return render_to_response(template, context, request=self.request)
def render_to_response(self, template, context):
"""
Locate and render an appropriate template, with the given

View file

@ -0,0 +1,75 @@
# -*- coding: utf-8; -*-
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Progress Views
"""
from wuttaweb.progress import get_progress_session
def progress(request):
"""
View which returns JSON with current progress status.
The URL is like ``/progress/XXX`` where ``XXX`` is the "key" to a
particular progress indicator, tied to a long-running operation.
This key is used to lookup the progress status within the Beaker
session storage. See also
:class:`~wuttaweb.progress.SessionProgress`.
"""
key = request.matchdict['key']
session = get_progress_session(request, key)
# session has 'complete' flag set when operation is over
if session.get('complete'):
# set a flash msg for user if one is defined. this is the
# time to do it since user is about to get redirected.
msg = session.get('success_msg')
if msg:
request.session.flash(msg)
elif session.get('error'): # uh-oh
# set an error flash msg for user. this is the time to do it
# since user is about to get redirected.
msg = session.get('error_msg', "An unspecified error occurred.")
request.session.flash(msg, 'error')
# nb. we return the session as-is; since it is dict-like (and only
# contains relevant progress data) it can be used directly for the
# JSON response context
return session
def defaults(config, **kwargs):
base = globals()
progress = kwargs.get('progress', base['progress'])
config.add_route('progress', '/progress/{key}')
config.add_view(progress, route_name='progress', renderer='json')
def includeme(config):
defaults(config)

62
tests/test_progress.py Normal file
View file

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

View file

@ -14,6 +14,7 @@ from pyramid.httpexceptions import HTTPNotFound
from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import master as mod
from wuttaweb.views import View
from wuttaweb.progress import SessionProgress
from wuttaweb.subscribers import new_request_set_user
from tests.util import WebTestCase
@ -428,6 +429,22 @@ class TestMasterView(WebTestCase):
self.assertIn('click me', html)
self.assertIn('is-primary', html)
def test_make_progress(self):
# basic
view = self.make_view()
self.request.session.id = 'mockid'
progress = view.make_progress('foo')
self.assertIsInstance(progress, SessionProgress)
def test_render_progress(self):
self.pyramid_config.add_route('progress', '/progress/{key}')
# sanity / coverage check
view = self.make_view()
progress = MagicMock()
response = view.render_progress(progress)
def test_render_to_response(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
@ -1098,6 +1115,7 @@ class TestMasterView(WebTestCase):
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'},
@ -1132,13 +1150,35 @@ class TestMasterView(WebTestCase):
data = grid.get_visible_data()
self.assertEqual(len(data), 2)
# okay now let's delete those (gets redirected)
with patch.object(view, 'make_model_grid', return_value=grid):
response = view.delete_bulk(session=self.session)
# 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)
def test_delete_bulk_data(self):
# 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 = [
@ -1167,10 +1207,68 @@ class TestMasterView(WebTestCase):
.filter(model.Setting.value.ilike('%s%'))\
.all()
self.assertEqual(len(settings), 2)
view.delete_bulk_data(settings, session=self.session)
view.delete_bulk_action(settings)
self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 7)
def test_delete_bulk_thread(self):
self.pyramid_config.add_route('settings', '/settings/')
model = self.app.model
sample_data = [
{'name': 'foo1', 'value': 'ONE'},
{'name': 'foo2', 'value': 'two'},
{'name': 'foo3', 'value': 'three'},
{'name': 'foo4', 'value': 'four'},
{'name': 'foo5', 'value': 'five'},
{'name': 'foo6', 'value': 'six'},
{'name': 'foo7', 'value': 'seven'},
{'name': 'foo8', 'value': 'eight'},
{'name': 'foo9', 'value': 'nine'},
]
for setting in sample_data:
self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
sample_query = self.session.query(model.Setting)
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
view = self.make_view()
# basic delete, no progress
self.assertEqual(self.session.query(model.Setting).count(), 9)
settings = self.session.query(model.Setting)\
.filter(model.Setting.value.ilike('%s%'))
self.assertEqual(settings.count(), 2)
with patch.object(self.app, 'make_session', return_value=self.session):
view.delete_bulk_thread(settings)
self.assertEqual(self.session.query(model.Setting).count(), 7)
# basic delete, with progress
settings = self.session.query(model.Setting)\
.filter(model.Setting.name == 'foo1')
self.assertEqual(settings.count(), 1)
with patch.object(self.app, 'make_session', return_value=self.session):
view.delete_bulk_thread(settings, progress=MagicMock())
self.assertEqual(self.session.query(model.Setting).count(), 6)
# error, no progress
settings = self.session.query(model.Setting)\
.filter(model.Setting.name == 'foo2')
self.assertEqual(settings.count(), 1)
with patch.object(self.app, 'make_session', return_value=self.session):
with patch.object(view, 'delete_bulk_action', side_effect=RuntimeError):
view.delete_bulk_thread(settings)
# nb. nothing was deleted
self.assertEqual(self.session.query(model.Setting).count(), 6)
# error, with progress
self.assertEqual(settings.count(), 1)
with patch.object(self.app, 'make_session', return_value=self.session):
with patch.object(view, 'delete_bulk_action', side_effect=RuntimeError):
view.delete_bulk_thread(settings, progress=MagicMock())
# nb. nothing was deleted
self.assertEqual(self.session.query(model.Setting).count(), 6)
def test_autocomplete(self):
model = self.app.model

View file

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