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}%def> @@ -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> <%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> -<%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> <%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)): +
+% 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