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:
parent
6fa8b0aeaa
commit
1a8900c9f4
|
@ -20,6 +20,7 @@
|
||||||
handler
|
handler
|
||||||
helpers
|
helpers
|
||||||
menus
|
menus
|
||||||
|
progress
|
||||||
static
|
static
|
||||||
subscribers
|
subscribers
|
||||||
util
|
util
|
||||||
|
@ -30,6 +31,7 @@
|
||||||
views.essential
|
views.essential
|
||||||
views.master
|
views.master
|
||||||
views.people
|
views.people
|
||||||
|
views.progress
|
||||||
views.roles
|
views.roles
|
||||||
views.settings
|
views.settings
|
||||||
views.upgrades
|
views.upgrades
|
||||||
|
|
6
docs/api/wuttaweb/progress.rst
Normal file
6
docs/api/wuttaweb/progress.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttaweb.progress``
|
||||||
|
=====================
|
||||||
|
|
||||||
|
.. automodule:: wuttaweb.progress
|
||||||
|
:members:
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
``wuttaweb.views.people``
|
``wuttaweb.views.people``
|
||||||
===========================
|
=========================
|
||||||
|
|
||||||
.. automodule:: wuttaweb.views.people
|
.. automodule:: wuttaweb.views.people
|
||||||
:members:
|
:members:
|
||||||
|
|
6
docs/api/wuttaweb/views.progress.rst
Normal file
6
docs/api/wuttaweb/views.progress.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttaweb.views.progress``
|
||||||
|
===========================
|
||||||
|
|
||||||
|
.. automodule:: wuttaweb.views.progress
|
||||||
|
:members:
|
165
src/wuttaweb/progress.py
Normal file
165
src/wuttaweb/progress.py
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# wuttaweb -- Web App for Wutta Framework
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Wutta Framework.
|
||||||
|
#
|
||||||
|
# Wutta Framework is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by the Free
|
||||||
|
# Software Foundation, either version 3 of the License, or (at your option) any
|
||||||
|
# later version.
|
||||||
|
#
|
||||||
|
# Wutta Framework is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along with
|
||||||
|
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Progress Indicators
|
||||||
|
"""
|
||||||
|
|
||||||
|
from wuttjamaican.progress import ProgressBase
|
||||||
|
|
||||||
|
from beaker.session import Session as BeakerSession
|
||||||
|
|
||||||
|
|
||||||
|
def get_basic_session(request, **kwargs):
|
||||||
|
"""
|
||||||
|
Create/get a "basic" Beaker session object.
|
||||||
|
"""
|
||||||
|
kwargs['use_cookies'] = False
|
||||||
|
return BeakerSession(request, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def get_progress_session(request, key, **kwargs):
|
||||||
|
"""
|
||||||
|
Create/get a Beaker session object, to be used for progress.
|
||||||
|
"""
|
||||||
|
kwargs['id'] = f'{request.session.id}.progress.{key}'
|
||||||
|
return get_basic_session(request, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionProgress(ProgressBase):
|
||||||
|
"""
|
||||||
|
Progress indicator which uses Beaker session storage to track
|
||||||
|
current status.
|
||||||
|
|
||||||
|
This is a subclass of
|
||||||
|
:class:`wuttjamaican:wuttjamaican.progress.ProgressBase`.
|
||||||
|
|
||||||
|
A view callable can create one of these, and then pass it into
|
||||||
|
:meth:`~wuttjamaican.app.AppHandler.progress_loop()` or similar.
|
||||||
|
|
||||||
|
As the loop updates progress along the way, this indicator will
|
||||||
|
update the Beaker session to match.
|
||||||
|
|
||||||
|
Separately then, the client side can send requests for the
|
||||||
|
:func:`~wuttaweb.views.progress.progress()` view, to fetch current
|
||||||
|
status out of the Beaker session.
|
||||||
|
|
||||||
|
:param request: Current :term:`request` object.
|
||||||
|
|
||||||
|
:param key: Unique key for this progress indicator. Used to
|
||||||
|
distinguish progress indicators in the Beaker session.
|
||||||
|
|
||||||
|
Note that in addition to
|
||||||
|
:meth:`~wuttjamaican:wuttjamaican.progress.ProgressBase.update()`
|
||||||
|
and
|
||||||
|
:meth:`~wuttjamaican:wuttjamaican.progress.ProgressBase.finish()`
|
||||||
|
this progres class has some extra attributes and methods:
|
||||||
|
|
||||||
|
.. attribute:: success_msg
|
||||||
|
|
||||||
|
Optional message to display to the user (via session flash)
|
||||||
|
when the operation completes successfully.
|
||||||
|
|
||||||
|
.. attribute:: success_url
|
||||||
|
|
||||||
|
URL to which user should be redirected, once the operation
|
||||||
|
completes.
|
||||||
|
|
||||||
|
.. attribute:: error_url
|
||||||
|
|
||||||
|
URL to which user should be redirected, if the operation
|
||||||
|
encounters an error. If not specified, will fall back to
|
||||||
|
:attr:`success_url`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, request, key, success_msg=None, success_url=None, error_url=None):
|
||||||
|
self.key = key
|
||||||
|
self.success_msg = success_msg
|
||||||
|
self.success_url = success_url
|
||||||
|
self.error_url = error_url or self.success_url
|
||||||
|
self.session = get_progress_session(request, key)
|
||||||
|
self.clear()
|
||||||
|
|
||||||
|
def __call__(self, message, maximum):
|
||||||
|
self.clear()
|
||||||
|
self.session['message'] = message
|
||||||
|
self.session['maximum'] = maximum
|
||||||
|
self.session['maximum_display'] = f'{maximum:,d}'
|
||||||
|
self.session['value'] = 0
|
||||||
|
self.session.save()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
""" """
|
||||||
|
self.session.clear()
|
||||||
|
self.session['complete'] = False
|
||||||
|
self.session['error'] = False
|
||||||
|
self.session.save()
|
||||||
|
|
||||||
|
def update(self, value):
|
||||||
|
""" """
|
||||||
|
self.session.load()
|
||||||
|
self.session['value'] = value
|
||||||
|
self.session.save()
|
||||||
|
|
||||||
|
def handle_error(self, error, error_url=None):
|
||||||
|
"""
|
||||||
|
This should be called by the view code, within a try/catch
|
||||||
|
block upon error.
|
||||||
|
|
||||||
|
The session storage will be updated to reflect details of the
|
||||||
|
error. Next time client requests the progress status it will
|
||||||
|
learn of the error and redirect the user.
|
||||||
|
|
||||||
|
:param error: :class:`python:Exception` instance.
|
||||||
|
|
||||||
|
:param error_url: Optional redirect URL; if not specified
|
||||||
|
:attr:`error_url` is used.
|
||||||
|
"""
|
||||||
|
self.session.load()
|
||||||
|
self.session['error'] = True
|
||||||
|
self.session['error_msg'] = str(error)
|
||||||
|
self.session['error_url'] = error_url or self.error_url
|
||||||
|
self.session.save()
|
||||||
|
|
||||||
|
def handle_success(self, success_msg=None, success_url=None):
|
||||||
|
"""
|
||||||
|
This should be called by the view code, when the long-running
|
||||||
|
operation completes.
|
||||||
|
|
||||||
|
The session storage will be updated to reflect the completed
|
||||||
|
status. Next time client requests the progress status it will
|
||||||
|
discover it has completed, and redirect the user.
|
||||||
|
|
||||||
|
:param success_msg: Optional message to display to the user
|
||||||
|
(via session flash) when the operation completes
|
||||||
|
successfully. If not specified :attr:`success_msg` (or
|
||||||
|
nothing) is used
|
||||||
|
|
||||||
|
:param success_url: Optional redirect URL; if not specified
|
||||||
|
:attr:`success_url` is used.
|
||||||
|
"""
|
||||||
|
self.session.load()
|
||||||
|
self.session['complete'] = True
|
||||||
|
self.session['success_msg'] = success_msg or self.success_msg
|
||||||
|
self.session['success_url'] = success_url or self.success_url
|
||||||
|
self.session.save()
|
|
@ -3,13 +3,7 @@
|
||||||
<%namespace file="/wutta-components.mako" import="make_wutta_components" />
|
<%namespace file="/wutta-components.mako" import="make_wutta_components" />
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
${self.html_head()}
|
||||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
|
|
||||||
<title>${base_meta.global_title()} » ${capture(self.title)|n}</title>
|
|
||||||
${base_meta.favicon()}
|
|
||||||
${self.header_core()}
|
|
||||||
${self.head_tags()}
|
|
||||||
</head>
|
|
||||||
<body>
|
<body>
|
||||||
<div id="app" style="height: 100%;">
|
<div id="app" style="height: 100%;">
|
||||||
<whole-page />
|
<whole-page />
|
||||||
|
@ -30,7 +24,20 @@
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
## nb. this becomes part of the page <title> tag within <head>
|
<%def name="html_head()">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
|
||||||
|
<title>${self.head_title()}</title>
|
||||||
|
${base_meta.favicon()}
|
||||||
|
${self.header_core()}
|
||||||
|
${self.head_tags()}
|
||||||
|
</head>
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
## nb. this is the full <title> within html <head>
|
||||||
|
<%def name="head_title()">${base_meta.global_title()} » ${self.title()}</%def>
|
||||||
|
|
||||||
|
## nb. this becomes part of head_title() above
|
||||||
## it also is used as default value for content_title() below
|
## it also is used as default value for content_title() below
|
||||||
<%def name="title()"></%def>
|
<%def name="title()"></%def>
|
||||||
|
|
||||||
|
@ -39,9 +46,9 @@
|
||||||
<%def name="content_title()">${self.title()}</%def>
|
<%def name="content_title()">${self.title()}</%def>
|
||||||
|
|
||||||
<%def name="header_core()">
|
<%def name="header_core()">
|
||||||
${self.core_javascript()}
|
${self.base_javascript()}
|
||||||
${self.extra_javascript()}
|
${self.extra_javascript()}
|
||||||
${self.core_styles()}
|
${self.base_styles()}
|
||||||
${self.extra_styles()}
|
${self.extra_styles()}
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
@ -49,6 +56,10 @@
|
||||||
${self.vuejs()}
|
${self.vuejs()}
|
||||||
${self.buefy()}
|
${self.buefy()}
|
||||||
${self.fontawesome()}
|
${self.fontawesome()}
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
<%def name="base_javascript()">
|
||||||
|
${self.core_javascript()}
|
||||||
${self.hamburger_menu_js()}
|
${self.hamburger_menu_js()}
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
@ -99,7 +110,6 @@
|
||||||
|
|
||||||
<%def name="core_styles()">
|
<%def name="core_styles()">
|
||||||
${self.buefy_styles()}
|
${self.buefy_styles()}
|
||||||
${self.base_styles()}
|
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
<%def name="buefy_styles()">
|
<%def name="buefy_styles()">
|
||||||
|
@ -107,6 +117,7 @@
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
<%def name="base_styles()">
|
<%def name="base_styles()">
|
||||||
|
${self.core_styles()}
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
|
@ -194,16 +205,7 @@
|
||||||
|
|
||||||
<%def name="head_tags()"></%def>
|
<%def name="head_tags()"></%def>
|
||||||
|
|
||||||
<%def name="render_vue_template_whole_page()">
|
<%def name="whole_page_content()">
|
||||||
<script type="text/x-template" id="whole-page-template">
|
|
||||||
|
|
||||||
## nb. the whole-page contains 3 elements:
|
|
||||||
## 1) header-wrapper
|
|
||||||
## 2) content-wrapper
|
|
||||||
## 3) footer
|
|
||||||
<div id="whole-page"
|
|
||||||
style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
|
|
||||||
|
|
||||||
## nb. the header-wrapper contains 2 elements:
|
## nb. the header-wrapper contains 2 elements:
|
||||||
## 1) header proper (menu + index title area)
|
## 1) header proper (menu + index title area)
|
||||||
## 2) page/content title area
|
## 2) page/content title area
|
||||||
|
@ -327,7 +329,18 @@
|
||||||
${base_meta.footer()}
|
${base_meta.footer()}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
<%def name="render_vue_template_whole_page()">
|
||||||
|
<script type="text/x-template" id="whole-page-template">
|
||||||
|
|
||||||
|
## nb. the whole-page normally contains 3 elements:
|
||||||
|
## 1) header-wrapper
|
||||||
|
## 2) content-wrapper
|
||||||
|
## 3) footer
|
||||||
|
<div id="whole-page"
|
||||||
|
style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
|
||||||
|
${self.whole_page_content()}
|
||||||
</div>
|
</div>
|
||||||
</script>
|
</script>
|
||||||
</%def>
|
</%def>
|
||||||
|
@ -418,7 +431,7 @@
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
for (let hook of this.mountedHooks) {
|
for (let hook of this.mountedHooks) {
|
||||||
hook(this)
|
hook.call(this)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
122
src/wuttaweb/templates/progress.mako
Normal file
122
src/wuttaweb/templates/progress.mako
Normal 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>
|
|
@ -32,6 +32,7 @@ That will in turn include the following modules:
|
||||||
* :mod:`wuttaweb.views.auth`
|
* :mod:`wuttaweb.views.auth`
|
||||||
* :mod:`wuttaweb.views.common`
|
* :mod:`wuttaweb.views.common`
|
||||||
* :mod:`wuttaweb.views.settings`
|
* :mod:`wuttaweb.views.settings`
|
||||||
|
* :mod:`wuttaweb.views.progress`
|
||||||
* :mod:`wuttaweb.views.people`
|
* :mod:`wuttaweb.views.people`
|
||||||
* :mod:`wuttaweb.views.roles`
|
* :mod:`wuttaweb.views.roles`
|
||||||
* :mod:`wuttaweb.views.users`
|
* :mod:`wuttaweb.views.users`
|
||||||
|
@ -45,6 +46,7 @@ def defaults(config, **kwargs):
|
||||||
config.include(mod('wuttaweb.views.auth'))
|
config.include(mod('wuttaweb.views.auth'))
|
||||||
config.include(mod('wuttaweb.views.common'))
|
config.include(mod('wuttaweb.views.common'))
|
||||||
config.include(mod('wuttaweb.views.settings'))
|
config.include(mod('wuttaweb.views.settings'))
|
||||||
|
config.include(mod('wuttaweb.views.progress'))
|
||||||
config.include(mod('wuttaweb.views.people'))
|
config.include(mod('wuttaweb.views.people'))
|
||||||
config.include(mod('wuttaweb.views.roles'))
|
config.include(mod('wuttaweb.views.roles'))
|
||||||
config.include(mod('wuttaweb.views.users'))
|
config.include(mod('wuttaweb.views.users'))
|
||||||
|
|
|
@ -25,6 +25,7 @@ Base Logic for Master Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
@ -35,6 +36,7 @@ from webhelpers2.html import HTML
|
||||||
from wuttaweb.views import View
|
from wuttaweb.views import View
|
||||||
from wuttaweb.util import get_form_data, get_model_fields, render_csrf_token
|
from wuttaweb.util import get_form_data, get_model_fields, render_csrf_token
|
||||||
from wuttaweb.db import Session
|
from wuttaweb.db import Session
|
||||||
|
from wuttaweb.progress import SessionProgress
|
||||||
from wuttjamaican.util import get_class_hierarchy
|
from wuttjamaican.util import get_class_hierarchy
|
||||||
|
|
||||||
|
|
||||||
|
@ -295,6 +297,19 @@ class MasterView(View):
|
||||||
deleting" - i.e. it should have a :meth:`delete_bulk()` view.
|
deleting" - i.e. it should have a :meth:`delete_bulk()` view.
|
||||||
Default value is ``False``.
|
Default value is ``False``.
|
||||||
|
|
||||||
|
See also :attr:`deletable_bulk_quick`.
|
||||||
|
|
||||||
|
.. attribute:: deletable_bulk_quick
|
||||||
|
|
||||||
|
Boolean indicating whether the view model supports "quick" bulk
|
||||||
|
deleting, i.e. the operation is reliably quick enough that it
|
||||||
|
should happen *synchronously* with no progress indicator.
|
||||||
|
|
||||||
|
Default is ``False`` in which case a progress indicator is
|
||||||
|
shown while the bulk deletion is performed.
|
||||||
|
|
||||||
|
Only relevant if :attr:`deletable_bulk` is true.
|
||||||
|
|
||||||
.. attribute:: form_fields
|
.. attribute:: form_fields
|
||||||
|
|
||||||
List of fields for the model form.
|
List of fields for the model form.
|
||||||
|
@ -333,6 +348,7 @@ class MasterView(View):
|
||||||
editable = True
|
editable = True
|
||||||
deletable = True
|
deletable = True
|
||||||
deletable_bulk = False
|
deletable_bulk = False
|
||||||
|
deletable_bulk_quick = False
|
||||||
has_autocomplete = False
|
has_autocomplete = False
|
||||||
configurable = False
|
configurable = False
|
||||||
|
|
||||||
|
@ -634,7 +650,7 @@ class MasterView(View):
|
||||||
session = self.app.get_session(obj)
|
session = self.app.get_session(obj)
|
||||||
session.delete(obj)
|
session.delete(obj)
|
||||||
|
|
||||||
def delete_bulk(self, session=None):
|
def delete_bulk(self):
|
||||||
"""
|
"""
|
||||||
View to delete all records in the current :meth:`index()` grid
|
View to delete all records in the current :meth:`index()` grid
|
||||||
data set, i.e. those matching current query.
|
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
|
one of the related methods which are called (in)directly by
|
||||||
this one:
|
this one:
|
||||||
|
|
||||||
* :meth:`delete_bulk_data()`
|
* :meth:`delete_bulk_action()`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# get current data set from grid
|
# get current data set from grid
|
||||||
# nb. this must *not* be paginated, we need it all
|
# nb. this must *not* be paginated, we need it all
|
||||||
grid = self.make_model_grid(paginated=False)
|
grid = self.make_model_grid(paginated=False)
|
||||||
data = grid.get_visible_data()
|
data = grid.get_visible_data()
|
||||||
|
|
||||||
# delete it all and go back to listing
|
if self.deletable_bulk_quick:
|
||||||
self.delete_bulk_data(data, session=session)
|
|
||||||
return self.redirect(self.get_index_url())
|
|
||||||
|
|
||||||
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
|
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
|
Default logic will call :meth:`is_deletable()` for every data
|
||||||
record, and if that returns true then it calls
|
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
|
Subclass should override if needed.
|
||||||
simply wait until delete is finished.
|
|
||||||
"""
|
"""
|
||||||
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):
|
if self.is_deletable(obj):
|
||||||
self.delete_instance(obj)
|
self.delete_instance(obj)
|
||||||
|
|
||||||
|
self.app.progress_loop(delete, data, progress,
|
||||||
|
message=f"Deleting {model_title_plural}")
|
||||||
|
|
||||||
def delete_bulk_make_button(self):
|
def delete_bulk_make_button(self):
|
||||||
""" """
|
""" """
|
||||||
route_prefix = self.get_route_prefix()
|
route_prefix = self.get_route_prefix()
|
||||||
|
@ -1308,6 +1368,39 @@ class MasterView(View):
|
||||||
|
|
||||||
return HTML.tag('b-button', **btn_kw)
|
return HTML.tag('b-button', **btn_kw)
|
||||||
|
|
||||||
|
def make_progress(self, key, **kwargs):
|
||||||
|
"""
|
||||||
|
Create and return a
|
||||||
|
:class:`~wuttaweb.progress.SessionProgress` instance, with the
|
||||||
|
given key.
|
||||||
|
|
||||||
|
This is normally done just before calling
|
||||||
|
:meth:`render_progress()`.
|
||||||
|
"""
|
||||||
|
return SessionProgress(self.request, key, **kwargs)
|
||||||
|
|
||||||
|
def render_progress(self, progress, context=None, template=None):
|
||||||
|
"""
|
||||||
|
Render the progress page, with given template/context.
|
||||||
|
|
||||||
|
When a view method needs to start a long-running operation, it
|
||||||
|
first starts a thread to do the work, and then it renders the
|
||||||
|
"progress" page. As the operation continues the progress page
|
||||||
|
is updated. When the operation completes (or fails) the user
|
||||||
|
is redirected to the final destination.
|
||||||
|
|
||||||
|
TODO: should document more about how to do this..
|
||||||
|
|
||||||
|
:param progress: Progress indicator instance as returned by
|
||||||
|
:meth:`make_progress()`.
|
||||||
|
|
||||||
|
:returns: A :term:`response` with rendered progress page.
|
||||||
|
"""
|
||||||
|
template = template or '/progress.mako'
|
||||||
|
context = context or {}
|
||||||
|
context['progress'] = progress
|
||||||
|
return render_to_response(template, context, request=self.request)
|
||||||
|
|
||||||
def render_to_response(self, template, context):
|
def render_to_response(self, template, context):
|
||||||
"""
|
"""
|
||||||
Locate and render an appropriate template, with the given
|
Locate and render an appropriate template, with the given
|
||||||
|
|
75
src/wuttaweb/views/progress.py
Normal file
75
src/wuttaweb/views/progress.py
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# wuttaweb -- Web App for Wutta Framework
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Wutta Framework.
|
||||||
|
#
|
||||||
|
# Wutta Framework is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by the Free
|
||||||
|
# Software Foundation, either version 3 of the License, or (at your option) any
|
||||||
|
# later version.
|
||||||
|
#
|
||||||
|
# Wutta Framework is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along with
|
||||||
|
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Progress Views
|
||||||
|
"""
|
||||||
|
|
||||||
|
from wuttaweb.progress import get_progress_session
|
||||||
|
|
||||||
|
|
||||||
|
def progress(request):
|
||||||
|
"""
|
||||||
|
View which returns JSON with current progress status.
|
||||||
|
|
||||||
|
The URL is like ``/progress/XXX`` where ``XXX`` is the "key" to a
|
||||||
|
particular progress indicator, tied to a long-running operation.
|
||||||
|
|
||||||
|
This key is used to lookup the progress status within the Beaker
|
||||||
|
session storage. See also
|
||||||
|
:class:`~wuttaweb.progress.SessionProgress`.
|
||||||
|
"""
|
||||||
|
key = request.matchdict['key']
|
||||||
|
session = get_progress_session(request, key)
|
||||||
|
|
||||||
|
# session has 'complete' flag set when operation is over
|
||||||
|
if session.get('complete'):
|
||||||
|
|
||||||
|
# set a flash msg for user if one is defined. this is the
|
||||||
|
# time to do it since user is about to get redirected.
|
||||||
|
msg = session.get('success_msg')
|
||||||
|
if msg:
|
||||||
|
request.session.flash(msg)
|
||||||
|
|
||||||
|
elif session.get('error'): # uh-oh
|
||||||
|
|
||||||
|
# set an error flash msg for user. this is the time to do it
|
||||||
|
# since user is about to get redirected.
|
||||||
|
msg = session.get('error_msg', "An unspecified error occurred.")
|
||||||
|
request.session.flash(msg, 'error')
|
||||||
|
|
||||||
|
# nb. we return the session as-is; since it is dict-like (and only
|
||||||
|
# contains relevant progress data) it can be used directly for the
|
||||||
|
# JSON response context
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def defaults(config, **kwargs):
|
||||||
|
base = globals()
|
||||||
|
|
||||||
|
progress = kwargs.get('progress', base['progress'])
|
||||||
|
config.add_route('progress', '/progress/{key}')
|
||||||
|
config.add_view(progress, route_name='progress', renderer='json')
|
||||||
|
|
||||||
|
|
||||||
|
def includeme(config):
|
||||||
|
defaults(config)
|
62
tests/test_progress.py
Normal file
62
tests/test_progress.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from pyramid import testing
|
||||||
|
from beaker.session import Session as BeakerSession
|
||||||
|
|
||||||
|
from wuttaweb import progress as mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetBasicSession(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.request = testing.DummyRequest()
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
session = mod.get_basic_session(self.request)
|
||||||
|
self.assertIsInstance(session, BeakerSession)
|
||||||
|
self.assertFalse(session.use_cookies)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetProgressSession(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.request = testing.DummyRequest()
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
self.request.session.id = 'mockid'
|
||||||
|
session = mod.get_progress_session(self.request, 'foo')
|
||||||
|
self.assertIsInstance(session, BeakerSession)
|
||||||
|
self.assertEqual(session.id, 'mockid.progress.foo')
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionProgress(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.request = testing.DummyRequest()
|
||||||
|
self.request.session.id = 'mockid'
|
||||||
|
|
||||||
|
def test_error_url(self):
|
||||||
|
factory = mod.SessionProgress(self.request, 'foo', success_url='/blart')
|
||||||
|
self.assertEqual(factory.error_url, '/blart')
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
|
||||||
|
# sanity / coverage check
|
||||||
|
factory = mod.SessionProgress(self.request, 'foo')
|
||||||
|
prog = factory("doing things", 2)
|
||||||
|
prog.update(1)
|
||||||
|
prog.update(2)
|
||||||
|
prog.handle_success()
|
||||||
|
|
||||||
|
def test_error(self):
|
||||||
|
|
||||||
|
# sanity / coverage check
|
||||||
|
factory = mod.SessionProgress(self.request, 'foo')
|
||||||
|
prog = factory("doing things", 2)
|
||||||
|
prog.update(1)
|
||||||
|
try:
|
||||||
|
raise RuntimeError('omg')
|
||||||
|
except Exception as error:
|
||||||
|
prog.handle_error(error)
|
|
@ -14,6 +14,7 @@ from pyramid.httpexceptions import HTTPNotFound
|
||||||
from wuttjamaican.conf import WuttaConfig
|
from wuttjamaican.conf import WuttaConfig
|
||||||
from wuttaweb.views import master as mod
|
from wuttaweb.views import master as mod
|
||||||
from wuttaweb.views import View
|
from wuttaweb.views import View
|
||||||
|
from wuttaweb.progress import SessionProgress
|
||||||
from wuttaweb.subscribers import new_request_set_user
|
from wuttaweb.subscribers import new_request_set_user
|
||||||
from tests.util import WebTestCase
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
|
@ -428,6 +429,22 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertIn('click me', html)
|
self.assertIn('click me', html)
|
||||||
self.assertIn('is-primary', html)
|
self.assertIn('is-primary', html)
|
||||||
|
|
||||||
|
def test_make_progress(self):
|
||||||
|
|
||||||
|
# basic
|
||||||
|
view = self.make_view()
|
||||||
|
self.request.session.id = 'mockid'
|
||||||
|
progress = view.make_progress('foo')
|
||||||
|
self.assertIsInstance(progress, SessionProgress)
|
||||||
|
|
||||||
|
def test_render_progress(self):
|
||||||
|
self.pyramid_config.add_route('progress', '/progress/{key}')
|
||||||
|
|
||||||
|
# sanity / coverage check
|
||||||
|
view = self.make_view()
|
||||||
|
progress = MagicMock()
|
||||||
|
response = view.render_progress(progress)
|
||||||
|
|
||||||
def test_render_to_response(self):
|
def test_render_to_response(self):
|
||||||
self.pyramid_config.include('wuttaweb.views.common')
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
self.pyramid_config.include('wuttaweb.views.auth')
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
|
@ -1098,6 +1115,7 @@ class TestMasterView(WebTestCase):
|
||||||
|
|
||||||
def test_delete_bulk(self):
|
def test_delete_bulk(self):
|
||||||
self.pyramid_config.add_route('settings', '/settings/')
|
self.pyramid_config.add_route('settings', '/settings/')
|
||||||
|
self.pyramid_config.add_route('progress', '/progress/{key}')
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
sample_data = [
|
sample_data = [
|
||||||
{'name': 'foo1', 'value': 'ONE'},
|
{'name': 'foo1', 'value': 'ONE'},
|
||||||
|
@ -1132,13 +1150,35 @@ class TestMasterView(WebTestCase):
|
||||||
data = grid.get_visible_data()
|
data = grid.get_visible_data()
|
||||||
self.assertEqual(len(data), 2)
|
self.assertEqual(len(data), 2)
|
||||||
|
|
||||||
# okay now let's delete those (gets redirected)
|
# okay now let's delete those via quick method
|
||||||
with patch.object(view, 'make_model_grid', return_value=grid):
|
# (user should be redirected back to index)
|
||||||
response = view.delete_bulk(session=self.session)
|
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(response.status_code, 302)
|
||||||
self.assertEqual(self.session.query(model.Setting).count(), 7)
|
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/')
|
self.pyramid_config.add_route('settings', '/settings/')
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
sample_data = [
|
sample_data = [
|
||||||
|
@ -1167,10 +1207,68 @@ class TestMasterView(WebTestCase):
|
||||||
.filter(model.Setting.value.ilike('%s%'))\
|
.filter(model.Setting.value.ilike('%s%'))\
|
||||||
.all()
|
.all()
|
||||||
self.assertEqual(len(settings), 2)
|
self.assertEqual(len(settings), 2)
|
||||||
view.delete_bulk_data(settings, session=self.session)
|
view.delete_bulk_action(settings)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.assertEqual(self.session.query(model.Setting).count(), 7)
|
self.assertEqual(self.session.query(model.Setting).count(), 7)
|
||||||
|
|
||||||
|
def test_delete_bulk_thread(self):
|
||||||
|
self.pyramid_config.add_route('settings', '/settings/')
|
||||||
|
model = self.app.model
|
||||||
|
sample_data = [
|
||||||
|
{'name': 'foo1', 'value': 'ONE'},
|
||||||
|
{'name': 'foo2', 'value': 'two'},
|
||||||
|
{'name': 'foo3', 'value': 'three'},
|
||||||
|
{'name': 'foo4', 'value': 'four'},
|
||||||
|
{'name': 'foo5', 'value': 'five'},
|
||||||
|
{'name': 'foo6', 'value': 'six'},
|
||||||
|
{'name': 'foo7', 'value': 'seven'},
|
||||||
|
{'name': 'foo8', 'value': 'eight'},
|
||||||
|
{'name': 'foo9', 'value': 'nine'},
|
||||||
|
]
|
||||||
|
for setting in sample_data:
|
||||||
|
self.app.save_setting(self.session, setting['name'], setting['value'])
|
||||||
|
self.session.commit()
|
||||||
|
sample_query = self.session.query(model.Setting)
|
||||||
|
|
||||||
|
with patch.multiple(mod.MasterView, create=True,
|
||||||
|
model_class=model.Setting):
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# basic delete, no progress
|
||||||
|
self.assertEqual(self.session.query(model.Setting).count(), 9)
|
||||||
|
settings = self.session.query(model.Setting)\
|
||||||
|
.filter(model.Setting.value.ilike('%s%'))
|
||||||
|
self.assertEqual(settings.count(), 2)
|
||||||
|
with patch.object(self.app, 'make_session', return_value=self.session):
|
||||||
|
view.delete_bulk_thread(settings)
|
||||||
|
self.assertEqual(self.session.query(model.Setting).count(), 7)
|
||||||
|
|
||||||
|
# basic delete, with progress
|
||||||
|
settings = self.session.query(model.Setting)\
|
||||||
|
.filter(model.Setting.name == 'foo1')
|
||||||
|
self.assertEqual(settings.count(), 1)
|
||||||
|
with patch.object(self.app, 'make_session', return_value=self.session):
|
||||||
|
view.delete_bulk_thread(settings, progress=MagicMock())
|
||||||
|
self.assertEqual(self.session.query(model.Setting).count(), 6)
|
||||||
|
|
||||||
|
# error, no progress
|
||||||
|
settings = self.session.query(model.Setting)\
|
||||||
|
.filter(model.Setting.name == 'foo2')
|
||||||
|
self.assertEqual(settings.count(), 1)
|
||||||
|
with patch.object(self.app, 'make_session', return_value=self.session):
|
||||||
|
with patch.object(view, 'delete_bulk_action', side_effect=RuntimeError):
|
||||||
|
view.delete_bulk_thread(settings)
|
||||||
|
# nb. nothing was deleted
|
||||||
|
self.assertEqual(self.session.query(model.Setting).count(), 6)
|
||||||
|
|
||||||
|
# error, with progress
|
||||||
|
self.assertEqual(settings.count(), 1)
|
||||||
|
with patch.object(self.app, 'make_session', return_value=self.session):
|
||||||
|
with patch.object(view, 'delete_bulk_action', side_effect=RuntimeError):
|
||||||
|
view.delete_bulk_thread(settings, progress=MagicMock())
|
||||||
|
# nb. nothing was deleted
|
||||||
|
self.assertEqual(self.session.query(model.Setting).count(), 6)
|
||||||
|
|
||||||
def test_autocomplete(self):
|
def test_autocomplete(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
|
||||||
|
|
62
tests/views/test_progress.py
Normal file
62
tests/views/test_progress.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from pyramid import testing
|
||||||
|
|
||||||
|
from wuttaweb.views import progress as mod
|
||||||
|
from wuttaweb.progress import get_progress_session
|
||||||
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestProgressView(WebTestCase):
|
||||||
|
|
||||||
|
def test_includeme(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.progress')
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
self.request.session.id = 'mockid'
|
||||||
|
self.request.matchdict = {'key': 'foo'}
|
||||||
|
|
||||||
|
# first call with no setup, will create the progress session
|
||||||
|
# but it should be "empty" - except not really since beaker
|
||||||
|
# adds some keys by default
|
||||||
|
context = mod.progress(self.request)
|
||||||
|
self.assertIsInstance(context, dict)
|
||||||
|
|
||||||
|
# now let's establish a progress session of our own
|
||||||
|
progsess = get_progress_session(self.request, 'bar')
|
||||||
|
progsess['maximum'] = 2
|
||||||
|
progsess['value'] = 1
|
||||||
|
progsess.save()
|
||||||
|
|
||||||
|
# then call view, check results
|
||||||
|
self.request.matchdict = {'key': 'bar'}
|
||||||
|
context = mod.progress(self.request)
|
||||||
|
self.assertEqual(context['maximum'], 2)
|
||||||
|
self.assertEqual(context['value'], 1)
|
||||||
|
self.assertNotIn('complete', context)
|
||||||
|
|
||||||
|
# now mark it as complete, check results
|
||||||
|
progsess['complete'] = True
|
||||||
|
progsess['success_msg'] = "yay!"
|
||||||
|
progsess.save()
|
||||||
|
context = mod.progress(self.request)
|
||||||
|
self.assertTrue(context['complete'])
|
||||||
|
self.assertEqual(context['success_msg'], "yay!")
|
||||||
|
|
||||||
|
# now do that all again, with error
|
||||||
|
progsess = get_progress_session(self.request, 'baz')
|
||||||
|
progsess['maximum'] = 2
|
||||||
|
progsess['value'] = 1
|
||||||
|
progsess.save()
|
||||||
|
self.request.matchdict = {'key': 'baz'}
|
||||||
|
context = mod.progress(self.request)
|
||||||
|
self.assertEqual(context['maximum'], 2)
|
||||||
|
self.assertEqual(context['value'], 1)
|
||||||
|
self.assertNotIn('complete', context)
|
||||||
|
self.assertNotIn('error', context)
|
||||||
|
progsess['error'] = True
|
||||||
|
progsess['error_msg'] = "omg!"
|
||||||
|
progsess.save()
|
||||||
|
context = mod.progress(self.request)
|
||||||
|
self.assertTrue(context['error'])
|
||||||
|
self.assertEqual(context['error_msg'], "omg!")
|
Loading…
Reference in a new issue