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.

Files changed (63) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +15 -30
  6. config/urls.py +53 -1
  7. core/admin.py +540 -450
  8. core/apps.py +0 -6
  9. core/auto_upgrade.py +19 -4
  10. core/backends.py +13 -3
  11. core/changelog.py +66 -5
  12. core/environment.py +4 -5
  13. core/models.py +1566 -203
  14. core/notifications.py +1 -1
  15. core/reference_utils.py +10 -11
  16. core/release.py +55 -7
  17. core/sigil_builder.py +2 -2
  18. core/sigil_resolver.py +1 -66
  19. core/system.py +268 -2
  20. core/tasks.py +174 -48
  21. core/tests.py +314 -16
  22. core/user_data.py +42 -2
  23. core/views.py +278 -183
  24. nodes/admin.py +557 -65
  25. nodes/apps.py +11 -0
  26. nodes/models.py +658 -113
  27. nodes/rfid_sync.py +1 -1
  28. nodes/tasks.py +97 -2
  29. nodes/tests.py +1212 -116
  30. nodes/urls.py +15 -1
  31. nodes/utils.py +51 -3
  32. nodes/views.py +1239 -154
  33. ocpp/admin.py +979 -152
  34. ocpp/consumers.py +268 -28
  35. ocpp/models.py +488 -3
  36. ocpp/network.py +398 -0
  37. ocpp/store.py +6 -4
  38. ocpp/tasks.py +296 -2
  39. ocpp/test_export_import.py +1 -0
  40. ocpp/test_rfid.py +121 -4
  41. ocpp/tests.py +950 -11
  42. ocpp/transactions_io.py +9 -1
  43. ocpp/urls.py +3 -3
  44. ocpp/views.py +596 -51
  45. pages/admin.py +262 -30
  46. pages/apps.py +35 -0
  47. pages/context_processors.py +26 -21
  48. pages/defaults.py +1 -1
  49. pages/forms.py +31 -8
  50. pages/middleware.py +6 -2
  51. pages/models.py +77 -2
  52. pages/module_defaults.py +5 -5
  53. pages/site_config.py +137 -0
  54. pages/tests.py +885 -109
  55. pages/urls.py +13 -2
  56. pages/utils.py +70 -0
  57. pages/views.py +558 -55
  58. arthexis-0.1.16.dist-info/RECORD +0 -111
  59. core/workgroup_urls.py +0 -17
  60. core/workgroup_views.py +0 -94
  61. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  62. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
  63. {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 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
  )
@@ -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),