source: tailbone/tailbone/views/master.py @ 993ce92

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

Add basic "downloadable" support for ExportMasterView?

instead of it trying to do its own thing for that... more to come on this

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