wagtail-developer-reference 0.1.1__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wagtail_developer_reference-0.1.1/wagtail_developer_reference.egg-info → wagtail_developer_reference-0.1.2}/PKG-INFO +25 -5
- {wagtail_developer_reference-0.1.1 → wagtail_developer_reference-0.1.2}/README.md +20 -2
- {wagtail_developer_reference-0.1.1 → wagtail_developer_reference-0.1.2}/pyproject.toml +7 -5
- {wagtail_developer_reference-0.1.1 → wagtail_developer_reference-0.1.2}/setup.py +3 -3
- wagtail_developer_reference-0.1.2/wagtail_developer_reference/__init__.py +1 -0
- wagtail_developer_reference-0.1.2/wagtail_developer_reference/apps.py +9 -0
- wagtail_developer_reference-0.1.2/wagtail_developer_reference/migrations/0001_initial.py +39 -0
- wagtail_developer_reference-0.1.2/wagtail_developer_reference/migrations/__init__.py +1 -0
- wagtail_developer_reference-0.1.2/wagtail_developer_reference/models.py +17 -0
- wagtail_developer_reference-0.1.2/wagtail_developer_reference/wagtail_hooks.py +334 -0
- {wagtail_developer_reference-0.1.1 → wagtail_developer_reference-0.1.2/wagtail_developer_reference.egg-info}/PKG-INFO +25 -5
- {wagtail_developer_reference-0.1.1 → wagtail_developer_reference-0.1.2}/wagtail_developer_reference.egg-info/SOURCES.txt +3 -0
- {wagtail_developer_reference-0.1.1 → wagtail_developer_reference-0.1.2}/wagtail_developer_reference.egg-info/requires.txt +1 -1
- wagtail_developer_reference-0.1.1/wagtail_developer_reference/__init__.py +0 -0
- wagtail_developer_reference-0.1.1/wagtail_developer_reference/apps.py +0 -4
- wagtail_developer_reference-0.1.1/wagtail_developer_reference/wagtail_hooks.py +0 -192
- {wagtail_developer_reference-0.1.1 → wagtail_developer_reference-0.1.2}/MANIFEST.in +0 -0
- {wagtail_developer_reference-0.1.1 → wagtail_developer_reference-0.1.2}/setup.cfg +0 -0
- {wagtail_developer_reference-0.1.1 → wagtail_developer_reference-0.1.2}/wagtail_developer_reference/templates/detail.html +0 -0
- {wagtail_developer_reference-0.1.1 → wagtail_developer_reference-0.1.2}/wagtail_developer_reference/templates/index.html +0 -0
- {wagtail_developer_reference-0.1.1 → wagtail_developer_reference-0.1.2}/wagtail_developer_reference.egg-info/dependency_links.txt +0 -0
- {wagtail_developer_reference-0.1.1 → wagtail_developer_reference-0.1.2}/wagtail_developer_reference.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wagtail-developer-reference
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A visual auditing tool for Wagtail developers to track Page models and StreamField blocks.
|
|
5
5
|
Author-email: Four Digits <info@fourdigits.nl>
|
|
6
6
|
Project-URL: Homepage, https://github.com/fourdigits/wagtail-developer-reference
|
|
@@ -14,9 +14,11 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
-
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Requires-Python: >=3.9
|
|
18
20
|
Description-Content-Type: text/markdown
|
|
19
|
-
Requires-Dist: wagtail
|
|
21
|
+
Requires-Dist: wagtail<7.5,>=4.1
|
|
20
22
|
Provides-Extra: dev
|
|
21
23
|
Requires-Dist: ruff; extra == "dev"
|
|
22
24
|
Requires-Dist: pytest; extra == "dev"
|
|
@@ -37,6 +39,9 @@ In large Wagtail projects, it’s easy to lose track of where specific blocks ar
|
|
|
37
39
|
|
|
38
40
|
## Installation
|
|
39
41
|
|
|
42
|
+
Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4 LTS. Wagtail 7.4
|
|
43
|
+
itself requires Python 3.10+.
|
|
44
|
+
|
|
40
45
|
1. Add \`wagtail_developer_reference\` to your \`INSTALLED_APPS\`:
|
|
41
46
|
\`\`\`python
|
|
42
47
|
INSTALLED_APPS = [
|
|
@@ -45,9 +50,24 @@ In large Wagtail projects, it’s easy to lose track of where specific blocks ar
|
|
|
45
50
|
]
|
|
46
51
|
\`\`\`
|
|
47
52
|
|
|
48
|
-
2.
|
|
49
|
-
|
|
53
|
+
2. Run migrations so Django creates the package permission:
|
|
54
|
+
```bash
|
|
55
|
+
python manage.py migrate
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
3. Grant access in the Django admin:
|
|
59
|
+
* Open a user or group in the Django admin.
|
|
60
|
+
* Assign **Wagtail developer reference | developer reference access | Can access the
|
|
61
|
+
Wagtail developer reference**.
|
|
62
|
+
|
|
63
|
+
4. **Usage:**
|
|
64
|
+
* Login to the Wagtail Admin with an account that has that permission explicitly
|
|
65
|
+
assigned.
|
|
50
66
|
* Find the **"System Registry"** item in the sidebar menu.
|
|
51
67
|
|
|
68
|
+
By default, no account can see the menu item or access the URLs. Superusers are not
|
|
69
|
+
granted access automatically by this package; assign the permission directly or
|
|
70
|
+
through a group.
|
|
71
|
+
|
|
52
72
|
## License
|
|
53
73
|
**Internal Use Only** - Copyright © 2026 Fourdigits. All rights reserved.
|
|
@@ -13,6 +13,9 @@ In large Wagtail projects, it’s easy to lose track of where specific blocks ar
|
|
|
13
13
|
|
|
14
14
|
## Installation
|
|
15
15
|
|
|
16
|
+
Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4 LTS. Wagtail 7.4
|
|
17
|
+
itself requires Python 3.10+.
|
|
18
|
+
|
|
16
19
|
1. Add \`wagtail_developer_reference\` to your \`INSTALLED_APPS\`:
|
|
17
20
|
\`\`\`python
|
|
18
21
|
INSTALLED_APPS = [
|
|
@@ -21,9 +24,24 @@ In large Wagtail projects, it’s easy to lose track of where specific blocks ar
|
|
|
21
24
|
]
|
|
22
25
|
\`\`\`
|
|
23
26
|
|
|
24
|
-
2.
|
|
25
|
-
|
|
27
|
+
2. Run migrations so Django creates the package permission:
|
|
28
|
+
```bash
|
|
29
|
+
python manage.py migrate
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
3. Grant access in the Django admin:
|
|
33
|
+
* Open a user or group in the Django admin.
|
|
34
|
+
* Assign **Wagtail developer reference | developer reference access | Can access the
|
|
35
|
+
Wagtail developer reference**.
|
|
36
|
+
|
|
37
|
+
4. **Usage:**
|
|
38
|
+
* Login to the Wagtail Admin with an account that has that permission explicitly
|
|
39
|
+
assigned.
|
|
26
40
|
* Find the **"System Registry"** item in the sidebar menu.
|
|
27
41
|
|
|
42
|
+
By default, no account can see the menu item or access the URLs. Superusers are not
|
|
43
|
+
granted access automatically by this package; assign the permission directly or
|
|
44
|
+
through a group.
|
|
45
|
+
|
|
28
46
|
## License
|
|
29
47
|
**Internal Use Only** - Copyright © 2026 Fourdigits. All rights reserved.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "wagtail-developer-reference"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "A visual auditing tool for Wagtail developers to track Page models and StreamField blocks."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{name = "Four Digits", email = "info@fourdigits.nl" }]
|
|
@@ -14,13 +14,15 @@ classifiers = [
|
|
|
14
14
|
"Programming Language :: Python :: 3.9",
|
|
15
15
|
"Programming Language :: Python :: 3.10",
|
|
16
16
|
"Programming Language :: Python :: 3.11",
|
|
17
|
-
"Programming Language :: Python :: 3.12"
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14"
|
|
18
20
|
]
|
|
19
21
|
keywords = ["django", "wagtail", "audit", "streamfield"]
|
|
20
22
|
dependencies = [
|
|
21
|
-
"wagtail>=4.1",
|
|
23
|
+
"wagtail>=4.1,<7.5",
|
|
22
24
|
]
|
|
23
|
-
requires-python = ">=3.
|
|
25
|
+
requires-python = ">=3.9"
|
|
24
26
|
|
|
25
27
|
[project.optional-dependencies]
|
|
26
28
|
dev = [
|
|
@@ -44,7 +46,7 @@ include = ["wagtail_developer_reference*"]
|
|
|
44
46
|
|
|
45
47
|
[tool.ruff]
|
|
46
48
|
line-length = 88
|
|
47
|
-
target-version = "
|
|
49
|
+
target-version = "py39"
|
|
48
50
|
|
|
49
51
|
[tool.ruff.lint]
|
|
50
52
|
select = ["E", "F", "W", "I"]
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
from setuptools import
|
|
1
|
+
from setuptools import find_packages, setup
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="wagtail-developer-reference",
|
|
5
|
-
version="0.1.
|
|
5
|
+
version="0.1.2",
|
|
6
6
|
packages=find_packages(),
|
|
7
7
|
include_package_data=True,
|
|
8
8
|
install_requires=[
|
|
9
|
-
"wagtail>=4.1",
|
|
9
|
+
"wagtail>=4.1,<7.5",
|
|
10
10
|
],
|
|
11
11
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__: str = "0.1.2"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WagtailDeveloperReferenceConfig(AppConfig):
|
|
7
|
+
default_auto_field: str = "django.db.models.BigAutoField"
|
|
8
|
+
name: str = "wagtail_developer_reference"
|
|
9
|
+
verbose_name: str = "Wagtail developer reference"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Generated manually for wagtail-developer-reference.
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
from django.db import models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
initial = True
|
|
9
|
+
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.CreateModel(
|
|
14
|
+
name="DeveloperReferencePermission",
|
|
15
|
+
fields=[
|
|
16
|
+
(
|
|
17
|
+
"id",
|
|
18
|
+
models.BigAutoField(
|
|
19
|
+
auto_created=True,
|
|
20
|
+
primary_key=True,
|
|
21
|
+
serialize=False,
|
|
22
|
+
verbose_name="ID",
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
],
|
|
26
|
+
options={
|
|
27
|
+
"verbose_name": "developer reference access",
|
|
28
|
+
"verbose_name_plural": "developer reference access",
|
|
29
|
+
"permissions": (
|
|
30
|
+
(
|
|
31
|
+
"access_developer_reference",
|
|
32
|
+
"Can access the Wagtail developer reference",
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
"default_permissions": (),
|
|
36
|
+
"managed": False,
|
|
37
|
+
},
|
|
38
|
+
),
|
|
39
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.db import models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DeveloperReferencePermission(models.Model):
|
|
7
|
+
class Meta:
|
|
8
|
+
default_permissions: tuple[str, ...] = ()
|
|
9
|
+
managed: bool = False
|
|
10
|
+
permissions: tuple[tuple[str, str], ...] = (
|
|
11
|
+
(
|
|
12
|
+
"access_developer_reference",
|
|
13
|
+
"Can access the Wagtail developer reference",
|
|
14
|
+
),
|
|
15
|
+
)
|
|
16
|
+
verbose_name: str = "developer reference access"
|
|
17
|
+
verbose_name_plural: str = "developer reference access"
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from typing import Any, Literal, TypedDict
|
|
6
|
+
|
|
7
|
+
from django.contrib.auth.decorators import user_passes_test
|
|
8
|
+
from django.core.exceptions import PermissionDenied
|
|
9
|
+
from django.db.models import Field
|
|
10
|
+
from django.http import HttpRequest, HttpResponse
|
|
11
|
+
from django.shortcuts import render
|
|
12
|
+
from django.template.loader import get_template
|
|
13
|
+
from django.urls import path
|
|
14
|
+
from django.urls import reverse
|
|
15
|
+
from django.urls.resolvers import URLPattern
|
|
16
|
+
from wagtail import hooks
|
|
17
|
+
from wagtail.admin.menu import MenuItem
|
|
18
|
+
from wagtail.blocks import StructBlock
|
|
19
|
+
from wagtail.fields import StreamField
|
|
20
|
+
from wagtail.models import Page
|
|
21
|
+
from wagtail.models import Site, get_page_models
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ACCESS_PERMISSION_APP_LABEL: str = "wagtail_developer_reference"
|
|
25
|
+
ACCESS_PERMISSION_CODENAME: str = "access_developer_reference"
|
|
26
|
+
ACCESS_PERMISSION_MODEL: str = "developerreferencepermission"
|
|
27
|
+
FieldCategory = Literal["structure", "metadata"]
|
|
28
|
+
ComponentType = Literal["PAGE", "BLOCK"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UsageData(TypedDict):
|
|
32
|
+
live: int
|
|
33
|
+
total: int
|
|
34
|
+
draft: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FieldGroups(TypedDict):
|
|
38
|
+
structure: list[str]
|
|
39
|
+
metadata: list[str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuditItem(TypedDict):
|
|
43
|
+
site_name: str
|
|
44
|
+
hierarchy: str
|
|
45
|
+
clean_name: str
|
|
46
|
+
type: ComponentType
|
|
47
|
+
fields: FieldGroups
|
|
48
|
+
usage: UsageData
|
|
49
|
+
template: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def user_can_access_developer_reference(user: Any) -> bool:
|
|
53
|
+
if not getattr(user, "is_authenticated", False):
|
|
54
|
+
return False
|
|
55
|
+
if not getattr(user, "is_active", False):
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
user_has_permission = user.user_permissions.filter(
|
|
59
|
+
codename=ACCESS_PERMISSION_CODENAME,
|
|
60
|
+
content_type__app_label=ACCESS_PERMISSION_APP_LABEL,
|
|
61
|
+
content_type__model=ACCESS_PERMISSION_MODEL,
|
|
62
|
+
).exists()
|
|
63
|
+
group_has_permission = user.groups.filter(
|
|
64
|
+
permissions__codename=ACCESS_PERMISSION_CODENAME,
|
|
65
|
+
permissions__content_type__app_label=ACCESS_PERMISSION_APP_LABEL,
|
|
66
|
+
permissions__content_type__model=ACCESS_PERMISSION_MODEL,
|
|
67
|
+
).exists()
|
|
68
|
+
return user_has_permission or group_has_permission
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def require_developer_reference_access(user: Any) -> bool:
|
|
72
|
+
if user_can_access_developer_reference(user):
|
|
73
|
+
return True
|
|
74
|
+
raise PermissionDenied
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class WagtailAuditor:
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self.results: list[AuditItem] = []
|
|
80
|
+
self.seen_blocks: set[str] = set()
|
|
81
|
+
self.all_page_models: list[type[Page]] = get_page_models()
|
|
82
|
+
self.sites: Iterable[Site] = Site.objects.all()
|
|
83
|
+
self.models_with_streamfields: list[type[Page]] = [
|
|
84
|
+
model
|
|
85
|
+
for model in self.all_page_models
|
|
86
|
+
if any(isinstance(field, StreamField) for field in model._meta.get_fields())
|
|
87
|
+
]
|
|
88
|
+
self.excluded_fields: set[str] = {
|
|
89
|
+
"path",
|
|
90
|
+
"depth",
|
|
91
|
+
"numchild",
|
|
92
|
+
"content_type",
|
|
93
|
+
"page_ptr",
|
|
94
|
+
"go_live_at",
|
|
95
|
+
"expire_at",
|
|
96
|
+
"show_in_menus",
|
|
97
|
+
"first_published_at",
|
|
98
|
+
"last_published_at",
|
|
99
|
+
"latest_revision_created_at",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def get_template_path(self, template_name: str | None) -> str:
|
|
103
|
+
if not template_name:
|
|
104
|
+
return "Default Rendering"
|
|
105
|
+
try:
|
|
106
|
+
return get_template(template_name).origin.name
|
|
107
|
+
except Exception:
|
|
108
|
+
return "Template not found"
|
|
109
|
+
|
|
110
|
+
def _analyze_model_field(
|
|
111
|
+
self,
|
|
112
|
+
field: Field[Any, Any],
|
|
113
|
+
) -> tuple[str | None, FieldCategory | None]:
|
|
114
|
+
if field.name in self.excluded_fields:
|
|
115
|
+
return None, None
|
|
116
|
+
if not field.is_relation or field.many_to_one:
|
|
117
|
+
label = getattr(field, "verbose_name", field.name)
|
|
118
|
+
if hasattr(label, "__str__"):
|
|
119
|
+
label = str(label).capitalize()
|
|
120
|
+
field_type = type(field).__name__
|
|
121
|
+
is_structure = "StreamField" in field_type or "RichTextField" in field_type
|
|
122
|
+
category = "structure" if is_structure else "metadata"
|
|
123
|
+
return f"{label} ({field_type})", category
|
|
124
|
+
return None, None
|
|
125
|
+
|
|
126
|
+
def get_internal_structure(self, object_instance: Any) -> FieldGroups:
|
|
127
|
+
structure_fields: list[str] = []
|
|
128
|
+
metadata_fields: list[str] = []
|
|
129
|
+
|
|
130
|
+
if hasattr(object_instance, "child_blocks"):
|
|
131
|
+
for name, block in object_instance.child_blocks.items():
|
|
132
|
+
label = getattr(block.meta, "label", name)
|
|
133
|
+
structure_fields.append(f"{label} ({type(block).__name__})")
|
|
134
|
+
elif hasattr(object_instance, "_meta"):
|
|
135
|
+
for field in object_instance._meta.get_fields():
|
|
136
|
+
info, category = self._analyze_model_field(field)
|
|
137
|
+
if category == "structure":
|
|
138
|
+
structure_fields.append(info)
|
|
139
|
+
elif category == "metadata":
|
|
140
|
+
metadata_fields.append(info)
|
|
141
|
+
|
|
142
|
+
return {"structure": structure_fields, "metadata": metadata_fields}
|
|
143
|
+
|
|
144
|
+
def _get_usage_for_site(
|
|
145
|
+
self,
|
|
146
|
+
model: type[Page],
|
|
147
|
+
site: Site,
|
|
148
|
+
block_name: str | None = None,
|
|
149
|
+
) -> UsageData:
|
|
150
|
+
queryset = model.objects.descendant_of(site.root_page)
|
|
151
|
+
if block_name:
|
|
152
|
+
search_key = f'"{block_name}"'
|
|
153
|
+
fields = model._meta.get_fields()
|
|
154
|
+
stream_fields: list[str] = [
|
|
155
|
+
f.name for f in fields if isinstance(f, StreamField)
|
|
156
|
+
]
|
|
157
|
+
total_count = 0
|
|
158
|
+
live_count = 0
|
|
159
|
+
for field_name in stream_fields:
|
|
160
|
+
total_count += queryset.filter(
|
|
161
|
+
**{f"{field_name}__icontains": search_key}
|
|
162
|
+
).count()
|
|
163
|
+
live_count += queryset.live().filter(
|
|
164
|
+
**{f"{field_name}__icontains": search_key}
|
|
165
|
+
).count()
|
|
166
|
+
else:
|
|
167
|
+
total_count = queryset.count()
|
|
168
|
+
live_count = queryset.live().count()
|
|
169
|
+
return {
|
|
170
|
+
"live": live_count,
|
|
171
|
+
"total": total_count,
|
|
172
|
+
"draft": total_count - live_count,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
def process_block(
|
|
176
|
+
self,
|
|
177
|
+
block_name: str,
|
|
178
|
+
block_object: Any,
|
|
179
|
+
parent_label: str,
|
|
180
|
+
site: Site,
|
|
181
|
+
) -> None:
|
|
182
|
+
is_class = inspect.isclass(block_object)
|
|
183
|
+
block_class = (
|
|
184
|
+
block_object.__name__ if is_class else block_object.__class__.__name__
|
|
185
|
+
)
|
|
186
|
+
signature = f"{site.id}_{block_class}_{block_name}"
|
|
187
|
+
if signature in self.seen_blocks:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
site_usage = {"live": 0, "total": 0, "draft": 0}
|
|
191
|
+
for model in self.models_with_streamfields:
|
|
192
|
+
usage_data = self._get_usage_for_site(model, site, block_name)
|
|
193
|
+
site_usage["live"] += usage_data["live"]
|
|
194
|
+
site_usage["total"] += usage_data["total"]
|
|
195
|
+
site_usage["draft"] += usage_data["draft"]
|
|
196
|
+
|
|
197
|
+
block_label = str(getattr(block_object.meta, "label", block_name))
|
|
198
|
+
self.results.append(
|
|
199
|
+
{
|
|
200
|
+
"site_name": str(site),
|
|
201
|
+
"hierarchy": f"{parent_label} → {block_label}",
|
|
202
|
+
"clean_name": block_name,
|
|
203
|
+
"type": "BLOCK",
|
|
204
|
+
"fields": self.get_internal_structure(block_object),
|
|
205
|
+
"usage": site_usage,
|
|
206
|
+
"template": self.get_template_path(
|
|
207
|
+
getattr(block_object.meta, "template", None)
|
|
208
|
+
),
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
self.seen_blocks.add(signature)
|
|
212
|
+
|
|
213
|
+
if isinstance(block_object, StructBlock):
|
|
214
|
+
for child_name, child_object in block_object.child_blocks.items():
|
|
215
|
+
self.process_block(child_name, child_object, parent_label, site)
|
|
216
|
+
|
|
217
|
+
def run(self) -> list[AuditItem]:
|
|
218
|
+
for site in self.sites:
|
|
219
|
+
for model in self.all_page_models:
|
|
220
|
+
usage_data = self._get_usage_for_site(model, site)
|
|
221
|
+
default_tpl = f"{model._meta.app_label}/{model._meta.model_name}.html"
|
|
222
|
+
self.results.append(
|
|
223
|
+
{
|
|
224
|
+
"site_name": str(site),
|
|
225
|
+
"hierarchy": str(model._meta.verbose_name).title(),
|
|
226
|
+
"clean_name": model.__name__,
|
|
227
|
+
"type": "PAGE",
|
|
228
|
+
"fields": self.get_internal_structure(model),
|
|
229
|
+
"usage": usage_data,
|
|
230
|
+
"template": self.get_template_path(
|
|
231
|
+
getattr(model, "template", default_tpl)
|
|
232
|
+
),
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
for field in model._meta.get_fields():
|
|
236
|
+
if isinstance(field, StreamField):
|
|
237
|
+
child_blocks = field.stream_block.child_blocks.items()
|
|
238
|
+
for block_name, block_object in child_blocks:
|
|
239
|
+
self.process_block(
|
|
240
|
+
block_name,
|
|
241
|
+
block_object,
|
|
242
|
+
str(model._meta.verbose_name).title(),
|
|
243
|
+
site,
|
|
244
|
+
)
|
|
245
|
+
return self.results
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@user_passes_test(require_developer_reference_access)
|
|
249
|
+
def developer_reference_view(request: HttpRequest) -> HttpResponse:
|
|
250
|
+
auditor = WagtailAuditor()
|
|
251
|
+
audit_results = auditor.run()
|
|
252
|
+
sorted_results = sorted(
|
|
253
|
+
audit_results,
|
|
254
|
+
key=lambda item: (item["site_name"], item["hierarchy"]),
|
|
255
|
+
)
|
|
256
|
+
return render(request, "index.html", {"audit_data": sorted_results})
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@user_passes_test(require_developer_reference_access)
|
|
260
|
+
def developer_usage_detail_view(
|
|
261
|
+
request: HttpRequest,
|
|
262
|
+
component_type: str,
|
|
263
|
+
component_name: str,
|
|
264
|
+
) -> HttpResponse:
|
|
265
|
+
site_filter_name = request.GET.get("site")
|
|
266
|
+
found_pages: list[Page] = []
|
|
267
|
+
if component_type == "PAGE":
|
|
268
|
+
target_model = next(
|
|
269
|
+
(model for model in get_page_models() if model.__name__ == component_name),
|
|
270
|
+
None,
|
|
271
|
+
)
|
|
272
|
+
if target_model:
|
|
273
|
+
found_pages = list(target_model.objects.all())
|
|
274
|
+
else:
|
|
275
|
+
search_key = f'"{component_name}"'
|
|
276
|
+
for model in get_page_models():
|
|
277
|
+
stream_fields = [
|
|
278
|
+
f.name
|
|
279
|
+
for f in model._meta.get_fields()
|
|
280
|
+
if isinstance(f, StreamField)
|
|
281
|
+
]
|
|
282
|
+
for field_name in stream_fields:
|
|
283
|
+
found_pages.extend(
|
|
284
|
+
list(
|
|
285
|
+
model.objects.filter(
|
|
286
|
+
**{f"{field_name}__icontains": search_key}
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if site_filter_name:
|
|
292
|
+
found_pages = [
|
|
293
|
+
page
|
|
294
|
+
for page in found_pages
|
|
295
|
+
if str(page.get_site()) == site_filter_name
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
unique_pages = {page.id: page for page in found_pages}.values()
|
|
299
|
+
return render(
|
|
300
|
+
request,
|
|
301
|
+
"detail.html",
|
|
302
|
+
{
|
|
303
|
+
"component_name": component_name,
|
|
304
|
+
"pages": unique_pages,
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@hooks.register("register_admin_urls")
|
|
310
|
+
def register_admin_urls() -> list[URLPattern]:
|
|
311
|
+
return [
|
|
312
|
+
path("developer-reference/", developer_reference_view, name="dev_reference"),
|
|
313
|
+
path(
|
|
314
|
+
"developer-reference/<str:component_type>/<str:component_name>/",
|
|
315
|
+
developer_usage_detail_view,
|
|
316
|
+
name="dev_usage_detail",
|
|
317
|
+
),
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class DeveloperReferenceMenuItem(MenuItem):
|
|
322
|
+
def is_shown(self, request: HttpRequest) -> bool:
|
|
323
|
+
return user_can_access_developer_reference(request.user)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@hooks.register("register_admin_menu_item")
|
|
327
|
+
def register_menu() -> MenuItem:
|
|
328
|
+
url = reverse("dev_reference")
|
|
329
|
+
return DeveloperReferenceMenuItem(
|
|
330
|
+
"System Registry",
|
|
331
|
+
url,
|
|
332
|
+
icon_name="code",
|
|
333
|
+
order=10000,
|
|
334
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wagtail-developer-reference
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A visual auditing tool for Wagtail developers to track Page models and StreamField blocks.
|
|
5
5
|
Author-email: Four Digits <info@fourdigits.nl>
|
|
6
6
|
Project-URL: Homepage, https://github.com/fourdigits/wagtail-developer-reference
|
|
@@ -14,9 +14,11 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
-
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Requires-Python: >=3.9
|
|
18
20
|
Description-Content-Type: text/markdown
|
|
19
|
-
Requires-Dist: wagtail
|
|
21
|
+
Requires-Dist: wagtail<7.5,>=4.1
|
|
20
22
|
Provides-Extra: dev
|
|
21
23
|
Requires-Dist: ruff; extra == "dev"
|
|
22
24
|
Requires-Dist: pytest; extra == "dev"
|
|
@@ -37,6 +39,9 @@ In large Wagtail projects, it’s easy to lose track of where specific blocks ar
|
|
|
37
39
|
|
|
38
40
|
## Installation
|
|
39
41
|
|
|
42
|
+
Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4 LTS. Wagtail 7.4
|
|
43
|
+
itself requires Python 3.10+.
|
|
44
|
+
|
|
40
45
|
1. Add \`wagtail_developer_reference\` to your \`INSTALLED_APPS\`:
|
|
41
46
|
\`\`\`python
|
|
42
47
|
INSTALLED_APPS = [
|
|
@@ -45,9 +50,24 @@ In large Wagtail projects, it’s easy to lose track of where specific blocks ar
|
|
|
45
50
|
]
|
|
46
51
|
\`\`\`
|
|
47
52
|
|
|
48
|
-
2.
|
|
49
|
-
|
|
53
|
+
2. Run migrations so Django creates the package permission:
|
|
54
|
+
```bash
|
|
55
|
+
python manage.py migrate
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
3. Grant access in the Django admin:
|
|
59
|
+
* Open a user or group in the Django admin.
|
|
60
|
+
* Assign **Wagtail developer reference | developer reference access | Can access the
|
|
61
|
+
Wagtail developer reference**.
|
|
62
|
+
|
|
63
|
+
4. **Usage:**
|
|
64
|
+
* Login to the Wagtail Admin with an account that has that permission explicitly
|
|
65
|
+
assigned.
|
|
50
66
|
* Find the **"System Registry"** item in the sidebar menu.
|
|
51
67
|
|
|
68
|
+
By default, no account can see the menu item or access the URLs. Superusers are not
|
|
69
|
+
granted access automatically by this package; assign the permission directly or
|
|
70
|
+
through a group.
|
|
71
|
+
|
|
52
72
|
## License
|
|
53
73
|
**Internal Use Only** - Copyright © 2026 Fourdigits. All rights reserved.
|
|
@@ -4,11 +4,14 @@ pyproject.toml
|
|
|
4
4
|
setup.py
|
|
5
5
|
wagtail_developer_reference/__init__.py
|
|
6
6
|
wagtail_developer_reference/apps.py
|
|
7
|
+
wagtail_developer_reference/models.py
|
|
7
8
|
wagtail_developer_reference/wagtail_hooks.py
|
|
8
9
|
wagtail_developer_reference.egg-info/PKG-INFO
|
|
9
10
|
wagtail_developer_reference.egg-info/SOURCES.txt
|
|
10
11
|
wagtail_developer_reference.egg-info/dependency_links.txt
|
|
11
12
|
wagtail_developer_reference.egg-info/requires.txt
|
|
12
13
|
wagtail_developer_reference.egg-info/top_level.txt
|
|
14
|
+
wagtail_developer_reference/migrations/0001_initial.py
|
|
15
|
+
wagtail_developer_reference/migrations/__init__.py
|
|
13
16
|
wagtail_developer_reference/templates/detail.html
|
|
14
17
|
wagtail_developer_reference/templates/index.html
|
|
File without changes
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import inspect
|
|
2
|
-
|
|
3
|
-
from django.contrib.auth.decorators import user_passes_test
|
|
4
|
-
from django.core.exceptions import PermissionDenied
|
|
5
|
-
from django.shortcuts import render
|
|
6
|
-
from django.template.loader import get_template
|
|
7
|
-
from django.urls import path
|
|
8
|
-
from django.urls import reverse
|
|
9
|
-
from wagtail import hooks
|
|
10
|
-
from wagtail.admin.menu import MenuItem
|
|
11
|
-
from wagtail.blocks import StructBlock
|
|
12
|
-
from wagtail.fields import StreamField
|
|
13
|
-
from wagtail.models import Site, get_page_models
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def is_superuser(user):
|
|
17
|
-
if user.is_superuser:
|
|
18
|
-
return True
|
|
19
|
-
raise PermissionDenied
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class WagtailAuditor:
|
|
23
|
-
def __init__(self):
|
|
24
|
-
self.results = []
|
|
25
|
-
self.seen_blocks = set()
|
|
26
|
-
self.all_page_models = get_page_models()
|
|
27
|
-
self.sites = Site.objects.all()
|
|
28
|
-
self.models_with_streamfields = [
|
|
29
|
-
model for model in self.all_page_models
|
|
30
|
-
if any(isinstance(field, StreamField) for field in model._meta.get_fields())
|
|
31
|
-
]
|
|
32
|
-
self.excluded_fields = {
|
|
33
|
-
"path", "depth", "numchild", "content_type", "page_ptr",
|
|
34
|
-
"go_live_at", "expire_at", "show_in_menus", "first_published_at",
|
|
35
|
-
"last_published_at", "latest_revision_created_at",
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
def get_template_path(self, template_name):
|
|
39
|
-
if not template_name:
|
|
40
|
-
return "Default Rendering"
|
|
41
|
-
try:
|
|
42
|
-
return get_template(template_name).origin.name
|
|
43
|
-
except Exception:
|
|
44
|
-
return "Template not found"
|
|
45
|
-
|
|
46
|
-
def _analyze_model_field(self, field):
|
|
47
|
-
if field.name in self.excluded_fields:
|
|
48
|
-
return None, None
|
|
49
|
-
if not field.is_relation or field.many_to_one:
|
|
50
|
-
label = getattr(field, "verbose_name", field.name)
|
|
51
|
-
if hasattr(label, "__str__"):
|
|
52
|
-
label = str(label).capitalize()
|
|
53
|
-
field_type = type(field).__name__
|
|
54
|
-
is_structure = "StreamField" in field_type or "RichTextField" in field_type
|
|
55
|
-
category = "structure" if is_structure else "metadata"
|
|
56
|
-
return f"{label} ({field_type})", category
|
|
57
|
-
return None, None
|
|
58
|
-
|
|
59
|
-
def get_internal_structure(self, object_instance):
|
|
60
|
-
structure_fields = []
|
|
61
|
-
metadata_fields = []
|
|
62
|
-
|
|
63
|
-
if hasattr(object_instance, "child_blocks"):
|
|
64
|
-
for name, block in object_instance.child_blocks.items():
|
|
65
|
-
label = getattr(block.meta, "label", name)
|
|
66
|
-
structure_fields.append(f"{label} ({type(block).__name__})")
|
|
67
|
-
elif hasattr(object_instance, "_meta"):
|
|
68
|
-
for field in object_instance._meta.get_fields():
|
|
69
|
-
info, category = self._analyze_model_field(field)
|
|
70
|
-
if category == "structure":
|
|
71
|
-
structure_fields.append(info)
|
|
72
|
-
elif category == "metadata":
|
|
73
|
-
metadata_fields.append(info)
|
|
74
|
-
|
|
75
|
-
return {"structure": structure_fields, "metadata": metadata_fields}
|
|
76
|
-
|
|
77
|
-
def _get_usage_for_site(self, model, site, block_name=None):
|
|
78
|
-
queryset = model.objects.descendant_of(site.root_page)
|
|
79
|
-
if block_name:
|
|
80
|
-
search_key = f'"{block_name}"'
|
|
81
|
-
fields = model._meta.get_fields()
|
|
82
|
-
stream_fields = [f.name for f in fields if isinstance(f, StreamField)]
|
|
83
|
-
total_count = 0
|
|
84
|
-
live_count = 0
|
|
85
|
-
for field_name in stream_fields:
|
|
86
|
-
total_count += queryset.filter(**{f"{field_name}__icontains": search_key}).count()
|
|
87
|
-
live_count += queryset.live().filter(**{f"{field_name}__icontains": search_key}).count()
|
|
88
|
-
else:
|
|
89
|
-
total_count = queryset.count()
|
|
90
|
-
live_count = queryset.live().count()
|
|
91
|
-
return {"live": live_count, "total": total_count, "draft": total_count - live_count}
|
|
92
|
-
|
|
93
|
-
def process_block(self, block_name, block_object, parent_label, site):
|
|
94
|
-
is_class = inspect.isclass(block_object)
|
|
95
|
-
block_class = block_object.__name__ if is_class else block_object.__class__.__name__
|
|
96
|
-
signature = f"{site.id}_{block_class}_{block_name}"
|
|
97
|
-
if signature in self.seen_blocks:
|
|
98
|
-
return
|
|
99
|
-
|
|
100
|
-
site_usage = {"live": 0, "total": 0, "draft": 0}
|
|
101
|
-
for model in self.models_with_streamfields:
|
|
102
|
-
usage_data = self._get_usage_for_site(model, site, block_name)
|
|
103
|
-
site_usage["live"] += usage_data["live"]
|
|
104
|
-
site_usage["total"] += usage_data["total"]
|
|
105
|
-
site_usage["draft"] += usage_data["draft"]
|
|
106
|
-
|
|
107
|
-
self.results.append({
|
|
108
|
-
"site_name": str(site),
|
|
109
|
-
"hierarchy": f"{parent_label} → {str(getattr(block_object.meta, 'label', block_name))}",
|
|
110
|
-
"clean_name": block_name,
|
|
111
|
-
"type": "BLOCK",
|
|
112
|
-
"fields": self.get_internal_structure(block_object),
|
|
113
|
-
"usage": site_usage,
|
|
114
|
-
"template": self.get_template_path(getattr(block_object.meta, "template", None)),
|
|
115
|
-
})
|
|
116
|
-
self.seen_blocks.add(signature)
|
|
117
|
-
|
|
118
|
-
if isinstance(block_object, StructBlock):
|
|
119
|
-
for child_name, child_object in block_object.child_blocks.items():
|
|
120
|
-
self.process_block(child_name, child_object, parent_label, site)
|
|
121
|
-
|
|
122
|
-
def run(self):
|
|
123
|
-
for site in self.sites:
|
|
124
|
-
for model in self.all_page_models:
|
|
125
|
-
usage_data = self._get_usage_for_site(model, site)
|
|
126
|
-
default_tpl = f"{model._meta.app_label}/{model._meta.model_name}.html"
|
|
127
|
-
self.results.append({
|
|
128
|
-
"site_name": str(site),
|
|
129
|
-
"hierarchy": str(model._meta.verbose_name).title(),
|
|
130
|
-
"clean_name": model.__name__,
|
|
131
|
-
"type": "PAGE",
|
|
132
|
-
"fields": self.get_internal_structure(model),
|
|
133
|
-
"usage": usage_data,
|
|
134
|
-
"template": self.get_template_path(getattr(model, "template", default_tpl)),
|
|
135
|
-
})
|
|
136
|
-
for field in model._meta.get_fields():
|
|
137
|
-
if isinstance(field, StreamField):
|
|
138
|
-
for block_name, block_object in field.stream_block.child_blocks.items():
|
|
139
|
-
self.process_block(block_name, block_object, str(model._meta.verbose_name).title(), site)
|
|
140
|
-
return self.results
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
@user_passes_test(is_superuser)
|
|
144
|
-
def developer_reference_view(request):
|
|
145
|
-
auditor = WagtailAuditor()
|
|
146
|
-
audit_results = auditor.run()
|
|
147
|
-
sorted_results = sorted(audit_results, key=lambda item: (item["site_name"], item["hierarchy"]))
|
|
148
|
-
return render(request, "index.html", {"audit_data": sorted_results})
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
@user_passes_test(is_superuser)
|
|
152
|
-
def developer_usage_detail_view(request, component_type, component_name):
|
|
153
|
-
site_filter_name = request.GET.get('site')
|
|
154
|
-
found_pages = []
|
|
155
|
-
if component_type == "PAGE":
|
|
156
|
-
target_model = next((model for model in get_page_models() if model.__name__ == component_name), None)
|
|
157
|
-
if target_model:
|
|
158
|
-
found_pages = target_model.objects.all()
|
|
159
|
-
else:
|
|
160
|
-
search_key = f'"{component_name}"'
|
|
161
|
-
for model in get_page_models():
|
|
162
|
-
stream_fields = [f.name for f in model._meta.get_fields() if isinstance(f, StreamField)]
|
|
163
|
-
for field_name in stream_fields:
|
|
164
|
-
found_pages.extend(list(model.objects.filter(**{f"{field_name}__icontains": search_key})))
|
|
165
|
-
|
|
166
|
-
if site_filter_name:
|
|
167
|
-
found_pages = [page for page in found_pages if str(page.get_site()) == site_filter_name]
|
|
168
|
-
|
|
169
|
-
unique_pages = {page.id: page for page in found_pages}.values()
|
|
170
|
-
return render(request, "detail.html", {
|
|
171
|
-
"component_name": component_name,
|
|
172
|
-
"pages": unique_pages
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
@hooks.register("register_admin_urls")
|
|
177
|
-
def register_admin_urls():
|
|
178
|
-
return [
|
|
179
|
-
path("developer-reference/", developer_reference_view, name="dev_reference"),
|
|
180
|
-
path("developer-reference/<str:component_type>/<str:component_name>/", developer_usage_detail_view, name="dev_usage_detail"),
|
|
181
|
-
]
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
class SuperuserMenuItem(MenuItem):
|
|
185
|
-
def is_shown(self, request):
|
|
186
|
-
return request.user.is_superuser
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
@hooks.register("register_admin_menu_item")
|
|
190
|
-
def register_menu():
|
|
191
|
-
url = reverse("dev_reference")
|
|
192
|
-
return SuperuserMenuItem("System Registry", url, icon_name="code", order=10000)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|