Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions apps/core/reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from django.conf import settings
from django.forms import Media
from django.utils.html import strip_tags
from django.utils.translation import gettext_lazy as _
from wagtail.admin.views.reports import PageReportView
from wagtail.permissions import page_permission_policy

from apps.core.models import ContentPage


def iter_annotated_blocks(page):
"""Yield (index, StreamChild) for each text_annotated block in the page's body."""
for block_index, block in enumerate(page.body):
if block.block_type == "text_annotated":
yield block_index, block


def _truncate(text, max_length):
if len(text) > max_length:
return text[:max_length] + "…"
return text


def _parse_version(version):
if not version:
return None
try:
return tuple(int(part) for part in version.split("."))
except ValueError:
return None


def _build_block_detail(block_index, block, current_version):
value = block.value
change_type = value.get("change_type")
version = value.get("version")
content = value.get("content")

current = _parse_version(current_version)
parsed_version = _parse_version(version)
outdated = bool(current and parsed_version and parsed_version < current)

return {
"block_index": block_index,
"version": version,
"change_type": change_type,
"content_preview": _truncate(strip_tags(str(content)).strip(), 100),
"outdated": outdated,
}


class BlockUsageReportView(PageReportView):
page_title = _("Annotated blocks usage")
header_icon = "doc-empty-inverse"
results_template_name = "core/admin/reports/block_usage_results.html"
index_url_name = "block_usage_report"
index_results_url_name = "block_usage_report_results"
any_permission_required = ["add", "change", "publish"]

#: Loaded once via `{{ media.css }}` / `{{ media.js }}` in the listing
#: template (rendered on full page load, not re-included on every
#: AJAX filter/pagination refresh of the results fragment).
media = Media(
css={"all": ["core/css/block_usage_report.css"]},
js=["core/js/block_usage_report.js"],
)

def __init__(self, **kwargs):
super().__init__(**kwargs)
self.current_version = getattr(settings, "WAGTAIL_GUIDE_CURRENT_VERSION", None)

def get_queryset(self):
permitted_pages = page_permission_policy.instances_user_has_permission_for(
self.request.user, "change"
).exact_type(ContentPage)

pages = []
for page in permitted_pages.specific():
block_details = [
_build_block_detail(block_index, block, self.current_version)
for block_index, block in iter_annotated_blocks(page)
]
if not block_details:
continue

page.block_usage_blocks = block_details
page.block_usage_flagged_count = sum(
1 for b in block_details if b["outdated"]
)
page.block_usage_flagged = page.block_usage_flagged_count > 0
page.block_usage_min_version = min(
(b["version"] for b in block_details if b["version"]), default=""
)
pages.append(page)

pages.sort(
key=lambda p: (
not p.block_usage_flagged,
not p.live,
p.block_usage_min_version or "",
)
)
return pages

def decorate_paginated_queryset(self, object_list):
# The per-page block breakdown is already attached in get_queryset,
# but export relies on the decorated object_list too, so keep it as-is.
return object_list

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["current_version"] = self.current_version
context["media"] += self.media
return context
5 changes: 5 additions & 0 deletions apps/core/static/core/css/block_usage_focus.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.block-usage--highlight {
outline: 3px solid #ff9c00;
outline-offset: 2px;
transition: outline 0.3s ease;
}
68 changes: 68 additions & 0 deletions apps/core/static/core/css/block_usage_report.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
.block-usage-report ul {
list-style: none;
margin: 0;
padding: 0;
}

.block-usage-report li {
margin-bottom: 10px;
line-height: 1.5;
min-height: 1.5em;
}

.block-usage-report li:last-child {
margin-bottom: 0;
}

