Add basic support for performing / tracking app upgrades

also add `MasterView.executable` and friends
This commit is contained in:
Lance Edgar 2017-08-05 22:07:49 -05:00
parent f476c696fd
commit f5688f1f90
11 changed files with 386 additions and 26 deletions

View file

@ -33,7 +33,7 @@ import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY
from rattail.util import prettify from rattail.util import prettify, pretty_boolean
import colander import colander
from colanderalchemy import SQLAlchemySchemaNode from colanderalchemy import SQLAlchemySchemaNode
@ -42,6 +42,8 @@ from deform import widget as dfwidget
from pyramid.renderers import render from pyramid.renderers import render
from webhelpers2.html import tags, HTML from webhelpers2.html import tags, HTML
from tailbone.util import raw_datetime
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -176,7 +178,7 @@ class Form(object):
model_instance=None, model_class=None, labels={}, renderers={}, widgets={}, model_instance=None, model_class=None, labels={}, renderers={}, widgets={},
action_url=None, cancel_url=None): action_url=None, cancel_url=None):
self.fields = fields self.fields = list(fields) if fields is not None else None
self.schema = schema self.schema = schema
self.request = request self.request = request
self.readonly = readonly self.readonly = readonly
@ -274,9 +276,15 @@ class Form(object):
self.readonly_fields.remove(key) self.readonly_fields.remove(key)
def set_type(self, key, type_): def set_type(self, key, type_):
if type_ == 'codeblock': if type_ == 'datetime':
self.set_renderer(key, self.render_datetime)
elif type_ == 'boolean':
self.set_renderer(key, self.render_boolean)
elif type_ == 'codeblock':
self.set_renderer(key, self.render_codeblock) self.set_renderer(key, self.render_codeblock)
self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8))
else:
raise ValueError("unknown type for '{}' field: {}".format(key, type_))
def set_renderer(self, key, renderer): def set_renderer(self, key, renderer):
self.renderers[key] = renderer self.renderers[key] = renderer
@ -354,6 +362,16 @@ class Form(object):
return "" return ""
return six.text_type(value) return six.text_type(value)
def render_datetime(self, record, field_name):
value = self.obtain_value(record, field_name)
if value is None:
return ""
return raw_datetime(self.request.rattail_config, value)
def render_boolean(self, record, field_name):
value = self.obtain_value(record, field_name)
return pretty_boolean(value)
def render_codeblock(self, record, field_name): def render_codeblock(self, record, field_name):
value = self.obtain_value(record, field_name) value = self.obtain_value(record, field_name)
if value is None: if value is None:

View file

@ -1,4 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8; -*-
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
@ -24,8 +24,9 @@
Static Assets Static Assets
""" """
from __future__ import unicode_literals from __future__ import unicode_literals, absolute_import
def includeme(config): def includeme(config):
config.add_static_view('tailbone', 'tailbone:static') config.add_static_view('tailbone', 'tailbone:static')
config.add_static_view('deform', 'deform:static')

View file

