arthexis 0.1.16__py3-none-any.whl → 0.1.26__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.
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
- arthexis-0.1.26.dist-info/RECORD +111 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +15 -30
- config/urls.py +53 -1
- core/admin.py +540 -450
- core/apps.py +0 -6
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1566 -203
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +268 -2
- core/tasks.py +174 -48
- core/tests.py +314 -16
- core/user_data.py +42 -2
- core/views.py +278 -183
- nodes/admin.py +557 -65
- nodes/apps.py +11 -0
- nodes/models.py +658 -113
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +97 -2
- nodes/tests.py +1212 -116
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1239 -154
- ocpp/admin.py +979 -152
- ocpp/consumers.py +268 -28
- ocpp/models.py +488 -3
- ocpp/network.py +398 -0
- ocpp/store.py +6 -4
- ocpp/tasks.py +296 -2
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +121 -4
- ocpp/tests.py +950 -11
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +596 -51
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +26 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +77 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +885 -109
- pages/urls.py +13 -2
- pages/utils.py +70 -0
- pages/views.py +558 -55
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.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
|
|
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 =
|
|
74
|
-
if required_roles
|
|
75
|
-
allowed =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
715
|
-
|
|
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": _("
|
|
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
|
-
|
|
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
|
)
|
|
@@ -1070,6 +1204,7 @@ def _system_changelog_report_view(request):
|
|
|
1070
1204
|
{
|
|
1071
1205
|
"title": _("Changelog Report"),
|
|
1072
1206
|
"open_changelog_entries": _open_changelog_entries(),
|
|
1207
|
+
"latest_release_changelog": _latest_release_changelog(),
|
|
1073
1208
|
}
|
|
1074
1209
|
)
|
|
1075
1210
|
return TemplateResponse(request, "admin/system_changelog_report.html", context)
|
|
@@ -1086,6 +1221,132 @@ def _system_upgrade_report_view(request):
|
|
|
1086
1221
|
return TemplateResponse(request, "admin/system_upgrade_report.html", context)
|
|
1087
1222
|
|
|
1088
1223
|
|
|
1224
|
+
class PendingTodoForm(forms.ModelForm):
|
|
1225
|
+
mark_done = forms.BooleanField(required=False, label=_("Approve"))
|
|
1226
|
+
|
|
1227
|
+
class Meta:
|
|
1228
|
+
model = Todo
|
|
1229
|
+
fields = [
|
|
1230
|
+
"request",
|
|
1231
|
+
"request_details",
|
|
1232
|
+
"url",
|
|
1233
|
+
"generated_for_version",
|
|
1234
|
+
"generated_for_revision",
|
|
1235
|
+
"on_done_condition",
|
|
1236
|
+
]
|
|
1237
|
+
widgets = {
|
|
1238
|
+
"request_details": forms.Textarea(attrs={"rows": 3}),
|
|
1239
|
+
"on_done_condition": forms.Textarea(attrs={"rows": 2}),
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
def __init__(self, *args, **kwargs):
|
|
1243
|
+
super().__init__(*args, **kwargs)
|
|
1244
|
+
for name in [
|
|
1245
|
+
"request",
|
|
1246
|
+
"url",
|
|
1247
|
+
"generated_for_version",
|
|
1248
|
+
"generated_for_revision",
|
|
1249
|
+
]:
|
|
1250
|
+
self.fields[name].widget.attrs.setdefault("class", "vTextField")
|
|
1251
|
+
for name in ["request_details", "on_done_condition"]:
|
|
1252
|
+
self.fields[name].widget.attrs.setdefault("class", "vLargeTextField")
|
|
1253
|
+
|
|
1254
|
+
mark_done_widget = self.fields["mark_done"].widget
|
|
1255
|
+
existing_classes = mark_done_widget.attrs.get("class", "").split()
|
|
1256
|
+
if "approve-checkbox" not in existing_classes:
|
|
1257
|
+
existing_classes.append("approve-checkbox")
|
|
1258
|
+
mark_done_widget.attrs["class"] = " ".join(
|
|
1259
|
+
class_name for class_name in existing_classes if class_name
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
PendingTodoFormSet = modelformset_factory(Todo, form=PendingTodoForm, extra=0)
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def _system_pending_todos_report_view(request):
|
|
1267
|
+
queryset = (
|
|
1268
|
+
Todo.objects.filter(is_deleted=False, done_on__isnull=True)
|
|
1269
|
+
.order_by("request")
|
|
1270
|
+
)
|
|
1271
|
+
formset = PendingTodoFormSet(
|
|
1272
|
+
request.POST or None,
|
|
1273
|
+
queryset=queryset,
|
|
1274
|
+
prefix="todos",
|
|
1275
|
+
)
|
|
1276
|
+
|
|
1277
|
+
if request.method == "POST":
|
|
1278
|
+
if formset.is_valid():
|
|
1279
|
+
approved_count = 0
|
|
1280
|
+
edited_count = 0
|
|
1281
|
+
for form in formset.forms:
|
|
1282
|
+
mark_done = form.cleaned_data.get("mark_done")
|
|
1283
|
+
todo = form.save(commit=False)
|
|
1284
|
+
has_changes = form.has_changed()
|
|
1285
|
+
if mark_done and todo.done_on is None:
|
|
1286
|
+
todo.done_on = timezone.now()
|
|
1287
|
+
todo.populate_done_metadata(request.user)
|
|
1288
|
+
approved_count += 1
|
|
1289
|
+
has_changes = True
|
|
1290
|
+
if has_changes:
|
|
1291
|
+
todo.save()
|
|
1292
|
+
if form.has_changed():
|
|
1293
|
+
edited_count += 1
|
|
1294
|
+
if has_changes and form.has_changed():
|
|
1295
|
+
form.save_m2m()
|
|
1296
|
+
|
|
1297
|
+
if approved_count or edited_count:
|
|
1298
|
+
message_parts: list[str] = []
|
|
1299
|
+
if edited_count:
|
|
1300
|
+
message_parts.append(
|
|
1301
|
+
ngettext(
|
|
1302
|
+
"%(count)d TODO updated.",
|
|
1303
|
+
"%(count)d TODOs updated.",
|
|
1304
|
+
edited_count,
|
|
1305
|
+
)
|
|
1306
|
+
% {"count": edited_count}
|
|
1307
|
+
)
|
|
1308
|
+
if approved_count:
|
|
1309
|
+
message_parts.append(
|
|
1310
|
+
ngettext(
|
|
1311
|
+
"%(count)d TODO approved.",
|
|
1312
|
+
"%(count)d TODOs approved.",
|
|
1313
|
+
approved_count,
|
|
1314
|
+
)
|
|
1315
|
+
% {"count": approved_count}
|
|
1316
|
+
)
|
|
1317
|
+
messages.success(request, " ".join(message_parts))
|
|
1318
|
+
else:
|
|
1319
|
+
messages.info(
|
|
1320
|
+
request,
|
|
1321
|
+
_("No changes were applied to the pending TODOs."),
|
|
1322
|
+
)
|
|
1323
|
+
return HttpResponseRedirect(reverse("admin:system-pending-todos-report"))
|
|
1324
|
+
else:
|
|
1325
|
+
messages.error(request, _("Please correct the errors below."))
|
|
1326
|
+
|
|
1327
|
+
rows = [
|
|
1328
|
+
{
|
|
1329
|
+
"form": form,
|
|
1330
|
+
"todo": form.instance,
|
|
1331
|
+
}
|
|
1332
|
+
for form in formset.forms
|
|
1333
|
+
]
|
|
1334
|
+
|
|
1335
|
+
context = admin.site.each_context(request)
|
|
1336
|
+
context.update(
|
|
1337
|
+
{
|
|
1338
|
+
"title": _("Pending TODOs Report"),
|
|
1339
|
+
"formset": formset,
|
|
1340
|
+
"rows": rows,
|
|
1341
|
+
}
|
|
1342
|
+
)
|
|
1343
|
+
return TemplateResponse(
|
|
1344
|
+
request,
|
|
1345
|
+
"admin/system_pending_todos_report.html",
|
|
1346
|
+
context,
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
|
|
1089
1350
|
def _trigger_upgrade_check() -> bool:
|
|
1090
1351
|
"""Return ``True`` when the upgrade check was queued asynchronously."""
|
|
1091
1352
|
|
|
@@ -1142,6 +1403,11 @@ def patch_admin_system_view() -> None:
|
|
|
1142
1403
|
admin.site.admin_view(_system_changelog_report_view),
|
|
1143
1404
|
name="system-changelog-report",
|
|
1144
1405
|
),
|
|
1406
|
+
path(
|
|
1407
|
+
"system/pending-todos-report/",
|
|
1408
|
+
admin.site.admin_view(_system_pending_todos_report_view),
|
|
1409
|
+
name="system-pending-todos-report",
|
|
1410
|
+
),
|
|
1145
1411
|
path(
|
|
1146
1412
|
"system/upgrade-report/",
|
|
1147
1413
|
admin.site.admin_view(_system_upgrade_report_view),
|