diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 739935b..7299034 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -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 diff --git a/docs/api/wuttaweb/progress.rst b/docs/api/wuttaweb/progress.rst new file mode 100644 index 0000000..498d641 --- /dev/null +++ b/docs/api/wuttaweb/progress.rst @@ -0,0 +1,6 @@ + +``wuttaweb.progress`` +===================== + +.. automodule:: wuttaweb.progress + :members: diff --git a/docs/api/wuttaweb/views.people.rst b/docs/api/wuttaweb/views.people.rst index 89c6883..2dc919b 100644 --- a/docs/api/wuttaweb/views.people.rst +++ b/docs/api/wuttaweb/views.people.rst @@ -1,6 +1,6 @@ ``wuttaweb.views.people`` -=========================== +========================= .. automodule:: wuttaweb.views.people :members: diff --git a/docs/api/wuttaweb/views.progress.rst b/docs/api/wuttaweb/views.progress.rst new file mode 100644 index 0000000..34e2661 --- /dev/null +++ b/docs/api/wuttaweb/views.progress.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.progress`` +=========================== + +.. automodule:: wuttaweb.views.progress + :members: diff --git a/src/wuttaweb/progress.py b/src/wuttaweb/progress.py new file mode 100644 index 0000000..759c2da --- /dev/null +++ b/src/wuttaweb/progress.py @@ -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 . +# +################################################################################ +""" +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() diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index a85bc2d..3b3f115 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -3,13 +3,7 @@ <%namespace file="/wutta-components.mako" import="make_wutta_components" /> - - - ${base_meta.global_title()} » ${capture(self.title)|n} - ${base_meta.favicon()} - ${self.header_core()} - ${self.head_tags()} - + ${self.html_head()}
@@ -30,7 +24,20 @@ -## nb. this becomes part of the page tag within <head> +<%def name="html_head()"> + <head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> + <title>${self.head_title()} + ${base_meta.favicon()} + ${self.header_core()} + ${self.head_tags()} + + + +## nb. this is the full within html <head> +<%def name="head_title()">${base_meta.global_title()} » ${self.title()}</%def> + +## nb. this becomes part of head_title() above ## it also is used as default value for content_title() below <%def name="title()"></%def> @@ -39,9 +46,9 @@ <%def name="content_title()">${self.title()}</%def> <%def name="header_core()"> - ${self.core_javascript()} + ${self.base_javascript()} ${self.extra_javascript()} - ${self.core_styles()} + ${self.base_styles()} ${self.extra_styles()} </%def> @@ -49,6 +56,10 @@ ${self.vuejs()} ${self.buefy()} ${self.fontawesome()} +</%def> + +<%def name="base_javascript()"> + ${self.core_javascript()} ${self.hamburger_menu_js()} </%def> @@ -99,7 +110,6 @@ <%def name="core_styles()"> ${self.buefy_styles()} - ${self.base_styles()} </%def> <%def name="buefy_styles()"> @@ -107,6 +117,7 @@ </%def> <%def name="base_styles()"> + ${self.core_styles()} <style> ############################## @@ -194,16 +205,7 @@ <%def name="head_tags()"></%def> -<%def name="render_vue_template_whole_page()"> - <script type="text/x-template" id="whole-page-template"> - - ## nb. the whole-page contains 3 elements: - ## 1) header-wrapper - ## 2) content-wrapper - ## 3) footer - <div id="whole-page" - style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> - +<%def name="whole_page_content()"> ## nb. the header-wrapper contains 2 elements: ## 1) header proper (menu + index title area) ## 2) page/content title area @@ -327,7 +329,18 @@ ${base_meta.footer()} </div> </footer> +</%def> +<%def name="render_vue_template_whole_page()"> + <script type="text/x-template" id="whole-page-template"> + + ## nb. the whole-page normally contains 3 elements: + ## 1) header-wrapper + ## 2) content-wrapper + ## 3) footer + <div id="whole-page" + style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> + ${self.whole_page_content()} </div> </script> </%def> @@ -418,7 +431,7 @@ mounted() { for (let hook of this.mountedHooks) { - hook(this) + hook.call(this) } }, diff --git a/src/wuttaweb/templates/progress.mako b/src/wuttaweb/templates/progress.mako new file mode 100644 index 0000000..35223a1 --- /dev/null +++ b/src/wuttaweb/templates/progress.mako @@ -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> diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py index b6a05b1..56c669b 100644 --- a/src/wuttaweb/views/essential.py +++ b/src/wuttaweb/views/essential.py @@ -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')) diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index d1e9bef..7477ec2 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -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() - # delete it all and go back to listing - self.delete_bulk_data(data, session=session) - return self.redirect(self.get_index_url()) + if self.deletable_bulk_quick: - def delete_bulk_data(self, data, session=None): + # 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, "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 diff --git a/src/wuttaweb/views/progress.py b/src/wuttaweb/views/progress.py new file mode 100644 index 0000000..a06ebf2 --- /dev/null +++ b/src/wuttaweb/views/progress.py @@ -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) diff --git a/tests/test_progress.py b/tests/test_progress.py new file mode 100644 index 0000000..8bfce3c --- /dev/null +++ b/tests/test_progress.py @@ -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) diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 0224468..6fdb55d 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -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 diff --git a/tests/views/test_progress.py b/tests/views/test_progress.py new file mode 100644 index 0000000..06a67f8 --- /dev/null +++ b/tests/views/test_progress.py @@ -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!")