@ -174,6 +174,9 @@ $(function() {
$('button, a.button').button(); $('button, a.button').button();
$('input[type=submit]').button(); $('input[type=submit]').button();
$('input[type=reset]').button(); $('input[type=reset]').button();
$('input[type="submit"].autodisable').click(function() {
disable_button(this);
});
/* /*
* enhance dropdowns * enhance dropdowns

View file

@ -1,4 +1,4 @@
## -*- coding: utf-8 -*- ## -*- coding: utf-8; -*-
<%inherit file="/base.mako" /> <%inherit file="/base.mako" />
<%def name="title()">New ${model_title}</%def> <%def name="title()">New ${model_title}</%def>
@ -6,6 +6,17 @@
<%def name="extra_javascript()"> <%def name="extra_javascript()">
${parent.extra_javascript()} ${parent.extra_javascript()}
${self.disable_button_js()} ${self.disable_button_js()}
% if dform is not Undefined:
% for field in dform:
<% resources = field.get_widget_resources() %>
% for path in resources['js']:
${h.javascript_link(request.static_url(path))}
% endfor
% for path in resources['css']:
${h.stylesheet_link(request.static_url(path))}
% endfor
% endfor
% endif
</%def> </%def>
<%def name="disable_button_js()"> <%def name="disable_button_js()">

View file

@ -1,10 +1,10 @@
## -*- coding: utf-8 -*- ## -*- coding: utf-8; -*-
<%inherit file="/base.mako" /> <%inherit file="/base.mako" />
<%def name="title()">Edit ${model_title}: ${instance_title}</%def> <%def name="title()">Edit ${model_title}: ${instance_title}</%def>
<%def name="head_tags()"> <%def name="extra_javascript()">
${parent.head_tags()} ${parent.extra_javascript()}
<script type="text/javascript"> <script type="text/javascript">
$(function() { $(function() {
@ -18,6 +18,17 @@
}); });
</script> </script>
% if dform is not Undefined:
% for field in dform:
<% resources = field.get_widget_resources() %>
% for path in resources['js']:
${h.javascript_link(request.static_url(path))}
% endfor
% for path in resources['css']:
${h.stylesheet_link(request.static_url(path))}
% endfor
% endfor
% endif
</%def> </%def>
<%def name="context_menu_items()"> <%def name="context_menu_items()">

View file

@ -0,0 +1,19 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" />
${parent.body()}
% if not instance.executed and request.has_perm('{}.execute'.format(permission_prefix)):
<div class="buttons">
% if instance.enabled and not instance.executing:
${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid))}
${h.csrf_token(request)}
${h.submit('execute', "Execute this upgrade", class_='autodisable')}
${h.end_form()}
% elif instance.enabled:
<button type="button" disabled="disabled" title="This upgrade is currently executing">Execute this upgrade</button>
% else:
<button type="button" disabled="disabled" title="This upgrade is not enabled">Execute this upgrade</button>
% endif
</div>
% endif

View file

@ -69,6 +69,7 @@ def includeme(config):
config.include('tailbone.views.stores') config.include('tailbone.views.stores')
config.include('tailbone.views.subdepartments') config.include('tailbone.views.subdepartments')
config.include('tailbone.views.taxes') config.include('tailbone.views.taxes')
config.include('tailbone.views.upgrades')
config.include('tailbone.views.users') config.include('tailbone.views.users')
config.include('tailbone.views.vendors') config.include('tailbone.views.vendors')

View file

@ -69,6 +69,7 @@ class BatchMasterView(MasterView):
refresh_after_create = False refresh_after_create = False
edit_with_rows = False edit_with_rows = False
cloneable = False cloneable = False
executable = True
supports_mobile = True supports_mobile = True
mobile_filterable = True mobile_filterable = True
mobile_rows_viewable = True mobile_rows_viewable = True
@ -107,13 +108,13 @@ class BatchMasterView(MasterView):
kwargs['batch'] = batch kwargs['batch'] = batch
kwargs['handler'] = self.handler kwargs['handler'] = self.handler
kwargs['execute_title'] = self.get_execute_title(batch) kwargs['execute_title'] = self.get_execute_title(batch)
kwargs['execute_enabled'] = self.executable(batch) kwargs['execute_enabled'] = self.instance_executable(batch)
if kwargs['execute_enabled'] and self.has_execution_options: if kwargs['execute_enabled'] and self.has_execution_options:
kwargs['rendered_execution_options'] = self.render_execution_options(batch) kwargs['rendered_execution_options'] = self.render_execution_options(batch)
return kwargs return kwargs
def template_kwargs_index(self, **kwargs): def template_kwargs_index(self, **kwargs):
kwargs['execute_enabled'] = self.executable() kwargs['execute_enabled'] = self.instance_executable(None)
if kwargs['execute_enabled'] and self.has_execution_options: if kwargs['execute_enabled'] and self.has_execution_options:
kwargs['rendered_execution_options'] = self.render_execution_options() kwargs['rendered_execution_options'] = self.render_execution_options()
return kwargs return kwargs
@ -339,7 +340,7 @@ class BatchMasterView(MasterView):
'form': form, 'form': form,
'batch': batch, 'batch': batch,
'execute_title': self.get_execute_title(batch), 'execute_title': self.get_execute_title(batch),
'execute_enabled': self.executable(batch), 'execute_enabled': self.instance_executable(batch),
} }
if self.edit_with_rows: if self.edit_with_rows:
@ -449,7 +450,7 @@ class BatchMasterView(MasterView):
def after_edit_row(self, row): def after_edit_row(self, row):
self.handler.refresh_row(row) self.handler.refresh_row(row)
def executable(self, batch=None): def instance_executable(self, batch=None):
return self.handler.executable(batch) return self.handler.executable(batch)
def batch_refreshable(self, batch): def batch_refreshable(self, batch):
@ -1018,14 +1019,6 @@ class BatchMasterView(MasterView):
config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix), config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix),
permission='{}.edit'.format(permission_prefix)) permission='{}.edit'.format(permission_prefix))
# execute batch
config.add_route('{}.execute'.format(route_prefix), '{}/{{uuid}}/execute'.format(url_prefix))
config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix),
permission='{}.execute'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix),
"Execute {}".format(model_title))
# execute (multiple) batch results # execute (multiple) batch results
config.add_route('{}.execute_results'.format(route_prefix), '{}/execute-results'.format(url_prefix)) config.add_route('{}.execute_results'.format(route_prefix), '{}/execute-results'.format(url_prefix))
config.add_view(cls, attr='execute_results', route_name='{}.execute_results'.format(route_prefix), config.add_view(cls, attr='execute_results', route_name='{}.execute_results'.format(route_prefix),

View file

@ -26,21 +26,25 @@ Model Master View
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
import os
import logging
import six import six
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
import sqlalchemy_continuum as continuum import sqlalchemy_continuum as continuum
from rattail.db import Session as RattailSession from rattail.db import model, Session as RattailSession
from rattail.db.continuum import model_transaction_query from rattail.db.continuum import model_transaction_query
from rattail.util import prettify from rattail.util import prettify
from rattail.time import localtime from rattail.time import localtime #, make_utc
from rattail.threads import Thread from rattail.threads import Thread
import formalchemy as fa import formalchemy as fa
from pyramid import httpexceptions from pyramid import httpexceptions
from pyramid.renderers import get_renderer, render_to_response, render from pyramid.renderers import get_renderer, render_to_response, render
from pyramid.response import FileResponse
from webhelpers2.html import HTML, tags from webhelpers2.html import HTML, tags
from tailbone import forms, grids from tailbone import forms, grids
@ -48,6 +52,9 @@ from tailbone.views import View
from tailbone.progress import SessionProgress from tailbone.progress import SessionProgress
log = logging.getLogger(__name__)
class MasterView(View): class MasterView(View):
""" """
Base "master" view class. All model master views should derive from this. Base "master" view class. All model master views should derive from this.
@ -64,6 +71,7 @@ class MasterView(View):
bulk_deletable = False bulk_deletable = False
mergeable = False mergeable = False
downloadable = False downloadable = False
executable = False
supports_mobile = False supports_mobile = False
mobile_creatable = False mobile_creatable = False
@ -236,7 +244,10 @@ class MasterView(View):
self.after_create(obj) self.after_create(obj)
self.flash_after_create(obj) self.flash_after_create(obj)
return self.redirect_after_create(obj) return self.redirect_after_create(obj)
return self.render_to_response('create', {'form': form}) context = {'form': form}
if hasattr(form, 'make_deform_form'):
context['dform'] = form.make_deform_form()
return self.render_to_response('create', context)
def mobile_create(self): def mobile_create(self):
""" """
@ -624,6 +635,27 @@ class MasterView(View):
self.grid_count = len(data) self.grid_count = len(data)
return self.view(instance) return self.view(instance)
def download(self):
"""
View for downloading a data file.
"""
obj = self.get_instance()
filename = self.request.GET.get('filename', None)
path = self.download_path(obj, filename)
response = FileResponse(path, request=self.request)
response.content_length = os.path.getsize(path)
content_type = self.download_content_type(path, filename)
if content_type:
response.content_type = six.binary_type(content_type)
filename = os.path.basename(path).encode('ascii', 'replace')
response.content_disposition = b'attachment; filename={}'.format(filename)
return response
def download_content_type(self, path, filename):
"""
Return a content type for a file download, if known.
"""
def edit(self): def edit(self):
""" """
View for editing an existing model record. View for editing an existing model record.
@ -647,11 +679,15 @@ class MasterView(View):
self.get_model_title(), self.get_instance_title(instance))) self.get_model_title(), self.get_instance_title(instance)))
return self.redirect_after_edit(instance) return self.redirect_after_edit(instance)
return self.render_to_response('edit', { context = {
'instance': instance, 'instance': instance,
'instance_title': instance_title, 'instance_title': instance_title,
'instance_deletable': self.deletable_instance(instance), 'instance_deletable': self.deletable_instance(instance),
'form': form}) 'form': form,
}
if hasattr(form, 'make_deform_form'):
context['dform'] = form.make_deform_form()
return self.render_to_response('edit', context)
def validate_form(self, form): def validate_form(self, form):
return form.validate() return form.validate()
@ -760,6 +796,64 @@ class MasterView(View):
progress.session['success_url'] = self.get_index_url() progress.session['success_url'] = self.get_index_url()
progress.session.save() progress.session.save()
def execute(self):
"""
Execute an object.
"""
obj = self.get_instance()
model_title = self.get_model_title()
if self.request.method == 'POST':
key = '{}.execute'.format(self.get_grid_key())
kwargs = {'progress': SessionProgress(self.request, key)}
thread = Thread(target=self.execute_thread, args=(obj.uuid, self.request.user.uuid), kwargs=kwargs)
thread.start()
return self.render_progress({
'key': key,
'cancel_url': self.get_action_url('view', obj),
'cancel_msg': "{} execution was canceled".format(model_title),
})
self.request.session.flash("Sorry, you must POST to execute a {}.".format(model_title), 'error')
return self.redirect(self.get_action_url('view', obj))
def execute_thread(self, uuid, user_uuid, progress=None, **kwargs):
"""
Thread target for executing an object.
"""
session = RattailSession()
obj = session.query(self.model_class).get(uuid)
user = session.query(model.User).get(user_uuid)
try:
self.execute_instance(obj, user, progress=progress, **kwargs)
# If anything goes wrong, rollback and log the error etc.
except Exception as error:
session.rollback()
log.exception("execution failed for object: {}".format(obj))
session.close()
if progress:
progress.session.load()
progress.session['error'] = True
progress.session['error_msg'] = self.execute_error_message(error)
progress.session.save()
# If no error, check result flag (false means user canceled).
else:
session.commit()
session.refresh(obj)
success_url = self.get_execute_success_url(obj)
session.close()
if progress:
progress.session.load()
progress.session['complete'] = True
progress.session['success_url'] = success_url
progress.session.save()
def get_execute_success_url(self, obj, **kwargs):
return self.get_action_url('view', obj, **kwargs)
def get_merge_fields(self): def get_merge_fields(self):
if hasattr(self, 'merge_fields'): if hasattr(self, 'merge_fields'):
return self.merge_fields return self.merge_fields
@ -1709,6 +1803,14 @@ class MasterView(View):
config.add_tailbone_permission(permission_prefix, '{0}.edit'.format(permission_prefix), config.add_tailbone_permission(permission_prefix, '{0}.edit'.format(permission_prefix),
"Edit {0}".format(model_title)) "Edit {0}".format(model_title))
# execute
if cls.executable:
config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix),
"Execute {}".format(model_title))
config.add_route('{}.execute'.format(route_prefix), '{}/{{{}}}/execute'.format(url_prefix, model_key))
config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix),
permission='{}.execute'.format(permission_prefix))
# delete # delete
if cls.deletable: if cls.deletable:
config.add_route('{0}.delete'.format(route_prefix), '{0}/{{{1}}}/delete'.format(url_prefix, model_key)) config.add_route('{0}.delete'.format(route_prefix), '{0}/{{{1}}}/delete'.format(url_prefix, model_key))

View file

@ -124,10 +124,14 @@ class MasterView3(MasterView2):
def save_create_form(self, form): def save_create_form(self, form):
self.before_create(form) self.before_create(form)
obj = form.schema.objectify(self.form_deserialized) obj = form.schema.objectify(self.form_deserialized)
self.before_create_flush(obj)
self.Session.add(obj) self.Session.add(obj)
self.Session.flush() self.Session.flush()
return obj return obj
def before_create_flush(self, obj):
pass
def save_edit_form(self, form): def save_edit_form(self, form):
obj = form.schema.objectify(self.form_deserialized, context=form.model_instance) obj = form.schema.objectify(self.form_deserialized, context=form.model_instance)
self.after_edit(obj) self.after_edit(obj)

197
tailbone/views/upgrades.py Normal file
View file

@ -0,0 +1,197 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail 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.
#
# Rattail 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
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Views for app upgrades
"""
from __future__ import unicode_literals, absolute_import
import os
from sqlalchemy import orm
from rattail.db import model, Session as RattailSession
from rattail.time import make_utc
from rattail.threads import Thread
from rattail.upgrades import get_upgrade_handler
from deform import widget as dfwidget
from webhelpers2.html import tags
from tailbone.views import MasterView3 as MasterView
from tailbone.progress import SessionProgress
class UpgradeView(MasterView):
"""
Master view for all user events
"""
model_class = model.Upgrade
executable = True
downloadable = True
grid_columns = [
'created',
'description',
# 'not_until',
'enabled',
'executing',
'executed',
'executed_by',
]
form_fields = [
'description',
# 'not_until',
# 'requirements',
'notes',
'created',
'created_by',
'enabled',
'executing',
'executed',
'executed_by',
'stdout_file',
'stderr_file',
]
def __init__(self, request):
super(UpgradeView, self).__init__(request)
self.handler = self.get_handler()
def get_handler(self):
"""
Returns the ``UpgradeHandler`` instance for the view. The handler
factory for this may be defined by config, e.g.:
.. code-block:: ini
[rattail.upgrades]
handler = myapp.upgrades:CustomUpgradeHandler
"""
return get_upgrade_handler(self.rattail_config)
def configure_grid(self, g):
super(UpgradeView, self).configure_grid(g)
g.set_joiner('executed_by', lambda q: q.join(model.User).outerjoin(model.Person))
g.set_sorter('executed_by', model.Person.display_name)
g.set_type('created', 'datetime')
g.set_type('executed', 'datetime')
g.default_sortkey = 'created'
g.default_sortdir = 'desc'
g.set_label('executed_by', "Executed by")
g.set_link('created')
g.set_link('description')
# g.set_link('not_until')
g.set_link('executed')
def configure_form(self, f):
super(UpgradeView, self).configure_form(f)
f.set_type('created', 'datetime')
f.set_type('enabled', 'boolean')
f.set_type('executing', 'boolean')
f.set_type('executed', 'datetime')
# f.set_widget('not_until', dfwidget.DateInputWidget())
f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=8))
f.set_renderer('stdout_file', self.render_stdout_file)
f.set_renderer('stderr_file', self.render_stdout_file)
# f.set_readonly('created')
# f.set_readonly('created_by')
f.set_readonly('executing')
f.set_readonly('executed')
f.set_readonly('executed_by')
f.set_label('stdout_file', "STDOUT")
f.set_label('stderr_file', "STDERR")
upgrade = f.model_instance
if self.creating or self.editing:
f.remove_field('created')
f.remove_field('created_by')
f.remove_field('stdout_file')
f.remove_field('stderr_file')
if self.creating or not upgrade.executed:
f.remove_field('executing')
f.remove_field('executed')
f.remove_field('executed_by')
if self.editing and upgrade.executed:
f.remove_field('enabled')
elif f.model_instance.executed:
f.remove_field('enabled')
f.remove_field('executing')
else:
f.remove_field('executed')
f.remove_field('executed_by')
f.remove_field('stdout_file')
f.remove_field('stderr_file')
def render_stdout_file(self, upgrade, fieldname):
if fieldname.startswith('stderr'):
filename = 'stderr.log'
else:
filename = 'stdout.log'
path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename)
if path:
content = "{} ({})".format(filename, self.readable_size(path))
url = '{}?filename={}'.format(self.get_action_url('download', upgrade), filename)
return tags.link_to(content, url)
return filename
def get_size(self, path):
try:
return os.path.getsize(path)
except os.error:
return 0
def readable_size(self, path):
# TODO: this was shamelessly copied from FormAlchemy ...
length = self.get_size(path)
if length == 0:
return '0 KB'
if length <= 1024:
return '1 KB'
if length > 1048576:
return '%0.02f MB' % (length / 1048576.0)
return '%0.02f KB' % (length / 1024.0)
def download_path(self, upgrade, filename):
return self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename)
def download_content_type(self, path, filename):
return 'text/plain'
def before_create_flush(self, upgrade):
upgrade.created_by = self.request.user
def execute_instance(self, upgrade, user, **kwargs):
session = orm.object_session(upgrade)
upgrade.executing = True
session.commit()
self.handler.execute(upgrade, user, **kwargs)
upgrade.executing = False
upgrade.executed = make_utc()
upgrade.executed_by = user
def includeme(config):
UpgradeView.defaults(config)