Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/views/master.py: 100%
619 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-26 14:40 -0500
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-26 14:40 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Base Logic for Master Views
25"""
27import logging
28import os
29import threading
31import sqlalchemy as sa
32from sqlalchemy import orm
34from pyramid.renderers import render_to_response
35from webhelpers2.html import HTML
37from wuttaweb.views import View
38from wuttaweb.util import get_form_data, get_model_fields, render_csrf_token
39from wuttaweb.db import Session
40from wuttaweb.progress import SessionProgress
41from wuttjamaican.util import get_class_hierarchy
44log = logging.getLogger(__name__)
47class MasterView(View):
48 """
49 Base class for "master" views.
51 Master views typically map to a table in a DB, though not always.
52 They essentially are a set of CRUD views for a certain type of
53 data record.
55 Many attributes may be overridden in subclass. For instance to
56 define :attr:`model_class`::
58 from wuttaweb.views import MasterView
59 from wuttjamaican.db.model import Person
61 class MyPersonView(MasterView):
62 model_class = Person
64 def includeme(config):
65 MyPersonView.defaults(config)
67 .. note::
69 Many of these attributes will only exist if they have been
70 explicitly defined in a subclass. There are corresponding
71 ``get_xxx()`` methods which should be used instead of accessing
72 these attributes directly.
74 .. attribute:: model_class
76 Optional reference to a data model class. While not strictly
77 required, most views will set this to a SQLAlchemy mapped
78 class,
79 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
81 Code should not access this directly but instead call
82 :meth:`get_model_class()`.
84 .. attribute:: model_name
86 Optional override for the view's data model name,
87 e.g. ``'WuttaWidget'``.
89 Code should not access this directly but instead call
90 :meth:`get_model_name()`.
92 .. attribute:: model_name_normalized
94 Optional override for the view's "normalized" data model name,
95 e.g. ``'wutta_widget'``.
97 Code should not access this directly but instead call
98 :meth:`get_model_name_normalized()`.
100 .. attribute:: model_title
102 Optional override for the view's "humanized" (singular) model
103 title, e.g. ``"Wutta Widget"``.
105 Code should not access this directly but instead call
106 :meth:`get_model_title()`.
108 .. attribute:: model_title_plural
110 Optional override for the view's "humanized" (plural) model
111 title, e.g. ``"Wutta Widgets"``.
113 Code should not access this directly but instead call
114 :meth:`get_model_title_plural()`.
116 .. attribute:: model_key
118 Optional override for the view's "model key" - e.g. ``'id'``
119 (string for simple case) or composite key such as
120 ``('id_field', 'name_field')``.
122 If :attr:`model_class` is set to a SQLAlchemy mapped class, the
123 model key can be determined automatically.
125 Code should not access this directly but instead call
126 :meth:`get_model_key()`.
128 .. attribute:: grid_key
130 Optional override for the view's grid key, e.g. ``'widgets'``.
132 Code should not access this directly but instead call
133 :meth:`get_grid_key()`.
135 .. attribute:: config_title
137 Optional override for the view's "config" title, e.g. ``"Wutta
138 Widgets"`` (to be displayed as **Configure Wutta Widgets**).
140 Code should not access this directly but instead call
141 :meth:`get_config_title()`.
143 .. attribute:: route_prefix
145 Optional override for the view's route prefix,
146 e.g. ``'wutta_widgets'``.
148 Code should not access this directly but instead call
149 :meth:`get_route_prefix()`.
151 .. attribute:: permission_prefix
153 Optional override for the view's permission prefix,
154 e.g. ``'wutta_widgets'``.
156 Code should not access this directly but instead call
157 :meth:`get_permission_prefix()`.
159 .. attribute:: url_prefix
161 Optional override for the view's URL prefix,
162 e.g. ``'/widgets'``.
164 Code should not access this directly but instead call
165 :meth:`get_url_prefix()`.
167 .. attribute:: template_prefix
169 Optional override for the view's template prefix,
170 e.g. ``'/widgets'``.
172 Code should not access this directly but instead call
173 :meth:`get_template_prefix()`.
175 .. attribute:: listable
177 Boolean indicating whether the view model supports "listing" -
178 i.e. it should have an :meth:`index()` view. Default value is
179 ``True``.
181 .. attribute:: has_grid
183 Boolean indicating whether the :meth:`index()` view should
184 include a grid. Default value is ``True``.
186 .. attribute:: grid_columns
188 List of columns for the :meth:`index()` view grid.
190 This is optional; see also :meth:`get_grid_columns()`.
192 .. method:: grid_row_class(obj, data, i)
194 This method is *not* defined on the ``MasterView`` base class;
195 however if a subclass defines it then it will be automatically
196 used to provide :attr:`~wuttaweb.grids.base.Grid.row_class` for
197 the main :meth:`index()` grid.
199 For more info see
200 :meth:`~wuttaweb.grids.base.Grid.get_row_class()`.
202 .. attribute:: filterable
204 Boolean indicating whether the grid for the :meth:`index()`
205 view should allow filtering of data. Default is ``True``.
207 This is used by :meth:`make_model_grid()` to set the grid's
208 :attr:`~wuttaweb.grids.base.Grid.filterable` flag.
210 .. attribute:: filter_defaults
212 Optional dict of default filter state.
214 This is used by :meth:`make_model_grid()` to set the grid's
215 :attr:`~wuttaweb.grids.base.Grid.filter_defaults`.
217 Only relevant if :attr:`filterable` is true.
219 .. attribute:: sortable
221 Boolean indicating whether the grid for the :meth:`index()`
222 view should allow sorting of data. Default is ``True``.
224 This is used by :meth:`make_model_grid()` to set the grid's
225 :attr:`~wuttaweb.grids.base.Grid.sortable` flag.
227 See also :attr:`sort_on_backend` and :attr:`sort_defaults`.
229 .. attribute:: sort_on_backend
231 Boolean indicating whether the grid data for the
232 :meth:`index()` view should be sorted on the backend. Default
233 is ``True``.
235 This is used by :meth:`make_model_grid()` to set the grid's
236 :attr:`~wuttaweb.grids.base.Grid.sort_on_backend` flag.
238 Only relevant if :attr:`sortable` is true.
240 .. attribute:: sort_defaults
242 Optional list of default sorting info. Applicable for both
243 frontend and backend sorting.
245 This is used by :meth:`make_model_grid()` to set the grid's
246 :attr:`~wuttaweb.grids.base.Grid.sort_defaults`.
248 Only relevant if :attr:`sortable` is true.
250 .. attribute:: paginated
252 Boolean indicating whether the grid data for the
253 :meth:`index()` view should be paginated. Default is ``True``.
255 This is used by :meth:`make_model_grid()` to set the grid's
256 :attr:`~wuttaweb.grids.base.Grid.paginated` flag.
258 .. attribute:: paginate_on_backend
260 Boolean indicating whether the grid data for the
261 :meth:`index()` view should be paginated on the backend.
262 Default is ``True``.
264 This is used by :meth:`make_model_grid()` to set the grid's
265 :attr:`~wuttaweb.grids.base.Grid.paginate_on_backend` flag.
267 .. attribute:: creatable
269 Boolean indicating whether the view model supports "creating" -
270 i.e. it should have a :meth:`create()` view. Default value is
271 ``True``.
273 .. attribute:: viewable
275 Boolean indicating whether the view model supports "viewing" -
276 i.e. it should have a :meth:`view()` view. Default value is
277 ``True``.
279 .. attribute:: editable
281 Boolean indicating whether the view model supports "editing" -
282 i.e. it should have an :meth:`edit()` view. Default value is
283 ``True``.
285 See also :meth:`is_editable()`.
287 .. attribute:: deletable
289 Boolean indicating whether the view model supports "deleting" -
290 i.e. it should have a :meth:`delete()` view. Default value is
291 ``True``.
293 See also :meth:`is_deletable()`.
295 .. attribute:: deletable_bulk
297 Boolean indicating whether the view model supports "bulk
298 deleting" - i.e. it should have a :meth:`delete_bulk()` view.
299 Default value is ``False``.
301 See also :attr:`deletable_bulk_quick`.
303 .. attribute:: deletable_bulk_quick
305 Boolean indicating whether the view model supports "quick" bulk
306 deleting, i.e. the operation is reliably quick enough that it
307 should happen *synchronously* with no progress indicator.
309 Default is ``False`` in which case a progress indicator is
310 shown while the bulk deletion is performed.
312 Only relevant if :attr:`deletable_bulk` is true.
314 .. attribute:: form_fields
316 List of fields for the model form.
318 This is optional; see also :meth:`get_form_fields()`.
320 .. attribute:: has_autocomplete
322 Boolean indicating whether the view model supports
323 "autocomplete" - i.e. it should have an :meth:`autocomplete()`
324 view. Default is ``False``.
326 .. attribute:: downloadable
328 Boolean indicating whether the view model supports
329 "downloading" - i.e. it should have a :meth:`download()` view.
330 Default is ``False``.
332 .. attribute:: executable
334 Boolean indicating whether the view model supports "executing"
335 - i.e. it should have an :meth:`execute()` view. Default is
336 ``False``.
338 .. attribute:: configurable
340 Boolean indicating whether the master view supports
341 "configuring" - i.e. it should have a :meth:`configure()` view.
342 Default value is ``False``.
343 """
345 ##############################
346 # attributes
347 ##############################
349 # features
350 listable = True
351 has_grid = True
352 filterable = True
353 filter_defaults = None
354 sortable = True
355 sort_on_backend = True
356 sort_defaults = None
357 paginated = True
358 paginate_on_backend = True
359 creatable = True
360 viewable = True
361 editable = True
362 deletable = True
363 deletable_bulk = False
364 deletable_bulk_quick = False
365 has_autocomplete = False
366 downloadable = False
367 executable = False
368 execute_progress_template = None
369 configurable = False
371 # current action
372 listing = False
373 creating = False
374 viewing = False
375 editing = False
376 deleting = False
377 configuring = False
379 # default DB session
380 Session = Session
382 ##############################
383 # index methods
384 ##############################
386 def index(self):
387 """
388 View to "list" (filter/browse) the model data.
390 This is the "default" view for the model and is what user sees
391 when visiting the "root" path under the :attr:`url_prefix`,
392 e.g. ``/widgets/``.
394 By default, this view is included only if :attr:`listable` is
395 true.
397 The default view logic will show a "grid" (table) with the
398 model data (unless :attr:`has_grid` is false).
400 See also related methods, which are called by this one:
402 * :meth:`make_model_grid()`
403 """
404 self.listing = True
406 context = {
407 'index_url': None, # nb. avoid title link since this *is* the index
408 }
410 if self.has_grid:
411 grid = self.make_model_grid()
413 # handle "full" vs. "partial" differently
414 if self.request.GET.get('partial'):
416 # so-called 'partial' requests get just data, no html
417 context = grid.get_vue_context()
418 if grid.paginated and grid.paginate_on_backend:
419 context['pager_stats'] = grid.get_vue_pager_stats()
420 return self.json_response(context)
422 else: # full, not partial
424 # nb. when user asks to reset view, it is via the query
425 # string. if so we then redirect to discard that.
426 if self.request.GET.get('reset-view'):
428 # nb. we want to preserve url hash if applicable
429 kw = {'_query': None,
430 '_anchor': self.request.GET.get('hash')}
431 return self.redirect(self.request.current_route_url(**kw))
433 context['grid'] = grid
435 return self.render_to_response('index', context)
437 ##############################
438 # create methods
439 ##############################
441 def create(self):
442 """
443 View to "create" a new model record.
445 This usually corresponds to a URL like ``/widgets/new``.
447 By default, this view is included only if :attr:`creatable` is
448 true.
450 The default "create" view logic will show a form with field
451 widgets, allowing user to submit new values which are then
452 persisted to the DB (assuming typical SQLAlchemy model).
454 Subclass normally should not override this method, but rather
455 one of the related methods which are called (in)directly by
456 this one:
458 * :meth:`make_model_form()`
459 * :meth:`configure_form()`
460 * :meth:`create_save_form()`
461 """
462 self.creating = True
463 form = self.make_model_form(cancel_url_fallback=self.get_index_url())
465 if form.validate():
466 obj = self.create_save_form(form)
467 self.Session.flush()
468 return self.redirect(self.get_action_url('view', obj))
470 context = {
471 'form': form,
472 }
473 return self.render_to_response('create', context)
475 def create_save_form(self, form):
476 """
477 This method is responsible for "converting" the validated form
478 data to a model instance, and then "saving" the result,
479 e.g. to DB. It is called by :meth:`create()`.
481 Subclass may override this, or any of the related methods
482 called by this one:
484 * :meth:`objectify()`
485 * :meth:`persist()`
487 :returns: Should return the resulting model instance, e.g. as
488 produced by :meth:`objectify()`.
489 """
490 obj = self.objectify(form)
491 self.persist(obj)
492 return obj
494 ##############################
495 # view methods
496 ##############################
498 def view(self):
499 """
500 View to "view" details of an existing model record.
502 This usually corresponds to a URL like ``/widgets/XXX``
503 where ``XXX`` represents the key/ID for the record.
505 By default, this view is included only if :attr:`viewable` is
506 true.
508 The default view logic will show a read-only form with field
509 values displayed.
511 Subclass normally should not override this method, but rather
512 one of the related methods which are called (in)directly by
513 this one:
515 * :meth:`make_model_form()`
516 * :meth:`configure_form()`
517 """
518 self.viewing = True
519 instance = self.get_instance()
520 form = self.make_model_form(instance, readonly=True)
522 context = {
523 'instance': instance,
524 'form': form,
525 }
526 return self.render_to_response('view', context)
528 ##############################
529 # edit methods
530 ##############################
532 def edit(self):
533 """
534 View to "edit" details of an existing model record.
536 This usually corresponds to a URL like ``/widgets/XXX/edit``
537 where ``XXX`` represents the key/ID for the record.
539 By default, this view is included only if :attr:`editable` is
540 true.
542 The default "edit" view logic will show a form with field
543 widgets, allowing user to modify and submit new values which
544 are then persisted to the DB (assuming typical SQLAlchemy
545 model).
547 Subclass normally should not override this method, but rather
548 one of the related methods which are called (in)directly by
549 this one:
551 * :meth:`make_model_form()`
552 * :meth:`configure_form()`
553 * :meth:`edit_save_form()`
554 """
555 self.editing = True
556 instance = self.get_instance()
558 form = self.make_model_form(instance,
559 cancel_url_fallback=self.get_action_url('view', instance))
561 if form.validate():
562 self.edit_save_form(form)
563 return self.redirect(self.get_action_url('view', instance))
565 context = {
566 'instance': instance,
567 'form': form,
568 }
569 return self.render_to_response('edit', context)
571 def edit_save_form(self, form):
572 """
573 This method is responsible for "converting" the validated form
574 data to a model instance, and then "saving" the result,
575 e.g. to DB. It is called by :meth:`edit()`.
577 Subclass may override this, or any of the related methods
578 called by this one:
580 * :meth:`objectify()`
581 * :meth:`persist()`
583 :returns: Should return the resulting model instance, e.g. as
584 produced by :meth:`objectify()`.
585 """
586 obj = self.objectify(form)
587 self.persist(obj)
588 return obj
590 ##############################
591 # delete methods
592 ##############################
594 def delete(self):
595 """
596 View to delete an existing model instance.
598 This usually corresponds to a URL like ``/widgets/XXX/delete``
599 where ``XXX`` represents the key/ID for the record.
601 By default, this view is included only if :attr:`deletable` is
602 true.
604 The default "delete" view logic will show a "psuedo-readonly"
605 form with no fields editable, but with a submit button so user
606 must confirm, before deletion actually occurs.
608 Subclass normally should not override this method, but rather
609 one of the related methods which are called (in)directly by
610 this one:
612 * :meth:`make_model_form()`
613 * :meth:`configure_form()`
614 * :meth:`delete_save_form()`
615 * :meth:`delete_instance()`
616 """
617 self.deleting = True
618 instance = self.get_instance()
620 if not self.is_deletable(instance):
621 return self.redirect(self.get_action_url('view', instance))
623 # nb. this form proper is not readonly..
624 form = self.make_model_form(instance,
625 cancel_url_fallback=self.get_action_url('view', instance),
626 button_label_submit="DELETE Forever",
627 button_icon_submit='trash',
628 button_type_submit='is-danger')
629 # ..but *all* fields are readonly
630 form.readonly_fields = set(form.fields)
632 # nb. validate() often returns empty dict here
633 if form.validate() is not False:
634 self.delete_save_form(form)
635 return self.redirect(self.get_index_url())
637 context = {
638 'instance': instance,
639 'form': form,
640 }
641 return self.render_to_response('delete', context)
643 def delete_save_form(self, form):
644 """
645 Perform the delete operation(s) based on the given form data.
647 Default logic simply calls :meth:`delete_instance()` on the
648 form's :attr:`~wuttaweb.forms.base.Form.model_instance`.
650 This method is called by :meth:`delete()` after it has
651 validated the form.
652 """
653 obj = form.model_instance
654 self.delete_instance(obj)
656 def delete_instance(self, obj):
657 """
658 Delete the given model instance.
660 As of yet there is no default logic for this method; it will
661 raise ``NotImplementedError``. Subclass should override if
662 needed.
664 This method is called by :meth:`delete_save_form()`.
665 """
666 session = self.app.get_session(obj)
667 session.delete(obj)
669 def delete_bulk(self):
670 """
671 View to delete all records in the current :meth:`index()` grid
672 data set, i.e. those matching current query.
674 This usually corresponds to a URL like
675 ``/widgets/delete-bulk``.
677 By default, this view is included only if
678 :attr:`deletable_bulk` is true.
680 This view requires POST method. When it is finished deleting,
681 user is redirected back to :meth:`index()` view.
683 Subclass normally should not override this method, but rather
684 one of the related methods which are called (in)directly by
685 this one:
687 * :meth:`delete_bulk_action()`
688 """
690 # get current data set from grid
691 # nb. this must *not* be paginated, we need it all
692 grid = self.make_model_grid(paginated=False)
693 data = grid.get_visible_data()
695 if self.deletable_bulk_quick:
697 # delete it all and go back to listing
698 self.delete_bulk_action(data)
699 return self.redirect(self.get_index_url())
701 else:
703 # start thread for delete; show progress page
704 route_prefix = self.get_route_prefix()
705 key = f'{route_prefix}.delete_bulk'
706 progress = self.make_progress(key, success_url=self.get_index_url())
707 thread = threading.Thread(target=self.delete_bulk_thread,
708 args=(data,), kwargs={'progress': progress})
709 thread.start()
710 return self.render_progress(progress)
712 def delete_bulk_thread(self, query, success_url=None, progress=None):
713 """ """
714 model_title_plural = self.get_model_title_plural()
716 # nb. use new session, separate from web transaction
717 session = self.app.make_session()
718 records = query.with_session(session).all()
720 try:
721 self.delete_bulk_action(records, progress=progress)
723 except Exception as error:
724 session.rollback()
725 log.warning("failed to delete %s results for %s",
726 len(records), model_title_plural,
727 exc_info=True)
728 if progress:
729 progress.handle_error(error)
731 else:
732 session.commit()
733 if progress:
734 progress.handle_success()
736 finally:
737 session.close()
739 def delete_bulk_action(self, data, progress=None):
740 """
741 This method performs the actual bulk deletion, for the given
742 data set. This is called via :meth:`delete_bulk()`.
744 Default logic will call :meth:`is_deletable()` for every data
745 record, and if that returns true then it calls
746 :meth:`delete_instance()`. A progress indicator will be
747 updated if one is provided.
749 Subclass should override if needed.
750 """
751 model_title_plural = self.get_model_title_plural()
753 def delete(obj, i):
754 if self.is_deletable(obj):
755 self.delete_instance(obj)
757 self.app.progress_loop(delete, data, progress,
758 message=f"Deleting {model_title_plural}")
760 def delete_bulk_make_button(self):
761 """ """
762 route_prefix = self.get_route_prefix()
764 label = HTML.literal(
765 '{{ deleteResultsSubmitting ? "Working, please wait..." : "Delete Results" }}')
766 button = self.make_button(label,
767 variant='is-danger',
768 icon_left='trash',
769 **{'@click': 'deleteResultsSubmit()',
770 ':disabled': 'deleteResultsDisabled'})
772 form = HTML.tag('form',
773 method='post',
774 action=self.request.route_url(f'{route_prefix}.delete_bulk'),
775 ref='deleteResultsForm',
776 class_='control',
777 c=[
778 render_csrf_token(self.request),
779 button,
780 ])
781 return form
783 ##############################
784 # autocomplete methods
785 ##############################
787 def autocomplete(self):
788 """
789 View which accepts a single ``term`` param, and returns a JSON
790 list of autocomplete results to match.
792 By default, this view is included only if
793 :attr:`has_autocomplete` is true. It usually maps to a URL
794 like ``/widgets/autocomplete``.
796 Subclass generally does not need to override this method, but
797 rather should override the others which this calls:
799 * :meth:`autocomplete_data()`
800 * :meth:`autocomplete_normalize()`
801 """
802 term = self.request.GET.get('term', '')
803 if not term:
804 return []
806 data = self.autocomplete_data(term)
807 if not data:
808 return []
810 max_results = 100 # TODO
812 results = []
813 for obj in data[:max_results]:
814 normal = self.autocomplete_normalize(obj)
815 if normal:
816 results.append(normal)
818 return results
820 def autocomplete_data(self, term):
821 """
822 Should return the data/query for the "matching" model records,
823 based on autocomplete search term. This is called by
824 :meth:`autocomplete()`.
826 Subclass must override this; default logic returns no data.
828 :param term: String search term as-is from user, e.g. "foo bar".
830 :returns: List of data records, or SQLAlchemy query.
831 """
833 def autocomplete_normalize(self, obj):
834 """
835 Should return a "normalized" version of the given model
836 record, suitable for autocomplete JSON results. This is
837 called by :meth:`autocomplete()`.
839 Subclass may need to override this; default logic is
840 simplistic but will work for basic models. It returns the
841 "autocomplete results" dict for the object::
843 {
844 'value': obj.uuid,
845 'label': str(obj),
846 }
848 The 2 keys shown are required; any other keys will be ignored
849 by the view logic but may be useful on the frontend widget.
851 :param obj: Model record/instance.
853 :returns: Dict of "autocomplete results" format, as shown
854 above.
855 """
856 return {
857 'value': obj.uuid,
858 'label': str(obj),
859 }
861 ##############################
862 # download methods
863 ##############################
865 def download(self):
866 """
867 View to download a file associated with a model record.
869 This usually corresponds to a URL like
870 ``/widgets/XXX/download`` where ``XXX`` represents the key/ID
871 for the record.
873 By default, this view is included only if :attr:`downloadable`
874 is true.
876 This method will (try to) locate the file on disk, and return
877 it as a file download response to the client.
879 The GET request for this view may contain a ``filename`` query
880 string parameter, which can be used to locate one of various
881 files associated with the model record. This filename is
882 passed to :meth:`download_path()` for locating the file.
884 For instance: ``/widgets/XXX/download?filename=widget-specs.txt``
886 Subclass normally should not override this method, but rather
887 one of the related methods which are called (in)directly by
888 this one:
890 * :meth:`download_path()`
891 """
892 obj = self.get_instance()
893 filename = self.request.GET.get('filename', None)
895 path = self.download_path(obj, filename)
896 if not path or not os.path.exists(path):
897 return self.notfound()
899 return self.file_response(path)
901 def download_path(self, obj, filename):
902 """
903 Should return absolute path on disk, for the given object and
904 filename. Result will be used to return a file response to
905 client. This is called by :meth:`download()`.
907 Default logic always returns ``None``; subclass must override.
909 :param obj: Refefence to the model instance.
911 :param filename: Name of file for which to retrieve the path.
913 :returns: Path to file, or ``None`` if not found.
915 Note that ``filename`` may be ``None`` in which case the "default"
916 file path should be returned, if applicable.
918 If this method returns ``None`` (as it does by default) then
919 the :meth:`download()` view will return a 404 not found
920 response.
921 """
923 ##############################
924 # execute methods
925 ##############################
927 def execute(self):
928 """
929 View to "execute" a model record. Requires a POST request.
931 This usually corresponds to a URL like
932 ``/widgets/XXX/execute`` where ``XXX`` represents the key/ID
933 for the record.
935 By default, this view is included only if :attr:`executable` is
936 true.
938 Probably this is a "rare" view to implement for a model. But
939 there are two notable use cases so far, namely:
941 * upgrades (cf. :class:`~wuttaweb.views.upgrades.UpgradeView`)
942 * batches (not yet implemented;
943 cf. :doc:`rattail-manual:data/batch/index` in Rattail
944 Manual)
946 The general idea is to take some "irrevocable" action
947 associated with the model record. In the case of upgrades, it
948 is to run the upgrade script. For batches it is to "push
949 live" the data held within the batch.
951 Subclass normally should not override this method, but rather
952 one of the related methods which are called (in)directly by
953 this one:
955 * :meth:`execute_instance()`
956 """
957 route_prefix = self.get_route_prefix()
958 model_title = self.get_model_title()
959 obj = self.get_instance()
961 # make the progress tracker
962 progress = self.make_progress(f'{route_prefix}.execute',
963 success_msg=f"{model_title} was executed.",
964 success_url=self.get_action_url('view', obj))
966 # start thread for execute; show progress page
967 key = self.request.matchdict
968 thread = threading.Thread(target=self.execute_thread,
969 args=(key, self.request.user.uuid),
970 kwargs={'progress': progress})
971 thread.start()
972 return self.render_progress(progress, context={
973 'instance': obj,
974 }, template=self.execute_progress_template)
976 def execute_instance(self, obj, user, progress=None):
977 """
978 Perform the actual "execution" logic for a model record.
979 Called by :meth:`execute()`.
981 This method does nothing by default; subclass must override.
983 :param obj: Reference to the model instance.
985 :param user: Reference to the
986 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
987 is doing the execute.
989 :param progress: Optional progress indicator factory.
990 """
992 def execute_thread(self, key, user_uuid, progress=None):
993 """ """
994 model = self.app.model
995 model_title = self.get_model_title()
997 # nb. use new session, separate from web transaction
998 session = self.app.make_session()
1000 # fetch model instance and user for this session
1001 obj = self.get_instance(session=session, matchdict=key)
1002 user = session.get(model.User, user_uuid)
1004 try:
1005 self.execute_instance(obj, user, progress=progress)
1007 except Exception as error:
1008 session.rollback()
1009 log.warning("%s failed to execute: %s", model_title, obj, exc_info=True)
1010 if progress:
1011 progress.handle_error(error)
1013 else:
1014 session.commit()
1015 if progress:
1016 progress.handle_success()
1018 finally:
1019 session.close()
1021 ##############################
1022 # configure methods
1023 ##############################
1025 def configure(self, session=None):
1026 """
1027 View for configuring aspects of the app which are pertinent to
1028 this master view and/or model.
1030 By default, this view is included only if :attr:`configurable`
1031 is true. It usually maps to a URL like ``/widgets/configure``.
1033 The expected workflow is as follows:
1035 * user navigates to Configure page
1036 * user modifies settings and clicks Save
1037 * this view then *deletes* all "known" settings
1038 * then it saves user-submitted settings
1040 That is unless ``remove_settings`` is requested, in which case
1041 settings are deleted but then none are saved. The "known"
1042 settings by default include only the "simple" settings.
1044 As a general rule, a particular setting should be configurable
1045 by (at most) one master view. Some settings may never be
1046 exposed at all. But when exposing a setting, careful thought
1047 should be given to where it logically/best belongs.
1049 Some settings are "simple" and a master view subclass need
1050 only provide their basic definitions via
1051 :meth:`configure_get_simple_settings()`. If complex settings
1052 are needed, subclass must override one or more other methods
1053 to achieve the aim(s).
1055 See also related methods, used by this one:
1057 * :meth:`configure_get_simple_settings()`
1058 * :meth:`configure_get_context()`
1059 * :meth:`configure_gather_settings()`
1060 * :meth:`configure_remove_settings()`
1061 * :meth:`configure_save_settings()`
1062 """
1063 self.configuring = True
1064 config_title = self.get_config_title()
1066 # was form submitted?
1067 if self.request.method == 'POST':
1069 # maybe just remove settings
1070 if self.request.POST.get('remove_settings'):
1071 self.configure_remove_settings(session=session)
1072 self.request.session.flash(f"All settings for {config_title} have been removed.",
1073 'warning')
1075 # reload configure page
1076 return self.redirect(self.request.current_route_url())
1078 # gather/save settings
1079 data = get_form_data(self.request)
1080 settings = self.configure_gather_settings(data)
1081 self.configure_remove_settings(session=session)
1082 self.configure_save_settings(settings, session=session)
1083 self.request.session.flash("Settings have been saved.")
1085 # reload configure page
1086 return self.redirect(self.request.current_route_url())
1088 # render configure page
1089 context = self.configure_get_context()
1090 return self.render_to_response('configure', context)
1092 def configure_get_context(
1093 self,
1094 simple_settings=None,
1095 ):
1096 """
1097 Returns the full context dict, for rendering the
1098 :meth:`configure()` page template.
1100 Default context will include ``simple_settings`` (normalized
1101 to just name/value).
1103 You may need to override this method, to add additional
1104 "complex" settings etc.
1106 :param simple_settings: Optional list of simple settings, if
1107 already initialized. Otherwise it is retrieved via
1108 :meth:`configure_get_simple_settings()`.
1110 :returns: Context dict for the page template.
1111 """
1112 context = {}
1114 # simple settings
1115 if simple_settings is None:
1116 simple_settings = self.configure_get_simple_settings()
1117 if simple_settings:
1119 # we got some, so "normalize" each definition to name/value
1120 normalized = {}
1121 for simple in simple_settings:
1123 # name
1124 name = simple['name']
1126 # value
1127 if 'value' in simple:
1128 value = simple['value']
1129 elif simple.get('type') is bool:
1130 value = self.config.get_bool(name, default=simple.get('default', False))
1131 else:
1132 value = self.config.get(name)
1134 normalized[name] = value
1136 # add to template context
1137 context['simple_settings'] = normalized
1139 return context
1141 def configure_get_simple_settings(self):
1142 """
1143 This should return a list of "simple" setting definitions for
1144 the :meth:`configure()` view, which can be handled in a more
1145 automatic way. (This is as opposed to some settings which are
1146 more complex and must be handled manually; those should not be
1147 part of this method's return value.)
1149 Basically a "simple" setting is one which can be represented
1150 by a single field/widget on the Configure page.
1152 The setting definitions returned must each be a dict of
1153 "attributes" for the setting. For instance a *very* simple
1154 setting might be::
1156 {'name': 'wutta.app_title'}
1158 The ``name`` is required, everything else is optional. Here
1159 is a more complete example::
1161 {
1162 'name': 'wutta.production',
1163 'type': bool,
1164 'default': False,
1165 'save_if_empty': False,
1166 }
1168 Note that if specified, the ``default`` should be of the same
1169 data type as defined for the setting (``bool`` in the above
1170 example). The default ``type`` is ``str``.
1172 Normally if a setting's value is effectively null, the setting
1173 is removed instead of keeping it in the DB. This behavior can
1174 be changed per-setting via the ``save_if_empty`` flag.
1176 :returns: List of setting definition dicts as described above.
1177 Note that their order does not matter since the template
1178 must explicitly define field layout etc.
1179 """
1181 def configure_gather_settings(
1182 self,
1183 data,
1184 simple_settings=None,
1185 ):
1186 """
1187 Collect the full set of "normalized" settings from user
1188 request, so that :meth:`configure()` can save them.
1190 Settings are gathered from the given request (e.g. POST)
1191 ``data``, but also taking into account what we know based on
1192 the simple setting definitions.
1194 Subclass may need to override this method if complex settings
1195 are required.
1197 :param data: Form data submitted via POST request.
1199 :param simple_settings: Optional list of simple settings, if
1200 already initialized. Otherwise it is retrieved via
1201 :meth:`configure_get_simple_settings()`.
1203 This method must return a list of normalized settings, similar
1204 in spirit to the definition syntax used in
1205 :meth:`configure_get_simple_settings()`. However the format
1206 returned here is minimal and contains just name/value::
1208 {
1209 'name': 'wutta.app_title',
1210 'value': 'Wutta Wutta',
1211 }
1213 Note that the ``value`` will always be a string.
1215 Also note, whereas it's possible ``data`` will not contain all
1216 known settings, the return value *should* (potentially)
1217 contain all of them.
1219 The one exception is when a simple setting has null value, by
1220 default it will not be included in the result (hence, not
1221 saved to DB) unless the setting definition has the
1222 ``save_if_empty`` flag set.
1223 """
1224 settings = []
1226 # simple settings
1227 if simple_settings is None:
1228 simple_settings = self.configure_get_simple_settings()
1229 if simple_settings:
1231 # we got some, so "normalize" each definition to name/value
1232 for simple in simple_settings:
1233 name = simple['name']
1235 if name in data:
1236 value = data[name]
1237 else:
1238 value = simple.get('default')
1240 if simple.get('type') is bool:
1241 value = str(bool(value)).lower()
1242 elif simple.get('type') is int:
1243 value = str(int(value or '0'))
1244 elif value is None:
1245 value = ''
1246 else:
1247 value = str(value)
1249 # only want to save this setting if we received a
1250 # value, or if empty values are okay to save
1251 if value or simple.get('save_if_empty'):
1252 settings.append({'name': name,
1253 'value': value})
1255 return settings
1257 def configure_remove_settings(
1258 self,
1259 simple_settings=None,
1260 session=None,
1261 ):
1262 """
1263 Remove all "known" settings from the DB; this is called by
1264 :meth:`configure()`.
1266 The point of this method is to ensure *all* "known" settings
1267 which are managed by this master view, are purged from the DB.
1269 The default logic can handle this automatically for simple
1270 settings; subclass must override for any complex settings.
1272 :param simple_settings: Optional list of simple settings, if
1273 already initialized. Otherwise it is retrieved via
1274 :meth:`configure_get_simple_settings()`.
1275 """
1276 names = []
1278 # simple settings
1279 if simple_settings is None:
1280 simple_settings = self.configure_get_simple_settings()
1281 if simple_settings:
1282 names.extend([simple['name']
1283 for simple in simple_settings])
1285 if names:
1286 # nb. must avoid self.Session here in case that does not
1287 # point to our primary app DB
1288 session = session or self.Session()
1289 for name in names:
1290 self.app.delete_setting(session, name)
1292 def configure_save_settings(self, settings, session=None):
1293 """
1294 Save the given settings to the DB; this is called by
1295 :meth:`configure()`.
1297 This method expects a list of name/value dicts and will simply
1298 save each to the DB, with no "conversion" logic.
1300 :param settings: List of normalized setting definitions, as
1301 returned by :meth:`configure_gather_settings()`.
1302 """
1303 # nb. must avoid self.Session here in case that does not point
1304 # to our primary app DB
1305 session = session or self.Session()
1306 for setting in settings:
1307 self.app.save_setting(session, setting['name'], setting['value'],
1308 force_create=True)
1310 ##############################
1311 # grid rendering methods
1312 ##############################
1314 def grid_render_bool(self, record, key, value):
1315 """
1316 Custom grid value renderer for "boolean" fields.
1318 This converts a bool value to "Yes" or "No" - unless the value
1319 is ``None`` in which case this renders empty string.
1320 To use this feature for your grid::
1322 grid.set_renderer('my_bool_field', self.grid_render_bool)
1323 """
1324 if value is None:
1325 return
1327 return "Yes" if value else "No"
1329 def grid_render_currency(self, record, key, value, scale=2):
1330 """
1331 Custom grid value renderer for "currency" fields.
1333 This expects float or decimal values, and will round the
1334 decimal as appropriate, and add the currency symbol.
1336 :param scale: Number of decimal digits to be displayed;
1337 default is 2 places.
1339 To use this feature for your grid::
1341 grid.set_renderer('my_currency_field', self.grid_render_currency)
1343 # you can also override scale
1344 grid.set_renderer('my_currency_field', self.grid_render_currency, scale=4)
1345 """
1347 # nb. get new value since the one provided will just be a
1348 # (json-safe) *string* if the original type was Decimal
1349 value = record[key]
1351 if value is None:
1352 return
1354 if value < 0:
1355 fmt = f"(${{:0,.{scale}f}})"
1356 return fmt.format(0 - value)
1358 fmt = f"${{:0,.{scale}f}}"
1359 return fmt.format(value)
1361 def grid_render_datetime(self, record, key, value, fmt=None):
1362 """
1363 Custom grid value renderer for
1364 :class:`~python:datetime.datetime` fields.
1366 :param fmt: Optional format string to use instead of the
1367 default: ``'%Y-%m-%d %I:%M:%S %p'``
1369 To use this feature for your grid::
1371 grid.set_renderer('my_datetime_field', self.grid_render_datetime)
1373 # you can also override format
1374 grid.set_renderer('my_datetime_field', self.grid_render_datetime,
1375 fmt='%Y-%m-%d %H:%M:%S')
1376 """
1377 # nb. get new value since the one provided will just be a
1378 # (json-safe) *string* if the original type was datetime
1379 value = record[key]
1381 if value is None:
1382 return
1384 return value.strftime(fmt or '%Y-%m-%d %I:%M:%S %p')
1386 def grid_render_enum(self, record, key, value, enum=None):
1387 """
1388 Custom grid value renderer for "enum" fields.
1390 :param enum: Enum class for the field. This should be an
1391 instance of :class:`~python:enum.Enum`.
1393 To use this feature for your grid::
1395 from enum import Enum
1397 class MyEnum(Enum):
1398 ONE = 1
1399 TWO = 2
1400 THREE = 3
1402 grid.set_renderer('my_enum_field', self.grid_render_enum, enum=MyEnum)
1403 """
1404 if enum:
1405 original = record[key]
1406 if original:
1407 return original.name
1409 return value
1411 def grid_render_notes(self, record, key, value, maxlen=100):
1412 """
1413 Custom grid value renderer for "notes" fields.
1415 If the given text ``value`` is shorter than ``maxlen``
1416 characters, it is returned as-is.
1418 But if it is longer, then it is truncated and an ellispsis is
1419 added. The resulting ``<span>`` tag is also given a ``title``
1420 attribute with the original (full) text, so that appears on
1421 mouse hover.
1423 To use this feature for your grid::
1425 grid.set_renderer('my_notes_field', self.grid_render_notes)
1427 # you can also override maxlen
1428 grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50)
1429 """
1430 if value is None:
1431 return
1433 if len(value) < maxlen:
1434 return value
1436 return HTML.tag('span', title=value, c=f"{value[:maxlen]}...")
1438 ##############################
1439 # support methods
1440 ##############################
1442 def get_class_hierarchy(self, topfirst=True):
1443 """
1444 Convenience to return a list of classes from which the current
1445 class inherits.
1447 This is a wrapper around
1448 :func:`wuttjamaican.util.get_class_hierarchy()`.
1449 """
1450 return get_class_hierarchy(self.__class__, topfirst=topfirst)
1452 def has_perm(self, name):
1453 """
1454 Shortcut to check if current user has the given permission.
1456 This will automatically add the :attr:`permission_prefix` to
1457 ``name`` before passing it on to
1458 :func:`~wuttaweb.subscribers.request.has_perm()`.
1460 For instance within the
1461 :class:`~wuttaweb.views.users.UserView` these give the same
1462 result::
1464 self.request.has_perm('users.edit')
1466 self.has_perm('edit')
1468 So this shortcut only applies to permissions defined for the
1469 current master view. The first example above must still be
1470 used to check for "foreign" permissions (i.e. any needing a
1471 different prefix).
1472 """
1473 permission_prefix = self.get_permission_prefix()
1474 return self.request.has_perm(f'{permission_prefix}.{name}')
1476 def has_any_perm(self, *names):
1477 """
1478 Shortcut to check if current user has any of the given
1479 permissions.
1481 This calls :meth:`has_perm()` until one returns ``True``. If
1482 none do, returns ``False``.
1483 """
1484 for name in names:
1485 if self.has_perm(name):
1486 return True
1487 return False
1489 def make_button(
1490 self,
1491 label,
1492 variant=None,
1493 primary=False,
1494 **kwargs,
1495 ):
1496 """
1497 Make and return a HTML ``<b-button>`` literal.
1499 :param label: Text label for the button.
1501 :param variant: This is the "Buefy type" (or "Oruga variant")
1502 for the button. Buefy and Oruga represent this differently
1503 but this logic expects the Buefy format
1504 (e.g. ``is-danger``) and *not* the Oruga format
1505 (e.g. ``danger``), despite the param name matching Oruga's
1506 terminology.
1508 :param type: This param is not advertised in the method
1509 signature, but if caller specifies ``type`` instead of
1510 ``variant`` it should work the same.
1512 :param primary: If neither ``variant`` nor ``type`` are
1513 specified, this flag may be used to automatically set the
1514 Buefy type to ``is-primary``.
1516 This is the preferred method where applicable, since it
1517 avoids the Buefy vs. Oruga confusion, and the
1518 implementation can change in the future.
1520 :param \**kwargs: All remaining kwargs are passed to the
1521 underlying ``HTML.tag()`` call, so will be rendered as
1522 attributes on the button tag.
1524 :returns: HTML literal for the button element. Will be something
1525 along the lines of:
1527 .. code-block::
1529 <b-button type="is-primary"
1530 icon-pack="fas"
1531 icon-left="hand-pointer">
1532 Click Me
1533 </b-button>
1534 """
1535 btn_kw = kwargs
1536 btn_kw.setdefault('c', label)
1537 btn_kw.setdefault('icon_pack', 'fas')
1539 if 'type' not in btn_kw:
1540 if variant:
1541 btn_kw['type'] = variant
1542 elif primary:
1543 btn_kw['type'] = 'is-primary'
1545 return HTML.tag('b-button', **btn_kw)
1547 def make_progress(self, key, **kwargs):
1548 """
1549 Create and return a
1550 :class:`~wuttaweb.progress.SessionProgress` instance, with the
1551 given key.
1553 This is normally done just before calling
1554 :meth:`render_progress()`.
1555 """
1556 return SessionProgress(self.request, key, **kwargs)
1558 def render_progress(self, progress, context=None, template=None):
1559 """
1560 Render the progress page, with given template/context.
1562 When a view method needs to start a long-running operation, it
1563 first starts a thread to do the work, and then it renders the
1564 "progress" page. As the operation continues the progress page
1565 is updated. When the operation completes (or fails) the user
1566 is redirected to the final destination.
1568 TODO: should document more about how to do this..
1570 :param progress: Progress indicator instance as returned by
1571 :meth:`make_progress()`.
1573 :returns: A :term:`response` with rendered progress page.
1574 """
1575 template = template or '/progress.mako'
1576 context = context or {}
1577 context['progress'] = progress
1578 return render_to_response(template, context, request=self.request)
1580 def render_to_response(self, template, context):
1581 """
1582 Locate and render an appropriate template, with the given
1583 context, and return a :term:`response`.
1585 The specified ``template`` should be only the "base name" for
1586 the template - e.g. ``'index'`` or ``'edit'``. This method
1587 will then try to locate a suitable template file, based on
1588 values from :meth:`get_template_prefix()` and
1589 :meth:`get_fallback_templates()`.
1591 In practice this *usually* means two different template paths
1592 will be attempted, e.g. if ``template`` is ``'edit'`` and
1593 :attr:`template_prefix` is ``'/widgets'``:
1595 * ``/widgets/edit.mako``
1596 * ``/master/edit.mako``
1598 The first template found to exist will be used for rendering.
1599 It then calls
1600 :func:`pyramid:pyramid.renderers.render_to_response()` and
1601 returns the result.
1603 :param template: Base name for the template.
1605 :param context: Data dict to be used as template context.
1607 :returns: Response object containing the rendered template.
1608 """
1609 defaults = {
1610 'master': self,
1611 'route_prefix': self.get_route_prefix(),
1612 'index_title': self.get_index_title(),
1613 'index_url': self.get_index_url(),
1614 'model_title': self.get_model_title(),
1615 'config_title': self.get_config_title(),
1616 }
1618 # merge defaults + caller-provided context
1619 defaults.update(context)
1620 context = defaults
1622 # add crud flags if we have an instance
1623 if 'instance' in context:
1624 instance = context['instance']
1625 if 'instance_title' not in context:
1626 context['instance_title'] = self.get_instance_title(instance)
1627 if 'instance_editable' not in context:
1628 context['instance_editable'] = self.is_editable(instance)
1629 if 'instance_deletable' not in context:
1630 context['instance_deletable'] = self.is_deletable(instance)
1632 # first try the template path most specific to this view
1633 template_prefix = self.get_template_prefix()
1634 mako_path = f'{template_prefix}/{template}.mako'
1635 try:
1636 return render_to_response(mako_path, context, request=self.request)
1637 except IOError:
1639 # failing that, try one or more fallback templates
1640 for fallback in self.get_fallback_templates(template):
1641 try:
1642 return render_to_response(fallback, context, request=self.request)
1643 except IOError:
1644 pass
1646 # if we made it all the way here, then we found no
1647 # templates at all, in which case re-attempt the first and
1648 # let that error raise on up
1649 return render_to_response(mako_path, context, request=self.request)
1651 def get_fallback_templates(self, template):
1652 """
1653 Returns a list of "fallback" template paths which may be
1654 attempted for rendering a view. This is used within
1655 :meth:`render_to_response()` if the "first guess" template
1656 file was not found.
1658 :param template: Base name for a template (without prefix), e.g.
1659 ``'custom'``.
1661 :returns: List of full template paths to be tried, based on
1662 the specified template. For instance if ``template`` is
1663 ``'custom'`` this will (by default) return::
1665 ['/master/custom.mako']
1666 """
1667 return [f'/master/{template}.mako']
1669 def get_index_title(self):
1670 """
1671 Returns the main index title for the master view.
1673 By default this returns the value from
1674 :meth:`get_model_title_plural()`. Subclass may override as
1675 needed.
1676 """
1677 return self.get_model_title_plural()
1679 def get_index_url(self, **kwargs):
1680 """
1681 Returns the URL for master's :meth:`index()` view.
1683 NB. this returns ``None`` if :attr:`listable` is false.
1684 """
1685 if self.listable:
1686 route_prefix = self.get_route_prefix()
1687 return self.request.route_url(route_prefix, **kwargs)
1689 def set_labels(self, obj):
1690 """
1691 Set label overrides on a form or grid, based on what is
1692 defined by the view class and its parent class(es).
1694 This is called automatically from :meth:`configure_grid()` and
1695 :meth:`configure_form()`.
1697 This calls :meth:`collect_labels()` to find everything, then
1698 it assigns the labels using one of (based on ``obj`` type):
1700 * :func:`wuttaweb.forms.base.Form.set_label()`
1701 * :func:`wuttaweb.grids.base.Grid.set_label()`
1703 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
1704 :class:`~wuttaweb.forms.base.Form` instance.
1705 """
1706 labels = self.collect_labels()
1707 for key, label in labels.items():
1708 obj.set_label(key, label)
1710 def collect_labels(self):
1711 """
1712 Collect all labels defined by the view class and/or its parents.
1714 A master view can declare labels via class-level attribute,
1715 like so::
1717 from wuttaweb.views import MasterView
1719 class WidgetView(MasterView):
1721 labels = {
1722 'id': "Widget ID",
1723 'serial_no': "Serial Number",
1724 }
1726 All such labels, defined by any class from which the master
1727 view inherits, will be returned. However if the same label
1728 key is defined by multiple classes, the "subclass" always
1729 wins.
1731 Labels defined in this way will apply to both forms and grids.
1732 See also :meth:`set_labels()`.
1734 :returns: Dict of all labels found.
1735 """
1736 labels = {}
1737 hierarchy = self.get_class_hierarchy()
1738 for cls in hierarchy:
1739 if hasattr(cls, 'labels'):
1740 labels.update(cls.labels)
1741 return labels
1743 def make_model_grid(self, session=None, **kwargs):
1744 """
1745 Create and return a :class:`~wuttaweb.grids.base.Grid`
1746 instance for use with the :meth:`index()` view.
1748 See also related methods, which are called by this one:
1750 * :meth:`get_grid_key()`
1751 * :meth:`get_grid_columns()`
1752 * :meth:`get_grid_data()`
1753 * :meth:`configure_grid()`
1754 """
1755 if 'key' not in kwargs:
1756 kwargs['key'] = self.get_grid_key()
1758 if 'model_class' not in kwargs:
1759 model_class = self.get_model_class()
1760 if model_class:
1761 kwargs['model_class'] = model_class
1763 if 'columns' not in kwargs:
1764 kwargs['columns'] = self.get_grid_columns()
1766 if 'data' not in kwargs:
1767 kwargs['data'] = self.get_grid_data(columns=kwargs['columns'],
1768 session=session)
1770 if 'actions' not in kwargs:
1771 actions = []
1773 # TODO: should split this off into index_get_grid_actions() ?
1775 if self.viewable and self.has_perm('view'):
1776 actions.append(self.make_grid_action('view', icon='eye',
1777 url=self.get_action_url_view))
1779 if self.editable and self.has_perm('edit'):
1780 actions.append(self.make_grid_action('edit', icon='edit',
1781 url=self.get_action_url_edit))
1783 if self.deletable and self.has_perm('delete'):
1784 actions.append(self.make_grid_action('delete', icon='trash',
1785 url=self.get_action_url_delete,
1786 link_class='has-text-danger'))
1788 kwargs['actions'] = actions
1790 if 'tools' not in kwargs:
1791 tools = []
1793 if self.deletable_bulk and self.has_perm('delete_bulk'):
1794 tools.append(('delete-results', self.delete_bulk_make_button()))
1796 kwargs['tools'] = tools
1798 if hasattr(self, 'grid_row_class'):
1799 kwargs.setdefault('row_class', self.grid_row_class)
1800 kwargs.setdefault('filterable', self.filterable)
1801 kwargs.setdefault('filter_defaults', self.filter_defaults)
1802 kwargs.setdefault('sortable', self.sortable)
1803 kwargs.setdefault('sort_multiple', not self.request.use_oruga)
1804 kwargs.setdefault('sort_on_backend', self.sort_on_backend)
1805 kwargs.setdefault('sort_defaults', self.sort_defaults)
1806 kwargs.setdefault('paginated', self.paginated)
1807 kwargs.setdefault('paginate_on_backend', self.paginate_on_backend)
1809 grid = self.make_grid(**kwargs)
1810 self.configure_grid(grid)
1811 grid.load_settings()
1812 return grid
1814 def get_grid_columns(self):
1815 """
1816 Returns the default list of grid column names, for the
1817 :meth:`index()` view.
1819 This is called by :meth:`make_model_grid()`; in the resulting
1820 :class:`~wuttaweb.grids.base.Grid` instance, this becomes
1821 :attr:`~wuttaweb.grids.base.Grid.columns`.
1823 This method may return ``None``, in which case the grid may
1824 (try to) generate its own default list.
1826 Subclass may define :attr:`grid_columns` for simple cases, or
1827 can override this method if needed.
1829 Also note that :meth:`configure_grid()` may be used to further
1830 modify the final column set, regardless of what this method
1831 returns. So a common pattern is to declare all "supported"
1832 columns by setting :attr:`grid_columns` but then optionally
1833 remove or replace some of those within
1834 :meth:`configure_grid()`.
1835 """
1836 if hasattr(self, 'grid_columns'):
1837 return self.grid_columns
1839 def get_grid_data(self, columns=None, session=None):
1840 """
1841 Returns the grid data for the :meth:`index()` view.
1843 This is called by :meth:`make_model_grid()`; in the resulting
1844 :class:`~wuttaweb.grids.base.Grid` instance, this becomes
1845 :attr:`~wuttaweb.grids.base.Grid.data`.
1847 Default logic will call :meth:`get_query()` and if successful,
1848 return the list from ``query.all()``. Otherwise returns an
1849 empty list. Subclass should override as needed.
1850 """
1851 query = self.get_query(session=session)
1852 if query:
1853 return query
1854 return []
1856 def get_query(self, session=None):
1857 """
1858 Returns the main SQLAlchemy query object for the
1859 :meth:`index()` view. This is called by
1860 :meth:`get_grid_data()`.
1862 Default logic for this method returns a "plain" query on the
1863 :attr:`model_class` if that is defined; otherwise ``None``.
1864 """
1865 model_class = self.get_model_class()
1866 if model_class:
1867 session = session or self.Session()
1868 return session.query(model_class)
1870 def configure_grid(self, grid):
1871 """
1872 Configure the grid for the :meth:`index()` view.
1874 This is called by :meth:`make_model_grid()`.
1876 There is no default logic here; subclass should override as
1877 needed. The ``grid`` param will already be "complete" and
1878 ready to use as-is, but this method can further modify it
1879 based on request details etc.
1880 """
1881 if 'uuid' in grid.columns:
1882 grid.columns.remove('uuid')
1884 self.set_labels(grid)
1886 # TODO: i thought this was a good idea but if so it
1887 # needs a try/catch in case of no model class
1888 # for key in self.get_model_key():
1889 # grid.set_link(key)
1891 def get_instance(self, session=None, matchdict=None):
1892 """
1893 This should return the appropriate model instance, based on
1894 the ``matchdict`` of model keys.
1896 Normally this is called with no arguments, in which case the
1897 :attr:`pyramid:pyramid.request.Request.matchdict` is used, and
1898 will return the "current" model instance based on the request
1899 (route/params).
1901 If a ``matchdict`` is provided then that is used instead, to
1902 obtain the model keys. In the simple/common example of a
1903 "native" model in WuttaWeb, this would look like::
1905 keys = {'uuid': '38905440630d11ef9228743af49773a4'}
1906 obj = self.get_instance(matchdict=keys)
1908 Although some models may have different, possibly composite
1909 key names to use instead. The specific keys this logic is
1910 expecting are the same as returned by :meth:`get_model_key()`.
1912 If this method is unable to locate the instance, it should
1913 raise a 404 error,
1914 i.e. :meth:`~wuttaweb.views.base.View.notfound()`.
1916 Default implementation of this method should work okay for
1917 views which define a :attr:`model_class`. For other views
1918 however it will raise ``NotImplementedError``, so subclass
1919 may need to define.
1921 .. warning::
1923 If you are defining this method for a subclass, please note
1924 this point regarding the 404 "not found" logic.
1926 It is *not* enough to simply *return* this 404 response,
1927 you must explicitly *raise* the error. For instance::
1929 def get_instance(self, **kwargs):
1931 # ..try to locate instance..
1932 obj = self.locate_instance_somehow()
1934 if not obj:
1936 # NB. THIS MAY NOT WORK AS EXPECTED
1937 #return self.notfound()
1939 # nb. should always do this in get_instance()
1940 raise self.notfound()
1942 This lets calling code not have to worry about whether or
1943 not this method might return ``None``. It can safely
1944 assume it will get back a model instance, or else a 404
1945 will kick in and control flow goes elsewhere.
1946 """
1947 model_class = self.get_model_class()
1948 if model_class:
1949 session = session or self.Session()
1950 matchdict = matchdict or self.request.matchdict
1952 def filtr(query, model_key):
1953 key = matchdict[model_key]
1954 query = query.filter(getattr(self.model_class, model_key) == key)
1955 return query
1957 query = session.query(model_class)
1959 for key in self.get_model_key():
1960 query = filtr(query, key)
1962 try:
1963 return query.one()
1964 except orm.exc.NoResultFound:
1965 pass
1967 raise self.notfound()
1969 raise NotImplementedError("you must define get_instance() method "
1970 f" for view class: {self.__class__}")
1972 def get_instance_title(self, instance):
1973 """
1974 Return the human-friendly "title" for the instance, to be used
1975 in the page title when viewing etc.
1977 Default logic returns the value from ``str(instance)``;
1978 subclass may override if needed.
1979 """
1980 return str(instance)
1982 def get_action_url(self, action, obj, **kwargs):
1983 """
1984 Generate an "action" URL for the given model instance.
1986 This is a shortcut which generates a route name based on
1987 :meth:`get_route_prefix()` and the ``action`` param.
1989 It returns the URL based on generated route name and object's
1990 model key values.
1992 :param action: String name for the action, which corresponds
1993 to part of some named route, e.g. ``'view'`` or ``'edit'``.
1995 :param obj: Model instance object.
1996 """
1997 route_prefix = self.get_route_prefix()
1998 kw = dict([(key, obj[key])
1999 for key in self.get_model_key()])
2000 kw.update(kwargs)
2001 return self.request.route_url(f'{route_prefix}.{action}', **kw)
2003 def get_action_url_view(self, obj, i):
2004 """
2005 Returns the "view" grid action URL for the given object.
2007 Most typically this is like ``/widgets/XXX`` where ``XXX``
2008 represents the object's key/ID.
2010 Calls :meth:`get_action_url()` under the hood.
2011 """
2012 return self.get_action_url('view', obj)
2014 def get_action_url_edit(self, obj, i):
2015 """
2016 Returns the "edit" grid action URL for the given object, if
2017 applicable.
2019 Most typically this is like ``/widgets/XXX/edit`` where
2020 ``XXX`` represents the object's key/ID.
2022 This first calls :meth:`is_editable()` and if that is false,
2023 this method will return ``None``.
2025 Calls :meth:`get_action_url()` to generate the true URL.
2026 """
2027 if self.is_editable(obj):
2028 return self.get_action_url('edit', obj)
2030 def get_action_url_delete(self, obj, i):
2031 """
2032 Returns the "delete" grid action URL for the given object, if
2033 applicable.
2035 Most typically this is like ``/widgets/XXX/delete`` where
2036 ``XXX`` represents the object's key/ID.
2038 This first calls :meth:`is_deletable()` and if that is false,
2039 this method will return ``None``.
2041 Calls :meth:`get_action_url()` to generate the true URL.
2042 """
2043 if self.is_deletable(obj):
2044 return self.get_action_url('delete', obj)
2046 def is_editable(self, obj):
2047 """
2048 Returns a boolean indicating whether "edit" should be allowed
2049 for the given model instance (and for current user).
2051 By default this always return ``True``; subclass can override
2052 if needed.
2054 Note that the use of this method implies :attr:`editable` is
2055 true, so the method does not need to check that flag.
2056 """
2057 return True
2059 def is_deletable(self, obj):
2060 """
2061 Returns a boolean indicating whether "delete" should be
2062 allowed for the given model instance (and for current user).
2064 By default this always return ``True``; subclass can override
2065 if needed.
2067 Note that the use of this method implies :attr:`deletable` is
2068 true, so the method does not need to check that flag.
2069 """
2070 return True
2072 def make_model_form(self, model_instance=None, **kwargs):
2073 """
2074 Create and return a :class:`~wuttaweb.forms.base.Form`
2075 for the view model.
2077 Note that this method is called for multiple "CRUD" views,
2078 e.g.:
2080 * :meth:`view()`
2081 * :meth:`edit()`
2083 See also related methods, which are called by this one:
2085 * :meth:`get_form_fields()`
2086 * :meth:`configure_form()`
2087 """
2088 if 'model_class' not in kwargs:
2089 model_class = self.get_model_class()
2090 if model_class:
2091 kwargs['model_class'] = model_class
2093 kwargs['model_instance'] = model_instance
2095 if not kwargs.get('fields'):
2096 fields = self.get_form_fields()
2097 if fields:
2098 kwargs['fields'] = fields
2100 form = self.make_form(**kwargs)
2101 self.configure_form(form)
2102 return form
2104 def get_form_fields(self):
2105 """
2106 Returns the initial list of field names for the model form.
2108 This is called by :meth:`make_model_form()`; in the resulting
2109 :class:`~wuttaweb.forms.base.Form` instance, this becomes
2110 :attr:`~wuttaweb.forms.base.Form.fields`.
2112 This method may return ``None``, in which case the form may
2113 (try to) generate its own default list.
2115 Subclass may define :attr:`form_fields` for simple cases, or
2116 can override this method if needed.
2118 Note that :meth:`configure_form()` may be used to further
2119 modify the final field list, regardless of what this method
2120 returns. So a common pattern is to declare all "supported"
2121 fields by setting :attr:`form_fields` but then optionally
2122 remove or replace some in :meth:`configure_form()`.
2123 """
2124 if hasattr(self, 'form_fields'):
2125 return self.form_fields
2127 def configure_form(self, form):
2128 """
2129 Configure the given model form, as needed.
2131 This is called by :meth:`make_model_form()` - for multiple
2132 CRUD views (create, view, edit, delete, possibly others).
2134 The default logic here does just one thing: when "editing"
2135 (i.e. in :meth:`edit()` view) then all fields which are part
2136 of the :attr:`model_key` will be marked via
2137 :meth:`set_readonly()` so the user cannot change primary key
2138 values for a record.
2140 Subclass may override as needed. The ``form`` param will
2141 already be "complete" and ready to use as-is, but this method
2142 can further modify it based on request details etc.
2143 """
2144 form.remove('uuid')
2146 self.set_labels(form)
2148 if self.editing:
2149 for key in self.get_model_key():
2150 form.set_readonly(key)
2152 def objectify(self, form):
2153 """
2154 Must return a "model instance" object which reflects the
2155 validated form data.
2157 In simple cases this may just return the
2158 :attr:`~wuttaweb.forms.base.Form.validated` data dict.
2160 When dealing with SQLAlchemy models it would return a proper
2161 mapped instance, creating it if necessary.
2163 :param form: Reference to the *already validated*
2164 :class:`~wuttaweb.forms.base.Form` object. See the form's
2165 :attr:`~wuttaweb.forms.base.Form.validated` attribute for
2166 the data.
2168 See also :meth:`edit_save_form()` which calls this method.
2169 """
2171 # use ColanderAlchemy magic if possible
2172 schema = form.get_schema()
2173 if hasattr(schema, 'objectify'):
2174 # this returns a model instance
2175 return schema.objectify(form.validated,
2176 context=form.model_instance)
2178 # otherwise return data dict as-is
2179 return form.validated
2181 def persist(self, obj, session=None):
2182 """
2183 If applicable, this method should persist ("save") the given
2184 object's data (e.g. to DB), creating or updating it as needed.
2186 This is part of the "submit form" workflow; ``obj`` should be
2187 a model instance which already reflects the validated form
2188 data.
2190 Note that there is no default logic here, subclass must
2191 override if needed.
2193 :param obj: Model instance object as produced by
2194 :meth:`objectify()`.
2196 See also :meth:`edit_save_form()` which calls this method.
2197 """
2198 model = self.app.model
2199 model_class = self.get_model_class()
2200 if model_class and issubclass(model_class, model.Base):
2202 # add sqlalchemy model to session
2203 session = session or self.Session()
2204 session.add(obj)
2206 ##############################
2207 # class methods
2208 ##############################
2210 @classmethod
2211 def get_model_class(cls):
2212 """
2213 Returns the model class for the view (if defined).
2215 A model class will *usually* be a SQLAlchemy mapped class,
2216 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
2218 There is no default value here, but a subclass may override by
2219 assigning :attr:`model_class`.
2221 Note that the model class is not *required* - however if you
2222 do not set the :attr:`model_class`, then you *must* set the
2223 :attr:`model_name`.
2224 """
2225 if hasattr(cls, 'model_class'):
2226 return cls.model_class
2228 @classmethod
2229 def get_model_name(cls):
2230 """
2231 Returns the model name for the view.
2233 A model name should generally be in the format of a Python
2234 class name, e.g. ``'WuttaWidget'``. (Note this is
2235 *singular*, not plural.)
2237 The default logic will call :meth:`get_model_class()` and
2238 return that class name as-is. A subclass may override by
2239 assigning :attr:`model_name`.
2240 """
2241 if hasattr(cls, 'model_name'):
2242 return cls.model_name
2244 return cls.get_model_class().__name__
2246 @classmethod
2247 def get_model_name_normalized(cls):
2248 """
2249 Returns the "normalized" model name for the view.
2251 A normalized model name should generally be in the format of a
2252 Python variable name, e.g. ``'wutta_widget'``. (Note this is
2253 *singular*, not plural.)
2255 The default logic will call :meth:`get_model_name()` and
2256 simply lower-case the result. A subclass may override by
2257 assigning :attr:`model_name_normalized`.
2258 """
2259 if hasattr(cls, 'model_name_normalized'):
2260 return cls.model_name_normalized
2262 return cls.get_model_name().lower()
2264 @classmethod
2265 def get_model_title(cls):
2266 """
2267 Returns the "humanized" (singular) model title for the view.
2269 The model title will be displayed to the user, so should have
2270 proper grammar and capitalization, e.g. ``"Wutta Widget"``.
2271 (Note this is *singular*, not plural.)
2273 The default logic will call :meth:`get_model_name()` and use
2274 the result as-is. A subclass may override by assigning
2275 :attr:`model_title`.
2276 """
2277 if hasattr(cls, 'model_title'):
2278 return cls.model_title
2280 return cls.get_model_name()
2282 @classmethod
2283 def get_model_title_plural(cls):
2284 """
2285 Returns the "humanized" (plural) model title for the view.
2287 The model title will be displayed to the user, so should have
2288 proper grammar and capitalization, e.g. ``"Wutta Widgets"``.
2289 (Note this is *plural*, not singular.)
2291 The default logic will call :meth:`get_model_title()` and
2292 simply add a ``'s'`` to the end. A subclass may override by
2293 assigning :attr:`model_title_plural`.
2294 """
2295 if hasattr(cls, 'model_title_plural'):
2296 return cls.model_title_plural
2298 model_title = cls.get_model_title()
2299 return f"{model_title}s"
2301 @classmethod
2302 def get_model_key(cls):
2303 """
2304 Returns the "model key" for the master view.
2306 This should return a tuple containing one or more "field
2307 names" corresponding to the primary key for data records.
2309 In the most simple/common scenario, where the master view
2310 represents a Wutta-based SQLAlchemy model, the return value
2311 for this method is: ``('uuid',)``
2313 But there is no "sane" default for other scenarios, in which
2314 case subclass should define :attr:`model_key`. If the model
2315 key cannot be determined, raises ``AttributeError``.
2317 :returns: Tuple of field names comprising the model key.
2318 """
2319 if hasattr(cls, 'model_key'):
2320 keys = cls.model_key
2321 if isinstance(keys, str):
2322 keys = [keys]
2323 return tuple(keys)
2325 model_class = cls.get_model_class()
2326 if model_class:
2327 mapper = sa.inspect(model_class)
2328 return tuple([column.key for column in mapper.primary_key])
2330 raise AttributeError(f"you must define model_key for view class: {cls}")
2332 @classmethod
2333 def get_route_prefix(cls):
2334 """
2335 Returns the "route prefix" for the master view. This prefix
2336 is used for all named routes defined by the view class.
2338 For instance if route prefix is ``'widgets'`` then a view
2339 might have these routes:
2341 * ``'widgets'``
2342 * ``'widgets.create'``
2343 * ``'widgets.edit'``
2344 * ``'widgets.delete'``
2346 The default logic will call
2347 :meth:`get_model_name_normalized()` and simply add an ``'s'``
2348 to the end, making it plural. A subclass may override by
2349 assigning :attr:`route_prefix`.
2350 """
2351 if hasattr(cls, 'route_prefix'):
2352 return cls.route_prefix
2354 model_name = cls.get_model_name_normalized()
2355 return f'{model_name}s'
2357 @classmethod
2358 def get_permission_prefix(cls):
2359 """
2360 Returns the "permission prefix" for the master view. This
2361 prefix is used for all permissions defined by the view class.
2363 For instance if permission prefix is ``'widgets'`` then a view
2364 might have these permissions:
2366 * ``'widgets.list'``
2367 * ``'widgets.create'``
2368 * ``'widgets.edit'``
2369 * ``'widgets.delete'``
2371 The default logic will call :meth:`get_route_prefix()` and use
2372 that value as-is. A subclass may override by assigning
2373 :attr:`permission_prefix`.
2374 """
2375 if hasattr(cls, 'permission_prefix'):
2376 return cls.permission_prefix
2378 return cls.get_route_prefix()
2380 @classmethod
2381 def get_url_prefix(cls):
2382 """
2383 Returns the "URL prefix" for the master view. This prefix is
2384 used for all URLs defined by the view class.
2386 Using the same example as in :meth:`get_route_prefix()`, the
2387 URL prefix would be ``'/widgets'`` and the view would have
2388 defined routes for these URLs:
2390 * ``/widgets/``
2391 * ``/widgets/new``
2392 * ``/widgets/XXX/edit``
2393 * ``/widgets/XXX/delete``
2395 The default logic will call :meth:`get_route_prefix()` and
2396 simply add a ``'/'`` to the beginning. A subclass may
2397 override by assigning :attr:`url_prefix`.
2398 """
2399 if hasattr(cls, 'url_prefix'):
2400 return cls.url_prefix
2402 route_prefix = cls.get_route_prefix()
2403 return f'/{route_prefix}'
2405 @classmethod
2406 def get_instance_url_prefix(cls):
2407 """
2408 Generate the URL prefix specific to an instance for this model
2409 view. This will include model key param placeholders; it
2410 winds up looking like:
2412 * ``/widgets/{uuid}``
2413 * ``/resources/{foo}|{bar}|{baz}``
2415 The former being the most simple/common, and the latter
2416 showing what a "composite" model key looks like, with pipe
2417 symbols separating the key parts.
2418 """
2419 prefix = cls.get_url_prefix() + '/'
2420 for i, key in enumerate(cls.get_model_key()):
2421 if i:
2422 prefix += '|'
2423 prefix += f'{{{key}}}'
2424 return prefix
2426 @classmethod
2427 def get_template_prefix(cls):
2428 """
2429 Returns the "template prefix" for the master view. This
2430 prefix is used to guess which template path to render for a
2431 given view.
2433 Using the same example as in :meth:`get_url_prefix()`, the
2434 template prefix would also be ``'/widgets'`` and the templates
2435 assumed for those routes would be:
2437 * ``/widgets/index.mako``
2438 * ``/widgets/create.mako``
2439 * ``/widgets/edit.mako``
2440 * ``/widgets/delete.mako``
2442 The default logic will call :meth:`get_url_prefix()` and
2443 return that value as-is. A subclass may override by assigning
2444 :attr:`template_prefix`.
2445 """
2446 if hasattr(cls, 'template_prefix'):
2447 return cls.template_prefix
2449 return cls.get_url_prefix()
2451 @classmethod
2452 def get_grid_key(cls):
2453 """
2454 Returns the (presumably) unique key to be used for the primary
2455 grid in the :meth:`index()` view. This key may also be used
2456 as the basis (key prefix) for secondary grids.
2458 This is called from :meth:`make_model_grid()`; in the
2459 resulting :class:`~wuttaweb.grids.base.Grid` instance, this
2460 becomes :attr:`~wuttaweb.grids.base.Grid.key`.
2462 The default logic for this method will call
2463 :meth:`get_route_prefix()` and return that value as-is. A
2464 subclass may override by assigning :attr:`grid_key`.
2465 """
2466 if hasattr(cls, 'grid_key'):
2467 return cls.grid_key
2469 return cls.get_route_prefix()
2471 @classmethod
2472 def get_config_title(cls):
2473 """
2474 Returns the "config title" for the view/model.
2476 The config title is used for page title in the
2477 :meth:`configure()` view, as well as links to it. It is
2478 usually plural, e.g. ``"Wutta Widgets"`` in which case that
2479 winds up being displayed in the web app as: **Configure Wutta
2480 Widgets**
2482 The default logic will call :meth:`get_model_title_plural()`
2483 and return that as-is. A subclass may override by assigning
2484 :attr:`config_title`.
2485 """
2486 if hasattr(cls, 'config_title'):
2487 return cls.config_title
2489 return cls.get_model_title_plural()
2491 ##############################
2492 # configuration
2493 ##############################
2495 @classmethod
2496 def defaults(cls, config):
2497 """
2498 Provide default Pyramid configuration for a master view.
2500 This is generally called from within the module's
2501 ``includeme()`` function, e.g.::
2503 from wuttaweb.views import MasterView
2505 class WidgetView(MasterView):
2506 model_name = 'Widget'
2508 def includeme(config):
2509 WidgetView.defaults(config)
2511 :param config: Reference to the app's
2512 :class:`pyramid:pyramid.config.Configurator` instance.
2513 """
2514 cls._defaults(config)
2516 @classmethod
2517 def _defaults(cls, config):
2518 route_prefix = cls.get_route_prefix()
2519 permission_prefix = cls.get_permission_prefix()
2520 url_prefix = cls.get_url_prefix()
2521 model_title = cls.get_model_title()
2522 model_title_plural = cls.get_model_title_plural()
2524 # permission group
2525 config.add_wutta_permission_group(permission_prefix,
2526 model_title_plural,
2527 overwrite=False)
2529 # index
2530 if cls.listable:
2531 config.add_route(route_prefix, f'{url_prefix}/')
2532 config.add_view(cls, attr='index',
2533 route_name=route_prefix,
2534 permission=f'{permission_prefix}.list')
2535 config.add_wutta_permission(permission_prefix,
2536 f'{permission_prefix}.list',
2537 f"Browse / search {model_title_plural}")
2539 # create
2540 if cls.creatable:
2541 config.add_route(f'{route_prefix}.create',
2542 f'{url_prefix}/new')
2543 config.add_view(cls, attr='create',
2544 route_name=f'{route_prefix}.create',
2545 permission=f'{permission_prefix}.create')
2546 config.add_wutta_permission(permission_prefix,
2547 f'{permission_prefix}.create',
2548 f"Create new {model_title}")
2550 # edit
2551 if cls.editable:
2552 instance_url_prefix = cls.get_instance_url_prefix()
2553 config.add_route(f'{route_prefix}.edit',
2554 f'{instance_url_prefix}/edit')
2555 config.add_view(cls, attr='edit',
2556 route_name=f'{route_prefix}.edit',
2557 permission=f'{permission_prefix}.edit')
2558 config.add_wutta_permission(permission_prefix,
2559 f'{permission_prefix}.edit',
2560 f"Edit {model_title}")
2562 # delete
2563 if cls.deletable:
2564 instance_url_prefix = cls.get_instance_url_prefix()
2565 config.add_route(f'{route_prefix}.delete',
2566 f'{instance_url_prefix}/delete')
2567 config.add_view(cls, attr='delete',
2568 route_name=f'{route_prefix}.delete',
2569 permission=f'{permission_prefix}.delete')
2570 config.add_wutta_permission(permission_prefix,
2571 f'{permission_prefix}.delete',
2572 f"Delete {model_title}")
2574 # bulk delete
2575 if cls.deletable_bulk:
2576 config.add_route(f'{route_prefix}.delete_bulk',
2577 f'{url_prefix}/delete-bulk',
2578 request_method='POST')
2579 config.add_view(cls, attr='delete_bulk',
2580 route_name=f'{route_prefix}.delete_bulk',
2581 permission=f'{permission_prefix}.delete_bulk')
2582 config.add_wutta_permission(permission_prefix,
2583 f'{permission_prefix}.delete_bulk',
2584 f"Delete {model_title_plural} in bulk")
2586 # autocomplete
2587 if cls.has_autocomplete:
2588 config.add_route(f'{route_prefix}.autocomplete',
2589 f'{url_prefix}/autocomplete')
2590 config.add_view(cls, attr='autocomplete',
2591 route_name=f'{route_prefix}.autocomplete',
2592 renderer='json',
2593 permission=f'{route_prefix}.list')
2595 # download
2596 if cls.downloadable:
2597 config.add_route(f'{route_prefix}.download',
2598 f'{instance_url_prefix}/download')
2599 config.add_view(cls, attr='download',
2600 route_name=f'{route_prefix}.download',
2601 permission=f'{permission_prefix}.download')
2602 config.add_wutta_permission(permission_prefix,
2603 f'{permission_prefix}.download',
2604 f"Download file(s) for {model_title}")
2606 # execute
2607 if cls.executable:
2608 config.add_route(f'{route_prefix}.execute',
2609 f'{instance_url_prefix}/execute',
2610 request_method='POST')
2611 config.add_view(cls, attr='execute',
2612 route_name=f'{route_prefix}.execute',
2613 permission=f'{permission_prefix}.execute')
2614 config.add_wutta_permission(permission_prefix,
2615 f'{permission_prefix}.execute',
2616 f"Execute {model_title}")
2618 # configure
2619 if cls.configurable:
2620 config.add_route(f'{route_prefix}.configure',
2621 f'{url_prefix}/configure')
2622 config.add_view(cls, attr='configure',
2623 route_name=f'{route_prefix}.configure',
2624 permission=f'{permission_prefix}.configure')
2625 config.add_wutta_permission(permission_prefix,
2626 f'{permission_prefix}.configure',
2627 f"Configure {model_title_plural}")
2629 # view
2630 # nb. always register this one last, so it does not take
2631 # priority over model-wide action routes, e.g. delete_bulk
2632 if cls.viewable:
2633 instance_url_prefix = cls.get_instance_url_prefix()
2634 config.add_route(f'{route_prefix}.view', instance_url_prefix)
2635 config.add_view(cls, attr='view',
2636 route_name=f'{route_prefix}.view',
2637 permission=f'{permission_prefix}.view')
2638 config.add_wutta_permission(permission_prefix,
2639 f'{permission_prefix}.view',
2640 f"View {model_title}")