source: tailbone/tailbone/views/batch/core.py @ f8d26b4

Last change on this file since f8d26b4 was f8d26b4, checked in by Lance Edgar <ledgar@…>, 4 months ago

Fix some issues with progress "socket" workaround for batches

  • Property mode set to 100644
File size: 56.7 KB
Line 
1# -*- coding: utf-8; -*-
2################################################################################
3#
4#  Rattail -- Retail Software Framework
5#  Copyright © 2010-2019 Lance Edgar
6#
7#  This file is part of Rattail.
8#
9#  Rattail is free software: you can redistribute it and/or modify it under the
10#  terms of the GNU General Public License as published by the Free Software
11#  Foundation, either version 3 of the License, or (at your option) any later
12#  version.
13#
14#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
15#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
17#  details.
18#
19#  You should have received a copy of the GNU General Public License along with
20#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Base views for maintaining "new-style" batches.
25"""
26
27from __future__ import unicode_literals, absolute_import
28
29import os
30import sys
31import datetime
32import logging
33import socket
34import subprocess
35import tempfile
36from six import StringIO
37
38import json
39import six
40import sqlalchemy as sa
41from sqlalchemy import orm
42
43from rattail.db import model, Session as RattailSession
44from rattail.db.util import short_session
45from rattail.threads import Thread
46from rattail.util import load_object, prettify
47from rattail.progress import SocketProgress
48
49import colander
50import deform
51from pyramid.renderers import render_to_response
52from pyramid.response import FileResponse
53from webhelpers2.html import HTML, tags
54
55from tailbone import forms, grids
56from tailbone.db import Session
57from tailbone.views import MasterView
58from tailbone.progress import SessionProgress
59from tailbone.util import csrf_token
60
61
62log = logging.getLogger(__name__)
63
64
65class EverythingComplete(Exception):
66    pass
67
68
69class BatchMasterView(MasterView):
70    """
71    Base class for all "batch master" views.
72    """
73    default_handler_spec = None
74    has_rows = True
75    rows_deletable = True
76    rows_downloadable_csv = True
77    rows_downloadable_xlsx = True
78    refreshable = True
79    refresh_after_create = False
80    cloneable = False
81    executable = True
82    results_executable = False
83    supports_mobile = True
84    mobile_filterable = True
85    mobile_rows_viewable = True
86    has_worksheet = False
87
88    grid_columns = [
89        'id',
90        'description',
91        'created',
92        'created_by',
93        'rowcount',
94        # 'status_code',
95        # 'complete',
96        'executed',
97        'executed_by',
98    ]
99
100    form_fields = [
101        'id',
102        'description',
103        'notes',
104        'created',
105        'created_by',
106        'rowcount',
107        'status_code',
108        'executed',
109        'executed_by',
110    ]
111
112    row_labels = {
113        'upc': "UPC",
114        'item_id': "Item ID",
115        'status_code': "Status",
116    }
117
118    def __init__(self, request):
119        super(BatchMasterView, self).__init__(request)
120        self.handler = self.get_handler()
121
122    def get_handler(self):
123        """
124        Returns a `BatchHandler` instance for the view.  All (?) custom batch
125        views should define a default handler class; however this may in all
126        (?)  cases be overridden by config also.  The specific setting required
127        to do so will depend on the 'key' for the type of batch involved, e.g.
128        assuming the 'vendor_catalog' batch:
129
130        .. code-block:: ini
131
132           [rattail.batch]
133           vendor_catalog.handler = myapp.batch.vendorcatalog:CustomCatalogHandler
134
135        Note that the 'key' for a batch is generally the same as its primary
136        table name, although technically it is whatever value returns from the
137        ``batch_key`` attribute of the main batch model class.
138        """
139        key = self.model_class.batch_key
140        spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key),
141                                       default=self.default_handler_spec)
142        if spec:
143            return load_object(spec)(self.rattail_config)
144        return self.batch_handler_class(self.rattail_config)
145
146    def download_path(self, batch, filename):
147        return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename)
148
149    def template_kwargs_view(self, **kwargs):
150        batch = kwargs['instance']
151        kwargs['batch'] = batch
152        kwargs['handler'] = self.handler
153        kwargs['execute_title'] = self.get_execute_title(batch)
154        kwargs['execute_enabled'] = self.instance_executable(batch)
155        if kwargs['mobile']:
156            if self.mobile_rows_creatable:
157                kwargs.setdefault('add_item_title', "Add Item")
158            if self.mobile_rows_quickable:
159                kwargs.setdefault('quick_entry_placeholder', "Enter {}".format(
160                    self.rattail_config.product_key_title()))
161        if kwargs['execute_enabled']:
162            url = self.get_action_url('execute', batch)
163            kwargs['execute_form'] = self.make_execute_form(batch, action_url=url)
164        else:
165            kwargs['why_not_execute'] = self.handler.why_not_execute(batch)
166        kwargs['status_breakdown'] = self.make_status_breakdown(batch)
167        return kwargs
168
169    def make_status_breakdown(self, batch):
170        """
171        Returns a simple list of 2-tuples, each of which has the status display
172        title as first member, and number of rows with that status as second
173        member.
174        """
175        breakdown = {}
176        for row in batch.active_rows():
177            if row.status_code is not None:
178                if row.status_code not in breakdown:
179                    breakdown[row.status_code] = {
180                        'code': row.status_code,
181                        'title': row.STATUS[row.status_code],
182                        'count': 0,
183                    }
184                breakdown[row.status_code]['count'] += 1
185        breakdown = [
186            (status['title'], status['count'])
187            for code, status in six.iteritems(breakdown)]
188        return breakdown
189
190    def allow_worksheet(self, batch):
191        return not batch.executed and not batch.complete
192
193    def configure_grid(self, g):
194        super(BatchMasterView, self).configure_grid(g)
195
196        # created_by
197        CreatedBy = orm.aliased(model.User)
198        g.set_joiner('created_by', lambda q: q.join(CreatedBy,
199                                                    CreatedBy.uuid == self.model_class.created_by_uuid))
200        g.set_sorter('created_by', CreatedBy.username)
201        g.set_filter('created_by', CreatedBy.username)
202        g.set_label('created_by', "Created by")
203
204        # executed
205        g.filters['executed'].default_active = True
206        g.filters['executed'].default_verb = 'is_null'
207
208        # executed_by
209        ExecutedBy = orm.aliased(model.User)
210        g.set_joiner('executed_by', lambda q: q.outerjoin(ExecutedBy,
211                                                          ExecutedBy.uuid == self.model_class.executed_by_uuid))
212        g.set_sorter('executed_by', ExecutedBy.username)
213        g.set_filter('executed_by', ExecutedBy.username)
214        g.set_label('executed_by', "Executed by")
215
216        g.set_sort_defaults('id', 'desc')
217
218        g.set_enum('status_code', self.model_class.STATUS)
219
220        g.set_renderer('id', self.render_batch_id)
221
222        g.set_link('id')
223        g.set_link('description')
224        g.set_link('executed')
225
226        g.set_label('id', "Batch ID")
227        g.set_label('rowcount', "Rows")
228        g.set_label('status_code', "Status")
229
230    def render_batch_id(self, batch, column):
231        return batch.id_str
232
233    def template_kwargs_index(self, **kwargs):
234        route_prefix = self.get_route_prefix()
235        if self.results_executable:
236            url = self.request.route_url('{}.execute_results'.format(route_prefix))
237            kwargs['execute_form'] = self.make_execute_form(action_url=url)
238        return kwargs
239
240    def get_instance_title(self, batch):
241        if batch.description:
242            return "{} {}".format(batch.id_str, batch.description)
243        return batch.id_str
244
245    def get_mobile_data(self, session=None):
246        return super(BatchMasterView, self).get_mobile_data(session=session)\
247                                           .order_by(self.model_class.id.desc())
248
249    def make_mobile_filters(self):
250        """
251        Returns a set of filters for the mobile grid.
252        """
253        filters = grids.filters.GridFilterSet()
254        filters['status'] = MobileBatchStatusFilter(self.model_class, 'status', default_value='pending')
255        return filters
256
257    def configure_form(self, f):
258        super(BatchMasterView, self).configure_form(f)
259
260        # id
261        f.set_readonly('id')
262        f.set_renderer('id', self.render_batch_id)
263        f.set_label('id', "Batch ID")
264
265        # created
266        f.set_readonly('created')
267        f.set_readonly('created_by')
268        f.set_renderer('created_by', self.render_user)
269        f.set_label('created_by', "Created by")
270
271        # cognized
272        f.set_renderer('cognized_by', self.render_user)
273        f.set_label('cognized_by', "Cognized by")
274
275        # row count
276        f.set_readonly('rowcount')
277        f.set_label('rowcount', "Row Count")
278
279        # status_code
280        if self.creating:
281            f.remove_field('status_code')
282        else:
283            f.set_readonly('status_code')
284            f.set_renderer('status_code', self.make_status_renderer(self.model_class.STATUS))
285            f.set_label('status_code', "Status")
286
287        # complete
288        if self.viewing:
289            f.set_renderer('complete', self.render_complete)
290
291        # executed
292        f.set_readonly('executed')
293        f.set_readonly('executed_by')
294        f.set_renderer('executed_by', self.render_user)
295        f.set_label('executed_by', "Executed by")
296
297        # notes
298        f.set_type('notes', 'text')
299
300        # if self.creating and self.request.user:
301        #     batch = fs.model
302        #     batch.created_by_uuid = self.request.user.uuid
303
304        if self.creating:
305            f.remove_fields('id',
306                            'rowcount',
307                            'created',
308                            'created_by',
309                            'cognized',
310                            'cognized_by',
311                            'executed',
312                            'executed_by',
313                            'purge')
314
315        else: # not creating
316            batch = self.get_instance()
317            if not batch.executed:
318                f.remove_fields('executed',
319                                'executed_by')
320
321    def make_status_renderer(self, enum):
322        def render_status(batch, field):
323            value = batch.status_code
324            if value is None:
325                return ""
326            status_code_text = enum.get(value, six.text_type(value))
327            if batch.status_text:
328                return HTML.tag('span', title=batch.status_text, c=status_code_text)
329            return status_code_text
330        return render_status
331
332    def render_complete(self, batch, field):
333        content = [HTML.literal("Yes" if batch.complete else "No")]
334
335        if not batch.executed:
336            permission_prefix = self.get_permission_prefix()
337            if self.request.has_perm('{}.edit'.format(permission_prefix)):
338
339                if batch.complete:
340                    label = "Mark as NOT Complete"
341                    value = 'false'
342                else:
343                    label = "Mark as Complete"
344                    value = 'true'
345
346                content.extend([
347                    HTML.literal(' &nbsp; '),
348                    tags.form(self.get_action_url('toggle_complete', batch), class_='autodisable'),
349                    csrf_token(self.request),
350                    tags.hidden('complete', value=value),
351                    tags.submit('submit', label),
352                    tags.end_form(),
353                ])
354
355        return HTML.tag('div', c=content)
356
357    def render_user(self, batch, field):
358        user = getattr(batch, field)
359        if not user:
360            return ""
361        title = six.text_type(user)
362        url = self.request.route_url('users.view', uuid=user.uuid)
363        return tags.link_to(title, url)
364
365    def configure_mobile_form(self, f):
366        super(BatchMasterView, self).configure_mobile_form(f)
367        batch = f.model_instance
368
369        if self.creating:
370            f.remove_fields('id',
371                            'rowcount',
372                            'created',
373                            'created_by',
374                            'cognized',
375                            'cognized_by',
376                            'executed',
377                            'executed_by',
378                            'purge')
379
380        else: # not creating
381            if not batch.executed:
382                f.remove_fields('executed',
383                                'executed_by')
384                if not batch.complete:
385                    f.remove_field('complete')
386
387    def save_create_form(self, form):
388        uploads = self.normalize_uploads(form)
389        self.before_create(form)
390
391        session = self.Session()
392        with session.no_autoflush:
393
394            # transfer form data to batch instance
395            batch = self.objectify(form, self.form_deserialized)
396
397            # current user is batch creator
398            batch.created_by = self.request.user or self.late_login_user()
399
400            # obtain kwargs for making batch via handler, below
401            kwargs = self.get_batch_kwargs(batch)
402
403            # TODO: this needs work yet surely...why is this an issue?
404            # treat 'filename' field specially, for some reason it can be a filedict?
405            if 'filename' in kwargs and not isinstance(kwargs['filename'], six.string_types):
406                kwargs['filename'] = '' # null not allowed
407
408            # TODO: is this still necessary with colander?
409            # destroy initial batch and re-make using handler
410            # if batch in self.Session:
411            #     self.Session.expunge(batch)
412            batch = self.handler.make_batch(session, **kwargs)
413
414        self.Session.flush()
415        self.process_uploads(batch, form, uploads)
416        return batch
417
418    def process_uploads(self, batch, form, uploads):
419        for key, upload in six.iteritems(uploads):
420            self.handler.set_input_file(batch, upload['temp_path'], attr=key)
421            os.remove(upload['temp_path'])
422            os.rmdir(upload['tempdir'])
423
424    def save_mobile_create_form(self, form):
425        self.before_create(form)
426        session = self.Session()
427        with session.no_autoflush:
428
429            # transfer form data to batch instance
430            batch = self.objectify(form, self.form_deserialized)
431
432            # current user is batch creator
433            batch.created_by = self.request.user
434
435            # TODO: is this still necessary with colander?
436            # destroy initial batch and re-make using handler
437            kwargs = self.get_batch_kwargs(batch)
438            if batch in session:
439                session.expunge(batch)
440            batch = self.handler.make_batch(session, **kwargs)
441
442        session.flush()
443        return batch
444
445    def get_batch_kwargs(self, batch, mobile=False):
446        """
447        Return a kwargs dict for use with ``self.handler.make_batch()``, using
448        the given batch as a template.
449        """
450        kwargs = {}
451        if batch.created_by:
452            kwargs['created_by'] = batch.created_by
453        elif batch.created_by_uuid:
454            kwargs['created_by_uuid'] = batch.created_by_uuid
455        kwargs['description'] = batch.description
456        kwargs['notes'] = batch.notes
457        if hasattr(batch, 'filename'):
458            kwargs['filename'] = batch.filename
459        kwargs['complete'] = batch.complete
460        return kwargs
461
462    # TODO: deprecate / remove this (is it used at all now?)
463    def init_batch(self, batch):
464        """
465        Initialize a new batch.  Derived classes can override this to
466        effectively provide default values for a batch, etc.  This method is
467        invoked after a batch has been fully prepared for insertion to the
468        database, but before the push to the database occurs.
469
470        Note that the return value of this function matters; if it is boolean
471        false then the batch will not be persisted at all, and the user will be
472        redirected to the "create batch" page.
473        """
474        return True
475
476    def redirect_after_create(self, batch, mobile=False):
477        if self.handler.should_populate(batch):
478            return self.redirect(self.get_action_url('prefill', batch, mobile=mobile))
479        elif self.refresh_after_create:
480            return self.redirect(self.get_action_url('refresh', batch, mobile=mobile))
481        else:
482            return self.redirect(self.get_action_url('view', batch, mobile=mobile))
483
484    def template_kwargs_edit(self, **kwargs):
485        batch = kwargs['instance']
486        kwargs['batch'] = batch
487        return kwargs
488
489    def toggle_complete(self):
490        batch = self.get_instance()
491        if batch.executed:
492            self.request.session.flash("Request ignored, since batch has already been executed")
493        else:
494            form = forms.Form(schema=ToggleComplete(), request=self.request)
495            if form.validate(newstyle=True):
496                if form.validated['complete']:
497                    self.mark_batch_complete(batch)
498                else:
499                    self.mark_batch_incomplete(batch)
500        return self.redirect(self.get_action_url('view', batch))
501
502    def mark_batch_complete(self, batch):
503        self.handler.mark_complete(batch)
504
505    def mark_batch_incomplete(self, batch):
506        self.handler.mark_incomplete(batch)
507
508    def mobile_mark_complete(self):
509        batch = self.get_instance()
510        self.mark_batch_complete(batch)
511        return self.redirect(self.get_index_url(mobile=True))
512
513    def mobile_mark_pending(self):
514        batch = self.get_instance()
515        self.mark_batch_incomplete(batch)
516        return self.redirect(self.get_action_url('view', batch, mobile=True))
517
518    def rows_creatable_for(self, batch):
519        """
520        Only allow creating new rows on a batch if it hasn't yet been executed
521        or marked complete.
522        """
523        if batch.executed:
524            return False
525        if batch.complete:
526            return False
527        return True
528
529    def rows_quickable_for(self, batch):
530        """
531        Must return boolean indicating whether the "quick row" feature should
532        be allowed for the given batch.  By default, returns ``False`` if batch
533        has already been executed or marked complete, and ``True`` otherwise.
534        """
535        if batch.executed:
536            return False
537        if batch.complete:
538            return False
539        return True
540
541    def configure_row_grid(self, g):
542        super(BatchMasterView, self).configure_row_grid(g)
543
544        if 'status_code' in g.filters:
545            g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS))
546
547        g.set_sort_defaults('sequence')
548
549        if self.model_row_class:
550            g.set_enum('status_code', self.model_row_class.STATUS)
551
552        g.set_renderer('status_code', self.render_row_status)
553
554        g.set_label('sequence', "Seq.")
555        g.set_label('status_code', "Status")
556        g.set_label('item_id', "Item ID")
557
558    def get_row_status_enum(self):
559        return self.model_row_class.STATUS
560
561    def render_row_status(self, row, column):
562        code = row.status_code
563        if code is None:
564            return ""
565        text = self.get_row_status_enum().get(code, six.text_type(code))
566        if row.status_text:
567            return HTML.tag('span', title=row.status_text, c=text)
568        return text
569
570    def create_row(self):
571        """
572        Only allow creating a new row if the batch hasn't yet been executed.
573        """
574        batch = self.get_instance()
575        if batch.executed:
576            self.request.session.flash("You cannot add new rows to a batch which has been executed")
577            return self.redirect(self.get_action_url('view', batch))
578        return super(BatchMasterView, self).create_row()
579
580    def mobile_create_row(self):
581        """
582        Only allow creating a new row if the batch hasn't yet been executed.
583        """
584        batch = self.get_instance()
585        if batch.executed:
586            self.request.session.flash("You cannot add new rows to a batch which has been executed")
587            return self.redirect(self.get_action_url('view', batch, mobile=True))
588        return super(BatchMasterView, self).mobile_create_row()
589
590    def save_create_row_form(self, form):
591        batch = self.get_instance()
592        row = self.objectify(form, self.form_deserialized)
593        self.handler.add_row(batch, row)
594        self.Session.flush()
595        return row
596
597    def after_create_row(self, row):
598        self.handler.refresh_row(row)
599
600    def configure_row_form(self, f):
601        super(BatchMasterView, self).configure_row_form(f)
602
603        # sequence
604        f.set_readonly('sequence')
605
606        # status_code
607        if self.model_row_class:
608            f.set_enum('status_code', self.model_row_class.STATUS)
609        f.set_renderer('status_code', self.render_row_status)
610        f.set_readonly('status_code')
611        f.set_label('status_code', "Status")
612
613        # status text
614        f.set_readonly('status_text')
615
616    def configure_mobile_row_form(self, f):
617        super(BatchMasterView, self).configure_mobile_row_form(f)
618
619        # sequence
620        f.set_readonly('sequence')
621
622        # status_code
623        if self.model_row_class:
624            f.set_enum('status_code', self.model_row_class.STATUS)
625        f.set_renderer('status_code', self.render_row_status)
626        f.set_readonly('status_code')
627        f.set_label('status_code', "Status")
628
629    def make_default_row_grid_tools(self, batch):
630        if self.rows_creatable and not batch.executed and not batch.complete:
631            permission_prefix = self.get_permission_prefix()
632            if self.request.has_perm('{}.create_row'.format(permission_prefix)):
633                link = tags.link_to("Create a new {}".format(self.get_row_model_title()),
634                                    self.get_action_url('create_row', batch))
635                return HTML.tag('p', c=[link])
636
637    def make_batch_row_grid_tools(self, batch):
638        if self.rows_bulk_deletable and not batch.executed and self.request.has_perm('{}.delete_rows'.format(self.get_permission_prefix())):
639            url = self.request.route_url('{}.delete_rows'.format(self.get_route_prefix()), uuid=batch.uuid)
640            return HTML.tag('p', c=[tags.link_to("Delete all rows matching current search", url)])
641
642    def make_row_grid_kwargs(self, **kwargs):
643        """
644        Whether or not rows may be edited or deleted will depend partially on
645        whether the parent batch has been executed.
646        """
647        batch = self.get_instance()
648
649        # TODO: most of this logic is copied from MasterView, should refactor/merge somehow...
650        if 'main_actions' not in kwargs:
651            actions = []
652
653            # view action
654            if self.rows_viewable:
655                view = lambda r, i: self.get_row_action_url('view', r)
656                actions.append(grids.GridAction('view', icon='zoomin', url=view))
657
658            # edit and delete are NOT allowed after execution, or if batch is "complete"
659            if not batch.executed and not batch.complete:
660
661                # edit action
662                if self.rows_editable:
663                    actions.append(grids.GridAction('edit', icon='pencil', url=self.row_edit_action_url))
664
665                # delete action
666                permission_prefix = self.get_permission_prefix()
667                if self.rows_deletable and self.request.has_perm('{}.delete_row'.format(permission_prefix)):
668                    actions.append(grids.GridAction('delete', icon='trash', url=self.row_delete_action_url))
669                    kwargs.setdefault('delete_speedbump', self.rows_deletable_speedbump)
670
671            kwargs['main_actions'] = actions
672
673        return super(BatchMasterView, self).make_row_grid_kwargs(**kwargs)
674
675    def make_row_grid_tools(self, batch):
676        return (self.make_default_row_grid_tools(batch) or '') + (self.make_batch_row_grid_tools(batch) or '')
677
678    def sort_mobile_row_data(self, query):
679        return query.order_by(self.model_row_class.sequence)
680
681    def redirect_after_edit(self, batch):
682        """
683        If refresh flag is set, do that; otherwise go (back) to view/edit page.
684        """
685        if self.request.params.get('refresh') == 'true':
686            return self.redirect(self.get_action_url('refresh', batch))
687        return self.redirect(self.get_action_url('view', batch))
688
689    def delete_instance(self, batch):
690        """
691        Delete all data (files etc.) for the batch.
692        """
693        self.handler.delete(batch)
694        super(BatchMasterView, self).delete_instance(batch)
695
696    def get_fallback_templates(self, template, mobile=False):
697        if mobile:
698            return [
699                '/mobile/batch/{}.mako'.format(template),
700                '/mobile/master/{}.mako'.format(template),
701            ]
702        return [
703            '/batch/{}.mako'.format(template),
704            '/master/{}.mako'.format(template),
705        ]
706
707    def editable_instance(self, batch):
708        return not bool(batch.executed)
709
710    def after_edit_row(self, row):
711        self.handler.refresh_row(row)
712
713    def instance_executable(self, batch=None):
714        return self.handler.executable(batch)
715
716    def batch_refreshable(self, batch):
717        """
718        Return a boolean indicating whether the given batch should allow a
719        refresh operation.
720        """
721        # TODO: deprecate/remove this?
722        if not self.refreshable:
723            return False
724
725        # (this is how it should be done i think..)
726        if callable(self.handler.refreshable):
727            return self.handler.refreshable(batch)
728
729        # TODO: deprecate/remove this
730        return self.handler.refreshable and not batch.executed
731
732    def has_execution_options(self, batch=None):
733        return bool(self.execution_options_schema)
734
735    # TODO
736    execution_options_schema = None
737
738    def make_execute_schema(self, batch):
739        return self.execution_options_schema().bind(batch=batch)
740
741    def make_execute_form(self, batch=None, **kwargs):
742        """
743        Return a proper Form for execution options.
744        """
745        defaults = {}
746        route_prefix = self.get_route_prefix()
747
748        if self.has_execution_options(batch):
749            if batch is None:
750                batch = self.model_class
751            schema = self.make_execute_schema(batch)
752            for field in schema:
753
754                # if field does not yet have a default, maybe provide one from session storage
755                if field.default is colander.null:
756                    key = 'batch.{}.execute_option.{}'.format(batch.batch_key, field.name)
757                    if key in self.request.session:
758                        defaults[field.name] = self.request.session[key]
759
760                # make sure field label is preserved
761                if field.title:
762                    labels = kwargs.setdefault('labels', {})
763                    labels[field.name] = field.title
764
765        else:
766            schema = colander.Schema()
767
768        return forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs)
769
770    def get_execute_title(self, batch):
771        if hasattr(self.handler, 'get_execute_title'):
772            return self.handler.get_execute_title(batch)
773        return "Execute Batch"
774
775    def handler_action(self, batch, batch_action, **kwargs):
776        """
777        View which will attempt to refresh all data for the batch.  What
778        exactly this means will depend on the type of batch etc.
779        """
780        route_prefix = self.get_route_prefix()
781        permission_prefix = self.get_permission_prefix()
782
783        user = self.request.user
784        user_uuid = user.uuid if user else None
785        username = user.username if user else None
786
787        key = '{}.{}'.format(self.model_class.__tablename__, batch_action)
788        progress = SessionProgress(self.request, key)
789
790        # must ensure versioning is *disabled* during action, if handler says so
791        allow_versioning = self.handler.allow_versioning(batch_action)
792        if not allow_versioning and self.rattail_config.versioning_enabled():
793            can_cancel = False
794
795            # make socket for progress thread to listen to action thread
796            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
797            sock.bind(('127.0.0.1', 0))
798            sock.listen(1)
799            port = sock.getsockname()[1]
800
801            # launch thread to monitor progress
802            success_url = self.get_action_url('view', batch)
803            thread = Thread(target=self.progress_thread, args=(sock, success_url, progress))
804            thread.start()
805
806            # launch thread to invoke handler action
807            thread = Thread(target=self.action_subprocess_thread, args=(batch.uuid, port, username, batch_action, progress))
808            thread.start()
809
810        else: # either versioning is disabled, or handler doesn't mind
811            can_cancel = True
812
813            # launch thread to populate batch; that will update session progress directly
814            target = getattr(self, '{}_thread'.format(batch_action))
815            thread = Thread(target=target, args=(batch.uuid, user_uuid, progress), kwargs=kwargs)
816            thread.start()
817
818        return self.render_progress(progress, {
819            'can_cancel': can_cancel,
820            'cancel_url': self.get_action_url('view', batch),
821            'cancel_msg': "{} of batch was canceled.".format(batch_action.capitalize()),
822        })
823
824    def progress_thread(self, sock, success_url, progress):
825        """
826        This method is meant to be used as a thread target.  Its job is to read
827        progress data from ``connection`` and update the session progress
828        accordingly.  When a final "process complete" indication is read, the
829        socket will be closed and the thread will end.
830        """
831        while True:
832            try:
833                self.process_progress(sock, progress)
834            except EverythingComplete:
835                break
836
837        # close server socket
838        sock.close()
839
840        # finalize session progress
841        progress.session.load()
842        progress.session['complete'] = True
843        if callable(success_url):
844            success_url = success_url()
845        progress.session['success_url'] = success_url
846        progress.session.save()
847
848    def process_progress(self, sock, progress):
849        """
850        This method will accept a client connection on the given socket, and
851        then update the given progress object according to data written by the
852        client.
853        """
854        connection, client_address = sock.accept()
855        active_progress = None
856
857        # TODO: make this configurable?
858        suffix = "\n\n.".encode('utf_8')
859        data = b''
860
861        # listen for progress info, update session progress as needed
862        while True:
863
864            # accumulate data bytestring until we see the suffix
865            byte = connection.recv(1)
866            data += byte
867            if data.endswith(suffix):
868
869                # strip suffix, interpret data as JSON
870                data = data[:-len(suffix)]
871                if six.PY3:
872                    data = data.decode('utf_8')
873                data = json.loads(data)
874
875                if data.get('everything_complete'):
876                    if active_progress:
877                        active_progress.finish()
878                    raise EverythingComplete
879
880                elif data.get('process_complete'):
881                    active_progress.finish()
882                    active_progress = None
883                    break
884
885                elif 'value' in data:
886                    if not active_progress:
887                        active_progress = progress(data['message'], data['maximum'])
888                    active_progress.update(data['value'])
889
890                # reset data buffer
891                data = b''
892
893        # close client connection
894        connection.close()
895
896    def launch_subprocess(self, port=None, username=None,
897                          command='rattail', command_args=None,
898                          subcommand=None, subcommand_args=None):
899
900        # construct command
901        prefix = self.rattail_config.get('rattail', 'command_prefix',
902                                         default=sys.prefix)
903        cmd = [os.path.join(prefix, 'bin/{}'.format(command))]
904        for path in self.rattail_config.files_read:
905            cmd.extend(['--config', path])
906        if username:
907            cmd.extend(['--runas', username])
908        if command_args:
909            cmd.extend(command_args)
910        cmd.extend([
911            '--progress',
912            '--progress-socket', '127.0.0.1:{}'.format(port),
913            subcommand,
914        ])
915        if subcommand_args:
916            cmd.extend(subcommand_args)
917
918        # run command in subprocess
919        log.debug("launching command in subprocess: %s", cmd)
920        subprocess.check_call(cmd)
921
922    def action_subprocess_thread(self, batch_uuid, port, username, action, progress):
923        """
924        This method is sort of an alternative thread target for batch actions,
925        to be used in the event versioning is enabled for the main process but
926        the handler says versioning must be avoided during the action.  It must
927        launch a separate process with versioning disabled in order to act on
928        the batch.
929        """
930        # invoke command to act on batch in separate process
931        try:
932            self.launch_subprocess(port=port, username=username,
933                                   command='rattail',
934                                   command_args=[
935                                       '--no-versioning',
936                                   ],
937                                   subcommand='{}-batch'.format(action),
938                                   subcommand_args=[
939                                       '--batch-type',
940                                       self.handler.batch_key,
941                                       batch_uuid,
942                                   ])
943        except Exception as error:
944            log.warning("%s of '%s' batch failed: %s", action, self.handler.batch_key, batch_uuid, exc_info=True)
945
946            # TODO: write error info to socket
947
948            # if progress:
949            #     progress.session.load()
950            #     progress.session['error'] = True
951            #     progress.session['error_msg'] = "Batch population failed: {} - {}".format(error.__class__.__name__, error)
952            #     progress.session.save()
953
954            return
955
956        models = getattr(self.handler, 'version_catchup_{}'.format(action), None)
957        if models:
958            self.catchup_versions(port, batch_uuid, username, *models)
959
960        suffix = "\n\n.".encode('utf_8')
961        cxn = socket.create_connection(('127.0.0.1', port))
962        data = json.dumps({
963            'everything_complete': True,
964        })
965        if six.PY3:
966            data = data.encode('utf_8')
967        cxn.send(data)
968        cxn.send(suffix)
969        cxn.close()
970
971    def catchup_versions(self, port, batch_uuid, username, *models):
972        with short_session() as s:
973            batch = s.query(self.model_class).get(batch_uuid)
974            batch_id = batch.id_str
975            description = six.text_type(batch)
976
977        self.launch_subprocess(
978            port=port, username=username,
979            command='rattail',
980            subcommand='import-versions',
981            subcommand_args=[
982                '--comment',
983                "version catch-up for '{}' batch {}: {}".format(self.handler.batch_key, batch_id, description),
984            ] + list(models))
985
986    def prefill(self):
987        """
988        View which will attempt to prefill all data for the batch.  What
989        exactly this means will depend on the type of batch etc.
990        """
991        batch = self.get_instance()
992        return self.handler_action(batch, 'populate')
993
994    def populate_thread(self, batch_uuid, user_uuid, progress, **kwargs):
995        """
996        Thread target for populating batch data with progress indicator.
997        """
998        # mustn't use tailbone web session here
999        session = RattailSession()
1000        batch = session.query(self.model_class).get(batch_uuid)
1001        user = session.query(model.User).get(user_uuid)
1002        try:
1003            self.handler.do_populate(batch, user, progress=progress)
1004        except Exception as error:
1005            session.rollback()
1006            log.warning("batch population failed: %s", batch, exc_info=True)
1007            session.close()
1008            if progress:
1009                progress.session.load()
1010                progress.session['error'] = True
1011                progress.session['error_msg'] = "Batch population failed: {} - {}".format(error.__class__.__name__, error)
1012                progress.session.save()
1013            return
1014
1015        session.commit()
1016        session.refresh(batch)
1017        session.close()
1018
1019        # finalize progress
1020        if progress:
1021            progress.session.load()
1022            progress.session['complete'] = True
1023            progress.session['success_url'] = self.get_action_url('view', batch)
1024            progress.session.save()
1025
1026    def refresh(self):
1027        """
1028        View which will attempt to refresh all data for the batch.  What
1029        exactly this means will depend on the type of batch etc.
1030        """
1031        batch = self.get_instance()
1032        return self.handler_action(batch, 'refresh')
1033
1034    def refresh_data(self, session, batch, user, progress=None):
1035        """
1036        Instruct the batch handler to refresh all data for the batch.
1037        """
1038        # TODO: deprecate/remove this
1039        if hasattr(self.handler, 'refresh_data'):
1040            self.handler.refresh_data(session, batch, progress=progress)
1041            batch.cognized = datetime.datetime.utcnow()
1042            batch.cognized_by = cognizer or session.merge(self.request.user)
1043
1044        else: # the future
1045            user = user or session.merge(self.request.user)
1046            self.handler.do_refresh(batch, user, progress=progress)
1047
1048    def refresh_thread(self, batch_uuid, user_uuid, progress, **kwargs):
1049        """
1050        Thread target for refreshing batch data with progress indicator.
1051        """
1052        # Refresh data for the batch, with progress.  Note that we must use the
1053        # rattail session here; can't use tailbone because it has web request
1054        # transaction binding etc.
1055        session = RattailSession()
1056        batch = session.query(self.model_class).get(batch_uuid)
1057        cognizer = session.query(model.User).get(user_uuid) if user_uuid else None
1058        try:
1059            self.refresh_data(session, batch, cognizer, progress=progress)
1060        except Exception as error:
1061            session.rollback()
1062            log.warning("refreshing data for batch failed: {}".format(batch), exc_info=True)
1063            session.close()
1064            if progress:
1065                progress.session.load()
1066                progress.session['error'] = True
1067                progress.session['error_msg'] = "Data refresh failed: {} {}".format(error.__class__.__name__, error)
1068                progress.session.save()
1069            return
1070
1071        session.commit()
1072        session.refresh(batch)
1073        session.close()
1074
1075        # Finalize progress indicator.
1076        if progress:
1077            progress.session.load()
1078            progress.session['complete'] = True
1079            progress.session['success_url'] = self.get_action_url('view', batch)
1080            progress.session.save()
1081
1082    ########################################
1083    # batch rows
1084    ########################################
1085
1086    def get_row_instance_title(self, row):
1087        return "Row {}".format(row.sequence)
1088
1089    hide_row_status_codes = []
1090
1091    def get_row_data(self, batch):
1092        """
1093        Generate the base data set for a rows grid.
1094        """
1095        query = self.Session.query(self.model_row_class)\
1096                            .filter(self.model_row_class.batch == batch)\
1097                            .filter(self.model_row_class.removed == False)
1098        if self.hide_row_status_codes:
1099            query = query.filter(~self.model_row_class.status_code.in_(self.hide_row_status_codes))
1100        return query
1101
1102    def row_editable(self, row):
1103        """
1104        Batch rows are editable only until batch is complete or executed.
1105        """
1106        batch = self.get_parent(row)
1107        return self.rows_editable and not batch.executed and not batch.complete
1108
1109    def row_deletable(self, row):
1110        """
1111        Batch rows are deletable only until batch is complete or executed.
1112        """
1113        if self.rows_deletable:
1114            batch = self.get_parent(row)
1115            if not batch.executed and not batch.complete:
1116                return True
1117        return False
1118
1119    def template_kwargs_view_row(self, **kwargs):
1120        kwargs['batch_model_title'] = kwargs['parent_model_title']
1121        # TODO: should these be set somewhere else?
1122        kwargs['row'] = kwargs['instance']
1123        kwargs['batch'] = kwargs['row'].batch
1124        return kwargs
1125
1126    def get_parent(self, row):
1127        return row.batch
1128
1129    def delete_row_object(self, row):
1130        """
1131        Perform the actual deletion of given row object.
1132        """
1133        self.handler.do_remove_row(row)
1134
1135    def bulk_delete_rows(self):
1136        """
1137        "Delete" all rows matching the current row grid view query.  This sets
1138        the ``removed`` flag on the rows but does not truly delete them.
1139        """
1140        batch = self.get_instance()
1141        query = self.get_effective_row_data(sort=False)
1142
1143        # TODO: this should surely be handled by the handler...
1144        if batch.rowcount is not None:
1145            batch.rowcount -= query.count()
1146        query.update({'removed': True}, synchronize_session=False)
1147        self.Session.refresh(batch)
1148        self.handler.refresh_batch_status(batch)
1149
1150        return self.redirect(self.get_action_url('view', batch))
1151
1152    def execute(self):
1153        """
1154        Execute a batch.  Starts a separate thread for the execution, and
1155        displays a progress indicator page.
1156        """
1157        batch = self.get_instance()
1158        self.executing = True
1159        form = self.make_execute_form(batch)
1160        if form.validate(newstyle=True):
1161            kwargs = dict(form.validated)
1162
1163            # cache options to use as defaults next time
1164            for key, value in form.validated.items():
1165                self.request.session['batch.{}.execute_option.{}'.format(batch.batch_key, key)] = value
1166
1167            return self.handler_action(batch, 'execute', **kwargs)
1168
1169        self.request.session.flash("Invalid request: {}".format(form.make_deform_form().error), 'error')
1170        return self.redirect(self.get_action_url('view', batch))
1171
1172    def mobile_execute(self):
1173        """
1174        Mobile view which can prompt user for execution options if applicable,
1175        and/or execute a batch.  For now this is done in a "blocking" fashion,
1176        i.e. no progress bar.
1177        """
1178        batch = self.get_instance()
1179        model_title = self.get_model_title()
1180        instance_title = self.get_instance_title(batch)
1181        view_url = self.get_action_url('view', batch, mobile=True)
1182        self.executing = True
1183        form = self.make_execute_form(batch)
1184        if form.validate(newstyle=True):
1185            kwargs = dict(form.validated)
1186
1187            # cache options to use as defaults next time
1188            for key, value in form.validated.items():
1189                self.request.session['batch.{}.execute_option.{}'.format(batch.batch_key, key)] = value
1190
1191            try:
1192                result = self.handler.execute(batch, user=self.request.user, **kwargs)
1193            except Exception as err:
1194                log.exception("failed to execute %s %s", model_title, batch.id_str)
1195                self.request.session.flash(self.execute_error_message(err), 'error')
1196            else:
1197                if result:
1198                    batch.executed = datetime.datetime.utcnow()
1199                    batch.executed_by = self.request.user
1200                    self.request.session.flash("{} was executed: {}".format(model_title, instance_title))
1201                else:
1202                    log.error("not sure why, but failed to execute %s %s: %s", model_title, batch.id_str, batch)
1203                    self.request.session.flash("Failed to execute {}: {}".format(model_title, err), 'error')
1204            return self.redirect(view_url)
1205
1206        form.mobile = True
1207        form.submit_label = "Execute"
1208        form.cancel_url = view_url
1209        return self.render_to_response('execute', {
1210            'form': form,
1211            'instance_title': instance_title,
1212            'instance_url': view_url,
1213        }, mobile=True)
1214
1215    def execute_error_message(self, error):
1216        return "Batch execution failed: {}: {}".format(type(error).__name__, error)
1217
1218    def execute_thread(self, batch_uuid, user_uuid, progress, **kwargs):
1219        """
1220        Thread target for executing a batch with progress indicator.
1221        """
1222        # Execute the batch, with progress.  Note that we must use the rattail
1223        # session here; can't use tailbone because it has web request
1224        # transaction binding etc.
1225        session = RattailSession()
1226        batch = session.query(self.model_class).get(batch_uuid)
1227        user = session.query(model.User).get(user_uuid)
1228        try:
1229            result = self.handler.do_execute(batch, user=user, progress=progress, **kwargs)
1230
1231        # If anything goes wrong, rollback and log the error etc.
1232        except Exception as error:
1233            session.rollback()
1234            log.exception("execution failed for batch: {}".format(batch))
1235            session.close()
1236            if progress:
1237                progress.session.load()
1238                progress.session['error'] = True
1239                progress.session['error_msg'] = self.execute_error_message(error)
1240                progress.session.save()
1241
1242        # If no error, check result flag (false means user canceled).
1243        else:
1244            if result:
1245                session.commit()
1246                # TODO: this doesn't always work...?
1247                self.request.session.flash("{} has been executed: {}".format(
1248                    self.get_model_title(), batch.id_str))
1249            else:
1250                session.rollback()
1251
1252            session.refresh(batch)
1253            success_url = self.get_execute_success_url(batch, result, **kwargs)
1254            session.close()
1255
1256            if progress:
1257                progress.session.load()
1258                progress.session['complete'] = True
1259                progress.session['success_url'] = success_url
1260                progress.session.save()
1261
1262    def get_execute_success_url(self, batch, result, **kwargs):
1263        return self.get_action_url('view', batch)
1264
1265    def execute_results(self):
1266        """
1267        Execute all batches which are returned from the current index query.
1268        Starts a separate thread for the execution, and displays a progress
1269        indicator page.
1270        """
1271        form = self.make_execute_form()
1272        if form.validate(newstyle=True):
1273            kwargs = dict(form.validated)
1274
1275            # cache options to use as defaults next time
1276            for key, value in form.validated.items():
1277                self.request.session['batch.{}.execute_option.{}'.format(self.model_class.batch_key, key)] = value
1278
1279            key = '{}.execute_results'.format(self.model_class.__tablename__)
1280            batches = self.get_effective_data()
1281            progress = SessionProgress(self.request, key)
1282            kwargs['progress'] = progress
1283            thread = Thread(target=self.execute_results_thread, args=(batches, self.request.user.uuid), kwargs=kwargs)
1284            thread.start()
1285
1286            return self.render_progress(progress, {
1287                'cancel_url': self.get_index_url(),
1288                'cancel_msg': "Batch execution was canceled",
1289            })
1290
1291        self.request.session.flash("Invalid request: {}".format(form.make_deform_form().error), 'error')
1292        return self.redirect(self.get_index_url())
1293
1294    def execute_results_thread(self, batches, user_uuid, progress=None, **kwargs):
1295        """
1296        Thread target for executing multiple batches with progress indicator.
1297        """
1298        session = RattailSession()
1299        batches = batches.with_session(session).all()
1300        user = session.query(model.User).get(user_uuid)
1301        try:
1302            result = self.handler.execute_many(batches, user=user, progress=progress, **kwargs)
1303
1304        # If anything goes wrong, rollback and log the error etc.
1305        except Exception as error:
1306            session.rollback()
1307            log.exception("execution failed for batch results")
1308            session.close()
1309            if progress:
1310                progress.session.load()
1311                progress.session['error'] = True
1312                progress.session['error_msg'] = self.execute_error_message(error)
1313                progress.session.save()
1314
1315        # If no error, check result flag (false means user canceled).
1316        else:
1317            if result:
1318                session.commit()
1319                # TODO: this doesn't always work...?
1320                self.request.session.flash("{} {} were executed".format(
1321                    len(batches), self.get_model_title_plural()))
1322                success_url = self.get_execute_results_success_url(result, **kwargs)
1323            else:
1324                session.rollback()
1325                success_url = self.get_index_url()
1326            session.close()
1327
1328            if progress:
1329                progress.session.load()
1330                progress.session['complete'] = True
1331                progress.session['success_url'] = success_url
1332                progress.session.save()
1333
1334    def get_execute_results_success_url(self, result, **kwargs):
1335        return self.get_index_url()
1336
1337    def get_row_csv_fields(self):
1338        fields = super(BatchMasterView, self).get_row_csv_fields()
1339        fields = [field for field in fields
1340                  if field not in ('uuid', 'batch_uuid', 'removed')]
1341        return fields
1342
1343    def get_row_results_csv_filename(self, batch):
1344        return '{}.{}.csv'.format(self.get_route_prefix(), batch.id_str)
1345
1346    def get_row_results_xlsx_filename(self, batch):
1347        return '{}.{}.xlsx'.format(self.get_route_prefix(), batch.id_str)
1348
1349    def clone(self):
1350        """
1351        Clone current batch as new batch
1352        """
1353        batch = self.get_instance()
1354        batch = self.handler.clone(batch, created_by=self.request.user)
1355        return self.redirect(self.get_action_url('view', batch))
1356
1357    @classmethod
1358    def defaults(cls, config):
1359        cls._batch_defaults(config)
1360        cls._defaults(config)
1361
1362    @classmethod
1363    def _batch_defaults(cls, config):
1364        model_key = cls.get_model_key()
1365        route_prefix = cls.get_route_prefix()
1366        url_prefix = cls.get_url_prefix()
1367        permission_prefix = cls.get_permission_prefix()
1368        model_title = cls.get_model_title()
1369        model_title_plural = cls.get_model_title_plural()
1370
1371        # TODO: currently must do this here (in addition to `_defaults()` or
1372        # else the perm group label will not display correctly...
1373        config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False)
1374
1375        # populate row data
1376        config.add_route('{}.prefill'.format(route_prefix), '{}/{{uuid}}/prefill'.format(url_prefix))
1377        config.add_view(cls, attr='prefill', route_name='{}.prefill'.format(route_prefix),
1378                        permission='{}.create'.format(permission_prefix))
1379
1380        # worksheet
1381        if cls.has_worksheet:
1382            config.add_tailbone_permission(permission_prefix, '{}.worksheet'.format(permission_prefix),
1383                                           "Edit {} data as worksheet".format(model_title))
1384            config.add_route('{}.worksheet'.format(route_prefix), '{}/{{{}}}/worksheet'.format(url_prefix, model_key))
1385            config.add_view(cls, attr='worksheet', route_name='{}.worksheet'.format(route_prefix),
1386                            permission='{}.worksheet'.format(permission_prefix))
1387            config.add_route('{}.worksheet_update'.format(route_prefix), '{}/{{{}}}/worksheet/update'.format(url_prefix, model_key))
1388            config.add_view(cls, attr='worksheet_update', route_name='{}.worksheet_update'.format(route_prefix),
1389                            renderer='json', permission='{}.worksheet'.format(permission_prefix))
1390
1391        # refresh batch data
1392        if cls.refreshable:
1393            config.add_route('{}.refresh'.format(route_prefix), '{}/{{uuid}}/refresh'.format(url_prefix))
1394            config.add_view(cls, attr='refresh', route_name='{}.refresh'.format(route_prefix),
1395                            permission='{}.refresh'.format(permission_prefix))
1396            config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix),
1397                                           "Refresh data for {}".format(model_title))
1398
1399        # bulk delete rows
1400        if cls.rows_bulk_deletable:
1401            config.add_route('{}.delete_rows'.format(route_prefix), '{}/{{uuid}}/rows/delete'.format(url_prefix))
1402            config.add_view(cls, attr='bulk_delete_rows', route_name='{}.delete_rows'.format(route_prefix),
1403                            permission='{}.delete_rows'.format(permission_prefix))
1404            config.add_tailbone_permission(permission_prefix, '{}.delete_rows'.format(permission_prefix),
1405                                           "Bulk-delete data rows from {}".format(model_title))
1406
1407        # toggle complete
1408        config.add_route('{}.toggle_complete'.format(route_prefix), '{}/{{{}}}/toggle-complete'.format(url_prefix, model_key))
1409        config.add_view(cls, attr='toggle_complete', route_name='{}.toggle_complete'.format(route_prefix),
1410                        permission='{}.edit'.format(permission_prefix))
1411
1412        # mobile mark complete
1413        config.add_route('mobile.{}.mark_complete'.format(route_prefix), '/mobile{}/{{{}}}/mark-complete'.format(url_prefix, model_key))
1414        config.add_view(cls, attr='mobile_mark_complete', route_name='mobile.{}.mark_complete'.format(route_prefix),
1415                        permission='{}.edit'.format(permission_prefix))
1416
1417        # mobile mark pending
1418        config.add_route('mobile.{}.mark_pending'.format(route_prefix), '/mobile{}/{{{}}}/mark-pending'.format(url_prefix, model_key))
1419        config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix),
1420                        permission='{}.edit'.format(permission_prefix))
1421
1422        # execute (multiple) batch results
1423        if cls.results_executable:
1424            config.add_route('{}.execute_results'.format(route_prefix), '{}/execute-results'.format(url_prefix),
1425                             request_method='POST')
1426            config.add_view(cls, attr='execute_results', route_name='{}.execute_results'.format(route_prefix),
1427                            permission='{}.execute_multiple'.format(permission_prefix))
1428            config.add_tailbone_permission(permission_prefix, '{}.execute_multiple'.format(permission_prefix),
1429                                           "Execute multiple {}".format(model_title_plural))
1430
1431
1432class FileBatchMasterView(BatchMasterView):
1433    """
1434    Base class for all file-based "batch master" views.
1435    """
1436    downloadable = True
1437
1438    @property
1439    def upload_dir(self):
1440        """
1441        The path to the root upload folder, to be used as the ``storage_path``
1442        argument for the file field renderer.
1443        """
1444        uploads = os.path.join(
1445            self.rattail_config.require('rattail', 'batch.files'),
1446            'uploads')
1447        uploads = self.rattail_config.get('tailbone', 'batch.uploads',
1448                                          default=uploads)
1449        if not os.path.exists(uploads):
1450            os.makedirs(uploads)
1451        return uploads
1452
1453    def configure_form(self, f):
1454        super(FileBatchMasterView, self).configure_form(f)
1455        batch = f.model_instance
1456
1457        # filename
1458        if self.creating:
1459            # TODO: what's up with this re-insertion again..?
1460            # if 'filename' not in f.fields:
1461            #     f.fields.insert(0, 'filename')
1462            f.set_type('filename', 'file')
1463        else:
1464            f.set_readonly('filename')
1465            f.set_renderer('filename', self.render_downloadable_file)
1466
1467
1468class ToggleComplete(colander.MappingSchema):
1469
1470    complete = colander.SchemaNode(colander.Boolean())
1471
1472
1473class MobileBatchStatusFilter(grids.filters.MobileFilter):
1474
1475    value_choices = ['pending', 'complete', 'executed', 'all']
1476
1477    def __init__(self, model_class, key, **kwargs):
1478        self.model_class = model_class
1479        super(MobileBatchStatusFilter, self).__init__(key, **kwargs)
1480
1481    def filter_equal(self, query, value):
1482
1483        if value == 'pending':
1484            return query.filter(self.model_class.executed == None)\
1485                        .filter(sa.or_(
1486                            self.model_class.complete == None,
1487                            self.model_class.complete == False))
1488
1489        if value == 'complete':
1490            return query.filter(self.model_class.executed == None)\
1491                        .filter(self.model_class.complete == True)
1492
1493        if value == 'executed':
1494            return query.filter(self.model_class.executed != None)
1495
1496        return query
1497
1498    def iter_choices(self):
1499        for value in self.value_choices:
1500            yield value, prettify(value)
Note: See TracBrowser for help on using the repository browser.