source: tailbone/tailbone/views/master.py @ 839f6af

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

Add basic "DB picker" support, for views which allow multiple engines

i.e. whichever engine is "current" will determine where data comes from

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