diff --git a/docs/reference/viewsets.md b/docs/reference/viewsets.md index cba052ab78..040122861e 100644 --- a/docs/reference/viewsets.md +++ b/docs/reference/viewsets.md @@ -77,6 +77,7 @@ Viewsets are Wagtail's mechanism for defining a group of related admin views wit ```{eval-rst} .. autoclass:: wagtail.snippets.views.snippets.SnippetViewSet + .. autoattribute:: list_display .. autoattribute:: filterset_class .. autoattribute:: index_view_class .. autoattribute:: add_view_class diff --git a/docs/topics/snippets.md b/docs/topics/snippets.md index e92da25f07..af5f460717 100644 --- a/docs/topics/snippets.md +++ b/docs/topics/snippets.md @@ -323,7 +323,7 @@ You can also save revisions programmatically by calling the {meth}`~wagtail.mode The `DraftStateMixin` class was introduced. ``` -If a snippet model inherits from {class}`~wagtail.models.DraftStateMixin`, Wagtail will automatically change the "Save" action menu in the snippets admin to "Save draft" and add a new "Publish" action menu. Any changes you save in the snippets admin will be saved as revisions and will not be reflected to the "live" snippet instance until you publish the changes. For example, the `Advert` snippet could save draft changes by defining it as follows: +If a snippet model inherits from {class}`~wagtail.models.DraftStateMixin`, Wagtail will automatically add a live/draft status column to the listing view, change the "Save" action menu to "Save draft", and add a new "Publish" action menu in the editor. Any changes you save in the snippets admin will be saved as revisions and will not be reflected to the "live" snippet instance until you publish the changes. For example, the `Advert` snippet could save draft changes by defining it as follows: ```python # ... @@ -400,7 +400,7 @@ This can be done by removing the `@register_snippet` decorator on your model cla register_snippet(MyModel, viewset=MyModelViewSet) ``` -For example, with the following `Member` model: +For example, with the following `Member` model and a `MemberFilterSet` class: ```python # models.py @@ -418,6 +418,12 @@ class Member(models.Model): name = models.CharField(max_length=255) shirt_size = models.CharField(max_length=5, choices=ShirtSize.choices, default=ShirtSize.MEDIUM) + def get_shirt_size_display(self): + return self.ShirtSize(self.shirt_size).label + + get_shirt_size_display.admin_order_field = "shirt_size" + get_shirt_size_display.short_description = "Size description" + class MemberFilterSet(WagtailFilterSet): class Meta: @@ -425,16 +431,18 @@ class MemberFilterSet(WagtailFilterSet): fields = ["shirt_size"] ``` -You can add a `filterset_class` to the listing view by defining a subclass of `SnippetViewSet` as below: +You can define a {attr}`~wagtail.snippets.views.snippets.SnippetViewSet.list_display` attribute to specify the columns shown on the listing view. You can also add the ability to filter the listing view by defining a {attr}`~wagtail.snippets.views.snippets.SnippetViewSet.filterset_class` attribute on a subclass of `SnippetViewSet`. For example: ```python # views.py +from wagtail.admin.ui.tables import UpdatedAtColumn from wagtail.snippets.views.snippets import SnippetViewSet from myapp.models import MemberFilterSet class MemberViewSet(SnippetViewSet): + list_display = ["name", "shirt_size", "get_shirt_size_display", UpdatedAtColumn()] filterset_class = MemberFilterSet ``` diff --git a/wagtail/admin/views/generic/models.py b/wagtail/admin/views/generic/models.py index e763e24356..6da0a97571 100644 --- a/wagtail/admin/views/generic/models.py +++ b/wagtail/admin/views/generic/models.py @@ -1,5 +1,5 @@ from django import VERSION as DJANGO_VERSION -from django.contrib.admin.utils import quote, unquote +from django.contrib.admin.utils import label_for_field, quote, unquote from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ImproperlyConfigured from django.db import models, transaction @@ -22,7 +22,7 @@ from wagtail.admin import messages from wagtail.admin.forms.search import SearchForm from wagtail.admin.panels import get_edit_handler from wagtail.admin.templatetags.wagtailadmin_tags import user_display_name -from wagtail.admin.ui.tables import DateColumn, StatusTagColumn, Table, TitleColumn +from wagtail.admin.ui.tables import Column, Table, TitleColumn, UpdatedAtColumn from wagtail.log_actions import log from wagtail.log_actions import registry as log_registry from wagtail.models import DraftStateMixin, RevisionMixin @@ -86,6 +86,7 @@ class IndexView( filters = None filterset_class = None table_class = Table + list_display = ["__str__", UpdatedAtColumn()] def setup(self, request, *args, **kwargs): super().setup(request, *args, **kwargs) @@ -241,48 +242,49 @@ class IndexView( } return queryset.filter(**filters) - def _get_title_column(self, column_class=TitleColumn): - def title_accessor(obj): - draftstate_enabled = self.model and issubclass(self.model, DraftStateMixin) - - if draftstate_enabled and obj.latest_revision: - return obj.latest_revision.object_str - return str(obj) - - return column_class( - "name", - label=gettext_lazy("Name"), - accessor=title_accessor, - get_url=self.get_edit_url, + def _get_title_column(self, field_name, column_class=TitleColumn, **kwargs): + if not self.model: + return column_class( + "name", + label=gettext_lazy("Name"), + accessor=str, + get_url=self.get_edit_url, + ) + return self._get_custom_column( + field_name, column_class, get_url=self.get_edit_url, **kwargs ) - def _get_updated_at_column(self, column_class=DateColumn): - return column_class("_updated_at", label=_("Updated"), sort_key="_updated_at") + def _get_custom_column(self, field_name, column_class=Column, **kwargs): + label, attr = label_for_field(field_name, self.model, return_attr=True) + sort_key = getattr(attr, "admin_order_field", None) + + # attr is None if the field is an actual database field, + # so it's possible to sort by it + if attr is None: + sort_key = field_name - def _get_status_tag_column(self, column_class=StatusTagColumn): return column_class( - "status_string", - label=_("Status"), - sort_key="live", - primary=lambda instance: instance.live, + field_name, + label=label.title(), + sort_key=sort_key, + **kwargs, ) - def _get_default_columns(self): - columns = [ - self._get_title_column(), - self._get_updated_at_column(), - ] - - draftstate_enabled = self.model and issubclass(self.model, DraftStateMixin) - if draftstate_enabled: - columns.append(self._get_status_tag_column()) - return columns - def get_columns(self): try: return self.columns except AttributeError: - return self._get_default_columns() + columns = [] + for i, field in enumerate(self.list_display): + if isinstance(field, Column): + column = field + elif i == 0: + column = self._get_title_column(field) + else: + column = self._get_custom_column(field) + columns.append(column) + + return columns def get_index_url(self): if self.index_url_name: diff --git a/wagtail/snippets/views/snippets.py b/wagtail/snippets/views/snippets.py index 3c6f705946..e83fd198e0 100644 --- a/wagtail/snippets/views/snippets.py +++ b/wagtail/snippets/views/snippets.py @@ -23,6 +23,7 @@ from wagtail.admin.ui.tables import ( Column, DateColumn, InlineActionsTable, + LiveStatusTagColumn, TitleColumn, UserColumn, ) @@ -158,8 +159,19 @@ class IndexView(generic.IndexView): results_only = False table_class = InlineActionsTable - def _get_title_column(self, column_class=SnippetTitleColumn): - return super()._get_title_column(column_class) + def _get_title_column(self, field_name, column_class=SnippetTitleColumn, **kwargs): + accessor = kwargs.pop("accessor", None) + + if not accessor and field_name == "__str__": + + def accessor(obj): + if isinstance(obj, DraftStateMixin) and obj.latest_revision: + return obj.latest_revision.object_str + return str(obj) + + return super()._get_title_column( + field_name, column_class, accessor=accessor, **kwargs + ) def get_columns(self): return [ @@ -680,6 +692,21 @@ class SnippetViewSet(ViewSet): #: A subclass of ``wagtail.admin.filters.WagtailFilterSet``, which is a subclass of `django_filters.FilterSet `_. This will be passed to the ``filterset_class`` attribute of the index view. filterset_class = None + #: A list or tuple, where each item is either: + #: + #: - The name of a field on the model; + #: - The name of a callable or property on the model that accepts a single parameter for the model instance; or + #: - An instance of the ``wagtail.admin.ui.tables.Column`` class. + #: + #: If the name refers to a database field, the ability to sort the listing by the database column will be offerred and the field's verbose name will be used as the column header. + #: + #: If the name refers to a callable or property, a ``admin_order_field`` attribute can be defined on it to point to the database column for sorting. + #: A ``short_description`` attribute can also be defined on the callable or property to be used as the column header. + #: + #: This list will be passed to the ``list_display`` attribute of the index view. + #: If left unset, the ``list_display`` attribute of the index view will be used instead, which by default is defined as ``["__str__", wagtail.admin.ui.tables.UpdatedAtColumn()]``. + list_display = None + #: The view class to use for the index view; must be a subclass of ``wagtail.snippet.views.snippets.IndexView``. index_view_class = IndexView @@ -713,6 +740,17 @@ class SnippetViewSet(ViewSet): #: The view class to use for previewing on the edit view; must be a subclass of ``wagtail.snippet.views.snippets.PreviewOnEditView``. preview_on_edit_view_class = PreviewOnEditView + def __init__(self, name, **kwargs): + super().__init__(name, **kwargs) + self.preview_enabled = issubclass(self.model, PreviewableMixin) + self.revision_enabled = issubclass(self.model, RevisionMixin) + self.draftstate_enabled = issubclass(self.model, DraftStateMixin) + + if not self.list_display: + self.list_display = self.index_view_class.list_display.copy() + if self.draftstate_enabled: + self.list_display += [LiveStatusTagColumn()] + @property def revisions_revert_view_class(self): """ @@ -745,6 +783,7 @@ class SnippetViewSet(ViewSet): add_url_name=self.get_url_name("add"), edit_url_name=self.get_url_name("edit"), delete_multiple_url_name=self.get_url_name("delete-multiple"), + list_display=self.list_display, ) @property @@ -759,6 +798,7 @@ class SnippetViewSet(ViewSet): add_url_name=self.get_url_name("add"), edit_url_name=self.get_url_name("edit"), delete_multiple_url_name=self.get_url_name("delete-multiple"), + list_display=self.list_display, ) @property @@ -896,7 +936,7 @@ class SnippetViewSet(ViewSet): path("history//", self.history_view, name="history"), ] - if issubclass(self.model, PreviewableMixin): + if self.preview_enabled: urlpatterns += [ path("preview/", self.preview_on_add_view, name="preview_on_add"), path( @@ -906,8 +946,8 @@ class SnippetViewSet(ViewSet): ), ] - if issubclass(self.model, RevisionMixin): - if issubclass(self.model, PreviewableMixin): + if self.revision_enabled: + if self.preview_enabled: urlpatterns += [ path( "history//revisions//view/", @@ -929,7 +969,7 @@ class SnippetViewSet(ViewSet): ), ] - if issubclass(self.model, DraftStateMixin): + if self.draftstate_enabled: urlpatterns += [ path("unpublish//", self.unpublish_view, name="unpublish"), ]