arthexis 0.1.16__py3-none-any.whl → 0.1.28__py3-none-any.whl

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.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

Files changed (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
core/notifications.py CHANGED
@@ -39,7 +39,7 @@ class NotificationManager:
39
39
  self.lock_file.parent.mkdir(parents=True, exist_ok=True)
40
40
  # ``plyer`` is only available on Windows and can fail when used in
41
41
  # a non-interactive environment (e.g. service or CI).
42
- # Any failure will fallback to logging quietly.
42
+ # Any failure will fall back to logging quietly.
43
43
 
44
44
  def _write_lock_file(self, subject: str, body: str) -> None:
45
45
  self.lock_file.write_text(f"{subject}\n{body}\n", encoding="utf-8")
core/reference_utils.py CHANGED
@@ -70,17 +70,16 @@ def filter_visible_references(
70
70
  required_sites = {current_site.pk for current_site in ref.sites.all()}
71
71
 
72
72
  if required_roles or required_features or required_sites:
73
- allowed = False
74
- if required_roles and node_role_id and node_role_id in required_roles:
75
- allowed = True
76
- elif (
77
- required_features
78
- and node_active_feature_ids
79
- and node_active_feature_ids.intersection(required_features)
80
- ):
81
- allowed = True
82
- elif required_sites and site_id and site_id in required_sites:
83
- allowed = True
73
+ allowed = True
74
+ if required_roles:
75
+ allowed = bool(node_role_id and node_role_id in required_roles)
76
+ if allowed and required_features:
77
+ allowed = bool(
78
+ node_active_feature_ids
79
+ and node_active_feature_ids.intersection(required_features)
80
+ )
81
+ if allowed and required_sites:
82
+ allowed = bool(site_id and site_id in required_sites)
84
83
 
85
84
  if not allowed:
86
85
  continue
core/release.py CHANGED
@@ -114,6 +114,21 @@ class ReleaseError(Exception):
114
114
  pass
115
115
 
116
116
 
117
+ class PostPublishWarning(ReleaseError):
118
+ """Raised when distribution uploads succeed but post-publish tasks need attention."""
119
+
120
+ def __init__(
121
+ self,
122
+ message: str,
123
+ *,
124
+ uploaded: Sequence[str],
125
+ followups: Optional[Sequence[str]] = None,
126
+ ) -> None:
127
+ super().__init__(message)
128
+ self.uploaded = list(uploaded)
129
+ self.followups = list(followups or [])
130
+
131
+
117
132
  class TestsFailed(ReleaseError):
118
133
  """Raised when the test suite fails.
119
134
 
@@ -505,11 +520,6 @@ def build(
505
520
  if not version_path.exists():
506
521
  raise ReleaseError("VERSION file not found")
507
522
  version = version_path.read_text().strip()
508
- if bump:
509
- major, minor, patch = map(int, version.split("."))
510
- patch += 1
511
- version = f"{major}.{minor}.{patch}"
512
- version_path.write_text(version + "\n")
513
523
  else:
514
524
  # Ensure the VERSION file reflects the provided release version
515
525
  if version_path.parent != Path("."):
@@ -711,8 +721,46 @@ def publish(
711
721
  uploaded.append(target.name)
712
722
 
713
723
  tag_name = f"v{version}"
714
- _run(["git", "tag", tag_name])
715
- _push_tag(tag_name, package)
724
+ try:
725
+ _run(["git", "tag", tag_name])
726
+ except subprocess.CalledProcessError as exc:
727
+ details = _format_subprocess_error(exc)
728
+ if uploaded:
729
+ uploads = ", ".join(uploaded)
730
+ if details:
731
+ message = (
732
+ f"Upload to {uploads} completed, but creating git tag {tag_name} failed: {details}"
733
+ )
734
+ else:
735
+ message = (
736
+ f"Upload to {uploads} completed, but creating git tag {tag_name} failed."
737
+ )
738
+ followups = [f"Create and push git tag {tag_name} manually once the repository is ready."]
739
+ raise PostPublishWarning(
740
+ message,
741
+ uploaded=uploaded,
742
+ followups=followups,
743
+ ) from exc
744
+ raise ReleaseError(
745
+ f"Failed to create git tag {tag_name}: {details or exc}"
746
+ ) from exc
747
+
748
+ try:
749
+ _push_tag(tag_name, package)
750
+ except ReleaseError as exc:
751
+ if uploaded:
752
+ uploads = ", ".join(uploaded)
753
+ message = f"Upload to {uploads} completed, but {exc}"
754
+ followups = [
755
+ f"Push git tag {tag_name} to origin after resolving the reported issue."
756
+ ]
757
+ warning = PostPublishWarning(
758
+ message,
759
+ uploaded=uploaded,
760
+ followups=followups,
761
+ )
762
+ raise warning from exc
763
+ raise
716
764
  return uploaded
717
765
 
718
766
 
core/sigil_builder.py CHANGED
@@ -40,12 +40,12 @@ def _sigil_builder_view(request):
40
40
  {
41
41
  "prefix": "ENV",
42
42
  "url": reverse("admin:environment"),
43
- "label": _("Environ"),
43
+ "label": _("Environment"),
44
44
  },
45
45
  {
46
46
  "prefix": "CONF",
47
47
  "url": reverse("admin:config"),
48
- "label": _("Config"),
48
+ "label": _("Django Config"),
49
49
  },
50
50
  {
51
51
  "prefix": "SYS",
core/sigil_resolver.py CHANGED
@@ -1,8 +1,5 @@
1
1
  import logging
2
2
  import os
3
- import shutil
4
- import subprocess
5
- from functools import lru_cache
6
3
  from typing import Optional
7
4
 
8
5
  from django.apps import apps
@@ -16,15 +13,6 @@ from .system import get_system_sigil_values, resolve_system_namespace_value
16
13
  logger = logging.getLogger("core.entity")
17
14
 
18
15
 
19
- def _is_wizard_mode() -> bool:
20
- """Return ``True`` when the application is running in wizard mode."""
21
-
22
- flag = getattr(settings, "WIZARD_MODE", False)
23
- if isinstance(flag, str):
24
- return flag.lower() in {"1", "true", "yes", "on"}
25
- return bool(flag)
26
-
27
-
28
16
  def _first_instance(model: type[models.Model]) -> Optional[models.Model]:
29
17
  qs = model.objects
30
18
  ordering = list(getattr(model._meta, "ordering", []))
@@ -35,58 +23,8 @@ def _first_instance(model: type[models.Model]) -> Optional[models.Model]:
35
23
  return qs.first()
36
24
 
37
25
 
38
- @lru_cache(maxsize=1)
39
- def _find_gway_command() -> Optional[str]:
40
- path = shutil.which("gway")
41
- if path:
42
- return path
43
- for candidate in ("~/.local/bin/gway", "/usr/local/bin/gway"):
44
- expanded = os.path.expanduser(candidate)
45
- if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
46
- return expanded
47
- return None
48
-
49
-
50
- def _resolve_with_gway(sigil: str) -> Optional[str]:
51
- command = _find_gway_command()
52
- if not command:
53
- return None
54
- timeout = 60 if _is_wizard_mode() else 1
55
- try:
56
- result = subprocess.run(
57
- [command, "-e", sigil],
58
- check=False,
59
- stdout=subprocess.PIPE,
60
- stderr=subprocess.PIPE,
61
- text=True,
62
- timeout=timeout,
63
- )
64
- except subprocess.TimeoutExpired:
65
- logger.warning(
66
- "gway timed out after %s seconds while resolving sigil %s",
67
- timeout,
68
- sigil,
69
- )
70
- return None
71
- except Exception:
72
- logger.exception("Failed executing gway for sigil %s", sigil)
73
- return None
74
- if result.returncode != 0:
75
- logger.warning(
76
- "gway exited with status %s while resolving sigil %s",
77
- result.returncode,
78
- sigil,
79
- )
80
- return None
81
- return result.stdout.strip()
82
-
83
-
84
26
  def _failed_resolution(token: str) -> str:
85
- sigil = f"[{token}]"
86
- resolved = _resolve_with_gway(sigil)
87
- if resolved is not None:
88
- return resolved
89
- return sigil
27
+ return f"[{token}]"
90
28
 
91
29
 
92
30
  def _resolve_token(token: str, current: Optional[models.Model] = None) -> str:
@@ -197,9 +135,6 @@ def _resolve_token(token: str, current: Optional[models.Model] = None) -> str:
197
135
  value = getattr(settings, candidate, sentinel)
198
136
  if value is not sentinel:
199
137
  return str(value)
200
- fallback = _resolve_with_gway(f"[{original_token}]")
201
- if fallback is not None:
202
- return fallback
203
138
  return ""
204
139
  if root.prefix.upper() == "SYS":
205
140
  values = get_system_sigil_values()
core/system.py CHANGED
@@ -4,6 +4,7 @@ from collections import deque
4
4
  from contextlib import closing
5
5
  from dataclasses import dataclass
6
6
  from datetime import datetime
7
+ from functools import lru_cache
7
8
  from pathlib import Path
8
9
  import json
9
10
  import re
@@ -12,9 +13,12 @@ import subprocess
12
13
  import shutil
13
14
  import logging
14
15
  from typing import Callable, Iterable, Optional
16
+ from urllib.parse import urlparse
15
17
 
18
+ from django import forms
16
19
  from django.conf import settings
17
20
  from django.contrib import admin, messages
21
+ from django.forms import modelformset_factory
18
22
  from django.template.response import TemplateResponse
19
23
  from django.http import HttpResponseRedirect
20
24
  from django.urls import path, reverse
@@ -32,6 +36,7 @@ from core.release import (
32
36
  _remote_with_credentials,
33
37
  )
34
38
  from core.tasks import check_github_updates
39
+ from core.models import Todo
35
40
  from utils import revision
36
41
 
37
42
 
@@ -43,6 +48,69 @@ AUTO_UPGRADE_LOG_NAME = "auto-upgrade.log"
43
48
  logger = logging.getLogger(__name__)
44
49
 
45
50
 
51
+ def _github_repo_path(remote_url: str | None) -> str:
52
+ """Return the ``owner/repo`` path for a GitHub *remote_url* if possible."""
53
+
54
+ if not remote_url:
55
+ return ""
56
+
57
+ normalized = remote_url.strip()
58
+ if not normalized:
59
+ return ""
60
+
61
+ path = ""
62
+ if normalized.startswith("git@"):
63
+ host, _, remainder = normalized.partition(":")
64
+ if "github.com" not in host.lower():
65
+ return ""
66
+ path = remainder
67
+ else:
68
+ parsed = urlparse(normalized)
69
+ if "github.com" not in parsed.netloc.lower():
70
+ return ""
71
+ path = parsed.path
72
+
73
+ path = path.strip("/")
74
+ if path.endswith(".git"):
75
+ path = path[: -len(".git")]
76
+
77
+ if not path:
78
+ return ""
79
+
80
+ segments = [segment for segment in path.split("/") if segment]
81
+ if len(segments) < 2:
82
+ return ""
83
+
84
+ owner, repo = segments[-2], segments[-1]
85
+ return f"{owner}/{repo}"
86
+
87
+
88
+ @lru_cache()
89
+ def _github_commit_url_base() -> str:
90
+ """Return the GitHub commit URL template for the configured repository."""
91
+
92
+ try:
93
+ remote_url = _git_remote_url()
94
+ except FileNotFoundError: # pragma: no cover - depends on environment setup
95
+ logger.debug("Skipping GitHub commit URL generation; git executable not found")
96
+ remote_url = None
97
+
98
+ repo_path = _github_repo_path(remote_url)
99
+ if not repo_path:
100
+ return ""
101
+ return f"https://github.com/{repo_path}/commit/{{sha}}"
102
+
103
+
104
+ def _github_commit_url(sha: str) -> str:
105
+ """Return the GitHub commit URL for *sha* when available."""
106
+
107
+ base = _github_commit_url_base()
108
+ clean_sha = (sha or "").strip()
109
+ if not base or not clean_sha:
110
+ return ""
111
+ return base.replace("{sha}", clean_sha)
112
+
113
+
46
114
  def _auto_upgrade_mode_file(base_dir: Path) -> Path:
47
115
  return base_dir / "locks" / AUTO_UPGRADE_LOCK_NAME
48
116
 
@@ -93,11 +161,77 @@ def _open_changelog_entries() -> list[dict[str, str]]:
93
161
  parts = trimmed.split(" ", 1)
94
162
  sha = parts[0]
95
163
  message = parts[1] if len(parts) > 1 else ""
96
- entries.append({"sha": sha, "message": message})
164
+ entries.append({"sha": sha, "message": message, "url": _github_commit_url(sha)})
97
165
 
98
166
  return entries
99
167
 
100
168
 
169
+ def _latest_release_changelog() -> dict[str, object]:
170
+ """Return the most recent tagged release entries for display."""
171
+
172
+ changelog_path = Path("CHANGELOG.rst")
173
+ try:
174
+ text = changelog_path.read_text(encoding="utf-8")
175
+ except (FileNotFoundError, OSError):
176
+ return {"title": "", "entries": []}
177
+
178
+ lines = text.splitlines()
179
+ state = "before"
180
+ release_title = ""
181
+ entries: list[dict[str, str]] = []
182
+
183
+ for raw_line in lines:
184
+ stripped = raw_line.strip()
185
+
186
+ if state == "before":
187
+ if stripped == "Unreleased":
188
+ state = "unreleased-heading"
189
+ continue
190
+
191
+ if state == "unreleased-heading":
192
+ if set(stripped) == {"-"}:
193
+ state = "unreleased-body"
194
+ else:
195
+ state = "unreleased-body"
196
+ continue
197
+
198
+ if state == "unreleased-body":
199
+ if not stripped:
200
+ state = "after-unreleased"
201
+ continue
202
+
203
+ if state == "after-unreleased":
204
+ if not stripped:
205
+ continue
206
+ release_title = stripped
207
+ state = "release-heading"
208
+ continue
209
+
210
+ if state == "release-heading":
211
+ if set(stripped) == {"-"}:
212
+ state = "release-body"
213
+ else:
214
+ state = "release-body"
215
+ continue
216
+
217
+ if state == "release-body":
218
+ if not stripped:
219
+ if entries:
220
+ break
221
+ continue
222
+ if not stripped.startswith("- "):
223
+ break
224
+ trimmed = stripped[2:].strip()
225
+ if not trimmed:
226
+ continue
227
+ parts = trimmed.split(" ", 1)
228
+ sha = parts[0]
229
+ message = parts[1] if len(parts) > 1 else ""
230
+ entries.append({"sha": sha, "message": message, "url": _github_commit_url(sha)})
231
+
232
+ return {"title": release_title, "entries": entries}
233
+
234
+
101
235
  def _exclude_changelog_entries(shas: Iterable[str]) -> int:
102
236
  """Remove entries matching ``shas`` from the changelog.
103
237
 
@@ -171,7 +305,7 @@ def _regenerate_changelog() -> None:
171
305
  previous_text = (
172
306
  changelog_path.read_text(encoding="utf-8") if changelog_path.exists() else None
173
307
  )
174
- range_spec = changelog_utils.determine_range_spec()
308
+ range_spec = changelog_utils.determine_range_spec(previous_text=previous_text)
175
309
  sections = changelog_utils.collect_sections(
176
310
  range_spec=range_spec, previous_text=previous_text
177
311
  )
@@ -769,6 +903,21 @@ def _parse_runserver_port(command_line: str) -> int | None:
769
903
  return None
770
904
 
771
905
 
906
+ def _configured_backend_port(base_dir: Path) -> int:
907
+ lock_file = base_dir / "locks" / "backend_port.lck"
908
+ try:
909
+ raw = lock_file.read_text().strip()
910
+ except OSError:
911
+ return 8888
912
+ try:
913
+ value = int(raw)
914
+ except (TypeError, ValueError):
915
+ return 8888
916
+ if 1 <= value <= 65535:
917
+ return value
918
+ return 8888
919
+
920
+
772
921
  def _detect_runserver_process() -> tuple[bool, int | None]:
773
922
  """Return whether the dev server is running and the port if available."""
774
923
 
@@ -798,7 +947,7 @@ def _detect_runserver_process() -> tuple[bool, int | None]:
798
947
  break
799
948
 
800
949
  if port is None:
801
- port = 8000
950
+ port = _configured_backend_port(Path(settings.BASE_DIR))
802
951
 
803
952
  return True, port
804
953
 
@@ -847,7 +996,7 @@ def _gather_info() -> dict:
847
996
  raw_mode = ""
848
997
  mode = raw_mode.lower() or "internal"
849
998
  info["mode"] = mode
850
- default_port = 8000 if mode == "public" else 8888
999
+ default_port = _configured_backend_port(base_dir)
851
1000
  detected_port: int | None = None
852
1001
 
853
1002
  screen_file = lock_dir / "screen_mode.lck"
@@ -1070,6 +1219,7 @@ def _system_changelog_report_view(request):
1070
1219
  {
1071
1220
  "title": _("Changelog Report"),
1072
1221
  "open_changelog_entries": _open_changelog_entries(),
1222
+ "latest_release_changelog": _latest_release_changelog(),
1073
1223
  }
1074
1224
  )
1075
1225
  return TemplateResponse(request, "admin/system_changelog_report.html", context)
@@ -1086,6 +1236,132 @@ def _system_upgrade_report_view(request):
1086
1236
  return TemplateResponse(request, "admin/system_upgrade_report.html", context)
1087
1237
 
1088
1238
 
1239
+ class PendingTodoForm(forms.ModelForm):
1240
+ mark_done = forms.BooleanField(required=False, label=_("Approve"))
1241
+
1242
+ class Meta:
1243
+ model = Todo
1244
+ fields = [
1245
+ "request",
1246
+ "request_details",
1247
+ "url",
1248
+ "generated_for_version",
1249
+ "generated_for_revision",
1250
+ "on_done_condition",
1251
+ ]
1252
+ widgets = {
1253
+ "request_details": forms.Textarea(attrs={"rows": 3}),
1254
+ "on_done_condition": forms.Textarea(attrs={"rows": 2}),
1255
+ }
1256
+
1257
+ def __init__(self, *args, **kwargs):
1258
+ super().__init__(*args, **kwargs)
1259
+ for name in [
1260
+ "request",
1261
+ "url",
1262
+ "generated_for_version",
1263
+ "generated_for_revision",
1264
+ ]:
1265
+ self.fields[name].widget.attrs.setdefault("class", "vTextField")
1266
+ for name in ["request_details", "on_done_condition"]:
1267
+ self.fields[name].widget.attrs.setdefault("class", "vLargeTextField")
1268
+
1269
+ mark_done_widget = self.fields["mark_done"].widget
1270
+ existing_classes = mark_done_widget.attrs.get("class", "").split()
1271
+ if "approve-checkbox" not in existing_classes:
1272
+ existing_classes.append("approve-checkbox")
1273
+ mark_done_widget.attrs["class"] = " ".join(
1274
+ class_name for class_name in existing_classes if class_name
1275
+ )
1276
+
1277
+
1278
+ PendingTodoFormSet = modelformset_factory(Todo, form=PendingTodoForm, extra=0)
1279
+
1280
+
1281
+ def _system_pending_todos_report_view(request):
1282
+ queryset = (
1283
+ Todo.objects.filter(is_deleted=False, done_on__isnull=True)
1284
+ .order_by("request")
1285
+ )
1286
+ formset = PendingTodoFormSet(
1287
+ request.POST or None,
1288
+ queryset=queryset,
1289
+ prefix="todos",
1290
+ )
1291
+
1292
+ if request.method == "POST":
1293
+ if formset.is_valid():
1294
+ approved_count = 0
1295
+ edited_count = 0
1296
+ for form in formset.forms:
1297
+ mark_done = form.cleaned_data.get("mark_done")
1298
+ todo = form.save(commit=False)
1299
+ has_changes = form.has_changed()
1300
+ if mark_done and todo.done_on is None:
1301
+ todo.done_on = timezone.now()
1302
+ todo.populate_done_metadata(request.user)
1303
+ approved_count += 1
1304
+ has_changes = True
1305
+ if has_changes:
1306
+ todo.save()
1307
+ if form.has_changed():
1308
+ edited_count += 1
1309
+ if has_changes and form.has_changed():
1310
+ form.save_m2m()
1311
+
1312
+ if approved_count or edited_count:
1313
+ message_parts: list[str] = []
1314
+ if edited_count:
1315
+ message_parts.append(
1316
+ ngettext(
1317
+ "%(count)d TODO updated.",
1318
+ "%(count)d TODOs updated.",
1319
+ edited_count,
1320
+ )
1321
+ % {"count": edited_count}
1322
+ )
1323
+ if approved_count:
1324
+ message_parts.append(
1325
+ ngettext(
1326
+ "%(count)d TODO approved.",
1327
+ "%(count)d TODOs approved.",
1328
+ approved_count,
1329
+ )
1330
+ % {"count": approved_count}
1331
+ )
1332
+ messages.success(request, " ".join(message_parts))
1333
+ else:
1334
+ messages.info(
1335
+ request,
1336
+ _("No changes were applied to the pending TODOs."),
1337
+ )
1338
+ return HttpResponseRedirect(reverse("admin:system-pending-todos-report"))
1339
+ else:
1340
+ messages.error(request, _("Please correct the errors below."))
1341
+
1342
+ rows = [
1343
+ {
1344
+ "form": form,
1345
+ "todo": form.instance,
1346
+ }
1347
+ for form in formset.forms
1348
+ ]
1349
+
1350
+ context = admin.site.each_context(request)
1351
+ context.update(
1352
+ {
1353
+ "title": _("Pending TODOs Report"),
1354
+ "formset": formset,
1355
+ "rows": rows,
1356
+ }
1357
+ )
1358
+ return TemplateResponse(
1359
+ request,
1360
+ "admin/system_pending_todos_report.html",
1361
+ context,
1362
+ )
1363
+
1364
+
1089
1365
  def _trigger_upgrade_check() -> bool:
1090
1366
  """Return ``True`` when the upgrade check was queued asynchronously."""
1091
1367
 
@@ -1142,6 +1418,11 @@ def patch_admin_system_view() -> None:
1142
1418
  admin.site.admin_view(_system_changelog_report_view),
1143
1419
  name="system-changelog-report",
1144
1420
  ),
1421
+ path(
1422
+ "system/pending-todos-report/",
1423
+ admin.site.admin_view(_system_pending_todos_report_view),
1424
+ name="system-pending-todos-report",
1425
+ ),
1145
1426
  path(
1146
1427
  "system/upgrade-report/",
1147
1428
  admin.site.admin_view(_system_upgrade_report_view),