.block-usage-report .block-usage__block {
padding-bottom: 10px;
border-bottom: 1px solid var(--w-color-border-furniture, #d9d9d9);
}

.block-usage-report .block-usage__block:last-child {
border-bottom: none;
padding-bottom: 0;
}

.block-usage-report .block-usage__block--flagged .block-usage__block-link {
font-weight: 600;
}

.block-usage-report .block-usage__block-link {
display: block;
color: var(--w-color-text-link-default, #1e5b8c);
text-decoration: none;
}

.block-usage-report .block-usage__block-link:hover {
text-decoration: underline;
color: var(--w-color-text-link-hover, var(--w-color-text-link-default, #1e5b8c));
}

.block-usage-report .block-usage__version {
font-variant-numeric: tabular-nums;
}

.block-usage-report [data-block-index].block-usage__row--hover {
background-color: var(--w-color-surface-menu-item-active, #edf1f4);
outline: 2px solid var(--w-color-border-furniture, #d9d9d9);
outline-offset: 2px;
}

@media (max-width: 900px) {
.block-usage-report th.block-usage-col-type,
.block-usage-report td.block-usage-col-type {
display: none;
}
}

@media (max-width: 600px) {
.block-usage-report th.block-usage-col-change-type,
.block-usage-report td.block-usage-col-change-type {
display: none;
}

.block-usage-report .block-usage__block-link {
font-size: 0.85em;
}
}
51 changes: 51 additions & 0 deletions apps/core/static/core/js/block_usage_focus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
(function blockUsageFocus() {
// Scroll to and briefly highlight a StreamField block when the page
// editor is opened with a `#block-index-N` URL fragment (used by the
// "Annotated blocks usage" report to link directly to a specific block).
//
// Blocks are identified by their position in the stream rather than
// their UUID, because a block's UUID can differ between the live page
// and the latest draft revision, whereas its position is stable.
function focusBlock(index) {
const children = document.querySelectorAll('[data-streamfield-child]');
if (children.length <= index) return false;
const el = children[index];

// StreamField blocks render asynchronously (Telepath), so expanding
// a collapsed block and scrolling to it needs to happen after the
// element actually exists in the DOM.
const toggle = el.querySelector('[data-panel-toggle]');
if (toggle && toggle.getAttribute('aria-expanded') === 'false') {
toggle.click();
}

setTimeout(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('block-usage--highlight');
setTimeout(() => {
el.classList.remove('block-usage--highlight');
}, 3000);
}, 200);
return true;
}

function init() {
const hash = window.location.hash;
const prefix = '#block-index-';
if (!hash || hash.indexOf(prefix) !== 0) return;
const index = parseInt(hash.slice(prefix.length), 10);
if (Number.isNaN(index)) return;

// Poll for up to 10s: the target block may not have rendered yet.
let attempts = 0;
const interval = setInterval(() => {
if (focusBlock(index) || attempts > 100) {
clearInterval(interval);
}
attempts += 1;
}, 100);
}

if (document.readyState === 'complete') init();
else window.addEventListener('load', init);
})();
26 changes: 26 additions & 0 deletions apps/core/static/core/js/block_usage_report.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
(function blockUsageReport() {
function closestRowCell(target) {
return target.closest('.block-usage-report [data-block-index]');
}

function toggleHighlight(target, add) {
const cell = closestRowCell(target);
if (!cell) return;
const key = cell.getAttribute('data-block-index');
const table = cell.closest('.block-usage-report');
if (!table) return;
table
.querySelectorAll(`[data-block-index="${CSS.escape(key)}"]`)
.forEach((el) => {
el.classList.toggle('block-usage__row--hover', add);
});
}

document.addEventListener('mouseover', (event) => {
toggleHighlight(event.target, true);
});

document.addEventListener('mouseout', (event) => {
toggleHighlight(event.target, false);
});
})();
106 changes: 106 additions & 0 deletions apps/core/templates/core/admin/reports/block_usage_results.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
{% extends 'wagtailadmin/reports/base_page_report_results.html' %}
{% load i18n wagtailadmin_tags %}

{% block results %}
{% if current_version %}
<p class="help-block help-info">
{% blocktrans with version=current_version %}
The guide currently covers Wagtail {{ version }}.
{% endblocktrans %}
</p>
{% endif %}
<table class="listing block-usage-report">
<col width="25%" />
<col width="8%" />
<col width="10%" />
<col width="10%" />
<col width="8%" />
<col />
<col width="10%" />
<thead>
<tr>
<th class="title">{% trans 'Title' %}</th>
<th class="status">{% trans 'Status' %}</th>
<th class="type block-usage-col-type">{% trans 'Type' %}</th>
<th class="block-usage-col-change-type">{% trans 'Change type' %}</th>
<th>{% trans 'Version' %}</th>
<th>{% trans 'Content' %}</th>
<th>{% trans 'Review' %}</th>
</tr>
</thead>
<tbody>
{% for page in object_list %}
<tr class="{% if not page.live %}unpublished{% endif %}{% if page.block_usage_flagged %}serious{% endif %}">
<td class="title" valign="top">
<a href="{% url 'wagtailadmin_pages:edit' page.id %}">
{{ page.specific_deferred.get_admin_display_title }}
</a>
{% if page.live %}
<a href="{{ page.url }}" target="_blank" rel="noopener noreferrer" class="u-link is-live">{% trans 'View' %}</a>
{% endif %}
</td>
<td class="status" valign="top">
{% include "wagtailadmin/shared/page_status_tag.html" with page=page %}
</td>
<td class="type block-usage-col-type" valign="top">{{ page.page_type_display_name }}</td>
<td class="block-usage-col-change-type" valign="top">
<ul class="block-usage__change-types" role="list">
{% for block in page.block_usage_blocks %}
<li class="block-usage__change-type" data-block-index="{{ page.id }}-{{ block.block_index }}">
{% if block.change_type %}
<span class="status-tag status-tag--{% if block.change_type == 'removed' %}danger{% elif block.change_type == 'changed' %}warning{% else %}primary{% endif %}">
{{ block.change_type|title }}
</span>
{% else %}
—
{% endif %}
</li>
{% endfor %}
</ul>
</td>
<td valign="top">
<ul class="block-usage__versions" role="list">
{% for block in page.block_usage_blocks %}
<li class="block-usage__version" data-block-index="{{ page.id }}-{{ block.block_index }}">
{% if block.version %}
<a href="{% url 'wagtailadmin_pages:edit' page.id %}#block-index-{{ block.block_index }}"
class="block-usage__block-link"
aria-label="{% blocktrans with index=block.block_index|add:1 %}Edit block {{ index }}{% endblocktrans %}{% if block.change_type %}: {{ block.change_type|title }}{% endif %} (Wagtail {{ block.version }})">
{{ block.version }}
</a>
{% else %}
—
{% endif %}
</li>
{% endfor %}
</ul>
</td>
<td valign="top">
<ul class="block-usage__blocks" role="list">
{% for block in page.block_usage_blocks %}
<li class="block-usage__block{% if block.outdated %} block-usage__block--flagged{% endif %}" data-block-index="{{ page.id }}-{{ block.block_index }}">
<a href="{% url 'wagtailadmin_pages:edit' page.id %}#block-index-{{ block.block_index }}"
class="block-usage__block-link"
aria-label="{% blocktrans with index=block.block_index|add:1 %}Edit block {{ index }}{% endblocktrans %}{% if block.change_type %}: {{ block.change_type|title }}{% endif %}{% if block.version %} (Wagtail {{ block.version }}){% endif %}">
{{ block.content_preview }}
</a>
</li>
{% endfor %}
</ul>
</td>
<td valign="top">
{% if page.block_usage_flagged %}
<span class="status-tag status-tag--danger">{% trans 'Needs update' %}</span>
{% else %}
<span class="status-tag status-tag--primary">{% trans 'Up to date' %}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

{% block no_results_message %}
<p>{% trans 'No pages with versioned blocks found.' %}</p>
{% endblock %}
Loading
Loading