From f5688f1f909851f7587ab0a156242767cabc443e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 5 Aug 2017 22:07:49 -0500 Subject: [PATCH] Add basic support for performing / tracking app upgrades also add `MasterView.executable` and friends --- tailbone/forms2/core.py | 24 +++- tailbone/static/__init__.py | 5 +- tailbone/static/js/tailbone.js | 3 + tailbone/templates/master/create.mako | 13 +- tailbone/templates/master/edit.mako | 17 ++- tailbone/templates/upgrades/view.mako | 19 +++ tailbone/views/__init__.py | 1 + tailbone/views/batch/core.py | 17 +-- tailbone/views/master.py | 112 ++++++++++++++- tailbone/views/master3.py | 4 + tailbone/views/upgrades.py | 197 ++++++++++++++++++++++++++ 11 files changed, 386 insertions(+), 26 deletions(-) create mode 100644 tailbone/templates/upgrades/view.mako create mode 100644 tailbone/views/upgrades.py diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index 40dbc44d..b736df39 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -33,7 +33,7 @@ import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY -from rattail.util import prettify +from rattail.util import prettify, pretty_boolean import colander from colanderalchemy import SQLAlchemySchemaNode @@ -42,6 +42,8 @@ from deform import widget as dfwidget from pyramid.renderers import render from webhelpers2.html import tags, HTML +from tailbone.util import raw_datetime + log = logging.getLogger(__name__) @@ -176,7 +178,7 @@ class Form(object): model_instance=None, model_class=None, labels={}, renderers={}, widgets={}, action_url=None, cancel_url=None): - self.fields = fields + self.fields = list(fields) if fields is not None else None self.schema = schema self.request = request self.readonly = readonly @@ -274,9 +276,15 @@ class Form(object): self.readonly_fields.remove(key) 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_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) + else: + raise ValueError("unknown type for '{}' field: {}".format(key, type_)) def set_renderer(self, key, renderer): self.renderers[key] = renderer @@ -354,6 +362,16 @@ class Form(object): return "" 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): value = self.obtain_value(record, field_name) if value is None: diff --git a/tailbone/static/__init__.py b/tailbone/static/__init__.py index 8628dd54..2ad5161a 100644 --- a/tailbone/static/__init__.py +++ b/tailbone/static/__init__.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework @@ -24,8 +24,9 @@ Static Assets """ -from __future__ import unicode_literals +from __future__ import unicode_literals, absolute_import def includeme(config): config.add_static_view('tailbone', 'tailbone:static') + config.add_static_view('deform', 'deform:static') diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js index 334f570e..63141400 100644 --- a/tailbone/static/js/tailbone.js +++ b/tailbone/static/js/tailbone.js @@ -174,6 +174,9 @@ $(function() { $('button, a.button').button(); $('input[type=submit]').button(); $('input[type=reset]').button(); + $('input[type="submit"].autodisable').click(function() { + disable_button(this); + }); /* * enhance dropdowns diff --git a/tailbone/templates/master/create.mako b/tailbone/templates/master/create.mako index 76bee07c..fe52f76b 100644 --- a/tailbone/templates/master/create.mako +++ b/tailbone/templates/master/create.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> <%def name="title()">New ${model_title} @@ -6,6 +6,17 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} ${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 name="disable_button_js()"> diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako index 9599a2c2..cbe88fae 100644 --- a/tailbone/templates/master/edit.mako +++ b/tailbone/templates/master/edit.mako @@ -1,10 +1,10 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> <%def name="title()">Edit ${model_title}: ${instance_title} -<%def name="head_tags()"> - ${parent.head_tags()} +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + % 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 name="context_menu_items()"> diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako new file mode 100644 index 00000000..d65e874d --- /dev/null +++ b/tailbone/templates/upgrades/view.mako @@ -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)): +
+ % 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: + + % else: + + % endif +
+% endif diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 6b44f398..d8090a19 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -69,6 +69,7 @@ def includeme(config): config.include('tailbone.views.stores') config.include('tailbone.views.subdepartments') config.include('tailbone.views.taxes') + config.include('tailbone.views.upgrades') config.include('tailbone.views.users') config.include('tailbone.views.vendors') diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index fc9ef4c9..6c4f005e 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -69,6 +69,7 @@ class BatchMasterView(MasterView): refresh_after_create = False edit_with_rows = False cloneable = False + executable = True supports_mobile = True mobile_filterable = True mobile_rows_viewable = True @@ -107,13 +108,13 @@ class BatchMasterView(MasterView): kwargs['batch'] = batch kwargs['handler'] = self.handler 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: kwargs['rendered_execution_options'] = self.render_execution_options(batch) return 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: kwargs['rendered_execution_options'] = self.render_execution_options() return kwargs @@ -339,7 +340,7 @@ class BatchMasterView(MasterView): 'form': form, 'batch': batch, 'execute_title': self.get_execute_title(batch), - 'execute_enabled': self.executable(batch), + 'execute_enabled': self.instance_executable(batch), } if self.edit_with_rows: @@ -449,7 +450,7 @@ class BatchMasterView(MasterView): def after_edit_row(self, row): self.handler.refresh_row(row) - def executable(self, batch=None): + def instance_executable(self, batch=None): return self.handler.executable(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), 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 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), diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 0ba720fb..e4e32beb 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -26,21 +26,25 @@ Model Master View from __future__ import unicode_literals, absolute_import +import os +import logging + import six import sqlalchemy as sa from sqlalchemy import orm 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.util import prettify -from rattail.time import localtime +from rattail.time import localtime #, make_utc from rattail.threads import Thread import formalchemy as fa from pyramid import httpexceptions from pyramid.renderers import get_renderer, render_to_response, render +from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from tailbone import forms, grids @@ -48,6 +52,9 @@ from tailbone.views import View from tailbone.progress import SessionProgress +log = logging.getLogger(__name__) + + class MasterView(View): """ Base "master" view class. All model master views should derive from this. @@ -64,6 +71,7 @@ class MasterView(View): bulk_deletable = False mergeable = False downloadable = False + executable = False supports_mobile = False mobile_creatable = False @@ -236,7 +244,10 @@ class MasterView(View): self.after_create(obj) self.flash_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): """ @@ -624,6 +635,27 @@ class MasterView(View): self.grid_count = len(data) 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): """ View for editing an existing model record. @@ -647,11 +679,15 @@ class MasterView(View): self.get_model_title(), self.get_instance_title(instance))) return self.redirect_after_edit(instance) - return self.render_to_response('edit', { + context = { 'instance': instance, 'instance_title': instance_title, '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): return form.validate() @@ -760,6 +796,64 @@ class MasterView(View): progress.session['success_url'] = self.get_index_url() 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): if hasattr(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), "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 if cls.deletable: config.add_route('{0}.delete'.format(route_prefix), '{0}/{{{1}}}/delete'.format(url_prefix, model_key)) diff --git a/tailbone/views/master3.py b/tailbone/views/master3.py index e6e0d9a8..459ebfe0 100644 --- a/tailbone/views/master3.py +++ b/tailbone/views/master3.py @@ -124,10 +124,14 @@ class MasterView3(MasterView2): def save_create_form(self, form): self.before_create(form) obj = form.schema.objectify(self.form_deserialized) + self.before_create_flush(obj) self.Session.add(obj) self.Session.flush() return obj + def before_create_flush(self, obj): + pass + def save_edit_form(self, form): obj = form.schema.objectify(self.form_deserialized, context=form.model_instance) self.after_edit(obj) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py new file mode 100644 index 00000000..d83b382c --- /dev/null +++ b/tailbone/views/upgrades.py @@ -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 . +# +################################################################################ +""" +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)