Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/views/master.py: 100%
729 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-14 13:23 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-14 13:23 -0600
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 :term:`data model` class. While not
77 strictly required, most views will set this to a SQLAlchemy
78 mapped class,
79 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
81 The base logic 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``.
344 **ROW FEATURES**
346 .. attribute:: has_rows
348 Whether the model has "rows" which should also be displayed
349 when viewing model records.
351 This the "master switch" for all row features; if this is turned
352 on then many other things kick in.
354 See also :attr:`row_model_class`.
356 .. attribute:: row_model_class
358 Reference to a :term:`data model` class for the rows.
360 The base logic should not access this directly but instead call
361 :meth:`get_row_model_class()`.
363 .. attribute:: rows_title
365 Display title for the rows grid.
367 The base logic should not access this directly but instead call
368 :meth:`get_rows_title()`.
370 .. attribute:: row_grid_columns
372 List of columns for the row grid.
374 This is optional; see also :meth:`get_row_grid_columns()`.
376 This is optional; see also :meth:`get_row_grid_columns()`.
378 .. attribute:: rows_viewable
380 Boolean indicating whether the row model supports "viewing" -
381 i.e. it should have a "View" action in the row grid.
383 (For now) If you enable this, you must also override
384 :meth:`get_row_action_url_view()`.
386 .. note::
387 This eventually will cause there to be a ``row_view`` route
388 to be configured as well.
389 """
391 ##############################
392 # attributes
393 ##############################
395 # features
396 listable = True
397 has_grid = True
398 filterable = True
399 filter_defaults = None
400 sortable = True
401 sort_on_backend = True
402 sort_defaults = None
403 paginated = True
404 paginate_on_backend = True
405 creatable = True
406 viewable = True
407 editable = True
408 deletable = True
409 deletable_bulk = False
410 deletable_bulk_quick = False
411 has_autocomplete = False
412 downloadable = False
413 executable = False
414 execute_progress_template = None
415 configurable = False
417 # row features
418 has_rows = False
419 rows_filterable = True
420 rows_filter_defaults = None
421 rows_sortable = True
422 rows_sort_on_backend = True
423 rows_sort_defaults = None
424 rows_paginated = True
425 rows_paginate_on_backend = True
426 rows_viewable = False
428 # current action
429 listing = False
430 creating = False
431 viewing = False
432 editing = False
433 deleting = False
434 configuring = False
436 # default DB session
437 Session = Session
439 ##############################
440 # index methods
441 ##############################
443 def index(self):
444 """
445 View to "list" (filter/browse) the model data.
447 This is the "default" view for the model and is what user sees
448 when visiting the "root" path under the :attr:`url_prefix`,
449 e.g. ``/widgets/``.
451 By default, this view is included only if :attr:`listable` is
452 true.
454 The default view logic will show a "grid" (table) with the
455 model data (unless :attr:`has_grid` is false).
457 See also related methods, which are called by this one:
459 * :meth:`make_model_grid()`
460 """
461 self.listing = True
463 context = {
464 'index_url': None, # nb. avoid title link since this *is* the index
465 }
467 if self.has_grid:
468 grid = self.make_model_grid()
470 # handle "full" vs. "partial" differently
471 if self.request.GET.get('partial'):
473 # so-called 'partial' requests get just data, no html
474 context = grid.get_vue_context()
475 if grid.paginated and grid.paginate_on_backend:
476 context['pager_stats'] = grid.get_vue_pager_stats()
477 return self.json_response(context)
479 else: # full, not partial
481 # nb. when user asks to reset view, it is via the query
482 # string. if so we then redirect to discard that.
483 if self.request.GET.get('reset-view'):
485 # nb. we want to preserve url hash if applicable
486 kw = {'_query': None,
487 '_anchor': self.request.GET.get('hash')}
488 return self.redirect(self.request.current_route_url(**kw))
490 context['grid'] = grid
492 return self.render_to_response('index', context)
494 ##############################
495 # create methods
496 ##############################
498 def create(self):
499 """
500 View to "create" a new model record.
502 This usually corresponds to a URL like ``/widgets/new``.
504 By default, this view is included only if :attr:`creatable` is
505 true.
507 The default "create" view logic will show a form with field
508 widgets, allowing user to submit new values which are then
509 persisted to the DB (assuming typical SQLAlchemy model).
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 * :meth:`create_save_form()`
518 * :meth:`redirect_after_create()`
519 """
520 self.creating = True
521 form = self.make_model_form(cancel_url_fallback=self.get_index_url())
523 if form.validate():
524 obj = self.create_save_form(form)
525 self.Session.flush()
526 return self.redirect_after_create(obj)
528 context = {
529 'form': form,
530 }
531 return self.render_to_response('create', context)
533 def create_save_form(self, form):
534 """
535 This method is responsible for "converting" the validated form
536 data to a model instance, and then "saving" the result,
537 e.g. to DB. It is called by :meth:`create()`.
539 Subclass may override this, or any of the related methods
540 called by this one:
542 * :meth:`objectify()`
543 * :meth:`persist()`
545 :returns: Should return the resulting model instance, e.g. as
546 produced by :meth:`objectify()`.
547 """
548 obj = self.objectify(form)
549 self.persist(obj)
550 return obj
552 def redirect_after_create(self, obj):
553 """
554 Usually, this returns a redirect to which we send the user,
555 after a new model record has been created. By default this
556 sends them to the "view" page for the record.
558 It is called automatically by :meth:`create()`.
559 """
560 return self.redirect(self.get_action_url('view', obj))
562 ##############################
563 # view methods
564 ##############################
566 def view(self):
567 """
568 View to "view" details of an existing model record.
570 This usually corresponds to a URL like ``/widgets/XXX``
571 where ``XXX`` represents the key/ID for the record.
573 By default, this view is included only if :attr:`viewable` is
574 true.
576 The default view logic will show a read-only form with field
577 values displayed.
579 Subclass normally should not override this method, but rather
580 one of the related methods which are called (in)directly by
581 this one:
583 * :meth:`make_model_form()`
584 * :meth:`configure_form()`
585 * :meth:`make_row_model_grid()` - if :attr:`has_rows` is true
586 """
587 self.viewing = True
588 obj = self.get_instance()
589 form = self.make_model_form(obj, readonly=True)
590 context = {
591 'instance': obj,
592 'form': form,
593 }
595 if self.has_rows:
597 # always make the grid first. note that it already knows
598 # to "reset" its params when that is requested.
599 grid = self.make_row_model_grid(obj)
601 # but if user did request a "reset" then we want to
602 # redirect so the query string gets cleared out
603 if self.request.GET.get('reset-view'):
605 # nb. we want to preserve url hash if applicable
606 kw = {'_query': None,
607 '_anchor': self.request.GET.get('hash')}
608 return self.redirect(self.request.current_route_url(**kw))
610 # so-called 'partial' requests get just the grid data
611 if self.request.params.get('partial'):
612 context = grid.get_vue_context()
613 if grid.paginated and grid.paginate_on_backend:
614 context['pager_stats'] = grid.get_vue_pager_stats()
615 return self.json_response(context)
617 context['rows_grid'] = grid
619 context['xref_buttons'] = self.get_xref_buttons(obj)
621 return self.render_to_response('view', context)
623 ##############################
624 # edit methods
625 ##############################
627 def edit(self):
628 """
629 View to "edit" details of an existing model record.
631 This usually corresponds to a URL like ``/widgets/XXX/edit``
632 where ``XXX`` represents the key/ID for the record.
634 By default, this view is included only if :attr:`editable` is
635 true.
637 The default "edit" view logic will show a form with field
638 widgets, allowing user to modify and submit new values which
639 are then persisted to the DB (assuming typical SQLAlchemy
640 model).
642 Subclass normally should not override this method, but rather
643 one of the related methods which are called (in)directly by
644 this one:
646 * :meth:`make_model_form()`
647 * :meth:`configure_form()`
648 * :meth:`edit_save_form()`
649 """
650 self.editing = True
651 instance = self.get_instance()
653 form = self.make_model_form(instance,
654 cancel_url_fallback=self.get_action_url('view', instance))
656 if form.validate():
657 self.edit_save_form(form)
658 return self.redirect(self.get_action_url('view', instance))
660 context = {
661 'instance': instance,
662 'form': form,
663 }
664 return self.render_to_response('edit', context)
666 def edit_save_form(self, form):
667 """
668 This method is responsible for "converting" the validated form
669 data to a model instance, and then "saving" the result,
670 e.g. to DB. It is called by :meth:`edit()`.
672 Subclass may override this, or any of the related methods
673 called by this one:
675 * :meth:`objectify()`
676 * :meth:`persist()`
678 :returns: Should return the resulting model instance, e.g. as
679 produced by :meth:`objectify()`.
680 """
681 obj = self.objectify(form)
682 self.persist(obj)
683 return obj
685 ##############################
686 # delete methods
687 ##############################
689 def delete(self):
690 """
691 View to delete an existing model instance.
693 This usually corresponds to a URL like ``/widgets/XXX/delete``
694 where ``XXX`` represents the key/ID for the record.
696 By default, this view is included only if :attr:`deletable` is
697 true.
699 The default "delete" view logic will show a "psuedo-readonly"
700 form with no fields editable, but with a submit button so user
701 must confirm, before deletion actually occurs.
703 Subclass normally should not override this method, but rather
704 one of the related methods which are called (in)directly by
705 this one:
707 * :meth:`make_model_form()`
708 * :meth:`configure_form()`
709 * :meth:`delete_save_form()`
710 * :meth:`delete_instance()`
711 """
712 self.deleting = True
713 instance = self.get_instance()
715 if not self.is_deletable(instance):
716 return self.redirect(self.get_action_url('view', instance))
718 # nb. this form proper is not readonly..
719 form = self.make_model_form(instance,
720 cancel_url_fallback=self.get_action_url('view', instance),
721 button_label_submit="DELETE Forever",
722 button_icon_submit='trash',
723 button_type_submit='is-danger')
724 # ..but *all* fields are readonly
725 form.readonly_fields = set(form.fields)
727 # nb. validate() often returns empty dict here
728 if form.validate() is not False:
729 self.delete_save_form(form)
730 return self.redirect(self.get_index_url())
732 context = {
733 'instance': instance,
734 'form': form,
735 }
736 return self.render_to_response('delete', context)
738 def delete_save_form(self, form):
739 """
740 Perform the delete operation(s) based on the given form data.
742 Default logic simply calls :meth:`delete_instance()` on the
743 form's :attr:`~wuttaweb.forms.base.Form.model_instance`.
745 This method is called by :meth:`delete()` after it has
746 validated the form.
747 """
748 obj = form.model_instance
749 self.delete_instance(obj)
751 def delete_instance(self, obj):
752 """
753 Delete the given model instance.
755 As of yet there is no default logic for this method; it will
756 raise ``NotImplementedError``. Subclass should override if
757 needed.
759 This method is called by :meth:`delete_save_form()`.
760 """
761 session = self.app.get_session(obj)
762 session.delete(obj)
764 def delete_bulk(self):
765 """
766 View to delete all records in the current :meth:`index()` grid
767 data set, i.e. those matching current query.
769 This usually corresponds to a URL like
770 ``/widgets/delete-bulk``.
772 By default, this view is included only if
773 :attr:`deletable_bulk` is true.
775 This view requires POST method. When it is finished deleting,
776 user is redirected back to :meth:`index()` view.
778 Subclass normally should not override this method, but rather
779 one of the related methods which are called (in)directly by
780 this one:
782 * :meth:`delete_bulk_action()`
783 """
785 # get current data set from grid
786 # nb. this must *not* be paginated, we need it all
787 grid = self.make_model_grid(paginated=False)
788 data = grid.get_visible_data()
790 if self.deletable_bulk_quick:
792 # delete it all and go back to listing
793 self.delete_bulk_action(data)
794 return self.redirect(self.get_index_url())
796 else:
798 # start thread for delete; show progress page
799 route_prefix = self.get_route_prefix()
800 key = f'{route_prefix}.delete_bulk'
801 progress = self.make_progress(key, success_url=self.get_index_url())
802 thread = threading.Thread(target=self.delete_bulk_thread,
803 args=(data,), kwargs={'progress': progress})
804 thread.start()
805 return self.render_progress(progress)
807 def delete_bulk_thread(self, query, success_url=None, progress=None):
808 """ """
809 model_title_plural = self.get_model_title_plural()
811 # nb. use new session, separate from web transaction
812 session = self.app.make_session()
813 records = query.with_session(session).all()
815 try:
816 self.delete_bulk_action(records, progress=progress)
818 except Exception as error:
819 session.rollback()
820 log.warning("failed to delete %s results for %s",
821 len(records), model_title_plural,
822 exc_info=True)
823 if progress:
824 progress.handle_error(error)
826 else:
827 session.commit()
828 if progress:
829 progress.handle_success()
831 finally:
832 session.close()
834 def delete_bulk_action(self, data, progress=None):
835 """
836 This method performs the actual bulk deletion, for the given
837 data set. This is called via :meth:`delete_bulk()`.
839 Default logic will call :meth:`is_deletable()` for every data
840 record, and if that returns true then it calls
841 :meth:`delete_instance()`. A progress indicator will be
842 updated if one is provided.
844 Subclass should override if needed.
845 """
846 model_title_plural = self.get_model_title_plural()
848 def delete(obj, i):
849 if self.is_deletable(obj):
850 self.delete_instance(obj)
852 self.app.progress_loop(delete, data, progress,
853 message=f"Deleting {model_title_plural}")
855 def delete_bulk_make_button(self):
856 """ """
857 route_prefix = self.get_route_prefix()
859 label = HTML.literal(
860 '{{ deleteResultsSubmitting ? "Working, please wait..." : "Delete Results" }}')
861 button = self.make_button(label,
862 variant='is-danger',
863 icon_left='trash',
864 **{'@click': 'deleteResultsSubmit()',
865 ':disabled': 'deleteResultsDisabled'})
867 form = HTML.tag('form',
868 method='post',
869 action=self.request.route_url(f'{route_prefix}.delete_bulk'),
870 ref='deleteResultsForm',
871 class_='control',
872 c=[
873 render_csrf_token(self.request),
874 button,
875 ])
876 return form
878 ##############################
879 # autocomplete methods
880 ##############################
882 def autocomplete(self):
883 """
884 View which accepts a single ``term`` param, and returns a JSON
885 list of autocomplete results to match.
887 By default, this view is included only if
888 :attr:`has_autocomplete` is true. It usually maps to a URL
889 like ``/widgets/autocomplete``.
891 Subclass generally does not need to override this method, but
892 rather should override the others which this calls:
894 * :meth:`autocomplete_data()`
895 * :meth:`autocomplete_normalize()`
896 """
897 term = self.request.GET.get('term', '')
898 if not term:
899 return []
901 data = self.autocomplete_data(term)
902 if not data:
903 return []
905 max_results = 100 # TODO
907 results = []
908 for obj in data[:max_results]:
909 normal = self.autocomplete_normalize(obj)
910 if normal:
911 results.append(normal)
913 return results
915 def autocomplete_data(self, term):
916 """
917 Should return the data/query for the "matching" model records,
918 based on autocomplete search term. This is called by
919 :meth:`autocomplete()`.
921 Subclass must override this; default logic returns no data.
923 :param term: String search term as-is from user, e.g. "foo bar".
925 :returns: List of data records, or SQLAlchemy query.
926 """
928 def autocomplete_normalize(self, obj):
929 """
930 Should return a "normalized" version of the given model
931 record, suitable for autocomplete JSON results. This is
932 called by :meth:`autocomplete()`.
934 Subclass may need to override this; default logic is
935 simplistic but will work for basic models. It returns the
936 "autocomplete results" dict for the object::
938 {
939 'value': obj.uuid,
940 'label': str(obj),
941 }
943 The 2 keys shown are required; any other keys will be ignored
944 by the view logic but may be useful on the frontend widget.
946 :param obj: Model record/instance.
948 :returns: Dict of "autocomplete results" format, as shown
949 above.
950 """
951 return {
952 'value': obj.uuid,
953 'label': str(obj),
954 }
956 ##############################
957 # download methods
958 ##############################
960 def download(self):
961 """
962 View to download a file associated with a model record.
964 This usually corresponds to a URL like
965 ``/widgets/XXX/download`` where ``XXX`` represents the key/ID
966 for the record.
968 By default, this view is included only if :attr:`downloadable`
969 is true.
971 This method will (try to) locate the file on disk, and return
972 it as a file download response to the client.
974 The GET request for this view may contain a ``filename`` query
975 string parameter, which can be used to locate one of various
976 files associated with the model record. This filename is
977 passed to :meth:`download_path()` for locating the file.
979 For instance: ``/widgets/XXX/download?filename=widget-specs.txt``
981 Subclass normally should not override this method, but rather
982 one of the related methods which are called (in)directly by
983 this one:
985 * :meth:`download_path()`
986 """
987 obj = self.get_instance()
988 filename = self.request.GET.get('filename', None)
990 path = self.download_path(obj, filename)
991 if not path or not os.path.exists(path):
992 return self.notfound()
994 return self.file_response(path)
996 def download_path(self, obj, filename):
997 """
998 Should return absolute path on disk, for the given object and
999 filename. Result will be used to return a file response to
1000 client. This is called by :meth:`download()`.
1002 Default logic always returns ``None``; subclass must override.
1004 :param obj: Refefence to the model instance.
1006 :param filename: Name of file for which to retrieve the path.
1008 :returns: Path to file, or ``None`` if not found.
1010 Note that ``filename`` may be ``None`` in which case the "default"
1011 file path should be returned, if applicable.
1013 If this method returns ``None`` (as it does by default) then
1014 the :meth:`download()` view will return a 404 not found
1015 response.
1016 """
1018 ##############################
1019 # execute methods
1020 ##############################
1022 def execute(self):
1023 """
1024 View to "execute" a model record. Requires a POST request.
1026 This usually corresponds to a URL like
1027 ``/widgets/XXX/execute`` where ``XXX`` represents the key/ID
1028 for the record.
1030 By default, this view is included only if :attr:`executable` is
1031 true.
1033 Probably this is a "rare" view to implement for a model. But
1034 there are two notable use cases so far, namely:
1036 * upgrades (cf. :class:`~wuttaweb.views.upgrades.UpgradeView`)
1037 * batches (not yet implemented;
1038 cf. :doc:`rattail-manual:data/batch/index` in Rattail
1039 Manual)
1041 The general idea is to take some "irrevocable" action
1042 associated with the model record. In the case of upgrades, it
1043 is to run the upgrade script. For batches it is to "push
1044 live" the data held within the batch.
1046 Subclass normally should not override this method, but rather
1047 one of the related methods which are called (in)directly by
1048 this one:
1050 * :meth:`execute_instance()`
1051 """
1052 route_prefix = self.get_route_prefix()
1053 model_title = self.get_model_title()
1054 obj = self.get_instance()
1056 # make the progress tracker
1057 progress = self.make_progress(f'{route_prefix}.execute',
1058 success_msg=f"{model_title} was executed.",
1059 success_url=self.get_action_url('view', obj))
1061 # start thread for execute; show progress page
1062 key = self.request.matchdict
1063 thread = threading.Thread(target=self.execute_thread,
1064 args=(key, self.request.user.uuid),
1065 kwargs={'progress': progress})
1066 thread.start()
1067 return self.render_progress(progress, context={
1068 'instance': obj,
1069 }, template=self.execute_progress_template)
1071 def execute_instance(self, obj, user, progress=None):
1072 """
1073 Perform the actual "execution" logic for a model record.
1074 Called by :meth:`execute()`.
1076 This method does nothing by default; subclass must override.
1078 :param obj: Reference to the model instance.
1080 :param user: Reference to the
1081 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
1082 is doing the execute.
1084 :param progress: Optional progress indicator factory.
1085 """
1087 def execute_thread(self, key, user_uuid, progress=None):
1088 """ """
1089 model = self.app.model
1090 model_title = self.get_model_title()
1092 # nb. use new session, separate from web transaction
1093 session = self.app.make_session()
1095 # fetch model instance and user for this session
1096 obj = self.get_instance(session=session, matchdict=key)
1097 user = session.get(model.User, user_uuid)
1099 try:
1100 self.execute_instance(obj, user, progress=progress)
1102 except Exception as error:
1103 session.rollback()
1104 log.warning("%s failed to execute: %s", model_title, obj, exc_info=True)
1105 if progress:
1106 progress.handle_error(error)
1108 else:
1109 session.commit()
1110 if progress:
1111 progress.handle_success()
1113 finally:
1114 session.close()
1116 ##############################
1117 # configure methods
1118 ##############################
1120 def configure(self, session=None):
1121 """
1122 View for configuring aspects of the app which are pertinent to
1123 this master view and/or model.
1125 By default, this view is included only if :attr:`configurable`
1126 is true. It usually maps to a URL like ``/widgets/configure``.
1128 The expected workflow is as follows:
1130 * user navigates to Configure page
1131 * user modifies settings and clicks Save
1132 * this view then *deletes* all "known" settings
1133 * then it saves user-submitted settings
1135 That is unless ``remove_settings`` is requested, in which case
1136 settings are deleted but then none are saved. The "known"
1137 settings by default include only the "simple" settings.
1139 As a general rule, a particular setting should be configurable
1140 by (at most) one master view. Some settings may never be
1141 exposed at all. But when exposing a setting, careful thought
1142 should be given to where it logically/best belongs.
1144 Some settings are "simple" and a master view subclass need
1145 only provide their basic definitions via
1146 :meth:`configure_get_simple_settings()`. If complex settings
1147 are needed, subclass must override one or more other methods
1148 to achieve the aim(s).
1150 See also related methods, used by this one:
1152 * :meth:`configure_get_simple_settings()`
1153 * :meth:`configure_get_context()`
1154 * :meth:`configure_gather_settings()`
1155 * :meth:`configure_remove_settings()`
1156 * :meth:`configure_save_settings()`
1157 """
1158 self.configuring = True
1159 config_title = self.get_config_title()
1161 # was form submitted?
1162 if self.request.method == 'POST':
1164 # maybe just remove settings
1165 if self.request.POST.get('remove_settings'):
1166 self.configure_remove_settings(session=session)
1167 self.request.session.flash(f"All settings for {config_title} have been removed.",
1168 'warning')
1170 # reload configure page
1171 return self.redirect(self.request.current_route_url())
1173 # gather/save settings
1174 data = get_form_data(self.request)
1175 settings = self.configure_gather_settings(data)
1176 self.configure_remove_settings(session=session)
1177 self.configure_save_settings(settings, session=session)
1178 self.request.session.flash("Settings have been saved.")
1180 # reload configure page
1181 return self.redirect(self.request.url)
1183 # render configure page
1184 context = self.configure_get_context()
1185 return self.render_to_response('configure', context)
1187 def configure_get_context(
1188 self,
1189 simple_settings=None,
1190 ):
1191 """
1192 Returns the full context dict, for rendering the
1193 :meth:`configure()` page template.
1195 Default context will include ``simple_settings`` (normalized
1196 to just name/value).
1198 You may need to override this method, to add additional
1199 "complex" settings etc.
1201 :param simple_settings: Optional list of simple settings, if
1202 already initialized. Otherwise it is retrieved via
1203 :meth:`configure_get_simple_settings()`.
1205 :returns: Context dict for the page template.
1206 """
1207 context = {}
1209 # simple settings
1210 if simple_settings is None:
1211 simple_settings = self.configure_get_simple_settings()
1212 if simple_settings:
1214 # we got some, so "normalize" each definition to name/value
1215 normalized = {}
1216 for simple in simple_settings:
1218 # name
1219 name = simple['name']
1221 # value
1222 if 'value' in simple:
1223 value = simple['value']
1224 elif simple.get('type') is bool:
1225 value = self.config.get_bool(name, default=simple.get('default', False))
1226 else:
1227 value = self.config.get(name, default=simple.get('default'))
1229 normalized[name] = value
1231 # add to template context
1232 context['simple_settings'] = normalized
1234 return context
1236 def configure_get_simple_settings(self):
1237 """
1238 This should return a list of "simple" setting definitions for
1239 the :meth:`configure()` view, which can be handled in a more
1240 automatic way. (This is as opposed to some settings which are
1241 more complex and must be handled manually; those should not be
1242 part of this method's return value.)
1244 Basically a "simple" setting is one which can be represented
1245 by a single field/widget on the Configure page.
1247 The setting definitions returned must each be a dict of
1248 "attributes" for the setting. For instance a *very* simple
1249 setting might be::
1251 {'name': 'wutta.app_title'}
1253 The ``name`` is required, everything else is optional. Here
1254 is a more complete example::
1256 {
1257 'name': 'wutta.production',
1258 'type': bool,
1259 'default': False,
1260 'save_if_empty': False,
1261 }
1263 Note that if specified, the ``default`` should be of the same
1264 data type as defined for the setting (``bool`` in the above
1265 example). The default ``type`` is ``str``.
1267 Normally if a setting's value is effectively null, the setting
1268 is removed instead of keeping it in the DB. This behavior can
1269 be changed per-setting via the ``save_if_empty`` flag.
1271 :returns: List of setting definition dicts as described above.
1272 Note that their order does not matter since the template
1273 must explicitly define field layout etc.
1274 """
1276 def configure_gather_settings(
1277 self,
1278 data,
1279 simple_settings=None,
1280 ):
1281 """
1282 Collect the full set of "normalized" settings from user
1283 request, so that :meth:`configure()` can save them.
1285 Settings are gathered from the given request (e.g. POST)
1286 ``data``, but also taking into account what we know based on
1287 the simple setting definitions.
1289 Subclass may need to override this method if complex settings
1290 are required.
1292 :param data: Form data submitted via POST request.
1294 :param simple_settings: Optional list of simple settings, if
1295 already initialized. Otherwise it is retrieved via
1296 :meth:`configure_get_simple_settings()`.
1298 This method must return a list of normalized settings, similar
1299 in spirit to the definition syntax used in
1300 :meth:`configure_get_simple_settings()`. However the format
1301 returned here is minimal and contains just name/value::
1303 {
1304 'name': 'wutta.app_title',
1305 'value': 'Wutta Wutta',
1306 }
1308 Note that the ``value`` will always be a string.
1310 Also note, whereas it's possible ``data`` will not contain all
1311 known settings, the return value *should* (potentially)
1312 contain all of them.
1314 The one exception is when a simple setting has null value, by
1315 default it will not be included in the result (hence, not
1316 saved to DB) unless the setting definition has the
1317 ``save_if_empty`` flag set.
1318 """
1319 settings = []
1321 # simple settings
1322 if simple_settings is None:
1323 simple_settings = self.configure_get_simple_settings()
1324 if simple_settings:
1326 # we got some, so "normalize" each definition to name/value
1327 for simple in simple_settings:
1328 name = simple['name']
1330 if name in data:
1331 value = data[name]
1332 elif simple.get('type') is bool:
1333 # nb. bool false will be *missing* from data
1334 value = False
1335 else:
1336 value = simple.get('default')
1338 if simple.get('type') is bool:
1339 value = str(bool(value)).lower()
1340 elif simple.get('type') is int:
1341 value = str(int(value or '0'))
1342 elif value is None:
1343 value = ''
1344 else:
1345 value = str(value)
1347 # only want to save this setting if we received a
1348 # value, or if empty values are okay to save
1349 if value or simple.get('save_if_empty'):
1350 settings.append({'name': name,
1351 'value': value})
1353 return settings
1355 def configure_remove_settings(
1356 self,
1357 simple_settings=None,
1358 session=None,
1359 ):
1360 """
1361 Remove all "known" settings from the DB; this is called by
1362 :meth:`configure()`.
1364 The point of this method is to ensure *all* "known" settings
1365 which are managed by this master view, are purged from the DB.
1367 The default logic can handle this automatically for simple
1368 settings; subclass must override for any complex settings.
1370 :param simple_settings: Optional list of simple settings, if
1371 already initialized. Otherwise it is retrieved via
1372 :meth:`configure_get_simple_settings()`.
1373 """
1374 names = []
1376 # simple settings
1377 if simple_settings is None:
1378 simple_settings = self.configure_get_simple_settings()
1379 if simple_settings:
1380 names.extend([simple['name']
1381 for simple in simple_settings])
1383 if names:
1384 # nb. must avoid self.Session here in case that does not
1385 # point to our primary app DB
1386 session = session or self.Session()
1387 for name in names:
1388 self.app.delete_setting(session, name)
1390 def configure_save_settings(self, settings, session=None):
1391 """
1392 Save the given settings to the DB; this is called by
1393 :meth:`configure()`.
1395 This method expects a list of name/value dicts and will simply
1396 save each to the DB, with no "conversion" logic.
1398 :param settings: List of normalized setting definitions, as
1399 returned by :meth:`configure_gather_settings()`.
1400 """
1401 # nb. must avoid self.Session here in case that does not point
1402 # to our primary app DB
1403 session = session or self.Session()
1404 for setting in settings:
1405 self.app.save_setting(session, setting['name'], setting['value'],
1406 force_create=True)
1408 ##############################
1409 # grid rendering methods
1410 ##############################
1412 def grid_render_bool(self, record, key, value):
1413 """
1414 Custom grid value renderer for "boolean" fields.
1416 This converts a bool value to "Yes" or "No" - unless the value
1417 is ``None`` in which case this renders empty string.
1418 To use this feature for your grid::
1420 grid.set_renderer('my_bool_field', self.grid_render_bool)
1421 """
1422 if value is None:
1423 return
1425 return "Yes" if value else "No"
1427 def grid_render_currency(self, record, key, value, scale=2):
1428 """
1429 Custom grid value renderer for "currency" fields.
1431 This expects float or decimal values, and will round the
1432 decimal as appropriate, and add the currency symbol.
1434 :param scale: Number of decimal digits to be displayed;
1435 default is 2 places.
1437 To use this feature for your grid::
1439 grid.set_renderer('my_currency_field', self.grid_render_currency)
1441 # you can also override scale
1442 grid.set_renderer('my_currency_field', self.grid_render_currency, scale=4)
1443 """
1445 # nb. get new value since the one provided will just be a
1446 # (json-safe) *string* if the original type was Decimal
1447 value = record[key]
1449 if value is None:
1450 return
1452 if value < 0:
1453 fmt = f"(${{:0,.{scale}f}})"
1454 return fmt.format(0 - value)
1456 fmt = f"${{:0,.{scale}f}}"
1457 return fmt.format(value)
1459 def grid_render_datetime(self, record, key, value, fmt=None):
1460 """
1461 Custom grid value renderer for
1462 :class:`~python:datetime.datetime` fields.
1464 :param fmt: Optional format string to use instead of the
1465 default: ``'%Y-%m-%d %I:%M:%S %p'``
1467 To use this feature for your grid::
1469 grid.set_renderer('my_datetime_field', self.grid_render_datetime)
1471 # you can also override format
1472 grid.set_renderer('my_datetime_field', self.grid_render_datetime,
1473 fmt='%Y-%m-%d %H:%M:%S')
1474 """
1475 # nb. get new value since the one provided will just be a
1476 # (json-safe) *string* if the original type was datetime
1477 value = record[key]
1479 if value is None:
1480 return
1482 return value.strftime(fmt or '%Y-%m-%d %I:%M:%S %p')
1484 def grid_render_enum(self, record, key, value, enum=None):
1485 """
1486 Custom grid value renderer for "enum" fields.
1488 :param enum: Enum class for the field. This should be an
1489 instance of :class:`~python:enum.Enum`.
1491 To use this feature for your grid::
1493 from enum import Enum
1495 class MyEnum(Enum):
1496 ONE = 1
1497 TWO = 2
1498 THREE = 3
1500 grid.set_renderer('my_enum_field', self.grid_render_enum, enum=MyEnum)
1501 """
1502 if enum:
1503 original = record[key]
1504 if original:
1505 return original.name
1507 return value
1509 def grid_render_notes(self, record, key, value, maxlen=100):
1510 """
1511 Custom grid value renderer for "notes" fields.
1513 If the given text ``value`` is shorter than ``maxlen``
1514 characters, it is returned as-is.
1516 But if it is longer, then it is truncated and an ellispsis is
1517 added. The resulting ``<span>`` tag is also given a ``title``
1518 attribute with the original (full) text, so that appears on
1519 mouse hover.
1521 To use this feature for your grid::
1523 grid.set_renderer('my_notes_field', self.grid_render_notes)
1525 # you can also override maxlen
1526 grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50)
1527 """
1528 if value is None:
1529 return
1531 if len(value) < maxlen:
1532 return value
1534 return HTML.tag('span', title=value, c=f"{value[:maxlen]}...")
1536 ##############################
1537 # support methods
1538 ##############################
1540 def get_class_hierarchy(self, topfirst=True):
1541 """
1542 Convenience to return a list of classes from which the current
1543 class inherits.
1545 This is a wrapper around
1546 :func:`wuttjamaican.util.get_class_hierarchy()`.
1547 """
1548 return get_class_hierarchy(self.__class__, topfirst=topfirst)
1550 def has_perm(self, name):
1551 """
1552 Shortcut to check if current user has the given permission.
1554 This will automatically add the :attr:`permission_prefix` to
1555 ``name`` before passing it on to
1556 :func:`~wuttaweb.subscribers.request.has_perm()`.
1558 For instance within the
1559 :class:`~wuttaweb.views.users.UserView` these give the same
1560 result::
1562 self.request.has_perm('users.edit')
1564 self.has_perm('edit')
1566 So this shortcut only applies to permissions defined for the
1567 current master view. The first example above must still be
1568 used to check for "foreign" permissions (i.e. any needing a
1569 different prefix).
1570 """
1571 permission_prefix = self.get_permission_prefix()
1572 return self.request.has_perm(f'{permission_prefix}.{name}')
1574 def has_any_perm(self, *names):
1575 """
1576 Shortcut to check if current user has any of the given
1577 permissions.
1579 This calls :meth:`has_perm()` until one returns ``True``. If
1580 none do, returns ``False``.
1581 """
1582 for name in names:
1583 if self.has_perm(name):
1584 return True
1585 return False
1587 def make_button(
1588 self,
1589 label,
1590 variant=None,
1591 primary=False,
1592 url=None,
1593 **kwargs,
1594 ):
1595 """
1596 Make and return a HTML ``<b-button>`` literal.
1598 :param label: Text label for the button.
1600 :param variant: This is the "Buefy type" (or "Oruga variant")
1601 for the button. Buefy and Oruga represent this differently
1602 but this logic expects the Buefy format
1603 (e.g. ``is-danger``) and *not* the Oruga format
1604 (e.g. ``danger``), despite the param name matching Oruga's
1605 terminology.
1607 :param type: This param is not advertised in the method
1608 signature, but if caller specifies ``type`` instead of
1609 ``variant`` it should work the same.
1611 :param primary: If neither ``variant`` nor ``type`` are
1612 specified, this flag may be used to automatically set the
1613 Buefy type to ``is-primary``.
1615 This is the preferred method where applicable, since it
1616 avoids the Buefy vs. Oruga confusion, and the
1617 implementation can change in the future.
1619 :param url: Specify this (instead of ``href``) to make the
1620 button act like a link. This will yield something like:
1621 ``<b-button tag="a" href="{url}">``
1623 :param \**kwargs: All remaining kwargs are passed to the
1624 underlying ``HTML.tag()`` call, so will be rendered as
1625 attributes on the button tag.
1627 **NB.** You cannot specify a ``tag`` kwarg, for technical
1628 reasons.
1630 :returns: HTML literal for the button element. Will be something
1631 along the lines of:
1633 .. code-block::
1635 <b-button type="is-primary"
1636 icon-pack="fas"
1637 icon-left="hand-pointer">
1638 Click Me
1639 </b-button>
1640 """
1641 btn_kw = kwargs
1642 btn_kw.setdefault('c', label)
1643 btn_kw.setdefault('icon_pack', 'fas')
1645 if 'type' not in btn_kw:
1646 if variant:
1647 btn_kw['type'] = variant
1648 elif primary:
1649 btn_kw['type'] = 'is-primary'
1651 if url:
1652 btn_kw['href'] = url
1654 button = HTML.tag('b-button', **btn_kw)
1656 if url:
1657 # nb. unfortunately HTML.tag() calls its first arg 'tag'
1658 # and so we can't pass a kwarg with that name...so instead
1659 # we patch that into place manually
1660 button = str(button)
1661 button = button.replace('<b-button ',
1662 '<b-button tag="a" ')
1663 button = HTML.literal(button)
1665 return button
1667 def get_xref_buttons(self, obj):
1668 """
1669 Should return a list of "cross-reference" buttons to be shown
1670 when viewing the given object.
1672 Default logic always returns empty list; subclass can override
1673 as needed.
1675 If applicable, this method should do its own permission checks
1676 and only include the buttons current user should be allowed to
1677 see/use.
1679 See also :meth:`make_button()` - example::
1681 def get_xref_buttons(self, product):
1682 buttons = []
1683 if self.request.has_perm('external_products.view'):
1684 url = self.request.route_url('external_products.view',
1685 id=product.external_id)
1686 buttons.append(self.make_button("View External", url=url))
1687 return buttons
1688 """
1689 return []
1691 def make_progress(self, key, **kwargs):
1692 """
1693 Create and return a
1694 :class:`~wuttaweb.progress.SessionProgress` instance, with the
1695 given key.
1697 This is normally done just before calling
1698 :meth:`render_progress()`.
1699 """
1700 return SessionProgress(self.request, key, **kwargs)
1702 def render_progress(self, progress, context=None, template=None):
1703 """
1704 Render the progress page, with given template/context.
1706 When a view method needs to start a long-running operation, it
1707 first starts a thread to do the work, and then it renders the
1708 "progress" page. As the operation continues the progress page
1709 is updated. When the operation completes (or fails) the user
1710 is redirected to the final destination.
1712 TODO: should document more about how to do this..
1714 :param progress: Progress indicator instance as returned by
1715 :meth:`make_progress()`.
1717 :returns: A :term:`response` with rendered progress page.
1718 """
1719 template = template or '/progress.mako'
1720 context = context or {}
1721 context['progress'] = progress
1722 return render_to_response(template, context, request=self.request)
1724 def render_to_response(self, template, context):
1725 """
1726 Locate and render an appropriate template, with the given
1727 context, and return a :term:`response`.
1729 The specified ``template`` should be only the "base name" for
1730 the template - e.g. ``'index'`` or ``'edit'``. This method
1731 will then try to locate a suitable template file, based on
1732 values from :meth:`get_template_prefix()` and
1733 :meth:`get_fallback_templates()`.
1735 In practice this *usually* means two different template paths
1736 will be attempted, e.g. if ``template`` is ``'edit'`` and
1737 :attr:`template_prefix` is ``'/widgets'``:
1739 * ``/widgets/edit.mako``
1740 * ``/master/edit.mako``
1742 The first template found to exist will be used for rendering.
1743 It then calls
1744 :func:`pyramid:pyramid.renderers.render_to_response()` and
1745 returns the result.
1747 :param template: Base name for the template.
1749 :param context: Data dict to be used as template context.
1751 :returns: Response object containing the rendered template.
1752 """
1753 defaults = {
1754 'master': self,
1755 'route_prefix': self.get_route_prefix(),
1756 'index_title': self.get_index_title(),
1757 'index_url': self.get_index_url(),
1758 'model_title': self.get_model_title(),
1759 'config_title': self.get_config_title(),
1760 }
1762 # merge defaults + caller-provided context
1763 defaults.update(context)
1764 context = defaults
1766 # add crud flags if we have an instance
1767 if 'instance' in context:
1768 instance = context['instance']
1769 if 'instance_title' not in context:
1770 context['instance_title'] = self.get_instance_title(instance)
1771 if 'instance_editable' not in context:
1772 context['instance_editable'] = self.is_editable(instance)
1773 if 'instance_deletable' not in context:
1774 context['instance_deletable'] = self.is_deletable(instance)
1776 # supplement context further if needed
1777 context = self.get_template_context(context)
1779 # first try the template path most specific to this view
1780 page_templates = self.get_page_templates(template)
1781 mako_path = page_templates[0]
1782 try:
1783 return render_to_response(mako_path, context, request=self.request)
1784 except IOError:
1786 # failing that, try one or more fallback templates
1787 for fallback in page_templates[1:]:
1788 try:
1789 return render_to_response(fallback, context, request=self.request)
1790 except IOError:
1791 pass
1793 # if we made it all the way here, then we found no
1794 # templates at all, in which case re-attempt the first and
1795 # let that error raise on up
1796 return render_to_response(mako_path, context, request=self.request)
1798 def get_template_context(self, context):
1799 """
1800 This method should return the "complete" context for rendering
1801 the current view template.
1803 Default logic for this method returns the given context
1804 unchanged.
1806 You may wish to override to pass extra context to the view
1807 template. Check :attr:`viewing` and similar, or
1808 ``request.current_route_name`` etc. in order to add extra
1809 context only for certain view templates.
1811 :params: context: The context dict we have so far,
1812 auto-provided by the master view logic.
1814 :returns: Final context dict for the template.
1815 """
1816 return context
1818 def get_page_templates(self, template):
1819 """
1820 Returns a list of all templates which can be attempted, to
1821 render the current page. This is called by
1822 :meth:`render_to_response()`.
1824 The list should be in order of preference, e.g. the first
1825 entry will be the most "specific" template, with subsequent
1826 entries becoming more generic.
1828 In practice this method defines the first entry but calls
1829 :meth:`get_fallback_templates()` for the rest.
1831 :param template: Base name for a template (without prefix), e.g.
1832 ``'view'``.
1834 :returns: List of template paths to be tried, based on the
1835 specified template. For instance if ``template`` is
1836 ``'view'`` this will (by default) return::
1838 [
1839 '/widgets/view.mako',
1840 '/master/view.mako',
1841 ]
1843 """
1844 template_prefix = self.get_template_prefix()
1845 page_templates = [f'{template_prefix}/{template}.mako']
1846 page_templates.extend(self.get_fallback_templates(template))
1847 return page_templates
1849 def get_fallback_templates(self, template):
1850 """
1851 Returns a list of "fallback" template paths which may be
1852 attempted for rendering the current page. See also
1853 :meth:`get_page_templates()`.
1855 :param template: Base name for a template (without prefix), e.g.
1856 ``'view'``.
1858 :returns: List of template paths to be tried, based on the
1859 specified template. For instance if ``template`` is
1860 ``'view'`` this will (by default) return::
1862 ['/master/view.mako']
1863 """
1864 return [f'/master/{template}.mako']
1866 def get_index_title(self):
1867 """
1868 Returns the main index title for the master view.
1870 By default this returns the value from
1871 :meth:`get_model_title_plural()`. Subclass may override as
1872 needed.
1873 """
1874 return self.get_model_title_plural()
1876 def get_index_url(self, **kwargs):
1877 """
1878 Returns the URL for master's :meth:`index()` view.
1880 NB. this returns ``None`` if :attr:`listable` is false.
1881 """
1882 if self.listable:
1883 route_prefix = self.get_route_prefix()
1884 return self.request.route_url(route_prefix, **kwargs)
1886 def set_labels(self, obj):
1887 """
1888 Set label overrides on a form or grid, based on what is
1889 defined by the view class and its parent class(es).
1891 This is called automatically from :meth:`configure_grid()` and
1892 :meth:`configure_form()`.
1894 This calls :meth:`collect_labels()` to find everything, then
1895 it assigns the labels using one of (based on ``obj`` type):
1897 * :func:`wuttaweb.forms.base.Form.set_label()`
1898 * :func:`wuttaweb.grids.base.Grid.set_label()`
1900 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
1901 :class:`~wuttaweb.forms.base.Form` instance.
1902 """
1903 labels = self.collect_labels()
1904 for key, label in labels.items():
1905 obj.set_label(key, label)
1907 def collect_labels(self):
1908 """
1909 Collect all labels defined by the view class and/or its parents.
1911 A master view can declare labels via class-level attribute,
1912 like so::
1914 from wuttaweb.views import MasterView
1916 class WidgetView(MasterView):
1918 labels = {
1919 'id': "Widget ID",
1920 'serial_no': "Serial Number",
1921 }
1923 All such labels, defined by any class from which the master
1924 view inherits, will be returned. However if the same label
1925 key is defined by multiple classes, the "subclass" always
1926 wins.
1928 Labels defined in this way will apply to both forms and grids.
1929 See also :meth:`set_labels()`.
1931 :returns: Dict of all labels found.
1932 """
1933 labels = {}
1934 hierarchy = self.get_class_hierarchy()
1935 for cls in hierarchy:
1936 if hasattr(cls, 'labels'):
1937 labels.update(cls.labels)
1938 return labels
1940 def make_model_grid(self, session=None, **kwargs):
1941 """
1942 Create and return a :class:`~wuttaweb.grids.base.Grid`
1943 instance for use with the :meth:`index()` view.
1945 See also related methods, which are called by this one:
1947 * :meth:`get_grid_key()`
1948 * :meth:`get_grid_columns()`
1949 * :meth:`get_grid_data()`
1950 * :meth:`configure_grid()`
1951 """
1952 if 'key' not in kwargs:
1953 kwargs['key'] = self.get_grid_key()
1955 if 'model_class' not in kwargs:
1956 model_class = self.get_model_class()
1957 if model_class:
1958 kwargs['model_class'] = model_class
1960 if 'columns' not in kwargs:
1961 kwargs['columns'] = self.get_grid_columns()
1963 if 'data' not in kwargs:
1964 kwargs['data'] = self.get_grid_data(columns=kwargs['columns'],
1965 session=session)
1967 if 'actions' not in kwargs:
1968 actions = []
1970 # TODO: should split this off into index_get_grid_actions() ?
1972 if self.viewable and self.has_perm('view'):
1973 actions.append(self.make_grid_action('view', icon='eye',
1974 url=self.get_action_url_view))
1976 if self.editable and self.has_perm('edit'):
1977 actions.append(self.make_grid_action('edit', icon='edit',
1978 url=self.get_action_url_edit))
1980 if self.deletable and self.has_perm('delete'):
1981 actions.append(self.make_grid_action('delete', icon='trash',
1982 url=self.get_action_url_delete,
1983 link_class='has-text-danger'))
1985 kwargs['actions'] = actions
1987 if 'tools' not in kwargs:
1988 tools = []
1990 if self.deletable_bulk and self.has_perm('delete_bulk'):
1991 tools.append(('delete-results', self.delete_bulk_make_button()))
1993 kwargs['tools'] = tools
1995 if hasattr(self, 'grid_row_class'):
1996 kwargs.setdefault('row_class', self.grid_row_class)
1997 kwargs.setdefault('filterable', self.filterable)
1998 kwargs.setdefault('filter_defaults', self.filter_defaults)
1999 kwargs.setdefault('sortable', self.sortable)
2000 kwargs.setdefault('sort_multiple', not self.request.use_oruga)
2001 kwargs.setdefault('sort_on_backend', self.sort_on_backend)
2002 kwargs.setdefault('sort_defaults', self.sort_defaults)
2003 kwargs.setdefault('paginated', self.paginated)
2004 kwargs.setdefault('paginate_on_backend', self.paginate_on_backend)
2006 grid = self.make_grid(**kwargs)
2007 self.configure_grid(grid)
2008 grid.load_settings()
2009 return grid
2011 def get_grid_columns(self):
2012 """
2013 Returns the default list of grid column names, for the
2014 :meth:`index()` view.
2016 This is called by :meth:`make_model_grid()`; in the resulting
2017 :class:`~wuttaweb.grids.base.Grid` instance, this becomes
2018 :attr:`~wuttaweb.grids.base.Grid.columns`.
2020 This method may return ``None``, in which case the grid may
2021 (try to) generate its own default list.
2023 Subclass may define :attr:`grid_columns` for simple cases, or
2024 can override this method if needed.
2026 Also note that :meth:`configure_grid()` may be used to further
2027 modify the final column set, regardless of what this method
2028 returns. So a common pattern is to declare all "supported"
2029 columns by setting :attr:`grid_columns` but then optionally
2030 remove or replace some of those within
2031 :meth:`configure_grid()`.
2032 """
2033 if hasattr(self, 'grid_columns'):
2034 return self.grid_columns
2036 def get_grid_data(self, columns=None, session=None):
2037 """
2038 Returns the grid data for the :meth:`index()` view.
2040 This is called by :meth:`make_model_grid()`; in the resulting
2041 :class:`~wuttaweb.grids.base.Grid` instance, this becomes
2042 :attr:`~wuttaweb.grids.base.Grid.data`.
2044 Default logic will call :meth:`get_query()` and if successful,
2045 return the list from ``query.all()``. Otherwise returns an
2046 empty list. Subclass should override as needed.
2047 """
2048 query = self.get_query(session=session)
2049 if query:
2050 return query
2051 return []
2053 def get_query(self, session=None):
2054 """
2055 Returns the main SQLAlchemy query object for the
2056 :meth:`index()` view. This is called by
2057 :meth:`get_grid_data()`.
2059 Default logic for this method returns a "plain" query on the
2060 :attr:`model_class` if that is defined; otherwise ``None``.
2061 """
2062 model_class = self.get_model_class()
2063 if model_class:
2064 session = session or self.Session()
2065 return session.query(model_class)
2067 def configure_grid(self, grid):
2068 """
2069 Configure the grid for the :meth:`index()` view.
2071 This is called by :meth:`make_model_grid()`.
2073 There is minimal default logic here; subclass should override
2074 as needed. The ``grid`` param will already be "complete" and
2075 ready to use as-is, but this method can further modify it
2076 based on request details etc.
2077 """
2078 if 'uuid' in grid.columns:
2079 grid.columns.remove('uuid')
2081 self.set_labels(grid)
2083 # TODO: i thought this was a good idea but if so it
2084 # needs a try/catch in case of no model class
2085 # for key in self.get_model_key():
2086 # grid.set_link(key)
2088 def get_instance(self, session=None, matchdict=None):
2089 """
2090 This should return the appropriate model instance, based on
2091 the ``matchdict`` of model keys.
2093 Normally this is called with no arguments, in which case the
2094 :attr:`pyramid:pyramid.request.Request.matchdict` is used, and
2095 will return the "current" model instance based on the request
2096 (route/params).
2098 If a ``matchdict`` is provided then that is used instead, to
2099 obtain the model keys. In the simple/common example of a
2100 "native" model in WuttaWeb, this would look like::
2102 keys = {'uuid': '38905440630d11ef9228743af49773a4'}
2103 obj = self.get_instance(matchdict=keys)
2105 Although some models may have different, possibly composite
2106 key names to use instead. The specific keys this logic is
2107 expecting are the same as returned by :meth:`get_model_key()`.
2109 If this method is unable to locate the instance, it should
2110 raise a 404 error,
2111 i.e. :meth:`~wuttaweb.views.base.View.notfound()`.
2113 Default implementation of this method should work okay for
2114 views which define a :attr:`model_class`. For other views
2115 however it will raise ``NotImplementedError``, so subclass
2116 may need to define.
2118 .. warning::
2120 If you are defining this method for a subclass, please note
2121 this point regarding the 404 "not found" logic.
2123 It is *not* enough to simply *return* this 404 response,
2124 you must explicitly *raise* the error. For instance::
2126 def get_instance(self, **kwargs):
2128 # ..try to locate instance..
2129 obj = self.locate_instance_somehow()
2131 if not obj:
2133 # NB. THIS MAY NOT WORK AS EXPECTED
2134 #return self.notfound()
2136 # nb. should always do this in get_instance()
2137 raise self.notfound()
2139 This lets calling code not have to worry about whether or
2140 not this method might return ``None``. It can safely
2141 assume it will get back a model instance, or else a 404
2142 will kick in and control flow goes elsewhere.
2143 """
2144 model_class = self.get_model_class()
2145 if model_class:
2146 session = session or self.Session()
2147 matchdict = matchdict or self.request.matchdict
2149 def filtr(query, model_key):
2150 key = matchdict[model_key]
2151 query = query.filter(getattr(self.model_class, model_key) == key)
2152 return query
2154 query = session.query(model_class)
2156 for key in self.get_model_key():
2157 query = filtr(query, key)
2159 try:
2160 return query.one()
2161 except orm.exc.NoResultFound:
2162 pass
2164 raise self.notfound()
2166 raise NotImplementedError("you must define get_instance() method "
2167 f" for view class: {self.__class__}")
2169 def get_instance_title(self, instance):
2170 """
2171 Return the human-friendly "title" for the instance, to be used
2172 in the page title when viewing etc.
2174 Default logic returns the value from ``str(instance)``;
2175 subclass may override if needed.
2176 """
2177 return str(instance) or "(no title)"
2179 def get_action_route_kwargs(self, obj):
2180 """
2181 Get a dict of route kwargs for the given object.
2183 This is called from :meth:`get_action_url()` and must return
2184 kwargs suitable for use with ``request.route_url()``.
2186 In practice this should return a dict which has keys for each
2187 field from :meth:`get_model_key()` and values which come from
2188 the object.
2190 :param obj: Model instance object.
2192 :returns: The dict of route kwargs for the object.
2193 """
2194 try:
2195 return dict([(key, obj[key])
2196 for key in self.get_model_key()])
2197 except TypeError:
2198 return dict([(key, getattr(obj, key))
2199 for key in self.get_model_key()])
2201 def get_action_url(self, action, obj, **kwargs):
2202 """
2203 Generate an "action" URL for the given model instance.
2205 This is a shortcut which generates a route name based on
2206 :meth:`get_route_prefix()` and the ``action`` param.
2208 It calls :meth:`get_action_route_kwargs()` and then passes
2209 those along with route name to ``request.route_url()``, and
2210 returns the result.
2212 :param action: String name for the action, which corresponds
2213 to part of some named route, e.g. ``'view'`` or ``'edit'``.
2215 :param obj: Model instance object.
2217 :param \**kwargs: Additional kwargs to be passed to
2218 ``request.route_url()``, if needed.
2219 """
2220 kw = self.get_action_route_kwargs(obj)
2221 kw.update(kwargs)
2222 route_prefix = self.get_route_prefix()
2223 return self.request.route_url(f'{route_prefix}.{action}', **kw)
2225 def get_action_url_view(self, obj, i):
2226 """
2227 Returns the "view" grid action URL for the given object.
2229 Most typically this is like ``/widgets/XXX`` where ``XXX``
2230 represents the object's key/ID.
2232 Calls :meth:`get_action_url()` under the hood.
2233 """
2234 return self.get_action_url('view', obj)
2236 def get_action_url_edit(self, obj, i):
2237 """
2238 Returns the "edit" grid action URL for the given object, if
2239 applicable.
2241 Most typically this is like ``/widgets/XXX/edit`` where
2242 ``XXX`` represents the object's key/ID.
2244 This first calls :meth:`is_editable()` and if that is false,
2245 this method will return ``None``.
2247 Calls :meth:`get_action_url()` to generate the true URL.
2248 """
2249 if self.is_editable(obj):
2250 return self.get_action_url('edit', obj)
2252 def get_action_url_delete(self, obj, i):
2253 """
2254 Returns the "delete" grid action URL for the given object, if
2255 applicable.
2257 Most typically this is like ``/widgets/XXX/delete`` where
2258 ``XXX`` represents the object's key/ID.
2260 This first calls :meth:`is_deletable()` and if that is false,
2261 this method will return ``None``.
2263 Calls :meth:`get_action_url()` to generate the true URL.
2264 """
2265 if self.is_deletable(obj):
2266 return self.get_action_url('delete', obj)
2268 def is_editable(self, obj):
2269 """
2270 Returns a boolean indicating whether "edit" should be allowed
2271 for the given model instance (and for current user).
2273 By default this always return ``True``; subclass can override
2274 if needed.
2276 Note that the use of this method implies :attr:`editable` is
2277 true, so the method does not need to check that flag.
2278 """
2279 return True
2281 def is_deletable(self, obj):
2282 """
2283 Returns a boolean indicating whether "delete" should be
2284 allowed for the given model instance (and for current user).
2286 By default this always return ``True``; subclass can override
2287 if needed.
2289 Note that the use of this method implies :attr:`deletable` is
2290 true, so the method does not need to check that flag.
2291 """
2292 return True
2294 def make_model_form(self, model_instance=None, **kwargs):
2295 """
2296 Create and return a :class:`~wuttaweb.forms.base.Form`
2297 for the view model.
2299 Note that this method is called for multiple "CRUD" views,
2300 e.g.:
2302 * :meth:`view()`
2303 * :meth:`edit()`
2305 See also related methods, which are called by this one:
2307 * :meth:`get_form_fields()`
2308 * :meth:`configure_form()`
2309 """
2310 if 'model_class' not in kwargs:
2311 model_class = self.get_model_class()
2312 if model_class:
2313 kwargs['model_class'] = model_class
2315 kwargs['model_instance'] = model_instance
2317 if not kwargs.get('fields'):
2318 fields = self.get_form_fields()
2319 if fields:
2320 kwargs['fields'] = fields
2322 form = self.make_form(**kwargs)
2323 self.configure_form(form)
2324 return form
2326 def get_form_fields(self):
2327 """
2328 Returns the initial list of field names for the model form.
2330 This is called by :meth:`make_model_form()`; in the resulting
2331 :class:`~wuttaweb.forms.base.Form` instance, this becomes
2332 :attr:`~wuttaweb.forms.base.Form.fields`.
2334 This method may return ``None``, in which case the form may
2335 (try to) generate its own default list.
2337 Subclass may define :attr:`form_fields` for simple cases, or
2338 can override this method if needed.
2340 Note that :meth:`configure_form()` may be used to further
2341 modify the final field list, regardless of what this method
2342 returns. So a common pattern is to declare all "supported"
2343 fields by setting :attr:`form_fields` but then optionally
2344 remove or replace some in :meth:`configure_form()`.
2345 """
2346 if hasattr(self, 'form_fields'):
2347 return self.form_fields
2349 def configure_form(self, form):
2350 """
2351 Configure the given model form, as needed.
2353 This is called by :meth:`make_model_form()` - for multiple
2354 CRUD views (create, view, edit, delete, possibly others).
2356 The default logic here does just one thing: when "editing"
2357 (i.e. in :meth:`edit()` view) then all fields which are part
2358 of the :attr:`model_key` will be marked via
2359 :meth:`set_readonly()` so the user cannot change primary key
2360 values for a record.
2362 Subclass may override as needed. The ``form`` param will
2363 already be "complete" and ready to use as-is, but this method
2364 can further modify it based on request details etc.
2365 """
2366 form.remove('uuid')
2368 self.set_labels(form)
2370 if self.editing:
2371 for key in self.get_model_key():
2372 form.set_readonly(key)
2374 def objectify(self, form):
2375 """
2376 Must return a "model instance" object which reflects the
2377 validated form data.
2379 In simple cases this may just return the
2380 :attr:`~wuttaweb.forms.base.Form.validated` data dict.
2382 When dealing with SQLAlchemy models it would return a proper
2383 mapped instance, creating it if necessary.
2385 :param form: Reference to the *already validated*
2386 :class:`~wuttaweb.forms.base.Form` object. See the form's
2387 :attr:`~wuttaweb.forms.base.Form.validated` attribute for
2388 the data.
2390 See also :meth:`edit_save_form()` which calls this method.
2391 """
2393 # use ColanderAlchemy magic if possible
2394 schema = form.get_schema()
2395 if hasattr(schema, 'objectify'):
2396 # this returns a model instance
2397 return schema.objectify(form.validated,
2398 context=form.model_instance)
2400 # otherwise return data dict as-is
2401 return form.validated
2403 def persist(self, obj, session=None):
2404 """
2405 If applicable, this method should persist ("save") the given
2406 object's data (e.g. to DB), creating or updating it as needed.
2408 This is part of the "submit form" workflow; ``obj`` should be
2409 a model instance which already reflects the validated form
2410 data.
2412 Note that there is no default logic here, subclass must
2413 override if needed.
2415 :param obj: Model instance object as produced by
2416 :meth:`objectify()`.
2418 See also :meth:`edit_save_form()` which calls this method.
2419 """
2420 model = self.app.model
2421 model_class = self.get_model_class()
2422 if model_class and issubclass(model_class, model.Base):
2424 # add sqlalchemy model to session
2425 session = session or self.Session()
2426 session.add(obj)
2428 ##############################
2429 # row methods
2430 ##############################
2432 def get_rows_title(self):
2433 """
2434 Returns the display title for model **rows** grid, if
2435 applicable/desired. Only relevant if :attr:`has_rows` is
2436 true.
2438 There is no default here, but subclass may override by
2439 assigning :attr:`rows_title`.
2440 """
2441 if hasattr(self, 'rows_title'):
2442 return self.rows_title
2444 def make_row_model_grid(self, obj, **kwargs):
2445 """
2446 Create and return a grid for a record's **rows** data, for use
2447 in :meth:`view()`. Only applicable if :attr:`has_rows` is
2448 true.
2450 :param obj: Current model instance for which rows data is
2451 being displayed.
2453 :returns: :class:`~wuttaweb.grids.base.Grid` instance for the
2454 rows data.
2456 See also related methods, which are called by this one:
2458 * :meth:`get_row_grid_key()`
2459 * :meth:`get_row_grid_columns()`
2460 * :meth:`get_row_grid_data()`
2461 * :meth:`configure_row_grid()`
2462 """
2463 if 'key' not in kwargs:
2464 kwargs['key'] = self.get_row_grid_key()
2466 if 'model_class' not in kwargs:
2467 model_class = self.get_row_model_class()
2468 if model_class:
2469 kwargs['model_class'] = model_class
2471 if 'columns' not in kwargs:
2472 kwargs['columns'] = self.get_row_grid_columns()
2474 if 'data' not in kwargs:
2475 kwargs['data'] = self.get_row_grid_data(obj)
2477 kwargs.setdefault('filterable', self.rows_filterable)
2478 kwargs.setdefault('filter_defaults', self.rows_filter_defaults)
2479 kwargs.setdefault('sortable', self.rows_sortable)
2480 kwargs.setdefault('sort_multiple', not self.request.use_oruga)
2481 kwargs.setdefault('sort_on_backend', self.rows_sort_on_backend)
2482 kwargs.setdefault('sort_defaults', self.rows_sort_defaults)
2483 kwargs.setdefault('paginated', self.rows_paginated)
2484 kwargs.setdefault('paginate_on_backend', self.rows_paginate_on_backend)
2486 if 'actions' not in kwargs:
2487 actions = []
2489 if self.rows_viewable:
2490 actions.append(self.make_grid_action('view', icon='eye',
2491 url=self.get_row_action_url_view))
2493 if actions:
2494 kwargs['actions'] = actions
2496 grid = self.make_grid(**kwargs)
2497 self.configure_row_grid(grid)
2498 grid.load_settings()
2499 return grid
2501 def get_row_grid_key(self):
2502 """
2503 Returns the (presumably) unique key to be used for the
2504 **rows** grid in :meth:`view()`. Only relevant if
2505 :attr:`has_rows` is true.
2507 This is called from :meth:`make_row_model_grid()`; in the
2508 resulting grid, this becomes
2509 :attr:`~wuttaweb.grids.base.Grid.key`.
2511 Whereas you can define :attr:`grid_key` for the main grid, the
2512 row grid key is always generated dynamically. This
2513 incorporates the current record key (whose rows are in the
2514 grid) so that the rows grid for each record is unique.
2515 """
2516 parts = [self.get_grid_key()]
2517 for key in self.get_model_key():
2518 parts.append(str(self.request.matchdict[key]))
2519 return '.'.join(parts)
2521 def get_row_grid_columns(self):
2522 """
2523 Returns the default list of column names for the **rows**
2524 grid, for use in :meth:`view()`. Only relevant if
2525 :attr:`has_rows` is true.
2527 This is called by :meth:`make_row_model_grid()`; in the
2528 resulting grid, this becomes
2529 :attr:`~wuttaweb.grids.base.Grid.columns`.
2531 This method may return ``None``, in which case the grid may
2532 (try to) generate its own default list.
2534 Subclass may define :attr:`row_grid_columns` for simple cases,
2535 or can override this method if needed.
2537 Also note that :meth:`configure_row_grid()` may be used to
2538 further modify the final column set, regardless of what this
2539 method returns. So a common pattern is to declare all
2540 "supported" columns by setting :attr:`row_grid_columns` but
2541 then optionally remove or replace some of those within
2542 :meth:`configure_row_grid()`.
2543 """
2544 if hasattr(self, 'row_grid_columns'):
2545 return self.row_grid_columns
2547 def get_row_grid_data(self, obj):
2548 """
2549 Returns the data for the **rows** grid, for use in
2550 :meth:`view()`. Only relevant if :attr:`has_rows` is true.
2552 This is called by :meth:`make_row_model_grid()`; in the
2553 resulting grid, this becomes
2554 :attr:`~wuttaweb.grids.base.Grid.data`.
2556 Default logic not implemented; subclass must define this.
2557 """
2558 raise NotImplementedError
2560 def configure_row_grid(self, grid):
2561 """
2562 Configure the **rows** grid for use in :meth:`view()`. Only
2563 relevant if :attr:`has_rows` is true.
2565 This is called by :meth:`make_row_model_grid()`.
2567 There is minimal default logic here; subclass should override
2568 as needed. The ``grid`` param will already be "complete" and
2569 ready to use as-is, but this method can further modify it
2570 based on request details etc.
2571 """
2572 grid.remove('uuid')
2573 self.set_row_labels(grid)
2575 def set_row_labels(self, obj):
2576 """
2577 Set label overrides on a **row** form or grid, based on what
2578 is defined by the view class and its parent class(es).
2580 This is called automatically from
2581 :meth:`configure_row_grid()` and
2582 :meth:`configure_row_form()`.
2584 This calls :meth:`collect_row_labels()` to find everything,
2585 then it assigns the labels using one of (based on ``obj``
2586 type):
2588 * :func:`wuttaweb.forms.base.Form.set_label()`
2589 * :func:`wuttaweb.grids.base.Grid.set_label()`
2591 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
2592 :class:`~wuttaweb.forms.base.Form` instance.
2593 """
2594 labels = self.collect_row_labels()
2595 for key, label in labels.items():
2596 obj.set_label(key, label)
2598 def collect_row_labels(self):
2599 """
2600 Collect all **row** labels defined within the view class
2601 hierarchy.
2603 This is called by :meth:`set_row_labels()`.
2605 :returns: Dict of all labels found.
2606 """
2607 labels = {}
2608 hierarchy = self.get_class_hierarchy()
2609 for cls in hierarchy:
2610 if hasattr(cls, 'row_labels'):
2611 labels.update(cls.row_labels)
2612 return labels
2614 def get_row_action_url_view(self, row, i):
2615 """
2616 Must return the "view" action url for the given row object.
2618 Only relevant if :attr:`rows_viewable` is true.
2620 There is no default logic; subclass must override if needed.
2621 """
2622 raise NotImplementedError
2624 ##############################
2625 # class methods
2626 ##############################
2628 @classmethod
2629 def get_model_class(cls):
2630 """
2631 Returns the model class for the view (if defined).
2633 A model class will *usually* be a SQLAlchemy mapped class,
2634 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
2636 There is no default value here, but a subclass may override by
2637 assigning :attr:`model_class`.
2639 Note that the model class is not *required* - however if you
2640 do not set the :attr:`model_class`, then you *must* set the
2641 :attr:`model_name`.
2642 """
2643 if hasattr(cls, 'model_class'):
2644 return cls.model_class
2646 @classmethod
2647 def get_model_name(cls):
2648 """
2649 Returns the model name for the view.
2651 A model name should generally be in the format of a Python
2652 class name, e.g. ``'WuttaWidget'``. (Note this is
2653 *singular*, not plural.)
2655 The default logic will call :meth:`get_model_class()` and
2656 return that class name as-is. A subclass may override by
2657 assigning :attr:`model_name`.
2658 """
2659 if hasattr(cls, 'model_name'):
2660 return cls.model_name
2662 return cls.get_model_class().__name__
2664 @classmethod
2665 def get_model_name_normalized(cls):
2666 """
2667 Returns the "normalized" model name for the view.
2669 A normalized model name should generally be in the format of a
2670 Python variable name, e.g. ``'wutta_widget'``. (Note this is
2671 *singular*, not plural.)
2673 The default logic will call :meth:`get_model_name()` and
2674 simply lower-case the result. A subclass may override by
2675 assigning :attr:`model_name_normalized`.
2676 """
2677 if hasattr(cls, 'model_name_normalized'):
2678 return cls.model_name_normalized
2680 return cls.get_model_name().lower()
2682 @classmethod
2683 def get_model_title(cls):
2684 """
2685 Returns the "humanized" (singular) model title for the view.
2687 The model title will be displayed to the user, so should have
2688 proper grammar and capitalization, e.g. ``"Wutta Widget"``.
2689 (Note this is *singular*, not plural.)
2691 The default logic will call :meth:`get_model_name()` and use
2692 the result as-is. A subclass may override by assigning
2693 :attr:`model_title`.
2694 """
2695 if hasattr(cls, 'model_title'):
2696 return cls.model_title
2698 return cls.get_model_name()
2700 @classmethod
2701 def get_model_title_plural(cls):
2702 """
2703 Returns the "humanized" (plural) model title for the view.
2705 The model title will be displayed to the user, so should have
2706 proper grammar and capitalization, e.g. ``"Wutta Widgets"``.
2707 (Note this is *plural*, not singular.)
2709 The default logic will call :meth:`get_model_title()` and
2710 simply add a ``'s'`` to the end. A subclass may override by
2711 assigning :attr:`model_title_plural`.
2712 """
2713 if hasattr(cls, 'model_title_plural'):
2714 return cls.model_title_plural
2716 model_title = cls.get_model_title()
2717 return f"{model_title}s"
2719 @classmethod
2720 def get_model_key(cls):
2721 """
2722 Returns the "model key" for the master view.
2724 This should return a tuple containing one or more "field
2725 names" corresponding to the primary key for data records.
2727 In the most simple/common scenario, where the master view
2728 represents a Wutta-based SQLAlchemy model, the return value
2729 for this method is: ``('uuid',)``
2731 Any class mapped via SQLAlchemy should be supported
2732 automatically, the keys are determined from class inspection.
2734 But there is no "sane" default for other scenarios, in which
2735 case subclass should define :attr:`model_key`. If the model
2736 key cannot be determined, raises ``AttributeError``.
2738 :returns: Tuple of field names comprising the model key.
2739 """
2740 if hasattr(cls, 'model_key'):
2741 keys = cls.model_key
2742 if isinstance(keys, str):
2743 keys = [keys]
2744 return tuple(keys)
2746 model_class = cls.get_model_class()
2747 if model_class:
2748 # nb. we want the primary key but must avoid column names
2749 # in case mapped class uses different prop keys
2750 inspector = sa.inspect(model_class)
2751 keys = [col.name for col in inspector.primary_key]
2752 return tuple([prop.key for prop in inspector.column_attrs
2753 if all([col.name in keys for col in prop.columns])])
2755 raise AttributeError(f"you must define model_key for view class: {cls}")
2757 @classmethod
2758 def get_route_prefix(cls):
2759 """
2760 Returns the "route prefix" for the master view. This prefix
2761 is used for all named routes defined by the view class.
2763 For instance if route prefix is ``'widgets'`` then a view
2764 might have these routes:
2766 * ``'widgets'``
2767 * ``'widgets.create'``
2768 * ``'widgets.edit'``
2769 * ``'widgets.delete'``
2771 The default logic will call
2772 :meth:`get_model_name_normalized()` and simply add an ``'s'``
2773 to the end, making it plural. A subclass may override by
2774 assigning :attr:`route_prefix`.
2775 """
2776 if hasattr(cls, 'route_prefix'):
2777 return cls.route_prefix
2779 model_name = cls.get_model_name_normalized()
2780 return f'{model_name}s'
2782 @classmethod
2783 def get_permission_prefix(cls):
2784 """
2785 Returns the "permission prefix" for the master view. This
2786 prefix is used for all permissions defined by the view class.
2788 For instance if permission prefix is ``'widgets'`` then a view
2789 might have these permissions:
2791 * ``'widgets.list'``
2792 * ``'widgets.create'``
2793 * ``'widgets.edit'``
2794 * ``'widgets.delete'``
2796 The default logic will call :meth:`get_route_prefix()` and use
2797 that value as-is. A subclass may override by assigning
2798 :attr:`permission_prefix`.
2799 """
2800 if hasattr(cls, 'permission_prefix'):
2801 return cls.permission_prefix
2803 return cls.get_route_prefix()
2805 @classmethod
2806 def get_url_prefix(cls):
2807 """
2808 Returns the "URL prefix" for the master view. This prefix is
2809 used for all URLs defined by the view class.
2811 Using the same example as in :meth:`get_route_prefix()`, the
2812 URL prefix would be ``'/widgets'`` and the view would have
2813 defined routes for these URLs:
2815 * ``/widgets/``
2816 * ``/widgets/new``
2817 * ``/widgets/XXX/edit``
2818 * ``/widgets/XXX/delete``
2820 The default logic will call :meth:`get_route_prefix()` and
2821 simply add a ``'/'`` to the beginning. A subclass may
2822 override by assigning :attr:`url_prefix`.
2823 """
2824 if hasattr(cls, 'url_prefix'):
2825 return cls.url_prefix
2827 route_prefix = cls.get_route_prefix()
2828 return f'/{route_prefix}'
2830 @classmethod
2831 def get_instance_url_prefix(cls):
2832 """
2833 Generate the URL prefix specific to an instance for this model
2834 view. This will include model key param placeholders; it
2835 winds up looking like:
2837 * ``/widgets/{uuid}``
2838 * ``/resources/{foo}|{bar}|{baz}``
2840 The former being the most simple/common, and the latter
2841 showing what a "composite" model key looks like, with pipe
2842 symbols separating the key parts.
2843 """
2844 prefix = cls.get_url_prefix() + '/'
2845 for i, key in enumerate(cls.get_model_key()):
2846 if i:
2847 prefix += '|'
2848 prefix += f'{{{key}}}'
2849 return prefix
2851 @classmethod
2852 def get_template_prefix(cls):
2853 """
2854 Returns the "template prefix" for the master view. This
2855 prefix is used to guess which template path to render for a
2856 given view.
2858 Using the same example as in :meth:`get_url_prefix()`, the
2859 template prefix would also be ``'/widgets'`` and the templates
2860 assumed for those routes would be:
2862 * ``/widgets/index.mako``
2863 * ``/widgets/create.mako``
2864 * ``/widgets/edit.mako``
2865 * ``/widgets/delete.mako``
2867 The default logic will call :meth:`get_url_prefix()` and
2868 return that value as-is. A subclass may override by assigning
2869 :attr:`template_prefix`.
2870 """
2871 if hasattr(cls, 'template_prefix'):
2872 return cls.template_prefix
2874 return cls.get_url_prefix()
2876 @classmethod
2877 def get_grid_key(cls):
2878 """
2879 Returns the (presumably) unique key to be used for the primary
2880 grid in the :meth:`index()` view. This key may also be used
2881 as the basis (key prefix) for secondary grids.
2883 This is called from :meth:`make_model_grid()`; in the
2884 resulting :class:`~wuttaweb.grids.base.Grid` instance, this
2885 becomes :attr:`~wuttaweb.grids.base.Grid.key`.
2887 The default logic for this method will call
2888 :meth:`get_route_prefix()` and return that value as-is. A
2889 subclass may override by assigning :attr:`grid_key`.
2890 """
2891 if hasattr(cls, 'grid_key'):
2892 return cls.grid_key
2894 return cls.get_route_prefix()
2896 @classmethod
2897 def get_config_title(cls):
2898 """
2899 Returns the "config title" for the view/model.
2901 The config title is used for page title in the
2902 :meth:`configure()` view, as well as links to it. It is
2903 usually plural, e.g. ``"Wutta Widgets"`` in which case that
2904 winds up being displayed in the web app as: **Configure Wutta
2905 Widgets**
2907 The default logic will call :meth:`get_model_title_plural()`
2908 and return that as-is. A subclass may override by assigning
2909 :attr:`config_title`.
2910 """
2911 if hasattr(cls, 'config_title'):
2912 return cls.config_title
2914 return cls.get_model_title_plural()
2916 @classmethod
2917 def get_row_model_class(cls):
2918 """
2919 Returns the **row** model class for the view, if defined.
2920 Only relevant if :attr:`has_rows` is true.
2922 There is no default here, but a subclass may override by
2923 assigning :attr:`row_model_class`.
2924 """
2925 if hasattr(cls, 'row_model_class'):
2926 return cls.row_model_class
2928 ##############################
2929 # configuration
2930 ##############################
2932 @classmethod
2933 def defaults(cls, config):
2934 """
2935 Provide default Pyramid configuration for a master view.
2937 This is generally called from within the module's
2938 ``includeme()`` function, e.g.::
2940 from wuttaweb.views import MasterView
2942 class WidgetView(MasterView):
2943 model_name = 'Widget'
2945 def includeme(config):
2946 WidgetView.defaults(config)
2948 :param config: Reference to the app's
2949 :class:`pyramid:pyramid.config.Configurator` instance.
2950 """
2951 cls._defaults(config)
2953 @classmethod
2954 def _defaults(cls, config):
2955 route_prefix = cls.get_route_prefix()
2956 permission_prefix = cls.get_permission_prefix()
2957 url_prefix = cls.get_url_prefix()
2958 model_title = cls.get_model_title()
2959 model_title_plural = cls.get_model_title_plural()
2961 # permission group
2962 config.add_wutta_permission_group(permission_prefix,
2963 model_title_plural,
2964 overwrite=False)
2966 # index
2967 if cls.listable:
2968 config.add_route(route_prefix, f'{url_prefix}/')
2969 config.add_view(cls, attr='index',
2970 route_name=route_prefix,
2971 permission=f'{permission_prefix}.list')
2972 config.add_wutta_permission(permission_prefix,
2973 f'{permission_prefix}.list',
2974 f"Browse / search {model_title_plural}")
2976 # create
2977 if cls.creatable:
2978 config.add_route(f'{route_prefix}.create',
2979 f'{url_prefix}/new')
2980 config.add_view(cls, attr='create',
2981 route_name=f'{route_prefix}.create',
2982 permission=f'{permission_prefix}.create')
2983 config.add_wutta_permission(permission_prefix,
2984 f'{permission_prefix}.create',
2985 f"Create new {model_title}")
2987 # edit
2988 if cls.editable:
2989 instance_url_prefix = cls.get_instance_url_prefix()
2990 config.add_route(f'{route_prefix}.edit',
2991 f'{instance_url_prefix}/edit')
2992 config.add_view(cls, attr='edit',
2993 route_name=f'{route_prefix}.edit',
2994 permission=f'{permission_prefix}.edit')
2995 config.add_wutta_permission(permission_prefix,
2996 f'{permission_prefix}.edit',
2997 f"Edit {model_title}")
2999 # delete
3000 if cls.deletable:
3001 instance_url_prefix = cls.get_instance_url_prefix()
3002 config.add_route(f'{route_prefix}.delete',
3003 f'{instance_url_prefix}/delete')
3004 config.add_view(cls, attr='delete',
3005 route_name=f'{route_prefix}.delete',
3006 permission=f'{permission_prefix}.delete')
3007 config.add_wutta_permission(permission_prefix,
3008 f'{permission_prefix}.delete',
3009 f"Delete {model_title}")
3011 # bulk delete
3012 if cls.deletable_bulk:
3013 config.add_route(f'{route_prefix}.delete_bulk',
3014 f'{url_prefix}/delete-bulk',
3015 request_method='POST')
3016 config.add_view(cls, attr='delete_bulk',
3017 route_name=f'{route_prefix}.delete_bulk',
3018 permission=f'{permission_prefix}.delete_bulk')
3019 config.add_wutta_permission(permission_prefix,
3020 f'{permission_prefix}.delete_bulk',
3021 f"Delete {model_title_plural} in bulk")
3023 # autocomplete
3024 if cls.has_autocomplete:
3025 config.add_route(f'{route_prefix}.autocomplete',
3026 f'{url_prefix}/autocomplete')
3027 config.add_view(cls, attr='autocomplete',
3028 route_name=f'{route_prefix}.autocomplete',
3029 renderer='json',
3030 permission=f'{route_prefix}.list')
3032 # download
3033 if cls.downloadable:
3034 config.add_route(f'{route_prefix}.download',
3035 f'{instance_url_prefix}/download')
3036 config.add_view(cls, attr='download',
3037 route_name=f'{route_prefix}.download',
3038 permission=f'{permission_prefix}.download')
3039 config.add_wutta_permission(permission_prefix,
3040 f'{permission_prefix}.download',
3041 f"Download file(s) for {model_title}")
3043 # execute
3044 if cls.executable:
3045 config.add_route(f'{route_prefix}.execute',
3046 f'{instance_url_prefix}/execute',
3047 request_method='POST')
3048 config.add_view(cls, attr='execute',
3049 route_name=f'{route_prefix}.execute',
3050 permission=f'{permission_prefix}.execute')
3051 config.add_wutta_permission(permission_prefix,
3052 f'{permission_prefix}.execute',
3053 f"Execute {model_title}")
3055 # configure
3056 if cls.configurable:
3057 config.add_route(f'{route_prefix}.configure',
3058 f'{url_prefix}/configure')
3059 config.add_view(cls, attr='configure',
3060 route_name=f'{route_prefix}.configure',
3061 permission=f'{permission_prefix}.configure')
3062 config.add_wutta_permission(permission_prefix,
3063 f'{permission_prefix}.configure',
3064 f"Configure {model_title_plural}")
3066 # view
3067 # nb. always register this one last, so it does not take
3068 # priority over model-wide action routes, e.g. delete_bulk
3069 if cls.viewable:
3070 instance_url_prefix = cls.get_instance_url_prefix()
3071 config.add_route(f'{route_prefix}.view', instance_url_prefix)
3072 config.add_view(cls, attr='view',
3073 route_name=f'{route_prefix}.view',
3074 permission=f'{permission_prefix}.view')
3075 config.add_wutta_permission(permission_prefix,
3076 f'{permission_prefix}.view',
3077 f"View {model_title}")