diff --git a/src/wuttaweb/templates/progress.mako b/src/wuttaweb/templates/progress.mako
index 35223a1..ddf5a74 100644
--- a/src/wuttaweb/templates/progress.mako
+++ b/src/wuttaweb/templates/progress.mako
@@ -55,7 +55,7 @@
<%def name="modify_vue_vars()">
%def>
diff --git a/src/wuttaweb/templates/upgrade.mako b/src/wuttaweb/templates/upgrade.mako
new file mode 100644
index 0000000..72a4424
--- /dev/null
+++ b/src/wuttaweb/templates/upgrade.mako
@@ -0,0 +1,64 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/progress.mako" />
+
+<%def name="extra_styles()">
+ ${parent.extra_styles()}
+
+%def>
+
+<%def name="after_progress()">
+
+
+
+
+ ## nb. we auto-scroll down to "see" this element
+
+
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+
+%def>
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index fe333b1..c5ff5d7 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -76,7 +76,7 @@ class MasterView(View):
Optional reference to a data model class. While not strictly
required, most views will set this to a SQLAlchemy mapped
class,
- e.g. :class:`wuttjamaican:wuttjamaican.db.model.auth.User`.
+ e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
Code should not access this directly but instead call
:meth:`get_model_class()`.
@@ -365,6 +365,7 @@ class MasterView(View):
has_autocomplete = False
downloadable = False
executable = False
+ execute_progress_template = None
configurable = False
# current action
@@ -725,7 +726,7 @@ class MasterView(View):
len(records), model_title_plural,
exc_info=True)
if progress:
- progress.handle_error(error, "Bulk deletion failed")
+ progress.handle_error(error)
else:
session.commit()
@@ -953,21 +954,26 @@ class MasterView(View):
* :meth:`execute_instance()`
"""
+ route_prefix = self.get_route_prefix()
model_title = self.get_model_title()
obj = self.get_instance()
- try:
- self.execute_instance(obj)
- except Exception as error:
- log.exception("failed to execute %s: %s", model_title, obj)
- error = str(error) or error.__class__.__name__
- self.request.session.flash(error, 'error')
- else:
- self.request.session.flash(f"{model_title} was executed.")
+ # make the progress tracker
+ progress = self.make_progress(f'{route_prefix}.execute',
+ success_msg=f"{model_title} was executed.",
+ success_url=self.get_action_url('view', obj))
- return self.redirect(self.get_action_url('view', obj))
+ # start thread for execute; show progress page
+ key = self.request.matchdict
+ thread = threading.Thread(target=self.execute_thread,
+ args=(key, self.request.user.uuid),
+ kwargs={'progress': progress})
+ thread.start()
+ return self.render_progress(progress, context={
+ 'instance': obj,
+ }, template=self.execute_progress_template)
- def execute_instance(self, obj):
+ def execute_instance(self, obj, user, progress=None):
"""
Perform the actual "execution" logic for a model record.
Called by :meth:`execute()`.
@@ -975,8 +981,43 @@ class MasterView(View):
This method does nothing by default; subclass must override.
:param obj: Reference to the model instance.
+
+ :param user: Reference to the
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
+ is doing the execute.
+
+ :param progress: Optional progress indicator factory.
"""
+ def execute_thread(self, key, user_uuid, progress=None):
+ """ """
+ model = self.app.model
+ model_title = self.get_model_title()
+
+ # nb. use new session, separate from web transaction
+ session = self.app.make_session()
+
+ # fetch model instance and user for this session
+ obj = self.get_instance(session=session, matchdict=key)
+ user = session.get(model.User, user_uuid)
+
+ try:
+ self.execute_instance(obj, user, progress=progress)
+
+ except Exception as error:
+ session.rollback()
+ log.warning("%s failed to execute: %s", model_title, obj, exc_info=True)
+ if progress:
+ progress.handle_error(error)
+
+ else:
+ session.commit()
+ if progress:
+ progress.handle_success()
+
+ finally:
+ session.close()
+
##############################
# configure methods
##############################
@@ -1847,23 +1888,69 @@ class MasterView(View):
# for key in self.get_model_key():
# grid.set_link(key)
- def get_instance(self, session=None):
+ def get_instance(self, session=None, matchdict=None):
"""
- This should return the "current" model instance based on the
- request details (e.g. route kwargs).
+ This should return the appropriate model instance, based on
+ the ``matchdict`` of model keys.
- If the instance cannot be found, this should raise a HTTP 404
- exception, i.e. :meth:`~wuttaweb.views.base.View.notfound()`.
+ Normally this is called with no arguments, in which case the
+ :attr:`pyramid:pyramid.request.Request.matchdict` is used, and
+ will return the "current" model instance based on the request
+ (route/params).
- There is no "sane" default logic here; subclass *must*
- override or else a ``NotImplementedError`` is raised.
+ If a ``matchdict`` is provided then that is used instead, to
+ obtain the model keys. In the simple/common example of a
+ "native" model in WuttaWeb, this would look like::
+
+ keys = {'uuid': '38905440630d11ef9228743af49773a4'}
+ obj = self.get_instance(matchdict=keys)
+
+ Although some models may have different, possibly composite
+ key names to use instead. The specific keys this logic is
+ expecting are the same as returned by :meth:`get_model_key()`.
+
+ If this method is unable to locate the instance, it should
+ raise a 404 error,
+ i.e. :meth:`~wuttaweb.views.base.View.notfound()`.
+
+ Default implementation of this method should work okay for
+ views which define a :attr:`model_class`. For other views
+ however it will raise ``NotImplementedError``, so subclass
+ may need to define.
+
+ .. warning::
+
+ If you are defining this method for a subclass, please note
+ this point regarding the 404 "not found" logic.
+
+ It is *not* enough to simply *return* this 404 response,
+ you must explicitly *raise* the error. For instance::
+
+ def get_instance(self, **kwargs):
+
+ # ..try to locate instance..
+ obj = self.locate_instance_somehow()
+
+ if not obj:
+
+ # NB. THIS MAY NOT WORK AS EXPECTED
+ #return self.notfound()
+
+ # nb. should always do this in get_instance()
+ raise self.notfound()
+
+ This lets calling code not have to worry about whether or
+ not this method might return ``None``. It can safely
+ assume it will get back a model instance, or else a 404
+ will kick in and control flow goes elsewhere.
"""
model_class = self.get_model_class()
if model_class:
session = session or self.Session()
+ matchdict = matchdict or self.request.matchdict
def filtr(query, model_key):
- key = self.request.matchdict[model_key]
+ key = matchdict[model_key]
query = query.filter(getattr(self.model_class, model_key) == key)
return query
@@ -2126,7 +2213,7 @@ class MasterView(View):
Returns the model class for the view (if defined).
A model class will *usually* be a SQLAlchemy mapped class,
- e.g. :class:`wuttjamaican:wuttjamaican.db.model.base.Person`.
+ e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
There is no default value here, but a subclass may override by
assigning :attr:`model_class`.
diff --git a/src/wuttaweb/views/upgrades.py b/src/wuttaweb/views/upgrades.py
index ee4a2bd..03570f3 100644
--- a/src/wuttaweb/views/upgrades.py
+++ b/src/wuttaweb/views/upgrades.py
@@ -36,6 +36,7 @@ from wuttjamaican.db.model import Upgrade
from wuttaweb.views import MasterView
from wuttaweb.forms import widgets
from wuttaweb.forms.schema import UserRef, WuttaEnum, FileDownload
+from wuttaweb.progress import get_progress_session
log = logging.getLogger(__name__)
@@ -57,6 +58,7 @@ class UpgradeView(MasterView):
"""
model_class = Upgrade
executable = True
+ execute_progress_template = '/upgrade.mako'
downloadable = True
configurable = True
@@ -222,7 +224,7 @@ class UpgradeView(MasterView):
path = os.path.join(path, filename)
return path
- def execute_instance(self, upgrade):
+ def execute_instance(self, upgrade, user, progress=None):
"""
This method runs the actual upgrade.
@@ -236,28 +238,79 @@ class UpgradeView(MasterView):
either ``SUCCESS`` or ``FAILURE``.
"""
enum = self.app.enum
- script = self.config.require(f'{self.app.appname}.upgrades.command',
- session=self.Session())
+
+ # locate file paths
+ script = self.config.require(f'{self.app.appname}.upgrades.command')
stdout_path = self.get_upgrade_filepath(upgrade, 'stdout.log')
stderr_path = self.get_upgrade_filepath(upgrade, 'stderr.log')
+ # record the fact that execution has begun for this upgrade
+ # nb. this is done in separate session to ensure it sticks,
+ # but also update local object to reflect the change
+ with self.app.short_session(commit=True) as s:
+ alt = s.merge(upgrade)
+ alt.status = enum.UpgradeStatus.EXECUTING
+ upgrade.status = enum.UpgradeStatus.EXECUTING
+
# run the command
log.debug("running upgrade command: %s", script)
with open(stdout_path, 'wb') as stdout:
with open(stderr_path, 'wb') as stderr:
- upgrade.exit_code = subprocess.call(script, shell=True,
+ upgrade.exit_code = subprocess.call(script, shell=True, text=True,
stdout=stdout, stderr=stderr)
logger = log.warning if upgrade.exit_code != 0 else log.debug
- logger("upgrade command had non-zero exit code: %s", upgrade.exit_code)
+ logger("upgrade command had exit code: %s", upgrade.exit_code)
# declare it complete
upgrade.executed = datetime.datetime.now()
- upgrade.executed_by = self.request.user
+ upgrade.executed_by = user
if upgrade.exit_code == 0:
upgrade.status = enum.UpgradeStatus.SUCCESS
else:
upgrade.status = enum.UpgradeStatus.FAILURE
+ def execute_progress(self):
+ """ """
+ route_prefix = self.get_route_prefix()
+ upgrade = self.get_instance()
+ session = get_progress_session(self.request, f'{route_prefix}.execute')
+
+ # 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:
+ self.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.")
+ self.request.session.flash(msg, 'error')
+
+ # our return value will include all from progress session
+ data = dict(session)
+
+ # add whatever might be new from upgrade process STDOUT
+ path = self.get_upgrade_filepath(upgrade, filename='stdout.log')
+ offset = session.get('stdout.offset', 0)
+ if os.path.exists(path):
+ size = os.path.getsize(path) - offset
+ if size > 0:
+ # with open(path, 'rb') as f:
+ with open(path) as f:
+ f.seek(offset)
+ chunk = f.read(size)
+ # data['stdout'] = chunk.decode('utf8').replace('\n', '