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

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

Invoke handler when marking batch as (in)complete

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