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
|
||||
helpers
|
||||
menus
|
||||
progress
|
||||
static
|
||||
subscribers
|
||||
util
|
||||
|
@ -30,6 +31,7 @@
|
|||
views.essential
|
||||
views.master
|
||||
views.people
|
||||
views.progress
|
||||
views.roles
|
||||
views.settings
|
||||
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``
|
||||
===========================
|
||||
=========================
|
||||
|
||||
.. automodule:: wuttaweb.views.people
|
||||
: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" />
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<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>
|
||||
${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()} » ${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)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
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.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'))
|
||||
|
|
|
@ -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
|
||||
|
|
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 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
|
||||
|
||||
|
|
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