source: tailbone/tailbone/views/master.py @ 8d6ecc3

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

Add basic "Buefy" support for grids (master index view)

still pretty experimental at this point, but making progress

  • Property mode set to 100644
File size: 134.6 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"""
24Model Master View
25"""
26
27from __future__ import unicode_literals, absolute_import
28
29import os
30import datetime
31import tempfile
32import logging
33
34import six
35import sqlalchemy as sa
36from sqlalchemy import orm
37
38import sqlalchemy_continuum as continuum
39
40from rattail.db import model, Session as RattailSession
41from rattail.db.continuum import model_transaction_query
42from rattail.util import prettify
43from rattail.time import localtime
44from rattail.threads import Thread
45from rattail.csvutil import UnicodeDictWriter
46from rattail.files import temp_path
47from rattail.excel import ExcelWriter
48from rattail.gpc import GPC
49
50import colander
51import deform
52from pyramid import httpexceptions
53from pyramid.renderers import get_renderer, render_to_response, render
54from pyramid.response import FileResponse
55from webhelpers2.html import HTML, tags
56
57from tailbone import forms, grids, diffs
58from tailbone.views import View
59from tailbone.progress import SessionProgress
60
61
62log = logging.getLogger(__name__)
63
64
65class MasterView(View):
66    """
67    Base "master" view class.  All model master views should derive from this.
68    """
69    filterable = True
70    pageable = True
71    checkboxes = False
72
73    # set to True in order to encode search values as utf-8
74    use_byte_string_filters = False
75
76    listable = True
77    sortable = True
78    results_downloadable_csv = False
79    results_downloadable_xlsx = False
80    creatable = True
81    show_create_link = True
82    viewable = True
83    editable = True
84    deletable = True
85    bulk_deletable = False
86    set_deletable = False
87    supports_set_enabled_toggle = False
88    populatable = False
89    mergeable = False
90    downloadable = False
91    cloneable = False
92    executable = False
93    execute_progress_template = None
94    execute_progress_initial_msg = None
95    supports_prev_next = False
96    supports_import_batch_from_file = False
97
98    # set to True to declare model as "contact"
99    is_contact = False
100
101    supports_mobile = False
102    mobile_creatable = False
103    mobile_editable = False
104    mobile_pageable = True
105    mobile_filterable = False
106    mobile_executable = False
107
108    mobile = False
109    listing = False
110    creating = False
111    creates_multiple = False
112    viewing = False
113    editing = False
114    deleting = False
115    executing = False
116    has_pk_fields = False
117    has_image = False
118    has_thumbnail = False
119
120    row_attrs = {}
121    cell_attrs = {}
122
123    grid_index = None
124    use_index_links = False
125    # this should be turned on per-view as progress is made
126    use_buefy = False
127
128    has_versions = False
129    help_url = None
130
131    labels = {'uuid': "UUID"}
132
133    # ROW-RELATED ATTRS FOLLOW:
134
135    has_rows = False
136    model_row_class = None
137    rows_pageable = True
138    rows_sortable = True
139    rows_filterable = True
140    rows_viewable = True
141    rows_creatable = False
142    rows_editable = False
143    rows_deletable = False
144    rows_deletable_speedbump = True
145    rows_bulk_deletable = False
146    rows_default_pagesize = 20
147    rows_downloadable_csv = False
148    rows_downloadable_xlsx = False
149
150    mobile_rows_creatable = False
151    mobile_rows_creatable_via_browse = False
152    mobile_rows_quickable = False
153    mobile_rows_filterable = False
154    mobile_rows_viewable = False
155    mobile_rows_editable = False
156    mobile_rows_deletable = False
157
158    row_labels = {}
159
160    @property
161    def Session(self):
162        """
163        SQLAlchemy scoped session to use when querying the database.  Defaults
164        to ``tailbone.db.Session``.
165        """
166        from tailbone.db import Session
167        return Session
168
169    @classmethod
170    def get_grid_factory(cls):
171        """
172        Returns the grid factory or class which is to be used when creating new
173        grid instances.
174        """
175        return getattr(cls, 'grid_factory', grids.Grid)
176
177    @classmethod
178    def get_row_grid_factory(cls):
179        """
180        Returns the grid factory or class which is to be used when creating new
181        row grid instances.
182        """
183        return getattr(cls, 'row_grid_factory', grids.Grid)
184
185    @classmethod
186    def get_version_grid_factory(cls):
187        """
188        Returns the grid factory or class which is to be used when creating new
189        version grid instances.
190        """
191        return getattr(cls, 'version_grid_factory', grids.Grid)
192
193    @classmethod
194    def get_mobile_grid_factory(cls):
195        """
196        Must return a callable to be used when creating new mobile grid
197        instances.  Instead of overriding this, you can set
198        :attr:`mobile_grid_factory`.  Default factory is :class:`MobileGrid`.
199        """
200        return getattr(cls, 'mobile_grid_factory', grids.MobileGrid)
201
202    @classmethod
203    def get_mobile_row_grid_factory(cls):
204        """
205        Must return a callable to be used when creating new mobile row grid
206        instances.  Instead of overriding this, you can set
207        :attr:`mobile_row_grid_factory`.  Default factory is :class:`MobileGrid`.
208        """
209        return getattr(cls, 'mobile_row_grid_factory', grids.MobileGrid)
210
211    def set_labels(self, obj):
212        labels = self.collect_labels()
213        for key, label in six.iteritems(labels):
214            obj.set_label(key, label)
215
216    def collect_labels(self):
217        """
218        Collect all labels defined within the master class hierarchy.
219        """
220        labels = {}
221        hierarchy = self.get_class_hierarchy()
222        for cls in hierarchy:
223            if hasattr(cls, 'labels'):
224                labels.update(cls.labels)
225        return labels
226
227    def get_class_hierarchy(self):
228        hierarchy = []
229
230        def traverse(cls):
231            if cls is not object:
232                hierarchy.append(cls)
233                for parent in cls.__bases__:
234                    traverse(parent)
235
236        traverse(self.__class__)
237        hierarchy.reverse()
238        return hierarchy
239
240    def set_row_labels(self, obj):
241        labels = self.collect_row_labels()
242        for key, label in six.iteritems(labels):
243            obj.set_label(key, label)
244
245    def collect_row_labels(self):
246        """
247        Collect all row labels defined within the master class hierarchy.
248        """
249        labels = {}
250        hierarchy = self.get_class_hierarchy()
251        for cls in hierarchy:
252            if hasattr(cls, 'row_labels'):
253                labels.update(cls.row_labels)
254        return labels
255
256    ##############################
257    # Available Views
258    ##############################
259
260    def index(self):
261        """
262        View to list/filter/sort the model data.
263
264        If this view receives a non-empty 'partial' parameter in the query
265        string, then the view will return the rendered grid only.  Otherwise
266        returns the full page.
267        """
268        self.listing = True
269        grid = self.make_grid()
270        use_buefy = self.use_buefy and self.rattail_config.getbool('tailbone', 'grids.use_buefy')
271
272        # If user just refreshed the page with a reset instruction, issue a
273        # redirect in order to clear out the query string.
274        if self.request.GET.get('reset-to-default-filters') == 'true':
275            return self.redirect(self.request.current_route_url(_query=None))
276
277        # Stash some grid stats, for possible use when generating URLs.
278        if grid.pageable and hasattr(grid, 'pager'):
279            self.first_visible_grid_index = grid.pager.first_item
280
281        # return grid only, if partial page was requested
282        if self.request.params.get('partial'):
283            if use_buefy:
284                # render grid data only, as JSON
285                return render_to_response('json', grid.get_buefy_data(),
286                                          request=self.request)
287            else: # just do traditional thing, render grid HTML
288                self.request.response.content_type = str('text/html')
289                self.request.response.text = grid.render_grid()
290                return self.request.response
291
292        context = {
293            'grid': grid,
294            'use_buefy': use_buefy,
295        }
296
297        if use_buefy:
298            context['grid_columns'] = grid.get_buefy_columns()
299            context['grid_data'] = grid.get_buefy_data()
300            context['static_data'] = grid.has_static_data()
301
302        return self.render_to_response('index', context)
303
304    def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
305        """
306        Creates a new grid instance
307        """
308        if factory is None:
309            factory = self.get_grid_factory()
310        if key is None:
311            key = self.get_grid_key()
312        if data is None:
313            data = self.get_data(session=kwargs.get('session'))
314        if columns is None:
315            columns = self.get_grid_columns()
316
317        kwargs.setdefault('request', self.request)
318        kwargs = self.make_grid_kwargs(**kwargs)
319        grid = factory(key, data, columns, **kwargs)
320        self.configure_grid(grid)
321        grid.load_settings()
322        return grid
323
324    def get_effective_data(self, session=None, **kwargs):
325        """
326        Convenience method which returns the "effective" data for the master
327        grid, filtered and sorted to match what would show on the UI, but not
328        paged etc.
329        """
330        if session is None:
331            session = self.Session()
332        kwargs.setdefault('pageable', False)
333        grid = self.make_grid(session=session, **kwargs)
334        return grid.make_visible_data()
335
336    def get_grid_columns(self):
337        """
338        Returns the default list of grid column names.  This may return
339        ``None``, in which case the grid will generate its own default list.
340        """
341        if hasattr(self, 'grid_columns'):
342            return self.grid_columns
343
344    def make_grid_kwargs(self, **kwargs):
345        """
346        Return a dictionary of kwargs to be passed to the factory when creating
347        new grid instances.
348        """
349        permission_prefix = self.get_permission_prefix()
350
351        checkboxes = self.checkboxes
352        if not checkboxes and self.mergeable and self.request.has_perm('{}.merge'.format(permission_prefix)):
353            checkboxes = True
354        if not checkboxes and self.supports_set_enabled_toggle and self.request.has_perm('{}.enable_disable_set'.format(permission_prefix)):
355            checkboxes = True
356        if not checkboxes and self.set_deletable and self.request.has_perm('{}.delete_set'.format(permission_prefix)):
357            checkboxes = True
358
359        defaults = {
360            'model_class': getattr(self, 'model_class', None),
361            'width': 'full',
362            'filterable': self.filterable,
363            'use_byte_string_filters': self.use_byte_string_filters,
364            'sortable': self.sortable,
365            'pageable': self.pageable,
366            'extra_row_class': self.grid_extra_class,
367            'url': lambda obj: self.get_action_url('view', obj),
368            'checkboxes': checkboxes,
369            'checked': self.checked,
370        }
371        if 'main_actions' not in kwargs and 'more_actions' not in kwargs:
372            main, more = self.get_grid_actions()
373            defaults['main_actions'] = main
374            defaults['more_actions'] = more
375        defaults.update(kwargs)
376        return defaults
377
378    def configure_grid(self, grid):
379        self.set_labels(grid)
380
381    def grid_extra_class(self, obj, i):
382        """
383        Returns string of extra class(es) for the table row corresponding to
384        the given object, or ``None``.
385        """
386
387    def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
388        """
389        Make and return a new (configured) rows grid instance.
390        """
391        instance = kwargs.pop('instance', None)
392        if not instance:
393            instance = self.get_instance()
394
395        if factory is None:
396            factory = self.get_row_grid_factory()
397        if key is None:
398            key = self.get_row_grid_key()
399        if data is None:
400            data = self.get_row_data(instance)
401        if columns is None:
402            columns = self.get_row_grid_columns()
403
404        kwargs.setdefault('request', self.request)
405        kwargs = self.make_row_grid_kwargs(**kwargs)
406        grid = factory(key, data, columns, **kwargs)
407        self.configure_row_grid(grid)
408        grid.load_settings()
409        return grid
410
411    def get_row_grid_columns(self):
412        if hasattr(self, 'row_grid_columns'):
413            return self.row_grid_columns
414        # TODO
415        raise NotImplementedError
416
417    def make_row_grid_kwargs(self, **kwargs):
418        """
419        Return a dict of kwargs to be used when constructing a new rows grid.
420        """
421        permission_prefix = self.get_permission_prefix()
422
423        defaults = {
424            'model_class': self.model_row_class,
425            'width': 'full',
426            'filterable': self.rows_filterable,
427            'sortable': self.rows_sortable,
428            'pageable': self.rows_pageable,
429            'default_pagesize': self.rows_default_pagesize,
430            'extra_row_class': self.row_grid_extra_class,
431            'url': lambda obj: self.get_row_action_url('view', obj),
432        }
433
434        if self.has_rows and 'main_actions' not in defaults:
435            actions = []
436
437            # view action
438            if self.rows_viewable:
439                view = lambda r, i: self.get_row_action_url('view', r)
440                actions.append(grids.GridAction('view', icon='zoomin', url=view))
441
442            # edit action
443            if self.rows_editable:
444                actions.append(grids.GridAction('edit', icon='pencil', url=self.row_edit_action_url))
445
446            # delete action
447            if self.rows_deletable and self.request.has_perm('{}.delete_row'.format(permission_prefix)):
448                actions.append(grids.GridAction('delete', icon='trash', url=self.row_delete_action_url))
449                defaults['delete_speedbump'] = self.rows_deletable_speedbump
450
451            defaults['main_actions'] = actions
452
453        defaults.update(kwargs)
454        return defaults
455
456    def configure_row_grid(self, grid):
457        # super(MasterView, self).configure_row_grid(grid)
458        self.set_row_labels(grid)
459
460    def row_grid_extra_class(self, obj, i):
461        """
462        Returns string of extra class(es) for the table row corresponding to
463        the given row object, or ``None``.
464        """
465
466    def make_version_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
467        """
468        Creates a new version grid instance
469        """
470        instance = kwargs.pop('instance', None)
471        if not instance:
472            instance = self.get_instance()
473
474        if factory is None:
475            factory = self.get_version_grid_factory()
476        if key is None:
477            key = self.get_version_grid_key()
478        if data is None:
479            data = self.get_version_data(instance)
480        if columns is None:
481            columns = self.get_version_grid_columns()
482
483        kwargs.setdefault('request', self.request)
484        kwargs = self.make_version_grid_kwargs(**kwargs)
485        grid = factory(key, data, columns, **kwargs)
486        self.configure_version_grid(grid)
487        grid.load_settings()
488        return grid
489
490    def get_version_grid_columns(self):
491        if hasattr(self, 'version_grid_columns'):
492            return self.version_grid_columns
493        # TODO
494        return [
495            'issued_at',
496            'user',
497            'remote_addr',
498            'comment',
499        ]
500
501    def make_version_grid_kwargs(self, **kwargs):
502        """
503        Return a dictionary of kwargs to be passed to the factory when
504        constructing a new version grid.
505        """
506        defaults = {
507            'model_class': continuum.transaction_class(self.get_model_class()),
508            'width': 'full',
509            'pageable': True,
510        }
511        if 'main_actions' not in kwargs:
512            route = '{}.version'.format(self.get_route_prefix())
513            instance = kwargs.get('instance') or self.get_instance()
514            url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id)
515            defaults['main_actions'] = [
516                self.make_action('view', icon='zoomin', url=url),
517            ]
518        defaults.update(kwargs)
519        return defaults
520
521    def configure_version_grid(self, g):
522        g.set_sort_defaults('issued_at', 'desc')
523        g.set_renderer('comment', self.render_version_comment)
524        g.set_label('issued_at', "Changed")
525        g.set_label('user', "Changed by")
526        g.set_label('remote_addr', "IP Address")
527        # TODO: why does this render '#' as url?
528        # g.set_link('issued_at')
529
530    def render_version_comment(self, transaction, column):
531        return transaction.meta.get('comment', "")
532
533    def mobile_index(self):
534        """
535        Mobile "home" page for the data model
536        """
537        self.mobile = True
538        self.listing = True
539        grid = self.make_mobile_grid()
540        return self.render_to_response('index', {'grid': grid}, mobile=True)
541
542    @classmethod
543    def get_mobile_grid_key(cls):
544        """
545        Must return a unique "config key" for the mobile grid, for sort/filter
546        purposes etc.  (It need only be unique among *mobile* grids.)  Instead
547        of overriding this, you can set :attr:`mobile_grid_key`.  Default is
548        the value returned by :meth:`get_route_prefix()`.
549        """
550        if hasattr(cls, 'mobile_grid_key'):
551            return cls.mobile_grid_key
552        return 'mobile.{}'.format(cls.get_route_prefix())
553
554    def make_mobile_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
555        """
556        Creates a new mobile grid instance
557        """
558        if factory is None:
559            factory = self.get_mobile_grid_factory()
560        if key is None:
561            key = self.get_mobile_grid_key()
562        if data is None:
563            data = self.get_mobile_data(session=kwargs.get('session'))
564        if columns is None:
565            columns = self.get_mobile_grid_columns()
566
567        kwargs.setdefault('request', self.request)
568        kwargs.setdefault('mobile', True)
569        kwargs = self.make_mobile_grid_kwargs(**kwargs)
570        grid = factory(key, data, columns, **kwargs)
571        self.configure_mobile_grid(grid)
572        grid.load_settings()
573        return grid
574
575    def get_mobile_grid_columns(self):
576        if hasattr(self, 'mobile_grid_columns'):
577            return self.mobile_grid_columns
578        # TODO
579        return ['listitem']
580
581    def get_mobile_data(self, session=None):
582        """
583        Must return the "raw" / full data set for the mobile grid.  This data
584        should *not* yet be sorted or filtered in any way; that happens later.
585        Default is the value returned by :meth:`get_data()`, in which case all
586        records visible in the traditional view, are visible in mobile too.
587        """
588        return self.get_data(session=session)
589
590    def make_mobile_grid_kwargs(self, **kwargs):
591        """
592        Must return a dictionary of kwargs to be passed to the factory when
593        creating new mobile grid instances.
594        """
595        defaults = {
596            'model_class': getattr(self, 'model_class', None),
597            'pageable': self.mobile_pageable,
598            'sortable': False,
599            'filterable': self.mobile_filterable,
600            'renderers': self.make_mobile_grid_renderers(),
601            'url': lambda obj: self.get_action_url('view', obj, mobile=True),
602        }
603        # TODO: this seems wrong..
604        if self.mobile_filterable:
605            defaults['filters'] = self.make_mobile_filters()
606        defaults.update(kwargs)
607        return defaults
608
609    def make_mobile_grid_renderers(self):
610        return {
611            'listitem': self.render_mobile_listitem,
612        }
613
614    def render_mobile_listitem(self, obj, i):
615        return obj
616
617    def configure_mobile_grid(self, grid):
618        pass
619
620    def make_mobile_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
621        """
622        Make a new (configured) rows grid instance for mobile.
623        """
624        instance = kwargs.pop('instance', self.get_instance())
625
626        if factory is None:
627            factory = self.get_mobile_row_grid_factory()
628        if key is None:
629            key = 'mobile.{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()])
630        if data is None:
631            data = self.get_mobile_row_data(instance)
632        if columns is None:
633            columns = self.get_mobile_row_grid_columns()
634
635        kwargs.setdefault('request', self.request)
636        kwargs.setdefault('mobile', True)
637        kwargs = self.make_mobile_row_grid_kwargs(**kwargs)
638        grid = factory(key, data, columns, **kwargs)
639        self.configure_mobile_row_grid(grid)
640        grid.load_settings()
641        return grid
642
643    def get_mobile_row_grid_columns(self):
644        if hasattr(self, 'mobile_row_grid_columns'):
645            return self.mobile_row_grid_columns
646        # TODO
647        return ['listitem']
648
649    def make_mobile_row_grid_kwargs(self, **kwargs):
650        """
651        Must return a dictionary of kwargs to be passed to the factory when
652        creating new mobile *row* grid instances.
653        """
654        defaults = {
655            'model_class': self.model_row_class,
656            # TODO
657            'pageable': self.pageable,
658            'sortable': False,
659            'filterable': self.mobile_rows_filterable,
660            'renderers': self.make_mobile_row_grid_renderers(),
661            'url': lambda obj: self.get_row_action_url('view', obj, mobile=True),
662        }
663        # TODO: this seems wrong..
664        if self.mobile_rows_filterable:
665            defaults['filters'] = self.make_mobile_row_filters()
666        defaults.update(kwargs)
667        return defaults
668
669    def make_mobile_row_grid_renderers(self):
670        return {
671            'listitem': self.render_mobile_row_listitem,
672        }
673
674    def configure_mobile_row_grid(self, grid):
675        pass
676
677    def make_mobile_filters(self):
678        """
679        Returns a set of filters for the mobile grid, if applicable.
680        """
681
682    def make_mobile_row_filters(self):
683        """
684        Returns a set of filters for the mobile row grid, if applicable.
685        """
686
687    def render_mobile_row_listitem(self, obj, i):
688        return obj
689
690    def create(self, form=None, template='create'):
691        """
692        View for creating a new model record.
693        """
694        self.creating = True
695        if form is None:
696            form = self.make_form(self.get_model_class())
697        if self.request.method == 'POST':
698            if self.validate_form(form):
699                # let save_create_form() return alternate object if necessary
700                obj = self.save_create_form(form)
701                self.after_create(obj)
702                self.flash_after_create(obj)
703                return self.redirect_after_create(obj)
704        context = {'form': form}
705        if hasattr(form, 'make_deform_form'):
706            context['dform'] = form.make_deform_form()
707        return self.render_to_response(template, context)
708
709    def mobile_create(self):
710        """
711        Mobile view for creating a new primary object
712        """
713        self.mobile = True
714        self.creating = True
715        form = self.make_mobile_form(self.get_model_class())
716        if self.request.method == 'POST':
717            if self.validate_mobile_form(form):
718                # let save_create_form() return alternate object if necessary
719                obj = self.save_mobile_create_form(form)
720                self.after_create(obj)
721                self.flash_after_create(obj)
722                return self.redirect_after_create(obj, mobile=True)
723        return self.render_to_response('create', {'form': form}, mobile=True)
724
725    def save_create_form(self, form):
726        uploads = self.normalize_uploads(form)
727        self.before_create(form)
728        with self.Session().no_autoflush:
729            obj = self.objectify(form, self.form_deserialized)
730            self.before_create_flush(obj, form)
731        self.Session.add(obj)
732        self.Session.flush()
733        self.process_uploads(obj, form, uploads)
734        return obj
735
736    def normalize_uploads(self, form, skip=None):
737        uploads = {}
738        for node in form.schema:
739            if isinstance(node.typ, deform.FileData):
740                if skip and node.name in skip:
741                    continue
742                # TODO: does form ever *not* have 'validated' attr here?
743                if hasattr(form, 'validated'):
744                    filedict = form.validated.get(node.name)
745                else:
746                    filedict = self.form_deserialized.get(node.name)
747                if filedict:
748                    tempdir = tempfile.mkdtemp()
749                    filepath = os.path.join(tempdir, filedict['filename'])
750                    tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid'])
751                    tmpdata = tmpinfo['fp'].read()
752                    with open(filepath, 'wb') as f:
753                        f.write(tmpdata)
754                    uploads[node.name] = {
755                        'tempdir': tempdir,
756                        'temp_path': filepath,
757                    }
758        return uploads
759
760    def process_uploads(self, obj, form, uploads):
761        pass
762
763    def import_batch_from_file(self, handler_factory, model_name,
764                               delete=False, schema=None, importer_host_title=None):
765
766        handler = handler_factory(self.rattail_config)
767
768        if not schema:
769            schema = forms.SimpleFileImport().bind(request=self.request)
770        form = forms.Form(schema=schema, request=self.request)
771        form.save_label = "Upload"
772        form.cancel_url = self.get_index_url()
773        if form.validate(newstyle=True):
774
775            uploads = self.normalize_uploads(form)
776            filepath = uploads['filename']['temp_path']
777            batches = handler.make_batches(model_name,
778                                           delete=delete,
779                                           # tdc_input_path=filepath,
780                                           # source_csv_path=filepath,
781                                           source_data_path=filepath,
782                                           runas_user=self.request.user)
783            batch = batches[0]
784            return self.redirect(self.request.route_url('batch.importer.view', uuid=batch.uuid))
785
786        if not importer_host_title:
787            importer_host_title = handler.host_title
788
789        return self.render_to_response('import_file', {
790            'form': form,
791            'dform': form.make_deform_form(),
792            'importer_host_title': importer_host_title,
793        })
794
795    def render_product_key_value(self, obj):
796        """
797        Render the "canonical" product key value for the given object.
798        """
799        product_key = self.rattail_config.product_key()
800        if product_key == 'upc':
801            return obj.upc.pretty() if obj.upc else ''
802        return getattr(obj, product_key)
803
804    def render_product(self, obj, field):
805        product = getattr(obj, field)
806        if not product:
807            return ""
808        text = six.text_type(product)
809        url = self.request.route_url('products.view', uuid=product.uuid)
810        return tags.link_to(text, url)
811
812    def before_create_flush(self, obj, form):
813        pass
814
815    def flash_after_create(self, obj):
816        self.request.session.flash("{} has been created: {}".format(
817            self.get_model_title(), self.get_instance_title(obj)))
818
819    def save_mobile_create_form(self, form):
820        self.before_create(form)
821        with self.Session.no_autoflush:
822            obj = self.objectify(form, self.form_deserialized)
823            self.before_create_flush(obj, form)
824        self.Session.add(obj)
825        self.Session.flush()
826        return obj
827
828    def redirect_after_create(self, instance, mobile=False):
829        if self.populatable and self.should_populate(instance):
830            return self.redirect(self.get_action_url('populate', instance, mobile=mobile))
831        return self.redirect(self.get_action_url('view', instance, mobile=mobile))
832
833    def should_populate(self, obj):
834        return True
835
836    def populate(self):
837        """
838        View for populating a new object.  What exactly this means / does will
839        depend on the logic in :meth:`populate_object()`.
840        """
841        obj = self.get_instance()
842        route_prefix = self.get_route_prefix()
843        permission_prefix = self.get_permission_prefix()
844
845        # showing progress requires a separate thread; start that first
846        key = '{}.populate'.format(route_prefix)
847        progress = SessionProgress(self.request, key)
848        thread = Thread(target=self.populate_thread, args=(obj.uuid, progress)) # TODO: uuid?
849        thread.start()
850
851        # Send user to progress page.
852        kwargs = {
853            'cancel_url': self.get_action_url('view', obj),
854            'cancel_msg': "{} population was canceled.".format(self.get_model_title()),
855        }
856
857        return self.render_progress(progress, kwargs)
858
859    def populate_thread(self, uuid, progress): # TODO: uuid?
860        """
861        Thread target for populating new object with progress indicator.
862        """
863        # mustn't use tailbone web session here
864        session = RattailSession()
865        obj = session.query(self.model_class).get(uuid)
866        try:
867            self.populate_object(session, obj, progress=progress)
868        except Exception as error:
869            session.rollback()
870            msg = "{} population failed".format(self.get_model_title())
871            log.warning("{}: {}".format(msg, obj), exc_info=True)
872            session.close()
873            if progress:
874                progress.session.load()
875                progress.session['error'] = True
876                progress.session['error_msg'] = "{}: {} {}".format(msg, error.__class__.__name__, error)
877                progress.session.save()
878            return
879
880        session.commit()
881        session.refresh(obj)
882        session.close()
883
884        # finalize progress
885        if progress:
886            progress.session.load()
887            progress.session['complete'] = True
888            progress.session['success_url'] = self.get_action_url('view', obj)
889            progress.session.save()
890
891    def populate_object(self, session, obj, progress=None):
892        """
893        You must define this if new objects require population.
894        """
895        raise NotImplementedError
896
897    def view(self, instance=None):
898        """
899        View for viewing details of an existing model record.
900        """
901        self.viewing = True
902        if instance is None:
903            instance = self.get_instance()
904        form = self.make_form(instance)
905        if self.has_rows:
906
907            # must make grid prior to redirecting from filter reset, b/c the
908            # grid will detect the filter reset request and store defaults in
909            # the session, that way redirect will then show The Right Thing
910            grid = self.make_row_grid(instance=instance)
911
912            # If user just refreshed the page with a reset instruction, issue a
913            # redirect in order to clear out the query string.
914            if self.request.GET.get('reset-to-default-filters') == 'true':
915                return self.redirect(self.request.current_route_url(_query=None))
916
917            if self.request.params.get('partial'):
918                if six.PY3:
919                    self.request.response.content_type = 'text/html'
920                else:
921                    self.request.response.content_type = b'text/html'
922                self.request.response.text = grid.render_grid()
923                return self.request.response
924
925        context = {
926            'instance': instance,
927            'instance_title': self.get_instance_title(instance),
928            'instance_editable': self.editable_instance(instance),
929            'instance_deletable': self.deletable_instance(instance),
930            'form': form,
931        }
932        if hasattr(form, 'make_deform_form'):
933            context['dform'] = form.make_deform_form()
934        if self.has_rows:
935            context['rows_grid'] = grid.render_complete(allow_save_defaults=False,
936                                                        tools=self.make_row_grid_tools(instance))
937        return self.render_to_response('view', context)
938
939    def image(self):
940        """
941        View which renders the object's image as a response.
942        """
943        obj = self.get_instance()
944        image_bytes = self.get_image_bytes(obj)
945        if not image_bytes:
946            raise self.notfound()
947        # TODO: how to properly detect image type?
948        self.request.response.content_type = str('image/jpeg')
949        self.request.response.body = image_bytes
950        return self.request.response
951
952    def get_image_bytes(self, obj):
953        raise NotImplementedError
954
955    def thumbnail(self):
956        """
957        View which renders the object's thumbnail image as a response.
958        """
959        obj = self.get_instance()
960        image_bytes = self.get_thumbnail_bytes(obj)
961        if not image_bytes:
962            raise self.notfound()
963        # TODO: how to properly detect image type?
964        self.request.response.content_type = str('image/jpeg')
965        self.request.response.body = image_bytes
966        return self.request.response
967
968    def get_thumbnail_bytes(self, obj):
969        raise NotImplementedError
970
971    def clone(self):
972        """
973        View for cloning an object's data into a new object.
974        """
975        self.viewing = True
976        instance = self.get_instance()
977        form = self.make_form(instance)
978        self.configure_clone_form(form)
979        if self.request.method == 'POST' and self.request.POST.get('clone') == 'clone':
980            cloned = self.clone_instance(instance)
981            return self.redirect(self.get_action_url('view', cloned))
982        return self.render_to_response('clone', {
983            'instance': instance,
984            'instance_title': self.get_instance_title(instance),
985            'instance_url': self.get_action_url('view', instance),
986            'form': form,
987        })
988
989    def configure_clone_form(self, form):
990        pass
991
992    def clone_instance(self, instance):
993        raise NotImplementedError
994
995    def versions(self):
996        """
997        View to list version history for an object.
998        """
999        instance = self.get_instance()
1000        instance_title = self.get_instance_title(instance)
1001        grid = self.make_version_grid(instance=instance)
1002
1003        # return grid only, if partial page was requested
1004        if self.request.params.get('partial'):
1005            self.request.response.content_type = b'text/html'
1006            self.request.response.text = grid.render_grid()
1007            return self.request.response
1008
1009        return self.render_to_response('versions', {
1010            'instance': instance,
1011            'instance_title': instance_title,
1012            'instance_url': self.get_action_url('view', instance),
1013            'grid': grid,
1014        })
1015
1016    @classmethod
1017    def get_version_grid_key(cls):
1018        """
1019        Returns the unique key to be used for the version grid, for caching
1020        sort/filter options etc.
1021        """
1022        if hasattr(cls, 'version_grid_key'):
1023            return cls.version_grid_key
1024        return '{}.history'.format(cls.get_route_prefix())
1025
1026    def get_version_data(self, instance):
1027        """
1028        Generate the base data set for the version grid.
1029        """
1030        model_class = self.get_model_class()
1031        transaction_class = continuum.transaction_class(model_class)
1032        query = model_transaction_query(self.Session(), instance, model_class,
1033                                        child_classes=self.normalize_version_child_classes())
1034        return query.order_by(transaction_class.issued_at.desc())
1035
1036    def get_version_child_classes(self):
1037        """
1038        If applicable, should return a list of child classes which should be
1039        considered when querying for version history of an object.
1040        """
1041        return []
1042
1043    def normalize_version_child_classes(self):
1044        classes = []
1045        for cls in self.get_version_child_classes():
1046            if not isinstance(cls, tuple):
1047                cls = (cls, 'uuid', 'uuid')
1048            elif len(cls) == 2:
1049                cls = tuple([cls[0], cls[1], 'uuid'])
1050            classes.append(cls)
1051        return classes
1052
1053    def view_version(self):
1054        """
1055        View showing diff details of a particular object version.
1056        """
1057        instance = self.get_instance()
1058        model_class = self.get_model_class()
1059        route_prefix = self.get_route_prefix()
1060        Transaction = continuum.transaction_class(model_class)
1061        transactions = model_transaction_query(self.Session(), instance, model_class,
1062                                               child_classes=self.normalize_version_child_classes())
1063        transaction_id = self.request.matchdict['txnid']
1064        transaction = transactions.filter(Transaction.id == transaction_id).first()
1065        if not transaction:
1066            return self.notfound()
1067        older = transactions.filter(Transaction.issued_at <= transaction.issued_at)\
1068                            .filter(Transaction.id != transaction_id)\
1069                            .order_by(Transaction.issued_at.desc())\
1070                            .first()
1071        newer = transactions.filter(Transaction.issued_at >= transaction.issued_at)\
1072                            .filter(Transaction.id != transaction_id)\
1073                            .order_by(Transaction.issued_at)\
1074                            .first()
1075
1076        instance_title = self.get_instance_title(instance)
1077
1078        prev_url = next_url = None
1079        if older:
1080            prev_url = self.request.route_url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=older.id)
1081        if newer:
1082            next_url = self.request.route_url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=newer.id)
1083
1084        return self.render_to_response('view_version', {
1085            'instance': instance,
1086            'instance_title': "{} (history)".format(instance_title),
1087            'instance_title_normal': instance_title,
1088            'instance_url': self.get_action_url('versions', instance),
1089            'transaction': transaction,
1090            'changed': localtime(self.rattail_config, transaction.issued_at, from_utc=True),
1091            'versions': self.get_relevant_versions(transaction, instance),
1092            'show_prev_next': True,
1093            'prev_url': prev_url,
1094            'next_url': next_url,
1095            'previous_transaction': older,
1096            'next_transaction': newer,
1097            'title_for_version': self.title_for_version,
1098            'fields_for_version': self.fields_for_version,
1099            'continuum': continuum,
1100        })
1101
1102    def title_for_version(self, version):
1103        cls = continuum.parent_class(version.__class__)
1104        return cls.get_model_title()
1105
1106    def fields_for_version(self, version):
1107        mapper = orm.class_mapper(version.__class__)
1108        fields = sorted(mapper.columns.keys())
1109        fields.remove('transaction_id')
1110        fields.remove('end_transaction_id')
1111        fields.remove('operation_type')
1112        return fields
1113
1114    def get_relevant_versions(self, transaction, instance):
1115        versions = []
1116        version_cls = self.get_model_version_class()
1117        query = self.Session.query(version_cls)\
1118                            .filter(version_cls.transaction == transaction)\
1119                            .filter(version_cls.uuid == instance.uuid)
1120        versions.extend(query.all())
1121        for cls, foreign_attr, primary_attr in self.normalize_version_child_classes():
1122            version_cls = continuum.version_class(cls)
1123            query = self.Session.query(version_cls)\
1124                                .filter(version_cls.transaction == transaction)\
1125                                .filter(getattr(version_cls, foreign_attr) == getattr(instance, primary_attr))
1126            versions.extend(query.all())
1127        return versions
1128
1129    def mobile_view(self):
1130        """
1131        Mobile view for displaying a single object's details
1132        """
1133        self.mobile = True
1134        self.viewing = True
1135        instance = self.get_instance()
1136        form = self.make_mobile_form(instance)
1137
1138        context = {
1139            'instance': instance,
1140            'instance_title': self.get_instance_title(instance),
1141            'instance_editable': self.editable_instance(instance),
1142            # 'instance_deletable': self.deletable_instance(instance),
1143            'form': form,
1144        }
1145        if self.has_rows:
1146            context['model_row_class'] = self.model_row_class
1147            context['grid'] = self.make_mobile_row_grid(instance=instance)
1148        return self.render_to_response('view', context, mobile=True)
1149
1150    def make_mobile_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs):
1151        """
1152        Creates a new mobile form for the given model class/instance.
1153        """
1154        if factory is None:
1155            factory = self.get_mobile_form_factory()
1156        if fields is None:
1157            fields = self.get_mobile_form_fields()
1158        if schema is None:
1159            schema = self.make_mobile_form_schema()
1160
1161        if not self.creating:
1162            kwargs['model_instance'] = instance
1163        kwargs = self.make_mobile_form_kwargs(**kwargs)
1164        form = factory(fields, schema, **kwargs)
1165        self.configure_mobile_form(form)
1166        return form
1167
1168    def get_mobile_form_fields(self):
1169        if hasattr(self, 'mobile_form_fields'):
1170            return self.mobile_form_fields
1171        # TODO
1172        # raise NotImplementedError
1173
1174    def make_mobile_form_schema(self):
1175        if not self.model_class:
1176            # TODO
1177            raise NotImplementedError
1178
1179    def make_mobile_form_kwargs(self, **kwargs):
1180        """
1181        Return a dictionary of kwargs to be passed to the factory when creating
1182        new mobile forms.
1183        """
1184        defaults = {
1185            'request': self.request,
1186            'readonly': self.viewing,
1187            'model_class': getattr(self, 'model_class', None),
1188            'action_url': self.request.current_route_url(_query=None),
1189        }
1190        if self.creating:
1191            defaults['cancel_url'] = self.get_index_url(mobile=True)
1192        else:
1193            instance = kwargs['model_instance']
1194            defaults['cancel_url'] = self.get_action_url('view', instance, mobile=True)
1195        defaults.update(kwargs)
1196        return defaults
1197
1198    def configure_common_form(self, form):
1199        """
1200        Configure the form in whatever way is deemed "common" - i.e. where
1201        configuration should be done the same for desktop and mobile.
1202
1203        By default this removes the 'uuid' field (if present), sets any primary
1204        key fields to be readonly (if we have a :attr:`model_class` and are in
1205        edit mode), and sets labels as defined by the master class hierarchy.
1206        """
1207        form.remove_field('uuid')
1208
1209        if self.editing:
1210            model_class = self.get_model_class(error=False)
1211            if model_class:
1212                # set readonly for all primary key fields
1213                mapper = orm.class_mapper(model_class)
1214                for key in mapper.primary_key:
1215                    for field in form.fields:
1216                        if field == key.name:
1217                            form.set_readonly(field)
1218                            break
1219
1220        self.set_labels(form)
1221
1222    def configure_mobile_form(self, form):
1223        """
1224        Configure the main "mobile" form for the view's data model.
1225        """
1226        self.configure_common_form(form)
1227
1228    def validate_mobile_form(self, form):
1229        if form.validate(newstyle=True):
1230            # TODO: deprecate / remove self.form_deserialized
1231            self.form_deserialized = form.validated
1232            return True
1233        else:
1234            return False
1235
1236    def make_mobile_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs):
1237        """
1238        Creates a new mobile form for the given model class/instance.
1239        """
1240        if factory is None:
1241            factory = self.get_mobile_row_form_factory()
1242        if fields is None:
1243            fields = self.get_mobile_row_form_fields()
1244        if schema is None:
1245            schema = self.make_mobile_row_form_schema()
1246
1247        if not self.creating:
1248            kwargs['model_instance'] = instance
1249        kwargs = self.make_mobile_row_form_kwargs(**kwargs)
1250        form = factory(fields, schema, **kwargs)
1251        self.configure_mobile_row_form(form)
1252        return form
1253
1254    def make_quick_row_form(self, instance=None, factory=None, fields=None, schema=None, mobile=False, **kwargs):
1255        """
1256        Creates a "quick" form for adding a new row to the given instance.
1257        """
1258        if factory is None:
1259            factory = self.get_quick_row_form_factory(mobile=mobile)
1260        if fields is None:
1261            fields = self.get_quick_row_form_fields(mobile=mobile)
1262        if schema is None:
1263            schema = self.make_quick_row_form_schema(mobile=mobile)
1264
1265        kwargs['mobile'] = mobile
1266        kwargs = self.make_quick_row_form_kwargs(**kwargs)
1267        form = factory(fields, schema, **kwargs)
1268        self.configure_quick_row_form(form, mobile=mobile)
1269        return form
1270
1271    def get_quick_row_form_factory(self, mobile=False):
1272        return forms.Form
1273
1274    def get_quick_row_form_fields(self, mobile=False):
1275        pass
1276
1277    def make_quick_row_form_schema(self, mobile=False):
1278        schema = colander.MappingSchema()
1279        schema.add(colander.SchemaNode(colander.String(), name='quick_entry'))
1280        return schema
1281
1282    def make_quick_row_form_kwargs(self, **kwargs):
1283        defaults = {
1284            'request': self.request,
1285            'model_class': getattr(self, 'model_row_class', None),
1286            'cancel_url': self.request.get_referrer(),
1287        }
1288        defaults.update(kwargs)
1289        return defaults
1290
1291    def configure_quick_row_form(self, form, mobile=False):
1292        pass
1293
1294    def get_mobile_row_form_fields(self):
1295        if hasattr(self, 'mobile_row_form_fields'):
1296            return self.mobile_row_form_fields
1297        # TODO
1298        # raise NotImplementedError
1299
1300    def make_mobile_row_form_schema(self):
1301        if not self.model_row_class:
1302            # TODO
1303            raise NotImplementedError
1304
1305    def make_mobile_row_form_kwargs(self, **kwargs):
1306        """
1307        Return a dictionary of kwargs to be passed to the factory when creating
1308        new mobile row forms.
1309        """
1310        defaults = {
1311            'request': self.request,
1312            'mobile': True,
1313            'readonly': self.viewing,
1314            'model_class': getattr(self, 'model_row_class', None),
1315            'action_url': self.request.current_route_url(_query=None),
1316        }
1317        if self.creating:
1318            defaults['cancel_url'] = self.request.get_referrer()
1319        else:
1320            instance = kwargs['model_instance']
1321            defaults['cancel_url'] = self.get_row_action_url('view', instance, mobile=True)
1322        defaults.update(kwargs)
1323        return defaults
1324
1325    def configure_mobile_row_form(self, form):
1326        """
1327        Configure the mobile row form.
1328        """
1329        # TODO: is any of this stuff from configure_form() needed?
1330        # if self.editing:
1331        #     model_class = self.get_model_class(error=False)
1332        #     if model_class:
1333        #         mapper = orm.class_mapper(model_class)
1334        #         for key in mapper.primary_key:
1335        #             for field in form.fields:
1336        #                 if field == key.name:
1337        #                     form.set_readonly(field)
1338        #                     break
1339        # form.remove_field('uuid')
1340
1341        self.set_row_labels(form)
1342
1343    def validate_mobile_row_form(self, form):
1344        controls = self.request.POST.items()
1345        try:
1346            self.form_deserialized = form.validate(controls)
1347        except deform.ValidationFailure:
1348            return False
1349        return True
1350
1351    def validate_quick_row_form(self, form):
1352        return form.validate(newstyle=True)
1353
1354    def get_mobile_row_data(self, parent):
1355        query = self.get_row_data(parent)
1356        return self.sort_mobile_row_data(query)
1357
1358    def sort_mobile_row_data(self, query):
1359        return query
1360
1361    def mobile_row_route_url(self, route_name, **kwargs):
1362        route_name = 'mobile.{}.{}_row'.format(self.get_route_prefix(), route_name)
1363        return self.request.route_url(route_name, **kwargs)
1364
1365    def mobile_view_row(self):
1366        """
1367        Mobile view for row items
1368        """
1369        self.mobile = True
1370        self.viewing = True
1371        row = self.get_row_instance()
1372        parent = self.get_parent(row)
1373        form = self.make_mobile_row_form(row)
1374        context = {
1375            'row': row,
1376            'parent_instance': parent,
1377            'parent_title': self.get_instance_title(parent),
1378            'parent_url': self.get_action_url('view', parent, mobile=True),
1379            'instance': row,
1380            'instance_title': self.get_row_instance_title(row),
1381            'instance_editable': self.row_editable(row),
1382            'parent_model_title': self.get_model_title(),
1383            'form': form,
1384        }
1385        return self.render_to_response('view_row', context, mobile=True)
1386       
1387    def make_default_row_grid_tools(self, obj):
1388        if self.rows_creatable:
1389            link = tags.link_to("Create a new {}".format(self.get_row_model_title()),
1390                                self.get_action_url('create_row', obj))
1391            return HTML.tag('p', c=[link])
1392
1393    def make_row_grid_tools(self, obj):
1394        return self.make_default_row_grid_tools(obj)
1395
1396    # TODO: depracate / remove this
1397    def get_effective_row_query(self):
1398        """
1399        Convenience method which returns the "effective" query for the master
1400        grid, filtered and sorted to match what would show on the UI, but not
1401        paged etc.
1402        """
1403        return self.get_effective_row_data(sort=False)
1404
1405    def get_row_data(self, instance):
1406        """
1407        Generate the base data set for a rows grid.
1408        """
1409        raise NotImplementedError
1410
1411    def get_effective_row_data(self, session=None, sort=False, **kwargs):
1412        """
1413        Convenience method which returns the "effective" data for the row grid,
1414        filtered (and optionally sorted) to match what would show on the UI,
1415        but not paged.
1416        """
1417        if session is None:
1418            session = self.Session()
1419        kwargs.setdefault('pageable', False)
1420        kwargs.setdefault('sortable', sort)
1421        grid = self.make_row_grid(session=session, **kwargs)
1422        return grid.make_visible_data()
1423
1424    @classmethod
1425    def get_row_url_prefix(cls):
1426        """
1427        Returns a prefix which (by default) applies to all URLs provided by the
1428        master view class, for "row" views, e.g. '/products/rows'.
1429        """
1430        return getattr(cls, 'row_url_prefix', '{}/rows'.format(cls.get_url_prefix()))
1431
1432    @classmethod
1433    def get_row_permission_prefix(cls):
1434        """
1435        Permission prefix specific to the row-level data for this batch type,
1436        e.g. ``'vendorcatalogs.rows'``.
1437        """
1438        return "{}.rows".format(cls.get_permission_prefix())
1439
1440    def row_editable(self, row):
1441        """
1442        Returns boolean indicating whether or not the given row can be
1443        considered "editable".  Returns ``True`` by default; override as
1444        necessary.
1445        """
1446        return True
1447
1448    def row_edit_action_url(self, row, i):
1449        if self.row_editable(row):
1450            return self.get_row_action_url('edit', row)
1451
1452    def row_deletable(self, row):
1453        """
1454        Returns boolean indicating whether or not the given row can be
1455        considered "deletable".  Returns ``True`` by default; override as
1456        necessary.
1457        """
1458        return True
1459
1460    def row_delete_action_url(self, row, i):
1461        if self.row_deletable(row):
1462            return self.get_row_action_url('delete', row)
1463
1464    def row_grid_row_attrs(self, row, i):
1465        return {}
1466
1467    @classmethod
1468    def get_row_model_title(cls):
1469        if hasattr(cls, 'row_model_title'):
1470            return cls.row_model_title
1471        return "{} Row".format(cls.get_model_title())
1472
1473    @classmethod
1474    def get_row_model_title_plural(cls):
1475        if hasattr(cls, 'row_model_title_plural'):
1476            return cls.row_model_title_plural
1477        return "{} Rows".format(cls.get_model_title())
1478
1479    def view_index(self):
1480        """
1481        View a record according to its grid index.
1482        """
1483        try:
1484            index = int(self.request.GET['index'])
1485        except (KeyError, ValueError):
1486            return self.redirect(self.get_index_url())
1487        if index < 1:
1488            return self.redirect(self.get_index_url())
1489        data = self.get_effective_data()
1490        try:
1491            instance = data[index-1]
1492        except IndexError:
1493            return self.redirect(self.get_index_url())
1494        self.grid_index = index
1495        if hasattr(data, 'count'):
1496            self.grid_count = data.count()
1497        else:
1498            self.grid_count = len(data)
1499        return self.view(instance)
1500
1501    def download(self):
1502        """
1503        View for downloading a data file.
1504        """
1505        obj = self.get_instance()
1506        filename = self.request.GET.get('filename', None)
1507        if not filename:
1508            raise self.notfound()
1509        path = self.download_path(obj, filename)
1510        response = FileResponse(path, request=self.request)
1511        response.content_length = os.path.getsize(path)
1512        content_type = self.download_content_type(path, filename)
1513        if content_type:
1514            if six.PY3:
1515                response.content_type = content_type
1516            else:
1517                response.content_type = six.binary_type(content_type)
1518
1519        # content-disposition
1520        filename = os.path.basename(path)
1521        if six.PY2:
1522            filename = filename.encode('ascii', 'replace')
1523        response.content_disposition = str('attachment; filename="{}"'.format(filename))
1524
1525        return response
1526
1527    def download_content_type(self, path, filename):
1528        """
1529        Return a content type for a file download, if known.
1530        """
1531
1532    def edit(self):
1533        """
1534        View for editing an existing model record.
1535        """
1536        self.editing = True
1537        instance = self.get_instance()
1538        instance_title = self.get_instance_title(instance)
1539
1540        if not self.editable_instance(instance):
1541            self.request.session.flash("Edit is not permitted for {}: {}".format(
1542                self.get_model_title(), instance_title), 'error')
1543            return self.redirect(self.get_action_url('view', instance))
1544
1545        form = self.make_form(instance)
1546
1547        if self.request.method == 'POST':
1548            if self.validate_form(form):
1549                self.save_edit_form(form)
1550                # note we must fetch new instance title, in case it changed
1551                self.request.session.flash("{} has been updated: {}".format(
1552                    self.get_model_title(), self.get_instance_title(instance)))
1553                return self.redirect_after_edit(instance)
1554
1555        context = {
1556            'instance': instance,
1557            'instance_title': instance_title,
1558            'instance_deletable': self.deletable_instance(instance),
1559            'form': form,
1560        }
1561        if hasattr(form, 'make_deform_form'):
1562            context['dform'] = form.make_deform_form()
1563        return self.render_to_response('edit', context)
1564
1565    def mobile_edit(self):
1566        """
1567        Mobile view for editing an existing model record.
1568        """
1569        self.mobile = True
1570        self.editing = True
1571        obj = self.get_instance()
1572
1573        if not self.editable_instance(obj):
1574            msg = "Edit is not permitted for {}: {}".format(
1575                self.get_model_title(),
1576                self.get_instance_title(obj))
1577            self.request.session.flash(msg, 'error')
1578            return self.redirect(self.get_action_url('view', obj))
1579
1580        form = self.make_mobile_form(obj)
1581
1582        if self.request.method == 'POST':
1583            if self.validate_mobile_form(form):
1584
1585                # note that save_form() may return alternate object
1586                obj = self.save_mobile_edit_form(form)
1587
1588                msg = "{} has been updated: {}".format(
1589                    self.get_model_title(),
1590                    self.get_instance_title(obj))
1591                self.request.session.flash(msg)
1592                return self.redirect_after_edit(obj, mobile=True)
1593
1594        context = {
1595            'instance': obj,
1596            'instance_title': self.get_instance_title(obj),
1597            'instance_deletable': self.deletable_instance(obj),
1598            'instance_url': self.get_action_url('view', obj, mobile=True),
1599            'form': form,
1600        }
1601        if hasattr(form, 'make_deform_form'):
1602            context['dform'] = form.make_deform_form()
1603        return self.render_to_response('edit', context, mobile=True)
1604
1605    def save_edit_form(self, form):
1606        if not self.mobile:
1607            uploads = self.normalize_uploads(form)
1608        obj = self.objectify(form)
1609        if not self.mobile:
1610            self.process_uploads(obj, form, uploads)
1611        self.after_edit(obj)
1612        self.Session.flush()
1613        return obj
1614
1615    def save_mobile_edit_form(self, form):
1616        return self.save_edit_form(form)
1617
1618    def redirect_after_edit(self, instance, mobile=False):
1619        return self.redirect(self.get_action_url('view', instance, mobile=mobile))
1620
1621    def delete(self):
1622        """
1623        View for deleting an existing model record.
1624        """
1625        if not self.deletable:
1626            raise httpexceptions.HTTPForbidden()
1627
1628        self.deleting = True
1629        instance = self.get_instance()
1630        instance_title = self.get_instance_title(instance)
1631
1632        if not self.deletable_instance(instance):
1633            self.request.session.flash("Deletion is not permitted for {}: {}".format(
1634                self.get_model_title(), instance_title), 'error')
1635            return self.redirect(self.get_action_url('view', instance))
1636
1637        form = self.make_form(instance)
1638
1639        # TODO: Add better validation, ideally CSRF etc.
1640        if self.request.method == 'POST':
1641
1642            # Let derived classes prep for (or cancel) deletion.
1643            result = self.before_delete(instance)
1644            if isinstance(result, httpexceptions.HTTPException):
1645                return result
1646
1647            self.delete_instance(instance)
1648            self.request.session.flash("{} has been deleted: {}".format(
1649                self.get_model_title(), instance_title))
1650            return self.redirect(self.get_after_delete_url(instance))
1651
1652        form.readonly = True
1653        return self.render_to_response('delete', {
1654            'instance': instance,
1655            'instance_title': instance_title,
1656            'form': form})
1657
1658    def bulk_delete(self):
1659        """
1660        Delete all records matching the current grid query
1661        """
1662        objects = self.get_effective_data()
1663        key = '{}.bulk_delete'.format(self.model_class.__tablename__)
1664        progress = SessionProgress(self.request, key)
1665        thread = Thread(target=self.bulk_delete_thread, args=(objects, progress))
1666        thread.start()
1667        return self.render_progress(progress, {
1668            'cancel_url': self.get_index_url(),
1669            'cancel_msg': "Bulk deletion was canceled",
1670        })
1671
1672    def bulk_delete_objects(self, session, objects, progress=None):
1673
1674        def delete(obj, i):
1675            session.delete(obj)
1676            if i % 1000 == 0:
1677                session.flush()
1678
1679        self.progress_loop(delete, objects, progress,
1680                           message="Deleting objects")
1681
1682    def get_bulk_delete_session(self):
1683        return RattailSession()
1684
1685    def bulk_delete_thread(self, objects, progress):
1686        """
1687        Thread target for bulk-deleting current results, with progress.
1688        """
1689        session = self.get_bulk_delete_session()
1690        objects = objects.with_session(session).all()
1691        try:
1692            self.bulk_delete_objects(session, objects, progress=progress)
1693
1694        # If anything goes wrong, rollback and log the error etc.
1695        except Exception as error:
1696            session.rollback()
1697            log.exception("execution failed for batch results")
1698            session.close()
1699            if progress:
1700                progress.session.load()
1701                progress.session['error'] = True
1702                progress.session['error_msg'] = "Bulk deletion failed: {}: {}".format(type(error).__name__, error)
1703                progress.session.save()
1704
1705        # If no error, check result flag (false means user canceled).
1706        else:
1707            session.commit()
1708            session.close()
1709            if progress:
1710                progress.session.load()
1711                progress.session['complete'] = True
1712                progress.session['success_url'] = self.get_index_url()
1713                progress.session.save()
1714
1715    def obtain_set(self):
1716        """
1717        Obtain the effective "set" (selection) of records from POST data.
1718        """
1719        # TODO: should have a cleaner way to parse object uuids?
1720        uuids = self.request.POST.get('uuids')
1721        if uuids:
1722            uuids = uuids.split(',')
1723            # TODO: probably need to allow override of fetcher callable
1724            fetcher = lambda uuid: self.Session.query(self.model_class).get(uuid)
1725            objects = []
1726            for uuid in uuids:
1727                obj = fetcher(uuid)
1728                if obj:
1729                    objects.append(obj)
1730            return objects
1731
1732    def enable_set(self):
1733        """
1734        View which can turn ON the 'enabled' flag for a specific set of records.
1735        """
1736        objects = self.obtain_set()
1737        if objects:
1738            enabled = 0
1739            for obj in objects:
1740                if not obj.enabled:
1741                    obj.enabled = True
1742                    enabled += 1
1743            model_title_plural = self.get_model_title_plural()
1744            self.request.session.flash("Enabled {} {}".format(enabled, model_title_plural))
1745        return self.redirect(self.get_index_url())
1746
1747    def disable_set(self):
1748        """
1749        View which can turn OFF the 'enabled' flag for a specific set of records.
1750        """
1751        objects = self.obtain_set()
1752        if objects:
1753            disabled = 0
1754            for obj in objects:
1755                if obj.enabled:
1756                    obj.enabled = False
1757                    disabled += 1
1758            model_title_plural = self.get_model_title_plural()
1759            self.request.session.flash("Disabled {} {}".format(disabled, model_title_plural))
1760        return self.redirect(self.get_index_url())
1761
1762    def delete_set(self):
1763        """
1764        View which can delete a specific set of records.
1765        """
1766        objects = self.obtain_set()
1767        if objects:
1768            for obj in objects:
1769                self.delete_instance(obj)
1770            model_title_plural = self.get_model_title_plural()
1771            self.request.session.flash("Deleted {} {}".format(len(objects), model_title_plural))
1772        return self.redirect(self.get_index_url())
1773
1774    def oneoff_import(self, importer, host_object=None):
1775        """
1776        Basic helper method, to do a one-off import (or export, depending on
1777        perspective) of the "current instance" object.  Where the data "goes"
1778        depends on the importer you provide.
1779        """
1780        if not host_object:
1781            host_object = self.get_instance()
1782
1783        host_data = importer.normalize_host_object(host_object)
1784        if not host_data:
1785            return
1786
1787        key = importer.get_key(host_data)
1788        local_object = importer.get_local_object(key)
1789        if local_object:
1790            if importer.allow_update:
1791                local_data = importer.normalize_local_object(local_object)
1792                if importer.data_diffs(local_data, host_data) and importer.allow_update:
1793                    local_object = importer.update_object(local_object, host_data, local_data)
1794            return local_object
1795        elif importer.allow_create:
1796            return importer.create_object(key, host_data)
1797
1798    def execute(self):
1799        """
1800        Execute an object.
1801        """
1802        obj = self.get_instance()
1803        model_title = self.get_model_title()
1804        if self.request.method == 'POST':
1805
1806            progress = self.make_execute_progress(obj)
1807            kwargs = {'progress': progress}
1808            thread = Thread(target=self.execute_thread, args=(obj.uuid, self.request.user.uuid), kwargs=kwargs)
1809            thread.start()
1810
1811            return self.render_progress(progress, {
1812                'instance': obj,
1813                'initial_msg': self.execute_progress_initial_msg,
1814                'cancel_url': self.get_action_url('view', obj),
1815                'cancel_msg': "{} execution was canceled".format(model_title),
1816            }, template=self.execute_progress_template)
1817
1818        self.request.session.flash("Sorry, you must POST to execute a {}.".format(model_title), 'error')
1819        return self.redirect(self.get_action_url('view', obj))
1820
1821    def make_execute_progress(self, obj):
1822        key = '{}.execute'.format(self.get_grid_key())
1823        return SessionProgress(self.request, key)
1824
1825    def execute_thread(self, uuid, user_uuid, progress=None, **kwargs):
1826        """
1827        Thread target for executing an object.
1828        """
1829        session = RattailSession()
1830        obj = session.query(self.model_class).get(uuid)
1831        user = session.query(model.User).get(user_uuid)
1832        try:
1833            self.execute_instance(obj, user, progress=progress, **kwargs)
1834
1835        # If anything goes wrong, rollback and log the error etc.
1836        except Exception as error:
1837            session.rollback()
1838            log.exception("{} failed to execute: {}".format(self.get_model_title(), obj))
1839            session.close()
1840            if progress:
1841                progress.session.load()
1842                progress.session['error'] = True
1843                progress.session['error_msg'] = self.execute_error_message(error)
1844                progress.session.save()
1845
1846        # If no error, check result flag (false means user canceled).
1847        else:
1848            session.commit()
1849            session.refresh(obj)
1850            success_url = self.get_execute_success_url(obj)
1851            session.close()
1852            if progress:
1853                progress.session.load()
1854                progress.session['complete'] = True
1855                progress.session['success_url'] = success_url
1856                progress.session.save()
1857
1858    def execute_error_message(self, error):
1859        return "Execution of {} failed: {}: {}".format(self.get_model_title(),
1860                                                       type(error).__name__, error)
1861
1862    def get_execute_success_url(self, obj, **kwargs):
1863        return self.get_action_url('view', obj, **kwargs)
1864
1865    def get_merge_fields(self):
1866        if hasattr(self, 'merge_fields'):
1867            return self.merge_fields
1868        mapper = orm.class_mapper(self.get_model_class())
1869        return mapper.columns.keys()
1870
1871    def get_merge_coalesce_fields(self):
1872        if hasattr(self, 'merge_coalesce_fields'):
1873            return self.merge_coalesce_fields
1874        return []
1875
1876    def get_merge_additive_fields(self):
1877        if hasattr(self, 'merge_additive_fields'):
1878            return self.merge_additive_fields
1879        return []
1880
1881    def merge(self):
1882        """
1883        Preview and execute a merge of two records.
1884        """
1885        object_to_remove = object_to_keep = None
1886        if self.request.method == 'POST':
1887            uuids = self.request.POST.get('uuids', '').split(',')
1888            if len(uuids) == 2:
1889                object_to_remove = self.Session.query(self.get_model_class()).get(uuids[0])
1890                object_to_keep = self.Session.query(self.get_model_class()).get(uuids[1])
1891
1892                if object_to_remove and object_to_keep and self.request.POST.get('commit-merge') == 'yes':
1893                    msg = six.text_type(object_to_remove)
1894                    try:
1895                        self.validate_merge(object_to_remove, object_to_keep)
1896                    except Exception as error:
1897                        self.request.session.flash("Requested merge cannot proceed (maybe swap kept/removed and try again?): {}".format(error), 'error')
1898                    else:
1899                        self.merge_objects(object_to_remove, object_to_keep)
1900                        self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep))
1901                        return self.redirect(self.get_action_url('view', object_to_keep))
1902
1903        if not object_to_remove or not object_to_keep or object_to_remove is object_to_keep:
1904            return self.redirect(self.get_index_url())
1905
1906        remove = self.get_merge_data(object_to_remove)
1907        keep = self.get_merge_data(object_to_keep)
1908        return self.render_to_response('merge', {'object_to_remove': object_to_remove,
1909                                                 'object_to_keep': object_to_keep,
1910                                                 'view_url': lambda obj: self.get_action_url('view', obj),
1911                                                 'merge_fields': self.get_merge_fields(),
1912                                                 'remove_data': remove,
1913                                                 'keep_data': keep,
1914                                                 'resulting_data': self.get_merge_resulting_data(remove, keep)})
1915
1916    def validate_merge(self, removing, keeping):
1917        """
1918        If applicable, your view should override this in order to confirm that
1919        the requested merge is valid, in your context.  If it is not - for *any
1920        reason* - you should raise an exception; the type does not matter.
1921        """
1922
1923    def get_merge_data(self, obj):
1924        raise NotImplementedError("please implement `{}.get_merge_data()`".format(self.__class__.__name__))
1925
1926    def get_merge_resulting_data(self, remove, keep):
1927        result = dict(keep)
1928        for field in self.get_merge_coalesce_fields():
1929            if remove[field] and not keep[field]:
1930                result[field] = remove[field]
1931        for field in self.get_merge_additive_fields():
1932            if isinstance(keep[field], (list, tuple)):
1933                result[field] = sorted(set(remove[field] + keep[field]))
1934            else:
1935                result[field] = remove[field] + keep[field]
1936        return result
1937
1938    def merge_objects(self, removing, keeping):
1939        """
1940        Merge the two given objects.  You should probably override this;
1941        default behavior is merely to delete the 'removing' object.
1942        """
1943        self.Session.delete(removing)
1944
1945    ##############################
1946    # Core Stuff
1947    ##############################
1948
1949    @classmethod
1950    def get_model_class(cls, error=True):
1951        """
1952        Returns the data model class for which the master view exists.
1953        """
1954        if not hasattr(cls, 'model_class') and error:
1955            raise NotImplementedError("You must define the `model_class` for: {}".format(cls))
1956        return getattr(cls, 'model_class', None)
1957
1958    @classmethod
1959    def get_model_version_class(cls):
1960        """
1961        Returns the version class for the master model class.
1962        """
1963        return continuum.version_class(cls.get_model_class())
1964
1965    @classmethod
1966    def get_normalized_model_name(cls):
1967        """
1968        Returns the "normalized" name for the view's model class.  This will be
1969        the value of the :attr:`normalized_model_name` attribute if defined;
1970        otherwise it will be a simple lower-cased version of the associated
1971        model class name.
1972        """
1973        if hasattr(cls, 'normalized_model_name'):
1974            return cls.normalized_model_name
1975        return cls.get_model_class().__name__.lower()
1976
1977    @classmethod
1978    def get_model_key(cls):
1979        """
1980        Return a string name for the primary key of the model class.
1981        """
1982        if hasattr(cls, 'model_key'):
1983            return cls.model_key
1984        mapper = orm.class_mapper(cls.get_model_class())
1985        return ','.join([k.key for k in mapper.primary_key])
1986
1987    @classmethod
1988    def get_model_title(cls):
1989        """
1990        Return a "humanized" version of the model name, for display in templates.
1991        """
1992        if hasattr(cls, 'model_title'):
1993            return cls.model_title
1994
1995        # model class itself may provide title
1996        model_class = cls.get_model_class()
1997        if hasattr(model_class, 'get_model_title'):
1998            return model_class.get_model_title()
1999
2000        # otherwise just use model class name
2001        return model_class.__name__
2002
2003    @classmethod
2004    def get_model_title_plural(cls):
2005        """
2006        Return a "humanized" (and plural) version of the model name, for
2007        display in templates.
2008        """
2009        if hasattr(cls, 'model_title_plural'):
2010            return cls.model_title_plural
2011        try:
2012            return cls.get_model_class().get_model_title_plural()
2013        except (NotImplementedError, AttributeError):
2014            return '{}s'.format(cls.get_model_title())
2015
2016    @classmethod
2017    def get_route_prefix(cls):
2018        """
2019        Returns a prefix which (by default) applies to all routes provided by
2020        the master view class.  This is the plural, lower-cased name of the
2021        model class by default, e.g. 'products'.
2022        """
2023        model_name = cls.get_normalized_model_name()
2024        return getattr(cls, 'route_prefix', '{0}s'.format(model_name))
2025
2026    @classmethod
2027    def get_url_prefix(cls):
2028        """
2029        Returns a prefix which (by default) applies to all URLs provided by the
2030        master view class.  By default this is the route prefix, preceded by a
2031        slash, e.g. '/products'.
2032        """
2033        return getattr(cls, 'url_prefix', '/{0}'.format(cls.get_route_prefix()))
2034
2035    @classmethod
2036    def get_template_prefix(cls):
2037        """
2038        Returns a prefix which (by default) applies to all templates required by
2039        the master view class.  This uses the URL prefix by default.
2040        """
2041        return getattr(cls, 'template_prefix', cls.get_url_prefix())
2042
2043    @classmethod
2044    def get_permission_prefix(cls):
2045        """
2046        Returns a prefix which (by default) applies to all permissions leveraged by
2047        the master view class.  This uses the route prefix by default.
2048        """
2049        return getattr(cls, 'permission_prefix', cls.get_route_prefix())
2050
2051    def get_index_url(self, mobile=False, **kwargs):
2052        """
2053        Returns the master view's index URL.
2054        """
2055        route = self.get_route_prefix()
2056        if mobile:
2057            route = 'mobile.{}'.format(route)
2058        return self.request.route_url(route)
2059
2060    @classmethod
2061    def get_index_title(cls):
2062        """
2063        Returns the title for the index page.
2064        """
2065        return getattr(cls, 'index_title', cls.get_model_title_plural())
2066
2067    def get_action_url(self, action, instance, mobile=False, **kwargs):
2068        """
2069        Generate a URL for the given action on the given instance
2070        """
2071        kw = self.get_action_route_kwargs(instance)
2072        kw.update(kwargs)
2073        route_prefix = self.get_route_prefix()
2074        if mobile:
2075            route_prefix = 'mobile.{}'.format(route_prefix)
2076        return self.request.route_url('{}.{}'.format(route_prefix, action), **kw)
2077
2078    def get_help_url(self):
2079        """
2080        May return a "help URL" if applicable.  Default behavior is to simply
2081        return the value of :attr:`help_url` (regardless of which view is in
2082        effect), which in turn defaults to ``None``.  If an actual URL is
2083        returned, then a Help button will be shown in the page header;
2084        otherwise it is not shown.
2085
2086        This method is invoked whenever a template is rendered for a response,
2087        so if you like you can return a different help URL depending on which
2088        type of CRUD view is in effect, etc.
2089        """
2090        return self.help_url
2091
2092    def render_to_response(self, template, data, mobile=False):
2093        """
2094        Return a response with the given template rendered with the given data.
2095        Note that ``template`` must only be a "key" (e.g. 'index' or 'view').
2096        First an attempt will be made to render using the :attr:`template_prefix`.
2097        If that doesn't work, another attempt will be made using '/master' as
2098        the template prefix.
2099        """
2100        context = {
2101            'master': self,
2102            'mobile': mobile,
2103            'model_title': self.get_model_title(),
2104            'model_title_plural': self.get_model_title_plural(),
2105            'route_prefix': self.get_route_prefix(),
2106            'permission_prefix': self.get_permission_prefix(),
2107            'index_title': self.get_index_title(),
2108            'index_url': self.get_index_url(mobile=mobile),
2109            'action_url': self.get_action_url,
2110            'grid_index': self.grid_index,
2111            'help_url': self.get_help_url(),
2112        }
2113
2114        if self.grid_index:
2115            context['grid_count'] = self.grid_count
2116
2117        if self.has_rows:
2118            context['row_permission_prefix'] = self.get_row_permission_prefix()
2119            context['row_model_title'] = self.get_row_model_title()
2120            context['row_model_title_plural'] = self.get_row_model_title_plural()
2121            context['row_action_url'] = self.get_row_action_url
2122
2123            if mobile and self.viewing and self.mobile_rows_quickable:
2124
2125                # quick row does *not* mimic keyboard wedge by default, but can
2126                context['quick_row_keyboard_wedge'] = False
2127
2128                # quick row does *not* use autocomplete by default, but can
2129                context['quick_row_autocomplete'] = False
2130                context['quick_row_autocomplete_url'] = '#'
2131
2132        context.update(data)
2133        context.update(self.template_kwargs(**context))
2134        if hasattr(self, 'template_kwargs_{}'.format(template)):
2135            context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context))
2136        if mobile and hasattr(self, 'mobile_template_kwargs_{}'.format(template)):
2137            context.update(getattr(self, 'mobile_template_kwargs_{}'.format(template))(**context))
2138
2139        # First try the template path most specific to the view.
2140        if mobile:
2141            mako_path = '/mobile{}/{}.mako'.format(self.get_template_prefix(), template)
2142        else:
2143            mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template)
2144        try:
2145            return render_to_response(mako_path, context, request=self.request)
2146
2147        except IOError:
2148
2149            # Failing that, try one or more fallback templates.
2150            for fallback in self.get_fallback_templates(template, mobile=mobile):
2151                try:
2152                    return render_to_response(fallback, context, request=self.request)
2153                except IOError:
2154                    pass
2155
2156            # If we made it all the way here, we found no templates at all, in
2157            # which case re-attempt the first and let that error raise on up.
2158            return render_to_response('{}/{}.mako'.format(self.get_template_prefix(), template),
2159                                      context, request=self.request)
2160
2161    # TODO: merge this logic with render_to_response()
2162    def render(self, template, data):
2163        """
2164        Render the given template with the given context data.
2165        """
2166        context = {
2167            'master': self,
2168            'model_title': self.get_model_title(),
2169            'model_title_plural': self.get_model_title_plural(),
2170            'route_prefix': self.get_route_prefix(),
2171            'permission_prefix': self.get_permission_prefix(),
2172            'index_title': self.get_index_title(),
2173            'index_url': self.get_index_url(),
2174            'action_url': self.get_action_url,
2175        }
2176        context.update(data)
2177
2178        # First try the template path most specific to the view.
2179        try:
2180            return render('{}/{}.mako'.format(self.get_template_prefix(), template),
2181                          context, request=self.request)
2182
2183        except IOError:
2184
2185            # Failing that, try one or more fallback templates.
2186            for fallback in self.get_fallback_templates(template):
2187                try:
2188                    return render(fallback, context, request=self.request)
2189                except IOError:
2190                    pass
2191
2192            # If we made it all the way here, we found no templates at all, in
2193            # which case re-attempt the first and let that error raise on up.
2194            return render('{}/{}.mako'.format(self.get_template_prefix(), template),
2195                          context, request=self.request)
2196
2197    def get_fallback_templates(self, template, mobile=False):
2198        if mobile:
2199            return ['/mobile/master/{}.mako'.format(template)]
2200        return ['/master/{}.mako'.format(template)]
2201
2202    def template_kwargs(self, **kwargs):
2203        """
2204        Supplement the template context, for all views.
2205        """
2206        return kwargs
2207
2208    ##############################
2209    # Grid Stuff
2210    ##############################
2211
2212    @classmethod
2213    def get_grid_key(cls):
2214        """
2215        Returns the unique key to be used for the grid, for caching sort/filter
2216        options etc.
2217        """
2218        if hasattr(cls, 'grid_key'):
2219            return cls.grid_key
2220        # default previously came from cls.get_normalized_model_name() but this is hopefully better
2221        return cls.get_route_prefix()
2222
2223    def get_row_grid_key(self):
2224        return '{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()])
2225
2226    def get_grid_actions(self):
2227        main, more = self.get_main_actions(), self.get_more_actions()
2228        if len(more) == 1:
2229            main, more = main + more, []
2230        if len(main + more) <= 3:
2231            main, more = main + more, []
2232        return main, more
2233
2234    def get_row_attrs(self, row, i):
2235        """
2236        Returns a dict of HTML attributes which is to be applied to the row's
2237        ``<tr>`` element.  Note that ``i`` will be a 1-based index value for
2238        the row within its table.  The meaning of ``row`` is basically not
2239        defined; it depends on the type of data the grid deals with.
2240        """
2241        if callable(self.row_attrs):
2242            attrs = self.row_attrs(row, i)
2243        else:
2244            attrs = dict(self.row_attrs)
2245        if self.mergeable:
2246            attrs['data-uuid'] = row.uuid
2247        return attrs
2248
2249    def get_cell_attrs(self, row, column):
2250        """
2251        Returns a dictionary of HTML attributes which should be applied to the
2252        ``<td>`` element in which the given row and column "intersect".
2253        """
2254        if callable(self.cell_attrs):
2255            return self.cell_attrs(row, column)
2256        return self.cell_attrs
2257
2258    def get_main_actions(self):
2259        """
2260        Return a list of 'main' actions for the grid.
2261        """
2262        actions = []
2263        prefix = self.get_permission_prefix()
2264        use_buefy = self.use_buefy and self.rattail_config.getbool('tailbone', 'grids.use_buefy')
2265        if self.viewable and self.request.has_perm('{}.view'.format(prefix)):
2266            url = self.get_view_index_url if self.use_index_links else None
2267            icon = 'eye' if use_buefy else 'zoomin'
2268            actions.append(self.make_action('view', icon=icon, url=url))
2269        return actions
2270
2271    def get_view_index_url(self, row, i):
2272        route = '{}.view_index'.format(self.get_route_prefix())
2273        return '{}?index={}'.format(self.request.route_url(route), self.first_visible_grid_index + i - 1)
2274
2275    def get_more_actions(self):
2276        """
2277        Return a list of 'more' actions for the grid.
2278        """
2279        actions = []
2280        prefix = self.get_permission_prefix()
2281        use_buefy = self.use_buefy and self.rattail_config.getbool('tailbone', 'grids.use_buefy')
2282        if self.editable and self.request.has_perm('{}.edit'.format(prefix)):
2283            icon = 'edit' if use_buefy else 'pencil'
2284            actions.append(self.make_action('edit', icon=icon, url=self.default_edit_url))
2285        if self.deletable and self.request.has_perm('{}.delete'.format(prefix)):
2286            actions.append(self.make_action('delete', icon='trash', url=self.default_delete_url))
2287        return actions
2288
2289    def default_edit_url(self, row, i=None):
2290        if self.editable_instance(row):
2291            return self.request.route_url('{}.edit'.format(self.get_route_prefix()),
2292                                          **self.get_action_route_kwargs(row))
2293
2294    def default_delete_url(self, row, i=None):
2295        if self.deletable_instance(row):
2296            return self.request.route_url('{}.delete'.format(self.get_route_prefix()),
2297                                          **self.get_action_route_kwargs(row))
2298
2299    def make_action(self, key, url=None, **kwargs):
2300        """
2301        Make a new :class:`GridAction` instance for the current grid.
2302        """
2303        if url is None:
2304            route = '{}.{}'.format(self.get_route_prefix(), key)
2305            url = lambda r, i: self.request.route_url(route, **self.get_action_route_kwargs(r))
2306        return grids.GridAction(key, url=url, **kwargs)
2307
2308    def get_action_route_kwargs(self, row):
2309        """
2310        Hopefully generic kwarg generator for basic action routes.
2311        """
2312        try:
2313            mapper = orm.object_mapper(row)
2314        except orm.exc.UnmappedInstanceError:
2315            return {self.model_key: row[self.model_key]}
2316        else:
2317            keys = [k.key for k in mapper.primary_key]
2318            values = [getattr(row, k) for k in keys]
2319            return dict(zip(keys, values))
2320
2321    def get_data(self, session=None):
2322        """
2323        Generate the base data set for the grid.  This typically will be a
2324        SQLAlchemy query against the view's model class, but subclasses may
2325        override this to support arbitrary data sets.
2326
2327        Note that if your view is typical and uses a SA model, you should not
2328        override this methid, but override :meth:`query()` instead.
2329        """
2330        if session is None:
2331            session = self.Session()
2332        return self.query(session)
2333
2334    def query(self, session):
2335        """
2336        Produce the initial/base query for the master grid.  By default this is
2337        simply a query against the model class, but you may override as
2338        necessary to apply any sort of pre-filtering etc.  This is useful if
2339        say, you don't ever want to show records of a certain type to non-admin
2340        users.  You would modify the base query to hide what you wanted,
2341        regardless of the user's filter selections.
2342        """
2343        return session.query(self.get_model_class())
2344
2345    def get_effective_query(self, session=None, **kwargs):
2346        return self.get_effective_data(session=session, **kwargs)
2347
2348    def checkbox(self, instance):
2349        """
2350        Returns a boolean indicating whether ot not a checkbox should be
2351        rendererd for the given row.  Default implementation returns ``True``
2352        in all cases.
2353        """
2354        return True
2355
2356    def checked(self, instance):
2357        """
2358        Returns a boolean indicating whether ot not a checkbox should be
2359        checked by default, for the given row.  Default implementation returns
2360        ``False`` in all cases.
2361        """
2362        return False
2363
2364    def results_csv(self):
2365        """
2366        Download current list results as CSV
2367        """
2368        results = self.get_effective_data()
2369        fields = self.get_csv_fields()
2370        data = six.StringIO()
2371        writer = UnicodeDictWriter(data, fields)
2372        writer.writeheader()
2373        for obj in results:
2374            writer.writerow(self.get_csv_row(obj, fields))
2375        response = self.request.response
2376        if six.PY3:
2377            response.text = data.getvalue()
2378            response.content_type = 'text/csv'
2379            response.content_disposition = 'attachment; filename={}.csv'.format(self.get_grid_key())
2380        else:
2381            response.body = data.getvalue()
2382            response.content_type = b'text/csv'
2383            response.content_disposition = b'attachment; filename={}.csv'.format(self.get_grid_key())
2384        data.close()
2385        response.content_length = len(response.body)
2386        return response
2387
2388    def results_xlsx(self):
2389        """
2390        Download current list results as XLSX.
2391        """
2392        results = self.get_effective_data()
2393        fields = self.get_xlsx_fields()
2394        path = temp_path(suffix='.xlsx')
2395        writer = ExcelWriter(path, fields, sheet_title=self.get_model_title_plural())
2396        writer.write_header()
2397
2398        rows = []
2399        for obj in results:
2400            data = self.get_xlsx_row(obj, fields)
2401            row = [data[field] for field in fields]
2402            rows.append(row)
2403
2404        writer.write_rows(rows)
2405        writer.auto_freeze()
2406        writer.auto_filter()
2407        writer.auto_resize()
2408        writer.save()
2409
2410        response = self.request.response
2411        with open(path, 'rb') as f:
2412            response.body = f.read()
2413        os.remove(path)
2414
2415        response.content_length = len(response.body)
2416        response.content_type = str('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
2417        response.content_disposition = str('attachment; filename={}.xlsx').format(self.get_grid_key())
2418        return response
2419
2420    def get_xlsx_fields(self):
2421        """
2422        Return the list of fields to be written to XLSX download.
2423        """
2424        fields = []
2425        mapper = orm.class_mapper(self.model_class)
2426        for prop in mapper.iterate_properties:
2427            if isinstance(prop, orm.ColumnProperty):
2428                fields.append(prop.key)
2429        return fields
2430
2431    def get_xlsx_row(self, obj, fields):
2432        """
2433        Return a dict for use when writing the row's data to CSV download.
2434        """
2435        row = {}
2436        for field in fields:
2437            row[field] = getattr(obj, field, None)
2438        return row
2439
2440    def row_results_xlsx(self):
2441        """
2442        Download current *row* results as XLSX.
2443        """
2444        obj = self.get_instance()
2445        results = self.get_effective_row_data(sort=True)
2446        fields = self.get_row_xlsx_fields()
2447        path = temp_path(suffix='.xlsx')
2448        writer = ExcelWriter(path, fields, sheet_title=self.get_row_model_title_plural())
2449        writer.write_header()
2450
2451        rows = []
2452        for row_obj in results:
2453            data = self.get_row_xlsx_row(row_obj, fields)
2454            row = [data[field] for field in fields]
2455            rows.append(row)
2456
2457        writer.write_rows(rows)
2458        writer.auto_freeze()
2459        writer.auto_filter()
2460        writer.auto_resize()
2461        writer.save()
2462
2463        response = self.request.response
2464        with open(path, 'rb') as f:
2465            response.body = f.read()
2466        os.remove(path)
2467
2468        response.content_length = len(response.body)
2469        response.content_type = str('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
2470        filename = self.get_row_results_xlsx_filename(obj)
2471        response.content_disposition = str('attachment; filename={}'.format(filename))
2472        return response
2473
2474    def get_row_xlsx_fields(self):
2475        """
2476        Return the list of row fields to be written to XLSX download.
2477        """
2478        # TODO: should this be shared at all? in a better way?
2479        return self.get_row_csv_fields()
2480
2481    def get_row_xlsx_row(self, row, fields):
2482        """
2483        Return a dict for use when writing the row's data to XLSX download.
2484        """
2485        xlrow = {}
2486        for field in fields:
2487            value = getattr(row, field, None)
2488
2489            if isinstance(value, GPC):
2490                value = six.text_type(value)
2491
2492            xlrow[field] = value
2493        return xlrow
2494
2495    def get_row_results_xlsx_filename(self, obj):
2496        return '{}.xlsx'.format(self.get_row_grid_key())
2497
2498    def row_results_csv(self):
2499        """
2500        Download current row results data for an object, as CSV
2501        """
2502        obj = self.get_instance()
2503        fields = self.get_row_csv_fields()
2504        data = six.StringIO()
2505        writer = UnicodeDictWriter(data, fields)
2506        writer.writeheader()
2507        for row in self.get_effective_row_data(sort=True):
2508            writer.writerow(self.get_row_csv_row(row, fields))
2509        response = self.request.response
2510        filename = self.get_row_results_csv_filename(obj)
2511        if six.PY3:
2512            response.text = data.getvalue()
2513            response.content_type = 'text/csv'
2514            response.content_disposition = 'attachment; filename={}'.format(filename)
2515        else:
2516            response.body = data.getvalue()
2517            response.content_type = b'text/csv'
2518            response.content_disposition = b'attachment; filename={}'.format(filename)
2519        data.close()
2520        response.content_length = len(response.body)
2521        return response
2522
2523    def get_row_results_csv_filename(self, instance):
2524        return '{}.csv'.format(self.get_row_grid_key())
2525
2526    def get_csv_fields(self):
2527        """
2528        Return the list of fields to be written to CSV download.  Default field
2529        list will be constructed from the underlying table columns.
2530        """
2531        fields = []
2532        mapper = orm.class_mapper(self.model_class)
2533        for prop in mapper.iterate_properties:
2534            if isinstance(prop, orm.ColumnProperty):
2535                fields.append(prop.key)
2536        return fields
2537
2538    def get_row_csv_fields(self):
2539        """
2540        Return the list of row fields to be written to CSV download.
2541        """
2542        fields = []
2543        mapper = orm.class_mapper(self.model_row_class)
2544        for prop in mapper.iterate_properties:
2545            if isinstance(prop, orm.ColumnProperty):
2546                fields.append(prop.key)
2547        return fields
2548
2549    def get_csv_row(self, obj, fields):
2550        """
2551        Return a dict for use when writing the row's data to CSV download.
2552        """
2553        csvrow = {}
2554        for field in fields:
2555            value = getattr(obj, field, None)
2556            if isinstance(value, datetime.datetime):
2557                # TODO: this assumes value is *always* naive UTC
2558                value = localtime(self.rattail_config, value, from_utc=True)
2559            csvrow[field] = '' if value is None else six.text_type(value)
2560        return csvrow
2561
2562    def get_row_csv_row(self, row, fields):
2563        """
2564        Return a dict for use when writing the row's data to CSV download.
2565        """
2566        csvrow = {}
2567        for field in fields:
2568            value = getattr(row, field, None)
2569            if isinstance(value, datetime.datetime):
2570                # TODO: this assumes value is *always* naive UTC
2571                value = localtime(self.rattail_config, value, from_utc=True)
2572            csvrow[field] = '' if value is None else six.text_type(value)
2573        return csvrow
2574
2575    ##############################
2576    # CRUD Stuff
2577    ##############################
2578
2579    def get_instance(self):
2580        """
2581        Fetch the current model instance by inspecting the route kwargs and
2582        doing a database lookup.  If the instance cannot be found, raises 404.
2583        """
2584        # TODO: this can't handle composite model key..is that needed?
2585        key = self.request.matchdict[self.get_model_key()]
2586        instance = self.Session.query(self.get_model_class()).get(key)
2587        if not instance:
2588            raise httpexceptions.HTTPNotFound()
2589        return instance
2590
2591    def get_instance_title(self, instance):
2592        """
2593        Return a "pretty" title for the instance, to be used in the page title etc.
2594        """
2595        return six.text_type(instance)
2596
2597    @classmethod
2598    def get_form_factory(cls):
2599        """
2600        Returns the grid factory or class which is to be used when creating new
2601        grid instances.
2602        """
2603        return getattr(cls, 'form_factory', forms.Form)
2604
2605    @classmethod
2606    def get_mobile_form_factory(cls):
2607        """
2608        Returns the factory or class which is to be used when creating new
2609        mobile forms.
2610        """
2611        return getattr(cls, 'mobile_form_factory', forms.Form)
2612
2613    @classmethod
2614    def get_row_form_factory(cls):
2615        """
2616        Returns the factory or class which is to be used when creating new row
2617        forms.
2618        """
2619        return getattr(cls, 'row_form_factory', forms.Form)
2620
2621    @classmethod
2622    def get_mobile_row_form_factory(cls):
2623        """
2624        Returns the factory or class which is to be used when creating new
2625        mobile row forms.
2626        """
2627        return getattr(cls, 'mobile_row_form_factory', forms.Form)
2628
2629    def render_downloadable_file(self, obj, field):
2630        filename = getattr(obj, field)
2631        if not filename:
2632            return ""
2633        path = self.download_path(obj, filename)
2634        url = self.get_action_url('download', obj, _query={'filename': filename})
2635        return self.render_file_field(path, url)
2636
2637    def render_file_field(self, path, url=None, filename=None):
2638        """
2639        Convenience for rendering a file with optional download link
2640        """
2641        if not filename:
2642            filename = os.path.basename(path)
2643        content = "{} ({})".format(filename, self.readable_size(path))
2644        if url:
2645            return tags.link_to(content, url)
2646        return content
2647
2648    def readable_size(self, path):
2649        # TODO: this was shamelessly copied from FormAlchemy ...
2650        length = self.get_size(path)
2651        if length == 0:
2652            return '0 KB'
2653        if length <= 1024:
2654            return '1 KB'
2655        if length > 1048576:
2656            return '%0.02f MB' % (length / 1048576.0)
2657        return '%0.02f KB' % (length / 1024.0)
2658
2659    def get_size(self, path):
2660        try:
2661            return os.path.getsize(path)
2662        except os.error:
2663            return 0
2664
2665    def make_form(self, instance=None, factory=None, fields=None, schema=None, make_kwargs=None, configure=None, **kwargs):
2666        """
2667        Creates a new form for the given model class/instance
2668        """
2669        if factory is None:
2670            factory = self.get_form_factory()
2671        if fields is None:
2672            fields = self.get_form_fields()
2673        if schema is None:
2674            schema = self.make_form_schema()
2675        if make_kwargs is None:
2676            make_kwargs = self.make_form_kwargs
2677        if configure is None:
2678            configure = self.configure_form
2679
2680        # TODO: SQLAlchemy class instance is assumed *unless* we get a dict
2681        # (seems like we should be smarter about this somehow)
2682        # if not self.creating and not isinstance(instance, dict):
2683        if not self.creating:
2684            kwargs['model_instance'] = instance
2685        kwargs = make_kwargs(**kwargs)
2686        form = factory(fields, schema, **kwargs)
2687        configure(form)
2688        return form
2689
2690    def get_form_fields(self):
2691        if hasattr(self, 'form_fields'):
2692            return self.form_fields
2693        # TODO
2694        # raise NotImplementedError
2695
2696    def make_form_schema(self):
2697        if not self.model_class:
2698            # TODO
2699            raise NotImplementedError
2700
2701    def make_form_kwargs(self, **kwargs):
2702        """
2703        Return a dictionary of kwargs to be passed to the factory when creating
2704        new form instances.
2705        """
2706        defaults = {
2707            'request': self.request,
2708            'readonly': self.viewing,
2709            'model_class': getattr(self, 'model_class', None),
2710            'action_url': self.request.current_route_url(_query=None),
2711        }
2712        if self.creating:
2713            kwargs.setdefault('cancel_url', self.get_index_url())
2714        else:
2715            instance = kwargs['model_instance']
2716            kwargs.setdefault('cancel_url', self.get_action_url('view', instance))
2717        defaults.update(kwargs)
2718        return defaults
2719
2720    def configure_form(self, form):
2721        """
2722        Configure the main "desktop" form for the view's data model.
2723        """
2724        self.configure_common_form(form)
2725
2726    def validate_form(self, form):
2727        if form.validate(newstyle=True):
2728            self.form_deserialized = form.validated
2729            return True
2730        return False
2731
2732    def objectify(self, form, data=None):
2733        if data is None:
2734            data = form.validated
2735        obj = form.schema.objectify(data, context=form.model_instance)
2736        if self.is_contact:
2737            obj = self.objectify_contact(obj, data)
2738        return obj
2739
2740    def objectify_contact(self, contact, data):
2741
2742        if 'default_email' in data:
2743            address = data['default_email']
2744            if contact.emails:
2745                if address:
2746                    email = contact.emails[0]
2747                    email.address = address
2748                else:
2749                    contact.emails.pop(0)
2750            elif address:
2751                contact.add_email_address(address)
2752
2753        if 'default_phone' in data:
2754            number = data['default_phone']
2755            if contact.phones:
2756                if number:
2757                    phone = contact.phones[0]
2758                    phone.number = number
2759                else:
2760                    contact.phones.pop(0)
2761            elif number:
2762                contact.add_phone_number(number)
2763
2764        address_fields = ('address_street',
2765                          'address_street2',
2766                          'address_city',
2767                          'address_state',
2768                          'address_zipcode')
2769
2770        addr = dict([(field, data[field])
2771                     for field in address_fields
2772                     if field in data])
2773
2774        if any(addr.values()):
2775            # we strip 'address_' prefix from fields
2776            addr = dict([(field[8:], value)
2777                         for field, value in addr.items()])
2778            if contact.addresses:
2779                address = contact.addresses[0]
2780                for field, value in addr.items():
2781                    setattr(address, field, value)
2782            else:
2783                contact.add_mailing_address(**addr)
2784
2785        elif any([field in data for field in address_fields]) and contact.addresses:
2786            contact.addresses.pop()
2787
2788        return contact
2789
2790    def save_form(self, form):
2791        form.save()
2792
2793    def before_create(self, form):
2794        """
2795        Event hook, called just after the form to create a new instance has
2796        been validated, but prior to the form itself being saved.
2797        """
2798
2799    def after_create(self, instance):
2800        """
2801        Event hook, called just after a new instance is saved.
2802        """
2803
2804    def editable_instance(self, instance):
2805        """
2806        Returns boolean indicating whether or not the given instance can be
2807        considered "editable".  Returns ``True`` by default; override as
2808        necessary.
2809        """
2810        return True
2811
2812    def after_edit(self, instance):
2813        """
2814        Event hook, called just after an existing instance is saved.
2815        """
2816
2817    def deletable_instance(self, instance):
2818        """
2819        Returns boolean indicating whether or not the given instance can be
2820        considered "deletable".  Returns ``True`` by default; override as
2821        necessary.
2822        """
2823        return True
2824
2825    def before_delete(self, instance):
2826        """
2827        Event hook, called just before deletion is attempted.
2828        """
2829
2830    def delete_instance(self, instance):
2831        """
2832        Delete the instance, or mark it as deleted, or whatever you need to do.
2833        """
2834        # Flush immediately to force any pending integrity errors etc.; that
2835        # way we don't set flash message until we know we have success.
2836        self.Session.delete(instance)
2837        self.Session.flush()
2838
2839    def get_after_delete_url(self, instance):
2840        """
2841        Returns the URL to which the user should be redirected after
2842        successfully "deleting" the given instance.
2843        """
2844        if hasattr(self, 'after_delete_url'):
2845            if callable(self.after_delete_url):
2846                return self.after_delete_url(instance)
2847            return self.after_delete_url
2848        return self.get_index_url()
2849
2850    ##############################
2851    # Associated Rows Stuff
2852    ##############################
2853
2854    def create_row(self):
2855        """
2856        View for creating a new row record.
2857        """
2858        self.creating = True
2859        parent = self.get_instance()
2860        index_url = self.get_action_url('view', parent)
2861        form = self.make_row_form(self.model_row_class, cancel_url=index_url)
2862        if self.request.method == 'POST':
2863            if self.validate_row_form(form):
2864                self.before_create_row(form)
2865                obj = self.save_create_row_form(form)
2866                self.after_create_row(obj)
2867                return self.redirect_after_create_row(obj)
2868        return self.render_to_response('create_row', {
2869            'index_url': index_url,
2870            'index_title': '{} {}'.format(
2871                self.get_model_title(),
2872                self.get_instance_title(parent)),
2873            'form': form})
2874
2875    # TODO: still need to verify this logic
2876    def save_create_row_form(self, form):
2877        # self.before_create(form)
2878        # with self.Session().no_autoflush:
2879        #     obj = self.objectify(form, self.form_deserialized)
2880        #     self.before_create_flush(obj, form)
2881        obj = self.objectify(form, self.form_deserialized)
2882        self.Session.add(obj)
2883        self.Session.flush()
2884        return obj
2885
2886    # def save_create_row_form(self, form):
2887    #     self.save_row_form(form)
2888
2889    def before_create_row(self, form):
2890        pass
2891
2892    def after_create_row(self, row_object):
2893        pass
2894
2895    def redirect_after_create_row(self, row, mobile=False):
2896        return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
2897
2898    def mobile_create_row(self):
2899        """
2900        Mobile view for creating a new row object
2901        """
2902        self.mobile = True
2903        self.creating = True
2904        parent = self.get_instance()
2905        instance_url = self.get_action_url('view', parent, mobile=True)
2906        form = self.make_mobile_row_form(self.model_row_class, cancel_url=instance_url)
2907        if self.request.method == 'POST':
2908            if self.validate_mobile_row_form(form):
2909                self.before_create_row(form)
2910                # let save() return alternate object if necessary
2911                obj = self.save_create_row_form(form)
2912                self.after_create_row(obj)
2913                return self.redirect_after_create_row(obj, mobile=True)
2914        return self.render_to_response('create_row', {
2915            'instance_title': self.get_instance_title(parent),
2916            'instance_url': instance_url,
2917            'parent_object': parent,
2918            'form': form,
2919        }, mobile=True)
2920
2921    def mobile_quick_row(self):
2922        """
2923        Mobile view for "quick" location or creation of a row object
2924        """
2925        parent = self.get_instance()
2926        parent_url = self.get_action_url('view', parent, mobile=True)
2927        form = self.make_quick_row_form(self.model_row_class, mobile=True, cancel_url=parent_url)
2928        if self.request.method == 'POST':
2929            if self.validate_quick_row_form(form):
2930                row = self.save_quick_row_form(form)
2931                if not row:
2932                    self.request.session.flash("Could not locate/create row for entry: "
2933                                               "{}".format(form.validated['quick_entry']),
2934                                               'error')
2935                    return self.redirect(parent_url)
2936                return self.redirect_after_quick_row(row, mobile=True)
2937        return self.redirect(parent_url)
2938
2939    def save_quick_row_form(self, form):
2940        raise NotImplementedError("You must define `{}:{}.save_quick_row_form()` "
2941                                  "in order to process quick row forms".format(
2942                                      self.__class__.__module__,
2943                                      self.__class__.__name__))
2944
2945    def redirect_after_quick_row(self, row, mobile=False):
2946        return self.redirect(self.get_row_action_url('edit', row, mobile=mobile))
2947
2948    def view_row(self):
2949        """
2950        View for viewing details of a single data row.
2951        """
2952        self.viewing = True
2953        row = self.get_row_instance()
2954        form = self.make_row_form(row)
2955        parent = self.get_parent(row)
2956        return self.render_to_response('view_row', {
2957            'instance': row,
2958            'instance_title': self.get_instance_title(parent),
2959            'row_title': self.get_row_instance_title(row),
2960            'instance_url': self.get_action_url('view', parent),
2961            'instance_editable': self.row_editable(row),
2962            'instance_deletable': self.row_deletable(row),
2963            'rows_creatable': self.rows_creatable and self.rows_creatable_for(parent),
2964            'model_title': self.get_row_model_title(),
2965            'model_title_plural': self.get_row_model_title_plural(),
2966            'parent_instance': parent,
2967            'parent_model_title': self.get_model_title(),
2968            'action_url': self.get_row_action_url,
2969            'form': form})
2970
2971    def rows_creatable_for(self, instance):
2972        """
2973        Returns boolean indicating whether or not the given instance should
2974        allow new rows to be added to it.
2975        """
2976        return True
2977
2978    def rows_quickable_for(self, instance):
2979        """
2980        Must return boolean indicating whether the "quick row" feature should
2981        be allowed for the given instance.  Returns ``True`` by default.
2982        """
2983        return True
2984
2985    def row_editable(self, row):
2986        """
2987        Returns boolean indicating whether or not the given row can be
2988        considered "editable".  Returns ``True`` by default; override as
2989        necessary.
2990        """
2991        return True
2992
2993    def edit_row(self):
2994        """
2995        View for editing an existing model record.
2996        """
2997        self.editing = True
2998        row = self.get_row_instance()
2999        form = self.make_row_form(row)
3000
3001        if self.request.method == 'POST':
3002            if self.validate_row_form(form):
3003                self.save_edit_row_form(form)
3004                return self.redirect_after_edit_row(row)
3005
3006        parent = self.get_parent(row)
3007        return self.render_to_response('edit_row', {
3008            'instance': row,
3009            'row_parent': parent,
3010            'parent_title': self.get_instance_title(parent),
3011            'parent_url': self.get_action_url('view', parent),
3012            'parent_instance': parent,
3013            'instance_title': self.get_row_instance_title(row),
3014            'instance_deletable': self.row_deletable(row),
3015            'form': form,
3016            'dform': form.make_deform_form(),
3017        })
3018
3019    def mobile_edit_row(self):
3020        """
3021        Mobile view for editing a row object
3022        """
3023        self.mobile = True
3024        self.editing = True
3025        row = self.get_row_instance()
3026        instance_url = self.get_row_action_url('view', row, mobile=True)
3027        form = self.make_mobile_row_form(row)
3028
3029        if self.request.method == 'POST':
3030            if self.validate_mobile_row_form(form):
3031                self.save_edit_row_form(form)
3032                return self.redirect_after_edit_row(row, mobile=True)
3033
3034        parent = self.get_parent(row)
3035        return self.render_to_response('edit_row', {
3036            'row': row,
3037            'instance': row,
3038            'parent_instance': parent,
3039            'instance_title': self.get_row_instance_title(row),
3040            'instance_url': instance_url,
3041            'instance_deletable': self.row_deletable(row),
3042            'parent_title': self.get_instance_title(parent),
3043            'parent_url': self.get_action_url('view', parent, mobile=True),
3044            'form': form},
3045        mobile=True)
3046
3047    def save_edit_row_form(self, form):
3048        obj = self.objectify(form, self.form_deserialized)
3049        self.after_edit_row(obj)
3050        self.Session.flush()
3051        return obj
3052
3053    # def save_row_form(self, form):
3054    #     form.save()
3055
3056    def after_edit_row(self, row):
3057        """
3058        Event hook, called just after an existing row object is saved.
3059        """
3060
3061    def redirect_after_edit_row(self, row, mobile=False):
3062        return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
3063
3064    def row_deletable(self, row):
3065        """
3066        Returns boolean indicating whether or not the given row can be
3067        considered "deletable".  Returns ``True`` by default; override as
3068        necessary.
3069        """
3070        return True
3071
3072    def delete_row_object(self, row):
3073        """
3074        Perform the actual deletion of given row object.
3075        """
3076        self.Session.delete(row)
3077
3078    def delete_row(self):
3079        """
3080        Desktop view which can "delete" a sub-row from the parent.
3081        """
3082        row = self.Session.query(self.model_row_class).get(self.request.matchdict['row_uuid'])
3083        if not row:
3084            raise self.notfound()
3085        self.delete_row_object(row)
3086        return self.redirect(self.get_action_url('view', self.get_parent(row)))
3087
3088    def mobile_delete_row(self):
3089        """
3090        Mobile view which can "delete" a sub-row from the parent.
3091        """
3092        if self.request.method == 'POST':
3093            parent = self.get_instance()
3094            row = self.get_row_instance()
3095            if self.get_parent(row) is not parent:
3096                raise RuntimeError("Can only delete rows which belong to current object")
3097
3098            self.delete_row_object(row)
3099            return self.redirect(self.get_action_url('view', parent, mobile=True))
3100
3101        self.session.flash("Must POST to delete a row", 'error')
3102        return self.redirect(self.request.get_referrer(mobile=True))
3103
3104    def get_parent(self, row):
3105        raise NotImplementedError
3106
3107    def get_row_instance_title(self, instance):
3108        return self.get_row_model_title()
3109
3110    def get_row_instance(self):
3111        # TODO: is this right..?
3112        # key = self.request.matchdict[self.get_model_key()]
3113        key = self.request.matchdict['row_uuid']
3114        instance = self.Session.query(self.model_row_class).get(key)
3115        if not instance:
3116            raise httpexceptions.HTTPNotFound()
3117        return instance
3118
3119    def make_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs):
3120        """
3121        Creates a new row form for the given model class/instance.
3122        """
3123        if factory is None:
3124            factory = self.get_row_form_factory()
3125        if fields is None:
3126            fields = self.get_row_form_fields()
3127        if schema is None:
3128            schema = self.make_row_form_schema()
3129
3130        if not self.creating:
3131            kwargs['model_instance'] = instance
3132        kwargs = self.make_row_form_kwargs(**kwargs)
3133        form = factory(fields, schema, **kwargs)
3134        self.configure_row_form(form)
3135        return form
3136
3137    def get_row_form_fields(self):
3138        if hasattr(self, 'row_form_fields'):
3139            return self.row_form_fields
3140        # TODO
3141        # raise NotImplementedError
3142
3143    def make_row_form_schema(self):
3144        if not self.model_row_class:
3145            # TODO
3146            raise NotImplementedError
3147
3148    def make_row_form_kwargs(self, **kwargs):
3149        """
3150        Return a dictionary of kwargs to be passed to the factory when creating
3151        new row forms.
3152        """
3153        defaults = {
3154            'request': self.request,
3155            'readonly': self.viewing,
3156            'model_class': getattr(self, 'model_row_class', None),
3157            'action_url': self.request.current_route_url(_query=None),
3158        }
3159        if self.creating:
3160            kwargs.setdefault('cancel_url', self.request.get_referrer())
3161        else:
3162            instance = kwargs['model_instance']
3163            if 'cancel_url' not in kwargs:
3164                kwargs['cancel_url'] = self.get_row_action_url('view', instance)
3165        defaults.update(kwargs)
3166        return defaults
3167
3168    def configure_row_form(self, form):
3169        """
3170        Configure a row form.
3171        """
3172        # TODO: is any of this stuff from configure_form() needed?
3173        # if self.editing:
3174        #     model_class = self.get_model_class(error=False)
3175        #     if model_class:
3176        #         mapper = orm.class_mapper(model_class)
3177        #         for key in mapper.primary_key:
3178        #             for field in form.fields:
3179        #                 if field == key.name:
3180        #                     form.set_readonly(field)
3181        #                     break
3182        # form.remove_field('uuid')
3183
3184        self.set_row_labels(form)
3185
3186    def validate_row_form(self, form):
3187        if form.validate(newstyle=True):
3188            self.form_deserialized = form.validated
3189            return True
3190        return False
3191
3192    def get_row_action_url(self, action, row, mobile=False):
3193        """
3194        Generate a URL for the given action on the given row.
3195        """
3196        route_name = '{}.{}_row'.format(self.get_route_prefix(), action)
3197        if mobile:
3198            route_name = 'mobile.{}'.format(route_name)
3199        return self.request.route_url(route_name, **self.get_row_action_route_kwargs(row))
3200
3201    def get_row_action_route_kwargs(self, row):
3202        """
3203        Hopefully generic kwarg generator for basic action routes.
3204        """
3205        # TODO: make this smarter?
3206        parent = self.get_parent(row)
3207        return {
3208            'uuid': parent.uuid,
3209            'row_uuid': row.uuid,
3210        }
3211
3212    def make_diff(self, old_data, new_data, **kwargs):
3213        return diffs.Diff(old_data, new_data, **kwargs)
3214
3215    ##############################
3216    # Config Stuff
3217    ##############################
3218
3219    @classmethod
3220    def defaults(cls, config):
3221        """
3222        Provide default configuration for a master view.
3223        """
3224        cls._defaults(config)
3225
3226    @classmethod
3227    def _defaults(cls, config):
3228        """
3229        Provide default configuration for a master view.
3230        """
3231        rattail_config = config.registry.settings.get('rattail_config')
3232        route_prefix = cls.get_route_prefix()
3233        url_prefix = cls.get_url_prefix()
3234        permission_prefix = cls.get_permission_prefix()
3235        model_key = cls.get_model_key()
3236        model_title = cls.get_model_title()
3237        model_title_plural = cls.get_model_title_plural()
3238        if cls.has_rows:
3239            row_model_title = cls.get_row_model_title()
3240
3241        config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False)
3242
3243        # list/search
3244        if cls.listable:
3245            config.add_tailbone_permission(permission_prefix, '{}.list'.format(permission_prefix),
3246                                           "List / search {}".format(model_title_plural))
3247            config.add_route(route_prefix, '{}/'.format(url_prefix))
3248            config.add_view(cls, attr='index', route_name=route_prefix,
3249                            permission='{}.list'.format(permission_prefix))
3250            if cls.supports_mobile:
3251                config.add_route('mobile.{}'.format(route_prefix), '/mobile{}/'.format(url_prefix))
3252                config.add_view(cls, attr='mobile_index', route_name='mobile.{}'.format(route_prefix),
3253                                permission='{}.list'.format(permission_prefix))
3254
3255            if cls.results_downloadable_csv:
3256                config.add_tailbone_permission(permission_prefix, '{}.results_csv'.format(permission_prefix),
3257                                               "Download {} as CSV".format(model_title_plural))
3258                config.add_route('{}.results_csv'.format(route_prefix), '{}/csv'.format(url_prefix))
3259                config.add_view(cls, attr='results_csv', route_name='{}.results_csv'.format(route_prefix),
3260                                permission='{}.results_csv'.format(permission_prefix))
3261
3262            if cls.results_downloadable_xlsx:
3263                config.add_tailbone_permission(permission_prefix, '{}.results_xlsx'.format(permission_prefix),
3264                                               "Download {} as XLSX".format(model_title_plural))
3265                config.add_route('{}.results_xlsx'.format(route_prefix), '{}/xlsx'.format(url_prefix))
3266                config.add_view(cls, attr='results_xlsx', route_name='{}.results_xlsx'.format(route_prefix),
3267                                permission='{}.results_xlsx'.format(permission_prefix))
3268
3269
3270        # create
3271        if cls.creatable or cls.mobile_creatable:
3272            config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix),
3273                                           "Create new {}".format(model_title))
3274        if cls.creatable:
3275            config.add_route('{}.create'.format(route_prefix), '{}/new'.format(url_prefix))
3276            config.add_view(cls, attr='create', route_name='{}.create'.format(route_prefix),
3277                            permission='{}.create'.format(permission_prefix))
3278        if cls.mobile_creatable:
3279            config.add_route('mobile.{}.create'.format(route_prefix), '/mobile{}/new'.format(url_prefix))
3280            config.add_view(cls, attr='mobile_create', route_name='mobile.{}.create'.format(route_prefix),
3281                            permission='{}.create'.format(permission_prefix))
3282
3283        # populate new object
3284        if cls.populatable:
3285            config.add_route('{}.populate'.format(route_prefix), '{}/{{uuid}}/populate'.format(url_prefix))
3286            config.add_view(cls, attr='populate', route_name='{}.populate'.format(route_prefix),
3287                            permission='{}.create'.format(permission_prefix))
3288
3289        # enable/disable set
3290        if cls.supports_set_enabled_toggle:
3291            config.add_tailbone_permission(permission_prefix, '{}.enable_disable_set'.format(permission_prefix),
3292                                           "Enable / disable set (selection) of {}".format(model_title_plural))
3293            config.add_route('{}.enable_set'.format(route_prefix), '{}/enable-set'.format(url_prefix),
3294                             request_method='POST')
3295            config.add_view(cls, attr='enable_set', route_name='{}.enable_set'.format(route_prefix),
3296                            permission='{}.enable_disable_set'.format(permission_prefix))
3297            config.add_route('{}.disable_set'.format(route_prefix), '{}/disable-set'.format(url_prefix),
3298                             request_method='POST')
3299            config.add_view(cls, attr='disable_set', route_name='{}.disable_set'.format(route_prefix),
3300                            permission='{}.enable_disable_set'.format(permission_prefix))
3301
3302        # delete set
3303        if cls.set_deletable:
3304            config.add_tailbone_permission(permission_prefix, '{}.delete_set'.format(permission_prefix),
3305                                           "Delete set (selection) of {}".format(model_title_plural))
3306            config.add_route('{}.delete_set'.format(route_prefix), '{}/delete-set'.format(url_prefix),
3307                             request_method='POST')
3308            config.add_view(cls, attr='delete_set', route_name='{}.delete_set'.format(route_prefix),
3309                            permission='{}.delete_set'.format(permission_prefix))
3310
3311        # bulk delete
3312        if cls.bulk_deletable:
3313            config.add_route('{}.bulk_delete'.format(route_prefix), '{}/bulk-delete'.format(url_prefix),
3314                             request_method='POST')
3315            config.add_view(cls, attr='bulk_delete', route_name='{}.bulk_delete'.format(route_prefix),
3316                            permission='{}.bulk_delete'.format(permission_prefix))
3317            config.add_tailbone_permission(permission_prefix, '{}.bulk_delete'.format(permission_prefix),
3318                                           "Bulk delete {}".format(model_title_plural))
3319
3320        # merge
3321        if cls.mergeable:
3322            config.add_route('{}.merge'.format(route_prefix), '{}/merge'.format(url_prefix))
3323            config.add_view(cls, attr='merge', route_name='{}.merge'.format(route_prefix),
3324                            permission='{}.merge'.format(permission_prefix))
3325            config.add_tailbone_permission(permission_prefix, '{}.merge'.format(permission_prefix),
3326                                           "Merge 2 {}".format(model_title_plural))
3327
3328        # view
3329        if cls.viewable:
3330            config.add_tailbone_permission(permission_prefix, '{}.view'.format(permission_prefix),
3331                                           "View details for {}".format(model_title))
3332            if cls.has_pk_fields:
3333                config.add_tailbone_permission(permission_prefix, '{}.view_pk_fields'.format(permission_prefix),
3334                                               "View all PK-type fields for {}".format(model_title_plural))
3335
3336            # view by grid index
3337            config.add_route('{}.view_index'.format(route_prefix), '{}/view'.format(url_prefix))
3338            config.add_view(cls, attr='view_index', route_name='{}.view_index'.format(route_prefix),
3339                            permission='{}.view'.format(permission_prefix))
3340
3341            # view by record key
3342            config.add_route('{}.view'.format(route_prefix), '{}/{{{}}}'.format(url_prefix, model_key))
3343            config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix),
3344                            permission='{}.view'.format(permission_prefix))
3345            if cls.supports_mobile:
3346                config.add_route('mobile.{}.view'.format(route_prefix), '/mobile{}/{{{}}}'.format(url_prefix, model_key))
3347                config.add_view(cls, attr='mobile_view', route_name='mobile.{}.view'.format(route_prefix),
3348                                permission='{}.view'.format(permission_prefix))
3349
3350            # version history
3351            if cls.has_versions and rattail_config and rattail_config.versioning_enabled():
3352                config.add_tailbone_permission(permission_prefix, '{}.versions'.format(permission_prefix),
3353                                               "View version history for {}".format(model_title))
3354                config.add_route('{}.versions'.format(route_prefix), '{}/{{{}}}/versions/'.format(url_prefix, model_key))
3355                config.add_view(cls, attr='versions', route_name='{}.versions'.format(route_prefix),
3356                                permission='{}.versions'.format(permission_prefix))
3357                config.add_route('{}.version'.format(route_prefix), '{}/{{{}}}/versions/{{txnid}}'.format(url_prefix, model_key))
3358                config.add_view(cls, attr='view_version', route_name='{}.version'.format(route_prefix),
3359                                permission='{}.versions'.format(permission_prefix))
3360
3361        # image
3362        if cls.has_image:
3363            config.add_route('{}.image'.format(route_prefix), '{}/{{{}}}/image'.format(url_prefix, model_key))
3364            config.add_view(cls, attr='image', route_name='{}.image'.format(route_prefix),
3365                            permission='{}.view'.format(permission_prefix))
3366
3367        # thumbnail
3368        if cls.has_thumbnail:
3369            config.add_route('{}.thumbnail'.format(route_prefix), '{}/{{{}}}/thumbnail'.format(url_prefix, model_key))
3370            config.add_view(cls, attr='thumbnail', route_name='{}.thumbnail'.format(route_prefix),
3371                            permission='{}.view'.format(permission_prefix))
3372
3373        # clone
3374        if cls.cloneable:
3375            config.add_tailbone_permission(permission_prefix, '{}.clone'.format(permission_prefix),
3376                                           "Clone an existing {0} as a new {0}".format(model_title))
3377            config.add_route('{}.clone'.format(route_prefix), '{}/{{{}}}/clone'.format(url_prefix, model_key))
3378            config.add_view(cls, attr='clone', route_name='{}.clone'.format(route_prefix),
3379                            permission='{}.clone'.format(permission_prefix))
3380
3381        # download
3382        if cls.downloadable:
3383            config.add_route('{}.download'.format(route_prefix), '{}/{{{}}}/download'.format(url_prefix, model_key))
3384            config.add_view(cls, attr='download', route_name='{}.download'.format(route_prefix),
3385                            permission='{}.download'.format(permission_prefix))
3386            config.add_tailbone_permission(permission_prefix, '{}.download'.format(permission_prefix),
3387                                           "Download associated data for {}".format(model_title))
3388
3389        # edit
3390        if cls.editable or cls.mobile_editable:
3391            config.add_tailbone_permission(permission_prefix, '{}.edit'.format(permission_prefix),
3392                                           "Edit {}".format(model_title))
3393        if cls.editable:
3394            config.add_route('{}.edit'.format(route_prefix), '{}/{{{}}}/edit'.format(url_prefix, model_key))
3395            config.add_view(cls, attr='edit', route_name='{}.edit'.format(route_prefix),
3396                            permission='{}.edit'.format(permission_prefix))
3397        if cls.mobile_editable:
3398            config.add_route('mobile.{}.edit'.format(route_prefix), '/mobile{}/{{{}}}/edit'.format(url_prefix, model_key))
3399            config.add_view(cls, attr='mobile_edit', route_name='mobile.{}.edit'.format(route_prefix),
3400                            permission='{}.edit'.format(permission_prefix))
3401
3402        # execute
3403        if cls.executable or cls.mobile_executable:
3404            config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix),
3405                                           "Execute {}".format(model_title))
3406        if cls.executable:
3407            config.add_route('{}.execute'.format(route_prefix), '{}/{{{}}}/execute'.format(url_prefix, model_key))
3408            config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix),
3409                            permission='{}.execute'.format(permission_prefix))
3410        if cls.mobile_executable:
3411            config.add_route('mobile.{}.execute'.format(route_prefix), '/mobile{}/{{{}}}/execute'.format(url_prefix, model_key))
3412            config.add_view(cls, attr='mobile_execute', route_name='mobile.{}.execute'.format(route_prefix),
3413                            permission='{}.execute'.format(permission_prefix))
3414
3415        # delete
3416        if cls.deletable:
3417            config.add_route('{0}.delete'.format(route_prefix), '{0}/{{{1}}}/delete'.format(url_prefix, model_key))
3418            config.add_view(cls, attr='delete', route_name='{0}.delete'.format(route_prefix),
3419                            permission='{0}.delete'.format(permission_prefix))
3420            config.add_tailbone_permission(permission_prefix, '{0}.delete'.format(permission_prefix),
3421                                           "Delete {0}".format(model_title))
3422
3423        # import batch from file
3424        if cls.supports_import_batch_from_file:
3425            config.add_tailbone_permission(permission_prefix, '{}.import_file'.format(permission_prefix),
3426                                           "Create a new import batch from data file")
3427
3428        ### sub-rows stuff follows
3429
3430        # download row results as CSV
3431        if cls.has_rows and cls.rows_downloadable_csv:
3432            config.add_tailbone_permission(permission_prefix, '{}.row_results_csv'.format(permission_prefix),
3433                                           "Download {} results as CSV".format(row_model_title))
3434            config.add_route('{}.row_results_csv'.format(route_prefix), '{}/{{uuid}}/rows-csv'.format(url_prefix))
3435            config.add_view(cls, attr='row_results_csv', route_name='{}.row_results_csv'.format(route_prefix),
3436                            permission='{}.row_results_csv'.format(permission_prefix))
3437
3438        # download row results as Excel
3439        if cls.has_rows and cls.rows_downloadable_xlsx:
3440            config.add_tailbone_permission(permission_prefix, '{}.row_results_xlsx'.format(permission_prefix),
3441                                           "Download {} results as XLSX".format(row_model_title))
3442            config.add_route('{}.row_results_xlsx'.format(route_prefix), '{}/{{uuid}}/rows-xlsx'.format(url_prefix))
3443            config.add_view(cls, attr='row_results_xlsx', route_name='{}.row_results_xlsx'.format(route_prefix),
3444                            permission='{}.row_results_xlsx'.format(permission_prefix))
3445
3446        # create row
3447        if cls.has_rows:
3448            if cls.rows_creatable or cls.mobile_rows_creatable:
3449                config.add_tailbone_permission(permission_prefix, '{}.create_row'.format(permission_prefix),
3450                                               "Create new {} rows".format(model_title))
3451            if cls.rows_creatable:
3452                config.add_route('{}.create_row'.format(route_prefix), '{}/{{{}}}/new-row'.format(url_prefix, model_key))
3453                config.add_view(cls, attr='create_row', route_name='{}.create_row'.format(route_prefix),
3454                                permission='{}.create_row'.format(permission_prefix))
3455            if cls.mobile_rows_creatable:
3456                config.add_route('mobile.{}.create_row'.format(route_prefix), '/mobile{}/{{{}}}/new-row'.format(url_prefix, model_key))
3457                config.add_view(cls, attr='mobile_create_row', route_name='mobile.{}.create_row'.format(route_prefix),
3458                                permission='{}.create_row'.format(permission_prefix))
3459                if cls.mobile_rows_quickable:
3460                    config.add_route('mobile.{}.quick_row'.format(route_prefix), '/mobile{}/{{{}}}/quick-row'.format(url_prefix, model_key))
3461                    config.add_view(cls, attr='mobile_quick_row', route_name='mobile.{}.quick_row'.format(route_prefix),
3462                                    permission='{}.create_row'.format(permission_prefix))
3463
3464        # view row
3465        if cls.has_rows:
3466            if cls.rows_viewable:
3467                config.add_route('{}.view_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}'.format(url_prefix))
3468                config.add_view(cls, attr='view_row', route_name='{}.view_row'.format(route_prefix),
3469                                permission='{}.view'.format(permission_prefix))
3470            if cls.mobile_rows_viewable:
3471                config.add_route('mobile.{}.view_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}'.format(url_prefix))
3472                config.add_view(cls, attr='mobile_view_row', route_name='mobile.{}.view_row'.format(route_prefix),
3473                                permission='{}.view'.format(permission_prefix))
3474
3475        # edit row
3476        if cls.has_rows:
3477            if cls.rows_editable or cls.mobile_rows_editable:
3478                config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix),
3479                                               "Edit individual {} rows".format(model_title))
3480            if cls.rows_editable:
3481                config.add_route('{}.edit_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/edit'.format(url_prefix))
3482                config.add_view(cls, attr='edit_row', route_name='{}.edit_row'.format(route_prefix),
3483                                permission='{}.edit_row'.format(permission_prefix))
3484            if cls.mobile_rows_editable:
3485                config.add_route('mobile.{}.edit_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/edit'.format(url_prefix))
3486                config.add_view(cls, attr='mobile_edit_row', route_name='mobile.{}.edit_row'.format(route_prefix),
3487                                permission='{}.edit_row'.format(permission_prefix))
3488
3489        # delete row
3490        if cls.has_rows:
3491            if cls.rows_deletable or cls.mobile_rows_deletable:
3492                config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix),
3493                                               "Delete individual {} rows".format(model_title))
3494            if cls.rows_deletable:
3495                config.add_route('{}.delete_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix))
3496                config.add_view(cls, attr='delete_row', route_name='{}.delete_row'.format(route_prefix),
3497                                permission='{}.delete_row'.format(permission_prefix))
3498            if cls.mobile_rows_deletable:
3499                config.add_route('mobile.{}.delete_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix))
3500                config.add_view(cls, attr='mobile_delete_row', route_name='mobile.{}.delete_row'.format(route_prefix),
3501                                permission='{}.delete_row'.format(permission_prefix))
Note: See TracBrowser for help on using the repository browser.