Add page types report (#10850)

pull/10830/head
Jhonatan Lopes 2023-09-01 10:42:48 -03:00 zatwierdzone przez Matt Westcott
rodzic 10f387aca0
commit 678dd15852
8 zmienionych plików z 680 dodań i 3 usunięć

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1,89 @@
{% load i18n wagtailadmin_tags wagtailcore_tags %}
<table class="listing {% block table_classname %}{% endblock table_classname %}">
<col width="12%" />
<col width="12%" />
<col width="10%" />
<col width="20%" />
<col />
<thead>
{% block post_parent_page_headers %}
<tr class="table-headers">
<th class="title">
{% trans 'Type' %}
</th>
<th class="app-label">
{% trans 'App' %}
</th>
<th class="count">
{% trans 'Pages' %}
</th>
<th class="last-edited-page">
{% trans 'Last edited page' %}
</th>
<th class="last-edited-at">
{% trans 'Last edit' %}
</th>
{% block extra_columns %}
{% endblock extra_columns %}
</tr>
{% endblock post_parent_page_headers %}
</thead>
<tbody>
{% if page_types %}
{% for page_type in page_types %}
<tr class="{% block page_row_classname %}{% endblock page_row_classname %}">
<td class="page-type" valign="top" data-listing-page-type>
{% block page_type %}
{{ page_type.name|title }}
{% endblock page_type %}
</td>
<td class="app-label" valign="top">
{{ page_type.app_label }}.{{ page_type.model }}
</td>
<td class="count" valign="top">
{% if page_type.count > 0 %}
<a href="{% url 'wagtailadmin_pages:type_use' content_type_app_name=page_type.app_label content_type_model_name=page_type.model %}">{{ page_type.count }}</a>
{% else %}
{{ page_type.count }}
{% endif %}
</td>
<td class="last-edited-page" valign="top">
{% 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 %}
<a href="{% url 'wagtailadmin_pages:edit' page_type.last_edited_page.id %}">
{{ page_display_title }}
</a>
{% else %}
<p>{{ page_display_title }}</p>
{% 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 %}
</td>
<td class="last-edited-at" valign="top">
{% if page_type.last_edited_page.latest_revision_created_at %}
{% human_readable_date page_type.last_edited_page.latest_revision_created_at %}
{% else %}
-
{% endif %}
</td>
{% block extra_page_data %}
{% endblock extra_page_data %}
</tr>
{% endfor %}
{% else %}
{% block no_results %}
<p>{% trans "No page types found." %}</p>
{% endblock no_results %}
{% endif %}
</tbody>
</table>

Wyświetl plik

@ -0,0 +1,18 @@
{% extends "wagtailadmin/reports/base_report.html" %}
{% load i18n wagtailadmin_tags %}
{% block results %}
{% with object_list as page_types %}
<div id="page-results">
{% if page_types %}
{% block listing %}
{% include "wagtailadmin/reports/listing/_list_page_types_usage.html" %}
{% endblock listing %}
{% else %}
{% block no_results %}
<p>{% trans "No page types found." %}</p>
{% endblock no_results %}
{% endif %}
</div>
{% endwith %}
{% endblock results %}

Wyświetl plik

@ -6,16 +6,30 @@ from django.conf import settings
from django.conf.locale import LANG_INFO
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.db.models import F
from django.test import RequestFactory, TestCase
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import timezone, translation
from openpyxl import load_workbook
from wagtail.admin.views.mixins import ExcelDateFormatter
from wagtail.admin.views.reports import page_types_usage
from wagtail.admin.views.reports.audit_logging import LogEntriesView
from wagtail.models import GroupPagePermission, ModelLogEntry, Page, PageLogEntry
from wagtail.test.testapp.models import Advert
from wagtail.models import (
GroupPagePermission,
Locale,
ModelLogEntry,
Page,
PageLogEntry,
Site,
)
from wagtail.test.testapp.models import (
Advert,
EventPage,
EventPageSpeaker,
SimplePage,
)
from wagtail.test.utils import WagtailTestUtils
@ -718,3 +732,381 @@ class TestFilteredAgingPagesView(WagtailTestUtils, TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.aboutus_page.title)
self.assertNotContains(response, self.home_page.title)
class PageTypesUsageReportViewTest(WagtailTestUtils, TestCase):
fixtures = ["test.json"]
def setUp(self):
self.user = self.login()
def get(self, params={}):
return self.client.get(reverse("wagtailadmin_reports:page_types_usage"), params)
@staticmethod
def display_name(content_type):
return f"{content_type.app_label}.{content_type.model}"
def test_simple(self):
response = self.get()
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "wagtailadmin/reports/page_types_usage.html")
def test_displays_only_page_types(self):
"""Asserts that the correct models are included in the queryset."""
response = self.get()
# Assert that the response contains page models:
event_page_content_type = ContentType.objects.get_for_model(EventPage)
event_page_content_type_full_name = self.display_name(event_page_content_type)
self.assertContains(response, event_page_content_type_full_name)
simple_page_content_type = ContentType.objects.get_for_model(SimplePage)
simple_page_content_type_full_name = self.display_name(simple_page_content_type)
self.assertContains(response, simple_page_content_type_full_name)
# But it should not contain non-page models:
event_page_speaker_content_type = ContentType.objects.get_for_model(
EventPageSpeaker
)
event_page_speaker_content_type_full_name = self.display_name(
event_page_speaker_content_type
)
self.assertNotContains(response, event_page_speaker_content_type_full_name)
def test_displays_wagtailcore_page_if_has_instances(self):
"""Asserts that the wagtailcore.Page model is included in the queryset if it has instances."""
page_content_type = ContentType.objects.get_for_model(Page)
page_content_type_full_name = self.display_name(page_content_type)
# Start with no pages:
Page.objects.filter(depth__gt=1, content_type=page_content_type).delete()
# There aren't any Page instances and it is not creatable, it shouldn't be included in the report
response = self.get()
self.assertNotContains(response, page_content_type_full_name)
# Create a page:
page = Page(title="Page")
Page.get_first_root_node().add_child(instance=page)
# There is a Page now, so report should include the `Page` ContentType
response = self.get()
self.assertContains(response, page_content_type_full_name)
class PageTypesUsageReportViewQuerysetTests(WagtailTestUtils, TestCase):
def setUp(self):
super().setUp()
self.view = page_types_usage.PageTypesUsageReportView()
self.view.request = RequestFactory().get(
reverse("wagtailadmin_reports:page_types_usage")
)
self.root = Page.objects.first()
self.home = Page.objects.get(slug="home")
self.simple_page_a = SimplePage(title="Simple page A", content="hello")
self.simple_page_b = SimplePage(title="Simple page B", content="hello")
self.simple_page_c = SimplePage(title="Simple page C", content="hello")
Page.get_first_root_node().add_child(instance=self.simple_page_a)
Page.get_first_root_node().add_child(instance=self.simple_page_b)
Page.get_first_root_node().add_child(instance=self.simple_page_c)
self.simple_page_a.save_revision().publish()
self.simple_page_b.save_revision().publish()
self.simple_page_c.save_revision().publish()
self.event_page = EventPage(
title="Event Page",
audience="public",
location="foo",
cost="bar",
date_from=datetime.date.today(),
)
Page.get_first_root_node().add_child(instance=self.event_page)
def test_queryset_ordering(self):
"""Asserts that the queryset is ordered by page model count."""
# Test that the queryset is correctly ordered by page model count
queryset = self.view.get_queryset()
# Convert queryset to list of ids to make it easier to test
queryset_list_pks = list(queryset.values_list("pk", flat=True))
# Get the positions of the content types in the list
simple_page_content_type = ContentType.objects.get_for_model(SimplePage)
simple_page_position = queryset_list_pks.index(simple_page_content_type.pk)
event_page_content_type = ContentType.objects.get_for_model(EventPage)
event_page_position = queryset_list_pks.index(event_page_content_type.pk)
# Assert that the SimplePage comes before EventPage, since it has more entries
self.assertTrue(simple_page_position < event_page_position)
# There should be 3 SimplePages
self.assertEqual(queryset.get(id=simple_page_content_type.pk).count, 3)
# There should be 1 EventPage
self.assertEqual(queryset.get(id=event_page_content_type.pk).count, 1)
def test_queryset_last_edited_page(self):
"""Tests that the queryset correctly returns the last edited page."""
# Edit the first simple page
revision = self.simple_page_a.save_revision()
revision.publish()
# Edit the second simple page
revision = self.simple_page_b.save_revision()
revision.publish()
# Edit the third simple page
revision = self.simple_page_c.save_revision()
revision.publish()
# Re-edit the first simple page
revision = self.simple_page_a.save_revision()
revision.publish()
# Get the queryset:
queryset = self.view.decorate_paginated_queryset(self.view.get_queryset())
# Assert that the first simple page is the last edited page
self.simple_page_a.refresh_from_db()
self.assertEqual(queryset[0].last_edited_page.specific, self.simple_page_a)
@override_settings(LANGUAGE_CODE="en", WAGTAIL_I18N_ENABLED=True)
class PageTypesReportFiltersTests(WagtailTestUtils, TestCase):
def setUp(self):
self.user = self.login()
self.default_locale = Locale.get_default()
self.fr_locale, _ = Locale.objects.get_or_create(language_code="fr")
def get(self, params={}):
return self.client.get(reverse("wagtailadmin_reports:page_types_usage"), params)
def test_locale_filtering(self):
# Create pages in default locale
event_page = EventPage(
title="Event Page",
audience="public",
location="foo",
cost="bar",
date_from=datetime.date.today(),
)
simple_page = SimplePage(title="Simple Page", content="hello")
Page.get_first_root_node().add_child(instance=event_page)
Page.get_first_root_node().add_child(instance=simple_page)
event_page.save_revision().publish()
simple_page.save_revision().publish()
# Translate pages to French
event_page.copy_for_translation(self.fr_locale)
simple_page.copy_for_translation(self.fr_locale)
# Edit the simple page in English to make sure that it's the latest
simple_page.title = "Updated Simple Page English title"
revision = simple_page.save_revision()
simple_page.publish(revision)
response = self.get()
page_types = {
content_type.id: content_type
for content_type in response.context["object_list"]
}
event_page_row = page_types.get(ContentType.objects.get_for_model(EventPage).pk)
simple_page_row = page_types.get(
ContentType.objects.get_for_model(SimplePage).pk
)
self.assertEqual(event_page_row.count, 2)
self.assertEqual(simple_page_row.count, 2)
# The last edited page should be the French version
self.assertEqual(event_page_row.last_edited_page.locale, self.fr_locale)
# The last edited SimplePage should be the English version
self.assertEqual(simple_page_row.last_edited_page.locale, self.default_locale)
# Filter by French locale
response = self.get({"page_locale": self.fr_locale.language_code})
page_types = {
content_type.id: content_type
for content_type in response.context["object_list"]
}
event_page_row = page_types.get(ContentType.objects.get_for_model(EventPage).pk)
simple_page_row = page_types.get(
ContentType.objects.get_for_model(SimplePage).pk
)
# There should be 1 of each page (only the French locale ones)
self.assertEqual(event_page_row.count, 1)
self.assertEqual(simple_page_row.count, 1)
# The last edited page should be the French version (even though page was later edited in English)
self.assertEqual(event_page_row.last_edited_page.locale, self.fr_locale)
self.assertEqual(simple_page_row.last_edited_page.locale, self.fr_locale)
def test_site_filtering_with_single_site(self):
"""Asserts that the site filter is not displayed when there is only one site."""
sites = Site.objects.all()
self.assertEqual(sites.count(), 1)
response = self.get()
filterset = response.context["filters"]
# Assert that the filterset does not have the site field
self.assertNotIn("site", filterset.form.fields)
self.assertNotIn("site", filterset.filters.keys())
self.assertFalse(filterset.sites_filter_enabled)
def test_site_filtering_with_multiple_sites(self):
root_page = Page.get_first_root_node()
# Create pages in default locale
event_page = EventPage(
title="Event Page",
audience="public",
location="foo",
cost="bar",
date_from=datetime.date.today(),
)
simple_page = SimplePage(title="Simple Page", content="hello")
root_page.add_child(instance=event_page)
root_page.add_child(instance=simple_page)
# Create a new site and add the pages to it
simple_page_site = Site.objects.create(
hostname="example.com", root_page=simple_page, is_default_site=False
)
self.assertEqual(Site.objects.count(), 2)
response = self.get()
page_types = {
content_type.id: content_type
for content_type in response.context["object_list"]
}
event_page_row = page_types.get(ContentType.objects.get_for_model(EventPage).pk)
simple_page_row = page_types.get(
ContentType.objects.get_for_model(SimplePage).pk
)
self.assertEqual(event_page_row.count, 1)
self.assertEqual(simple_page_row.count, 1)
# Filter by the simple_page_site
response = self.get({"site": simple_page_site.root_page.path})
page_types = {
content_type.id: content_type
for content_type in response.context["object_list"]
}
simple_page_row = page_types.get(
ContentType.objects.get_for_model(SimplePage).pk
)
# There should be 1 SimplePage
self.assertEqual(simple_page_row.count, 1)
# There shouldn't be a regular Page
self.assertFalse(ContentType.objects.get_for_model(EventPage).pk in page_types)
@override_settings(
WAGTAIL_CONTENT_LANGUAGES=[
("en", "English"),
("de", "German"),
("fr", "French"),
],
)
def test_get_locale_choices(self):
choices = page_types_usage._get_locale_choices()
expected_choices = [
("en", "English"),
("de", "German"),
("fr", "French"),
]
self.assertCountEqual(choices, expected_choices)
class TestPageTypesUsageReportViewPermissions(WagtailTestUtils, TestCase):
fixtures = ["test.json"]
def setUp(self):
self.user = self.login()
def get(self, params={}):
return self.client.get(reverse("wagtailadmin_reports:page_types_usage"), params)
def test_simple(self):
response = self.get()
self.assertEqual(response.status_code, 200)
def test_get_with_no_permission(self):
group = Group.objects.create(name="test group")
self.user.is_superuser = False
self.user.save()
self.user.groups.add(group)
self.user.user_permissions.add(
Permission.objects.get(
content_type__app_label="wagtailadmin", codename="access_admin"
)
)
# No GroupPagePermission created
response = self.get()
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, reverse("wagtailadmin_home"))
def test_get_with_minimal_permissions(self):
group = Group.objects.create(name="test group")
self.user.is_superuser = False
self.user.save()
self.user.groups.add(group)
self.user.user_permissions.add(
Permission.objects.get(
content_type__app_label="wagtailadmin", codename="access_admin"
)
)
GroupPagePermission.objects.create(
group=group,
page=Page.objects.first(),
permission_type="add",
)
response = self.get()
self.assertEqual(response.status_code, 200)
def test_get_with_page_specific_permissions(self):
group = Group.objects.create(name="test group")
self.user.is_superuser = False
self.user.save()
self.user.groups.add(group)
self.user.user_permissions.add(
Permission.objects.get(
content_type__app_label="wagtailadmin", codename="access_admin"
)
)
latest_edited_event_page = EventPage.objects.order_by(
F("latest_revision_created_at").desc(nulls_last=True), "title", "-pk"
).first()
latest_edited_simple_page = SimplePage.objects.order_by(
F("latest_revision_created_at").desc(nulls_last=True), "title", "-pk"
).first()
GroupPagePermission.objects.create(
group=group,
page=latest_edited_event_page,
permission_type="change",
)
response = self.get()
self.assertEqual(response.status_code, 200)
# For pages that the user can edit, it should show the page title and link to edit the page:
edit_event_page_url = reverse(
"wagtailadmin_pages:edit", args=(latest_edited_event_page.id,)
)
self.assertContains(
response,
f"<a href={edit_event_page_url}>{latest_edited_event_page.get_admin_display_title()}</a>",
html=True,
)
# For pages that the user cannot edit, it should only show the page title
self.assertContains(
response,
f"<p>{latest_edited_simple_page.get_admin_display_title()}</p>",
html=True,
)
edit_simple_page_url = reverse(
"wagtailadmin_pages:edit", args=(latest_edited_simple_page.id,)
)
self.assertNotContains(
response,
f"<a href={edit_simple_page_url}>{latest_edited_simple_page.get_admin_display_title()}</a>",
html=True,
)

Wyświetl plik

@ -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"
),
]

Wyświetl plik

@ -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)

Wyświetl plik

@ -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(