diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 86486cbfbc..60bf2238b0 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -13,6 +13,7 @@ Changelog * Allow `Page.permissions_for_user()` to be overridden by specific page types (Sébastien Corbin) * Improve visual alignment of explore icon in Page listings for longer content (Krzysztof Jeziorny) * Add `extra_actions` blocks to Snippets and generic index templates (Bhuvnesh Sharma) + * Added page types usage report (Jhonatan Lopes) * Fix: Update system check for overwriting storage backends to recognise the `STORAGES` setting introduced in Django 4.2 (phijma-leukeleu) * Fix: Prevent password change form from raising a validation error when browser autocomplete fills in the "Old password" field (Chiemezuo Akujobi) * Fix: Ensure that the legacy dropdown options, when closed, do not get accidentally clicked by other interactions wide viewports (CheesyPhoenix, Christer Jensen) diff --git a/docs/releases/6.0.md b/docs/releases/6.0.md index 447102e73f..d7d962d2fb 100644 --- a/docs/releases/6.0.md +++ b/docs/releases/6.0.md @@ -23,6 +23,7 @@ depth: 1 * Allow `Page.permissions_for_user()` to be overridden by specific page types (Sébastien Corbin) * Improve visual alignment of explore icon in Page listings for longer content (Krzysztof Jeziorny) * Add `extra_actions` blocks to Snippets and generic index templates (Bhuvnesh Sharma) + * Added page types usage report (Jhonatan Lopes) ### Bug fixes diff --git a/wagtail/admin/templates/wagtailadmin/reports/listing/_list_page_types_usage.html b/wagtail/admin/templates/wagtailadmin/reports/listing/_list_page_types_usage.html new file mode 100644 index 0000000000..54dbf798b0 --- /dev/null +++ b/wagtail/admin/templates/wagtailadmin/reports/listing/_list_page_types_usage.html @@ -0,0 +1,89 @@ +{% load i18n wagtailadmin_tags wagtailcore_tags %} + +
+ {% trans 'Type' %} + | ++ {% trans 'App' %} + | ++ {% trans 'Pages' %} + | ++ {% trans 'Last edited page' %} + | ++ {% trans 'Last edit' %} + | + {% block extra_columns %} + {% endblock extra_columns %} +
---|---|---|---|---|
+ {% block page_type %} + {{ page_type.name|title }} + {% endblock page_type %} + | ++ {{ page_type.app_label }}.{{ page_type.model }} + | ++ {% if page_type.count > 0 %} + {{ page_type.count }} + {% else %} + {{ page_type.count }} + {% endif %} + | +
+ {% if page_type.last_edited_page %}
+ {% page_permissions page_type.last_edited_page as perms %}
+ {% with page_display_title=page_type.last_edited_page.get_admin_display_title %}
+ {% if perms.can_edit %}
+
+ {{ page_display_title }}
+
+ {% else %}
+ {{ page_display_title }} + {% endif %} + {% endwith %} + {% i18n_enabled as show_locale_labels %} + {% if show_locale_labels %} + {% locale_label_from_id page_type.last_edited_page.locale_id as locale_label %} + {% status locale_label classname="w-status--label" %} + {% endif %} + {% else %} + - + {% endif %} + |
+ + {% if page_type.last_edited_page.latest_revision_created_at %} + {% human_readable_date page_type.last_edited_page.latest_revision_created_at %} + {% else %} + - + {% endif %} + | + {% block extra_page_data %} + {% endblock extra_page_data %} +
{% trans "No page types found." %}
+ {% endblock no_results %} + {% endif %} +{latest_edited_simple_page.get_admin_display_title()}
", + html=True, + ) + edit_simple_page_url = reverse( + "wagtailadmin_pages:edit", args=(latest_edited_simple_page.id,) + ) + self.assertNotContains( + response, + f"{latest_edited_simple_page.get_admin_display_title()}", + html=True, + ) diff --git a/wagtail/admin/urls/reports.py b/wagtail/admin/urls/reports.py index 9e1bde3d52..26400a2fcc 100644 --- a/wagtail/admin/urls/reports.py +++ b/wagtail/admin/urls/reports.py @@ -3,6 +3,9 @@ from django.urls import path from wagtail.admin.views.reports.aging_pages import AgingPagesView from wagtail.admin.views.reports.audit_logging import LogEntriesView from wagtail.admin.views.reports.locked_pages import LockedPagesView +from wagtail.admin.views.reports.page_types_usage import ( + PageTypesUsageReportView, +) from wagtail.admin.views.reports.workflows import WorkflowTasksView, WorkflowView app_name = "wagtailadmin_reports" @@ -12,4 +15,7 @@ urlpatterns = [ path("workflow_tasks/", WorkflowTasksView.as_view(), name="workflow_tasks"), path("site-history/", LogEntriesView.as_view(), name="site_history"), path("aging-pages/", AgingPagesView.as_view(), name="aging_pages"), + path( + "page-types-usage/", PageTypesUsageReportView.as_view(), name="page_types_usage" + ), ] diff --git a/wagtail/admin/views/reports/page_types_usage.py b/wagtail/admin/views/reports/page_types_usage.py new file mode 100644 index 0000000000..fca2cee452 --- /dev/null +++ b/wagtail/admin/views/reports/page_types_usage.py @@ -0,0 +1,152 @@ +import django_filters +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.db.models import Count, F, OuterRef, Q, Subquery +from django.utils.translation import gettext_lazy as _ + +from wagtail.admin.filters import WagtailFilterSet +from wagtail.admin.views.reports import ReportView +from wagtail.coreutils import get_content_languages +from wagtail.models import ContentType, Page, Site, get_page_models +from wagtail.permission_policies.pages import PagePermissionPolicy + + +def _get_locale_choices(): + return list(get_content_languages().items()) + + +def _get_site_choices(): + """Tuples of (site root page path, site display name) for all sites in project.""" + choices = [ + (site.root_page.path, str(site)) + for site in Site.objects.all().select_related("root_page") + ] + return choices + + +def _annotate_last_edit_info(queryset, language_code, site_root_path): + latest_edited_page_filter_kwargs = {} + page_count_filter_kwargs = {} + if language_code: + latest_edited_page_filter_kwargs["locale__language_code"] = language_code + page_count_filter_kwargs["pages__locale__language_code"] = language_code + if site_root_path: + latest_edited_page_filter_kwargs["path__startswith"] = site_root_path + page_count_filter_kwargs["pages__path__startswith"] = site_root_path + + latest_edited_page = Page.objects.filter( + content_type=OuterRef("pk"), **latest_edited_page_filter_kwargs + ).order_by(F("latest_revision_created_at").desc(nulls_last=True), "title", "-pk") + + queryset = queryset.annotate( + count=Count("pages", filter=Q(**page_count_filter_kwargs)), + last_edited_page_id=Subquery(latest_edited_page.values("pk")[:1]), + ) + + return queryset + + +class LocaleFilter(django_filters.ChoiceFilter): + def filter(self, qs, language_code): + if language_code: + return qs.filter(pages__locale__language_code=language_code) + return qs + + +class SiteFilter(django_filters.ChoiceFilter): + def filter(self, qs, site_root_path): + # Value passed will be the site root page path + # To check if a page is in a site, we check if the page path starts with the + # site's root page path + if site_root_path: + return qs.filter(pages__path__startswith=site_root_path) + return qs + + +class PageTypesUsageReportFilterSet(WagtailFilterSet): + page_locale = LocaleFilter( + label=_("Locale"), + choices=_get_locale_choices, + empty_label=None, + null_label=_("All"), + null_value=None, + ) + site = SiteFilter( + label=_("Site"), + choices=_get_site_choices, + empty_label=None, + null_label=_("All"), + null_value=None, + ) + + class Meta: + model = ContentType + fields = ["page_locale", "site"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.sites = { + site.pk: site for site in Site.objects.all().prefetch_related("root_page") + } + self.sites_filter_enabled = True + if len(self.sites) == 1: + # If there is only one site, we don't need to show the site filter + self.sites_filter_enabled = False + del self.filters["site"] + + +class PageTypesUsageReportView(ReportView): + template_name = "wagtailadmin/reports/page_types_usage.html" + title = _("Page types usage") + header_icon = "doc-empty-inverse" + filterset_class = PageTypesUsageReportFilterSet + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.page_models = [model.__name__.lower() for model in get_page_models()] + self.i18n_enabled = getattr(settings, "WAGTAIL_I18N_ENABLED", False) + + def decorate_paginated_queryset(self, page_types): + pages_mapping = Page.objects.specific().in_bulk( + obj.last_edited_page_id for obj in page_types if obj.last_edited_page_id + ) + + for item in page_types: + item.last_edited_page = ( + pages_mapping[item.last_edited_page_id] + if item.last_edited_page_id + else None + ) + + return page_types + + def get_queryset(self): + queryset = ContentType.objects.filter(model__in=self.page_models) + + page_content_type = ContentType.objects.get_for_model(Page) + has_pages = Page.objects.filter( + depth__gt=1, content_type=page_content_type + ).exists() + if not has_pages: + # If there are no `wagtailcore.Page` pages, we don't need to + # show it in the report + queryset = queryset.exclude(id=page_content_type.id) + + self.queryset = queryset + + self.filters, queryset = self.filter_queryset(queryset) + + language_code = self.filters.form.cleaned_data.get("page_locale", None) + site_root_path = self.filters.form.cleaned_data.get("site", None) + queryset = _annotate_last_edit_info(queryset, language_code, site_root_path) + + queryset = queryset.order_by("-count", "app_label", "model") + + return queryset + + def dispatch(self, request, *args, **kwargs): + if not PagePermissionPolicy().user_has_any_permission( + request.user, ["add", "change", "publish"] + ): + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) diff --git a/wagtail/admin/wagtail_hooks.py b/wagtail/admin/wagtail_hooks.py index 69bbfc84e7..c6ee7e64ec 100644 --- a/wagtail/admin/wagtail_hooks.py +++ b/wagtail/admin/wagtail_hooks.py @@ -874,6 +874,13 @@ class AgingPagesReportMenuItem(MenuItem): ) +class PageTypesReportMenuItem(MenuItem): + def is_shown(self, request): + return PagePermissionPolicy().user_has_any_permission( + request.user, ["add", "change", "publish"] + ) + + @hooks.register("register_reports_menu_item") def register_locked_pages_menu_item(): return LockedPagesMenuItem( @@ -929,6 +936,17 @@ def register_aging_pages_report_menu_item(): ) +@hooks.register("register_reports_menu_item") +def register_page_types_report_menu_item(): + return PageTypesReportMenuItem( + _("Page types usage"), + reverse("wagtailadmin_reports:page_types_usage"), + name="page-types-usage", + icon_name="doc-empty-inverse", + order=1200, + ) + + @hooks.register("register_admin_menu_item") def register_reports_menu(): return SubmenuMenuItem(