wagtail-developer-reference 0.1.0__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.
Files changed (22) hide show
  1. {wagtail_developer_reference-0.1.0/wagtail_developer_reference.egg-info → wagtail_developer_reference-0.1.2}/PKG-INFO +25 -5
  2. {wagtail_developer_reference-0.1.0 → wagtail_developer_reference-0.1.2}/README.md +20 -2
  3. {wagtail_developer_reference-0.1.0 → wagtail_developer_reference-0.1.2}/pyproject.toml +8 -6
  4. {wagtail_developer_reference-0.1.0 → wagtail_developer_reference-0.1.2}/setup.py +4 -4
  5. wagtail_developer_reference-0.1.2/wagtail_developer_reference/__init__.py +1 -0
  6. wagtail_developer_reference-0.1.2/wagtail_developer_reference/apps.py +9 -0
  7. wagtail_developer_reference-0.1.2/wagtail_developer_reference/migrations/0001_initial.py +39 -0
  8. wagtail_developer_reference-0.1.2/wagtail_developer_reference/migrations/__init__.py +1 -0
  9. wagtail_developer_reference-0.1.2/wagtail_developer_reference/models.py +17 -0
  10. wagtail_developer_reference-0.1.2/wagtail_developer_reference/wagtail_hooks.py +334 -0
  11. {wagtail_developer_reference-0.1.0 → wagtail_developer_reference-0.1.2/wagtail_developer_reference.egg-info}/PKG-INFO +25 -5
  12. {wagtail_developer_reference-0.1.0 → wagtail_developer_reference-0.1.2}/wagtail_developer_reference.egg-info/SOURCES.txt +3 -0
  13. {wagtail_developer_reference-0.1.0 → wagtail_developer_reference-0.1.2}/wagtail_developer_reference.egg-info/requires.txt +1 -1
  14. wagtail_developer_reference-0.1.0/wagtail_developer_reference/__init__.py +0 -0
  15. wagtail_developer_reference-0.1.0/wagtail_developer_reference/apps.py +0 -4
  16. wagtail_developer_reference-0.1.0/wagtail_developer_reference/wagtail_hooks.py +0 -185
  17. {wagtail_developer_reference-0.1.0 → wagtail_developer_reference-0.1.2}/MANIFEST.in +0 -0
  18. {wagtail_developer_reference-0.1.0 → wagtail_developer_reference-0.1.2}/setup.cfg +0 -0
  19. {wagtail_developer_reference-0.1.0 → wagtail_developer_reference-0.1.2}/wagtail_developer_reference/templates/detail.html +0 -0
  20. {wagtail_developer_reference-0.1.0 → wagtail_developer_reference-0.1.2}/wagtail_developer_reference/templates/index.html +0 -0
  21. {wagtail_developer_reference-0.1.0 → wagtail_developer_reference-0.1.2}/wagtail_developer_reference.egg-info/dependency_links.txt +0 -0
  22. {wagtail_developer_reference-0.1.0 → 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.0
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
- Requires-Python: >=3.10
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>=4.1
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. **Usage:**
49
- * Login to the Wagtail Admin as a **superuser**.
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. **Usage:**
25
- * Login to the Wagtail Admin as a **superuser**.
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.0"
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.10"
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 = "py310"
49
+ target-version = "py39"
48
50
 
49
51
  [tool.ruff.lint]
50
- select = ["E", "F", "W", "I"]
52
+ select = ["E", "F", "W", "I"]
@@ -1,11 +1,11 @@
1
- from setuptools import setup, find_packages
1
+ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="wagtail-developer-reference",
5
- version="0.1.0",
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,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.0
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
- Requires-Python: >=3.10
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>=4.1
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. **Usage:**
49
- * Login to the Wagtail Admin as a **superuser**.
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
@@ -1,4 +0,0 @@
1
- from django.apps import AppConfig
2
-
3
- class WagtailDeveloperReferenceConfig(AppConfig):
4
- name = "wagtail_developer_reference"
@@ -1,185 +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 wagtail import hooks
9
- from wagtail.admin.menu import MenuItem
10
- from wagtail.blocks import StructBlock
11
- from wagtail.fields import StreamField
12
- from wagtail.models import Site, get_page_models
13
-
14
-
15
- def is_superuser(user):
16
- if user.is_superuser:
17
- return True
18
- raise PermissionDenied
19
-
20
-
21
- class WagtailAuditor:
22
- def __init__(self):
23
- self.results = []
24
- self.seen_blocks = set()
25
- self.all_page_models = get_page_models()
26
- self.sites = Site.objects.all()
27
- self.models_with_streamfields = [
28
- model for model in self.all_page_models
29
- if any(isinstance(field, StreamField) for field in model._meta.get_fields())
30
- ]
31
- self.excluded_fields = {
32
- "path", "depth", "numchild", "content_type", "page_ptr",
33
- "go_live_at", "expire_at", "show_in_menus", "first_published_at",
34
- "last_published_at", "latest_revision_created_at",
35
- }
36
-
37
- def get_template_path(self, template_name):
38
- if not template_name:
39
- return "Default Rendering"
40
- try:
41
- return get_template(template_name).origin.name
42
- except Exception:
43
- return "Template not found"
44
-
45
- def _analyze_model_field(self, field):
46
- if field.name in self.excluded_fields:
47
- return None, None
48
- if not field.is_relation or field.many_to_one:
49
- label = getattr(field, "verbose_name", field.name)
50
- if hasattr(label, "__str__"):
51
- label = str(label).capitalize()
52
- field_type = type(field).__name__
53
- is_structure = "StreamField" in field_type or "RichTextField" in field_type
54
- category = "structure" if is_structure else "metadata"
55
- return f"{label} ({field_type})", category
56
- return None, None
57
-
58
- def get_internal_structure(self, object_instance):
59
- structure_fields = []
60
- metadata_fields = []
61
-
62
- if hasattr(object_instance, "child_blocks"):
63
- for name, block in object_instance.child_blocks.items():
64
- label = getattr(block.meta, "label", name)
65
- structure_fields.append(f"{label} ({type(block).__name__})")
66
- elif hasattr(object_instance, "_meta"):
67
- for field in object_instance._meta.get_fields():
68
- info, category = self._analyze_model_field(field)
69
- if category == "structure":
70
- structure_fields.append(info)
71
- elif category == "metadata":
72
- metadata_fields.append(info)
73
-
74
- return {"structure": structure_fields, "metadata": metadata_fields}
75
-
76
- def _get_usage_for_site(self, model, site, block_name=None):
77
- queryset = model.objects.descendant_of(site.root_page)
78
- if block_name:
79
- search_key = f'"{block_name}"'
80
- fields = model._meta.get_fields()
81
- stream_fields = [f.name for f in fields if isinstance(f, StreamField)]
82
- total_count = 0
83
- live_count = 0
84
- for field_name in stream_fields:
85
- total_count += queryset.filter(**{f"{field_name}__icontains": search_key}).count()
86
- live_count += queryset.live().filter(**{f"{field_name}__icontains": search_key}).count()
87
- else:
88
- total_count = queryset.count()
89
- live_count = queryset.live().count()
90
- return {"live": live_count, "total": total_count, "draft": total_count - live_count}
91
-
92
- def process_block(self, block_name, block_object, parent_label, site):
93
- is_class = inspect.isclass(block_object)
94
- block_class = block_object.__name__ if is_class else block_object.__class__.__name__
95
- signature = f"{site.id}_{block_class}_{block_name}"
96
- if signature in self.seen_blocks:
97
- return
98
-
99
- site_usage = {"live": 0, "total": 0, "draft": 0}
100
- for model in self.models_with_streamfields:
101
- usage_data = self._get_usage_for_site(model, site, block_name)
102
- site_usage["live"] += usage_data["live"]
103
- site_usage["total"] += usage_data["total"]
104
- site_usage["draft"] += usage_data["draft"]
105
-
106
- self.results.append({
107
- "site_name": str(site),
108
- "hierarchy": f"{parent_label} → {str(getattr(block_object.meta, 'label', block_name))}",
109
- "clean_name": block_name,
110
- "type": "BLOCK",
111
- "fields": self.get_internal_structure(block_object),
112
- "usage": site_usage,
113
- "template": self.get_template_path(getattr(block_object.meta, "template", None)),
114
- })
115
- self.seen_blocks.add(signature)
116
-
117
- if isinstance(block_object, StructBlock):
118
- for child_name, child_object in block_object.child_blocks.items():
119
- self.process_block(child_name, child_object, parent_label, site)
120
-
121
- def run(self):
122
- for site in self.sites:
123
- for model in self.all_page_models:
124
- usage_data = self._get_usage_for_site(model, site)
125
- default_tpl = f"{model._meta.app_label}/{model._meta.model_name}.html"
126
- self.results.append({
127
- "site_name": str(site),
128
- "hierarchy": str(model._meta.verbose_name).title(),
129
- "clean_name": model.__name__,
130
- "type": "PAGE",
131
- "fields": self.get_internal_structure(model),
132
- "usage": usage_data,
133
- "template": self.get_template_path(getattr(model, "template", default_tpl)),
134
- })
135
- for field in model._meta.get_fields():
136
- if isinstance(field, StreamField):
137
- for block_name, block_object in field.stream_block.child_blocks.items():
138
- self.process_block(block_name, block_object, str(model._meta.verbose_name).title(), site)
139
- return self.results
140
-
141
-
142
- @user_passes_test(is_superuser)
143
- def developer_reference_view(request):
144
- auditor = WagtailAuditor()
145
- audit_results = auditor.run()
146
- sorted_results = sorted(audit_results, key=lambda item: (item["site_name"], item["hierarchy"]))
147
- return render(request, "index.html", {"audit_data": sorted_results})
148
-
149
-
150
- @user_passes_test(is_superuser)
151
- def developer_usage_detail_view(request, component_type, component_name):
152
- site_filter_name = request.GET.get('site')
153
- found_pages = []
154
- if component_type == "PAGE":
155
- target_model = next((model for model in get_page_models() if model.__name__ == component_name), None)
156
- if target_model:
157
- found_pages = target_model.objects.all()
158
- else:
159
- search_key = f'"{component_name}"'
160
- for model in get_page_models():
161
- stream_fields = [f.name for f in model._meta.get_fields() if isinstance(f, StreamField)]
162
- for field_name in stream_fields:
163
- found_pages.extend(list(model.objects.filter(**{f"{field_name}__icontains": search_key})))
164
-
165
- if site_filter_name:
166
- found_pages = [page for page in found_pages if str(page.get_site()) == site_filter_name]
167
-
168
- unique_pages = {page.id: page for page in found_pages}.values()
169
- return render(request, "detail.html", {
170
- "component_name": component_name,
171
- "pages": unique_pages
172
- })
173
-
174
-
175
- @hooks.register("register_admin_urls")
176
- def register_admin_urls():
177
- return [
178
- path("developer-reference/", developer_reference_view, name="dev_reference"),
179
- path("developer-reference/<str:component_type>/<str:component_name>/", developer_usage_detail_view, name="dev_usage_detail"),
180
- ]
181
-
182
-
183
- @hooks.register("register_admin_menu_item")
184
- def register_menu():
185
- return MenuItem("System Registry", "/admin/developer-reference/", icon_name="code", order=10000)