source: tailbone/tailbone/views/batch/core.py @ 2c1985b

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

Add support for generic "product" batch type

  • Property mode set to 100644
File size: 56.5 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                data = json.loads(data)
872
873                if data.get('everything_complete'):
874                    if active_progress:
875                        active_progress.finish()
876                    raise EverythingComplete
877
878                elif data.get('process_complete'):
879                    active_progress.finish()
880                    active_progress = None
881                    break
882
883                elif 'value' in data:
884                    if not active_progress:
885                        active_progress = progress(data['message'], data['maximum'])
886                    active_progress.update(data['value'])
887
888                # reset data buffer
889                data = b''
890
891        # close client connection
892        connection.close()
893
894    def launch_subprocess(self, port=None, username=None,
895                          command='rattail', command_args=None,
896                          subcommand=None, subcommand_args=None):
897
898        # construct command
899        prefix = self.rattail_config.get('rattail', 'command_prefix',
900                                         default=sys.prefix)
901        cmd = [os.path.join(prefix, 'bin/{}'.format(command))]
902        for path in self.rattail_config.files_read:
903            cmd.extend(['--config', path])
904        if username:
905            cmd.extend(['--runas', username])
906        if command_args:
907            cmd.extend(command_args)
908        cmd.extend([
909            '--progress',
910            '--progress-socket', '127.0.0.1:{}'.format(port),
911            subcommand,
912        ])
913        if subcommand_args:
914            cmd.extend(subcommand_args)
915
916        # run command in subprocess
917        log.debug("launching command in subprocess: %s", cmd)
918        subprocess.check_call(cmd)
919
920    def action_subprocess_thread(self, batch_uuid, port, username, action, progress):
921        """
922        This method is sort of an alternative thread target for batch actions,
923        to be used in the event versioning is enabled for the main process but
924        the handler says versioning must be avoided during the action.  It must
925        launch a separate process with versioning disabled in order to act on
926        the batch.
927        """
928        # invoke command to act on batch in separate process
929        try:
930            self.launch_subprocess(port=port, username=username,
931                                   command='rattail',
932                                   command_args=[
933                                       '--no-versioning',
934                                   ],
935                                   subcommand='{}-batch'.format(action),
936                                   subcommand_args=[
937                                       self.handler.batch_key,
938                                       batch_uuid,
939                                   ])
940        except Exception as error:
941            log.warning("%s of '%s' batch failed: %s", action, self.handler.batch_key, batch_uuid, exc_info=True)
942
943            # TODO: write error info to socket
944
945            # if progress:
946            #     progress.session.load()
947            #     progress.session['error'] = True
948            #     progress.session['error_msg'] = "Batch population failed: {} - {}".format(error.__class__.__name__, error)
949            #     progress.session.save()
950
951            return
952
953        models = getattr(self.handler, 'version_catchup_{}'.format(action), None)
954        if models:
955            self.catchup_versions(port, batch_uuid, username, *models)
956
957        suffix = "\n\n.".encode('utf_8')
958        cxn = socket.create_connection(('127.0.0.1', port))
959        cxn.send(json.dumps({
960            'everything_complete': True,
961        }))
962        cxn.send(suffix)
963        cxn.close()
964
965    def catchup_versions(self, port, batch_uuid, username, *models):
966        with short_session() as s:
967            batch = s.query(self.model_class).get(batch_uuid)
968            batch_id = batch.id_str
969            description = six.text_type(batch)
970
971        self.launch_subprocess(
972            port=port, username=username,
973            command='rattail',
974            subcommand='import-versions',
975            subcommand_args=[
976                '--comment',
977                "version catch-up for '{}' batch {}: {}".format(self.handler.batch_key, batch_id, description),
978            ] + list(models))
979
980    def prefill(self):
981        """
982        View which will attempt to prefill all data for the batch.  What
983        exactly this means will depend on the type of batch etc.
984        """
985        batch = self.get_instance()
986        return self.handler_action(batch, 'populate')
987
988    def populate_thread(self, batch_uuid, user_uuid, progress, **kwargs):
989        """
990        Thread target for populating batch data with progress indicator.
991        """
992        # mustn't use tailbone web session here
993        session = RattailSession()
994        batch = session.query(self.model_class).get(batch_uuid)
995        user = session.query(model.User).get(user_uuid)
996        try:
997            self.handler.do_populate(batch, user, progress=progress)
998        except Exception as error:
999            session.rollback()
1000            log.warning("batch population failed: %s", batch, exc_info=True)
1001            session.close()
1002            if progress:
1003                progress.session.load()
1004                progress.session['error'] = True
1005                progress.session['error_msg'] = "Batch population failed: {} - {}".format(error.__class__.__name__, error)
1006                progress.session.save()
1007            return
1008
1009        session.commit()
1010        session.refresh(batch)
1011        session.close()
1012
1013        # finalize progress
1014        if progress:
1015            progress.session.load()
1016            progress.session['complete'] = True
1017            progress.session['success_url'] = self.get_action_url('view', batch)
1018            progress.session.save()
1019
1020    def refresh(self):
1021        """
1022        View which will attempt to refresh all data for the batch.  What
1023        exactly this means will depend on the type of batch etc.
1024        """
1025        batch = self.get_instance()
1026        return self.handler_action(batch, 'refresh')
1027
1028    def refresh_data(self, session, batch, user, progress=None):
1029        """
1030        Instruct the batch handler to refresh all data for the batch.
1031        """
1032        # TODO: deprecate/remove this
1033        if hasattr(self.handler, 'refresh_data'):
1034            self.handler.refresh_data(session, batch, progress=progress)
1035            batch.cognized = datetime.datetime.utcnow()
1036            batch.cognized_by = cognizer or session.merge(self.request.user)
1037
1038        else: # the future
1039            user = user or session.merge(self.request.user)
1040            self.handler.do_refresh(batch, user, progress=progress)
1041
1042    def refresh_thread(self, batch_uuid, user_uuid, progress, **kwargs):
1043        """
1044        Thread target for refreshing batch data with progress indicator.
1045        """
1046        # Refresh data for the batch, with progress.  Note that we must use the
1047        # rattail session here; can't use tailbone because it has web request
1048        # transaction binding etc.
1049        session = RattailSession()
1050        batch = session.query(self.model_class).get(batch_uuid)
1051        cognizer = session.query(model.User).get(user_uuid) if user_uuid else None
1052        try:
1053            self.refresh_data(session, batch, cognizer, progress=progress)
1054        except Exception as error:
1055            session.rollback()
1056            log.warning("refreshing data for batch failed: {}".format(batch), exc_info=True)
1057            session.close()
1058            if progress:
1059                progress.session.load()
1060                progress.session['error'] = True
1061                progress.session['error_msg'] = "Data refresh failed: {} {}".format(error.__class__.__name__, error)
1062                progress.session.save()
1063            return
1064
1065        session.commit()
1066        session.refresh(batch)
1067        session.close()
1068
1069        # Finalize progress indicator.
1070        if progress:
1071            progress.session.load()
1072            progress.session['complete'] = True
1073            progress.session['success_url'] = self.get_action_url('view', batch)
1074            progress.session.save()
1075
1076    ########################################
1077    # batch rows
1078    ########################################
1079
1080    def get_row_instance_title(self, row):
1081        return "Row {}".format(row.sequence)
1082
1083    hide_row_status_codes = []
1084
1085    def get_row_data(self, batch):
1086        """
1087        Generate the base data set for a rows grid.
1088        """
1089        query = self.Session.query(self.model_row_class)\
1090                            .filter(self.model_row_class.batch == batch)\
1091                            .filter(self.model_row_class.removed == False)
1092        if self.hide_row_status_codes:
1093            query = query.filter(~self.model_row_class.status_code.in_(self.hide_row_status_codes))
1094        return query
1095
1096    def row_editable(self, row):
1097        """
1098        Batch rows are editable only until batch is complete or executed.
1099        """
1100        batch = self.get_parent(row)
1101        return self.rows_editable and not batch.executed and not batch.complete
1102
1103    def row_deletable(self, row):
1104        """
1105        Batch rows are deletable only until batch is complete or executed.
1106        """
1107        if self.rows_deletable:
1108            batch = self.get_parent(row)
1109            if not batch.executed and not batch.complete:
1110                return True
1111        return False
1112
1113    def template_kwargs_view_row(self, **kwargs):
1114        kwargs['batch_model_title'] = kwargs['parent_model_title']
1115        # TODO: should these be set somewhere else?
1116        kwargs['row'] = kwargs['instance']
1117        kwargs['batch'] = kwargs['row'].batch
1118        return kwargs
1119
1120    def get_parent(self, row):
1121        return row.batch
1122
1123    def delete_row_object(self, row):
1124        """
1125        Perform the actual deletion of given row object.
1126        """
1127        self.handler.do_remove_row(row)
1128
1129    def bulk_delete_rows(self):
1130        """
1131        "Delete" all rows matching the current row grid view query.  This sets
1132        the ``removed`` flag on the rows but does not truly delete them.
1133        """
1134        batch = self.get_instance()
1135        query = self.get_effective_row_data(sort=False)
1136
1137        # TODO: this should surely be handled by the handler...
1138        if batch.rowcount is not None:
1139            batch.rowcount -= query.count()
1140        query.update({'removed': True}, synchronize_session=False)
1141        self.Session.refresh(batch)
1142        self.handler.refresh_batch_status(batch)
1143
1144        return self.redirect(self.get_action_url('view', batch))
1145
1146    def execute(self):
1147        """
1148        Execute a batch.  Starts a separate thread for the execution, and
1149        displays a progress indicator page.
1150        """
1151        batch = self.get_instance()
1152        self.executing = True
1153        form = self.make_execute_form(batch)
1154        if form.validate(newstyle=True):
1155            kwargs = dict(form.validated)
1156
1157            # cache options to use as defaults next time
1158            for key, value in form.validated.items():
1159                self.request.session['batch.{}.execute_option.{}'.format(batch.batch_key, key)] = value
1160
1161            return self.handler_action(batch, 'execute', **kwargs)
1162
1163        self.request.session.flash("Invalid request: {}".format(form.make_deform_form().error), 'error')
1164        return self.redirect(self.get_action_url('view', batch))
1165
1166    def mobile_execute(self):
1167        """
1168        Mobile view which can prompt user for execution options if applicable,
1169        and/or execute a batch.  For now this is done in a "blocking" fashion,
1170        i.e. no progress bar.
1171        """
1172        batch = self.get_instance()
1173        model_title = self.get_model_title()
1174        instance_title = self.get_instance_title(batch)
1175        view_url = self.get_action_url('view', batch, mobile=True)
1176        self.executing = True
1177        form = self.make_execute_form(batch)
1178        if form.validate(newstyle=True):
1179            kwargs = dict(form.validated)
1180
1181            # cache options to use as defaults next time
1182            for key, value in form.validated.items():
1183                self.request.session['batch.{}.execute_option.{}'.format(batch.batch_key, key)] = value
1184
1185            try:
1186                result = self.handler.execute(batch, user=self.request.user, **kwargs)
1187            except Exception as err:
1188                log.exception("failed to execute %s %s", model_title, batch.id_str)
1189                self.request.session.flash(self.execute_error_message(err), 'error')
1190            else:
1191                if result:
1192                    batch.executed = datetime.datetime.utcnow()
1193                    batch.executed_by = self.request.user
1194                    self.request.session.flash("{} was executed: {}".format(model_title, instance_title))
1195                else:
1196                    log.error("not sure why, but failed to execute %s %s: %s", model_title, batch.id_str, batch)
1197                    self.request.session.flash("Failed to execute {}: {}".format(model_title, err), 'error')
1198            return self.redirect(view_url)
1199
1200        form.mobile = True
1201        form.submit_label = "Execute"
1202        form.cancel_url = view_url
1203        return self.render_to_response('execute', {
1204            'form': form,
1205            'instance_title': instance_title,
1206            'instance_url': view_url,
1207        }, mobile=True)
1208
1209    def execute_error_message(self, error):
1210        return "Batch execution failed: {}: {}".format(type(error).__name__, error)
1211
1212    def execute_thread(self, batch_uuid, user_uuid, progress, **kwargs):
1213        """
1214        Thread target for executing a batch with progress indicator.
1215        """
1216        # Execute the batch, with progress.  Note that we must use the rattail
1217        # session here; can't use tailbone because it has web request
1218        # transaction binding etc.
1219        session = RattailSession()
1220        batch = session.query(self.model_class).get(batch_uuid)
1221        user = session.query(model.User).get(user_uuid)
1222        try:
1223            result = self.handler.do_execute(batch, user=user, progress=progress, **kwargs)
1224
1225        # If anything goes wrong, rollback and log the error etc.
1226        except Exception as error:
1227            session.rollback()
1228            log.exception("execution failed for batch: {}".format(batch))
1229            session.close()
1230            if progress:
1231                progress.session.load()
1232                progress.session['error'] = True
1233                progress.session['error_msg'] = self.execute_error_message(error)
1234                progress.session.save()
1235
1236        # If no error, check result flag (false means user canceled).
1237        else:
1238            if result:
1239                session.commit()
1240                # TODO: this doesn't always work...?
1241                self.request.session.flash("{} has been executed: {}".format(
1242                    self.get_model_title(), batch.id_str))
1243            else:
1244                session.rollback()
1245
1246            session.refresh(batch)
1247            success_url = self.get_execute_success_url(batch, result, **kwargs)
1248            session.close()
1249
1250            if progress:
1251                progress.session.load()
1252                progress.session['complete'] = True
1253                progress.session['success_url'] = success_url
1254                progress.session.save()
1255
1256    def get_execute_success_url(self, batch, result, **kwargs):
1257        return self.get_action_url('view', batch)
1258
1259    def execute_results(self):
1260        """
1261        Execute all batches which are returned from the current index query.
1262        Starts a separate thread for the execution, and displays a progress
1263        indicator page.
1264        """
1265        form = self.make_execute_form()
1266        if form.validate(newstyle=True):
1267            kwargs = dict(form.validated)
1268
1269            # cache options to use as defaults next time
1270            for key, value in form.validated.items():
1271                self.request.session['batch.{}.execute_option.{}'.format(self.model_class.batch_key, key)] = value
1272
1273            key = '{}.execute_results'.format(self.model_class.__tablename__)
1274            batches = self.get_effective_data()
1275            progress = SessionProgress(self.request, key)
1276            kwargs['progress'] = progress
1277            thread = Thread(target=self.execute_results_thread, args=(batches, self.request.user.uuid), kwargs=kwargs)
1278            thread.start()
1279
1280            return self.render_progress(progress, {
1281                'cancel_url': self.get_index_url(),
1282                'cancel_msg': "Batch execution was canceled",
1283            })
1284
1285        self.request.session.flash("Invalid request: {}".format(form.make_deform_form().error), 'error')
1286        return self.redirect(self.get_index_url())
1287
1288    def execute_results_thread(self, batches, user_uuid, progress=None, **kwargs):
1289        """
1290        Thread target for executing multiple batches with progress indicator.
1291        """
1292        session = RattailSession()
1293        batches = batches.with_session(session).all()
1294        user = session.query(model.User).get(user_uuid)
1295        try:
1296            result = self.handler.execute_many(batches, user=user, progress=progress, **kwargs)
1297
1298        # If anything goes wrong, rollback and log the error etc.
1299        except Exception as error:
1300            session.rollback()
1301            log.exception("execution failed for batch results")
1302            session.close()
1303            if progress:
1304                progress.session.load()
1305                progress.session['error'] = True
1306                progress.session['error_msg'] = self.execute_error_message(error)
1307                progress.session.save()
1308
1309        # If no error, check result flag (false means user canceled).
1310        else:
1311            if result:
1312                session.commit()
1313                # TODO: this doesn't always work...?
1314                self.request.session.flash("{} {} were executed".format(
1315                    len(batches), self.get_model_title_plural()))
1316                success_url = self.get_execute_results_success_url(result, **kwargs)
1317            else:
1318                session.rollback()
1319                success_url = self.get_index_url()
1320            session.close()
1321
1322            if progress:
1323                progress.session.load()
1324                progress.session['complete'] = True
1325                progress.session['success_url'] = success_url
1326                progress.session.save()
1327
1328    def get_execute_results_success_url(self, result, **kwargs):
1329        return self.get_index_url()
1330
1331    def get_row_csv_fields(self):
1332        fields = super(BatchMasterView, self).get_row_csv_fields()
1333        fields = [field for field in fields
1334                  if field not in ('uuid', 'batch_uuid', 'removed')]
1335        return fields
1336
1337    def get_row_results_csv_filename(self, batch):
1338        return '{}.{}.csv'.format(self.get_route_prefix(), batch.id_str)
1339
1340    def get_row_results_xlsx_filename(self, batch):
1341        return '{}.{}.xlsx'.format(self.get_route_prefix(), batch.id_str)
1342
1343    def clone(self):
1344        """
1345        Clone current batch as new batch
1346        """
1347        batch = self.get_instance()
1348        batch = self.handler.clone(batch, created_by=self.request.user)
1349        return self.redirect(self.get_action_url('view', batch))
1350
1351    @classmethod
1352    def defaults(cls, config):
1353        cls._batch_defaults(config)
1354        cls._defaults(config)
1355
1356    @classmethod
1357    def _batch_defaults(cls, config):
1358        model_key = cls.get_model_key()
1359        route_prefix = cls.get_route_prefix()
1360        url_prefix = cls.get_url_prefix()
1361        permission_prefix = cls.get_permission_prefix()
1362        model_title = cls.get_model_title()
1363        model_title_plural = cls.get_model_title_plural()
1364
1365        # TODO: currently must do this here (in addition to `_defaults()` or
1366        # else the perm group label will not display correctly...
1367        config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False)
1368
1369        # populate row data
1370        config.add_route('{}.prefill'.format(route_prefix), '{}/{{uuid}}/prefill'.format(url_prefix))
1371        config.add_view(cls, attr='prefill', route_name='{}.prefill'.format(route_prefix),
1372                        permission='{}.create'.format(permission_prefix))
1373
1374        # worksheet
1375        if cls.has_worksheet:
1376            config.add_tailbone_permission(permission_prefix, '{}.worksheet'.format(permission_prefix),
1377                                           "Edit {} data as worksheet".format(model_title))
1378            config.add_route('{}.worksheet'.format(route_prefix), '{}/{{{}}}/worksheet'.format(url_prefix, model_key))
1379            config.add_view(cls, attr='worksheet', route_name='{}.worksheet'.format(route_prefix),
1380                            permission='{}.worksheet'.format(permission_prefix))
1381            config.add_route('{}.worksheet_update'.format(route_prefix), '{}/{{{}}}/worksheet/update'.format(url_prefix, model_key))
1382            config.add_view(cls, attr='worksheet_update', route_name='{}.worksheet_update'.format(route_prefix),
1383                            renderer='json', permission='{}.worksheet'.format(permission_prefix))
1384
1385        # refresh batch data
1386        if cls.refreshable:
1387            config.add_route('{}.refresh'.format(route_prefix), '{}/{{uuid}}/refresh'.format(url_prefix))
1388            config.add_view(cls, attr='refresh', route_name='{}.refresh'.format(route_prefix),
1389                            permission='{}.refresh'.format(permission_prefix))
1390            config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix),
1391                                           "Refresh data for {}".format(model_title))
1392
1393        # bulk delete rows
1394        if cls.rows_bulk_deletable:
1395            config.add_route('{}.delete_rows'.format(route_prefix), '{}/{{uuid}}/rows/delete'.format(url_prefix))
1396            config.add_view(cls, attr='bulk_delete_rows', route_name='{}.delete_rows'.format(route_prefix),
1397                            permission='{}.delete_rows'.format(permission_prefix))
1398            config.add_tailbone_permission(permission_prefix, '{}.delete_rows'.format(permission_prefix),
1399                                           "Bulk-delete data rows from {}".format(model_title))
1400
1401        # toggle complete
1402        config.add_route('{}.toggle_complete'.format(route_prefix), '{}/{{{}}}/toggle-complete'.format(url_prefix, model_key))
1403        config.add_view(cls, attr='toggle_complete', route_name='{}.toggle_complete'.format(route_prefix),
1404                        permission='{}.edit'.format(permission_prefix))
1405
1406        # mobile mark complete
1407        config.add_route('mobile.{}.mark_complete'.format(route_prefix), '/mobile{}/{{{}}}/mark-complete'.format(url_prefix, model_key))
1408        config.add_view(cls, attr='mobile_mark_complete', route_name='mobile.{}.mark_complete'.format(route_prefix),
1409                        permission='{}.edit'.format(permission_prefix))
1410
1411        # mobile mark pending
1412        config.add_route('mobile.{}.mark_pending'.format(route_prefix), '/mobile{}/{{{}}}/mark-pending'.format(url_prefix, model_key))
1413        config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix),
1414                        permission='{}.edit'.format(permission_prefix))
1415
1416        # execute (multiple) batch results
1417        if cls.results_executable:
1418            config.add_route('{}.execute_results'.format(route_prefix), '{}/execute-results'.format(url_prefix),
1419                             request_method='POST')
1420            config.add_view(cls, attr='execute_results', route_name='{}.execute_results'.format(route_prefix),
1421                            permission='{}.execute_multiple'.format(permission_prefix))
1422            config.add_tailbone_permission(permission_prefix, '{}.execute_multiple'.format(permission_prefix),
1423                                           "Execute multiple {}".format(model_title_plural))
1424
1425
1426class FileBatchMasterView(BatchMasterView):
1427    """
1428    Base class for all file-based "batch master" views.
1429    """
1430    downloadable = True
1431
1432    @property
1433    def upload_dir(self):
1434        """
1435        The path to the root upload folder, to be used as the ``storage_path``
1436        argument for the file field renderer.
1437        """
1438        uploads = os.path.join(
1439            self.rattail_config.require('rattail', 'batch.files'),
1440            'uploads')
1441        uploads = self.rattail_config.get('tailbone', 'batch.uploads',
1442                                          default=uploads)
1443        if not os.path.exists(uploads):
1444            os.makedirs(uploads)
1445        return uploads
1446
1447    def configure_form(self, f):
1448        super(FileBatchMasterView, self).configure_form(f)
1449        batch = f.model_instance
1450
1451        # filename
1452        if self.creating:
1453            # TODO: what's up with this re-insertion again..?
1454            # if 'filename' not in f.fields:
1455            #     f.fields.insert(0, 'filename')
1456            f.set_type('filename', 'file')
1457        else:
1458            f.set_readonly('filename')
1459            f.set_renderer('filename', self.render_downloadable_file)
1460
1461
1462class ToggleComplete(colander.MappingSchema):
1463
1464    complete = colander.SchemaNode(colander.Boolean())
1465
1466
1467class MobileBatchStatusFilter(grids.filters.MobileFilter):
1468
1469    value_choices = ['pending', 'complete', 'executed', 'all']
1470
1471    def __init__(self, model_class, key, **kwargs):
1472        self.model_class = model_class
1473        super(MobileBatchStatusFilter, self).__init__(key, **kwargs)
1474
1475    def filter_equal(self, query, value):
1476
1477        if value == 'pending':
1478            return query.filter(self.model_class.executed == None)\
1479                        .filter(sa.or_(
1480                            self.model_class.complete == None,
1481                            self.model_class.complete == False))
1482
1483        if value == 'complete':
1484            return query.filter(self.model_class.executed == None)\
1485                        .filter(self.model_class.complete == True)
1486
1487        if value == 'executed':
1488            return query.filter(self.model_class.executed != None)
1489
1490        return query
1491
1492    def iter_choices(self):
1493        for value in self.value_choices:
1494            yield value, prettify(value)
Note: See TracBrowser for help on using the repository browser.