arthexis 0.1.7__py3-none-any.whl → 0.1.9__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 (82) hide show
  1. arthexis-0.1.9.dist-info/METADATA +168 -0
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +134 -16
  10. config/urls.py +71 -3
  11. core/admin.py +1331 -165
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +151 -0
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1136 -259
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +445 -58
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +17 -0
  42. core/workgroup_views.py +94 -0
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +4 -3
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.7.dist-info/METADATA +0 -126
  77. arthexis-0.1.7.dist-info/RECORD +0 -77
  78. arthexis-0.1.7.dist-info/licenses/LICENSE +0 -21
  79. config/workgroup_app.py +0 -7
  80. core/checks.py +0 -29
  81. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  82. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
core/github_issues.py ADDED
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Iterable, Mapping
9
+
10
+ import requests
11
+
12
+ from .models import Package, PackageRelease
13
+ from .release import DEFAULT_PACKAGE
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ BASE_DIR = Path(__file__).resolve().parent.parent
19
+ LOCK_DIR = BASE_DIR / "locks" / "github-issues"
20
+ LOCK_TTL = timedelta(hours=1)
21
+ REQUEST_TIMEOUT = 10
22
+
23
+
24
+ def resolve_repository() -> tuple[str, str]:
25
+ """Return the ``(owner, repo)`` tuple for the active package."""
26
+
27
+ package = Package.objects.filter(is_active=True).first()
28
+ repository_url = (
29
+ package.repository_url
30
+ if package and package.repository_url
31
+ else DEFAULT_PACKAGE.repository_url
32
+ )
33
+
34
+ owner: str
35
+ repo: str
36
+
37
+ if repository_url.startswith("git@"):
38
+ _, _, remainder = repository_url.partition(":")
39
+ path = remainder
40
+ else:
41
+ from urllib.parse import urlparse
42
+
43
+ parsed = urlparse(repository_url)
44
+ path = parsed.path
45
+
46
+ path = path.strip("/")
47
+ if path.endswith(".git"):
48
+ path = path[:-4]
49
+
50
+ segments = [segment for segment in path.split("/") if segment]
51
+ if len(segments) < 2:
52
+ raise ValueError(f"Invalid repository URL: {repository_url!r}")
53
+
54
+ owner, repo = segments[-2], segments[-1]
55
+ return owner, repo
56
+
57
+
58
+ def get_github_token() -> str:
59
+ """Return the configured GitHub token.
60
+
61
+ Preference is given to the latest :class:`~core.models.PackageRelease`.
62
+ When unavailable, fall back to the ``GITHUB_TOKEN`` environment variable.
63
+ """
64
+
65
+ latest_release = PackageRelease.latest()
66
+ if latest_release:
67
+ token = latest_release.get_github_token()
68
+ if token:
69
+ return token
70
+
71
+ try:
72
+ return os.environ["GITHUB_TOKEN"]
73
+ except KeyError as exc: # pragma: no cover - defensive guard
74
+ raise RuntimeError("GitHub token is not configured") from exc
75
+
76
+
77
+ def _ensure_lock_dir() -> None:
78
+ LOCK_DIR.mkdir(parents=True, exist_ok=True)
79
+
80
+
81
+ def _fingerprint_digest(fingerprint: str) -> str:
82
+ return hashlib.sha256(str(fingerprint).encode("utf-8")).hexdigest()
83
+
84
+
85
+ def _fingerprint_path(fingerprint: str) -> Path:
86
+ return LOCK_DIR / _fingerprint_digest(fingerprint)
87
+
88
+
89
+ def _has_recent_marker(lock_path: Path) -> bool:
90
+ if not lock_path.exists():
91
+ return False
92
+
93
+ marker_age = datetime.utcnow() - datetime.utcfromtimestamp(
94
+ lock_path.stat().st_mtime
95
+ )
96
+ return marker_age < LOCK_TTL
97
+
98
+
99
+ def build_issue_payload(
100
+ title: str,
101
+ body: str,
102
+ labels: Iterable[str] | None = None,
103
+ fingerprint: str | None = None,
104
+ ) -> Mapping[str, object] | None:
105
+ """Return an API payload for GitHub issues.
106
+
107
+ When ``fingerprint`` is provided, duplicate submissions within ``LOCK_TTL``
108
+ are ignored by returning ``None``. A marker is kept on disk to prevent
109
+ repeated reports during the cooldown window.
110
+ """
111
+
112
+ payload: dict[str, object] = {"title": title, "body": body}
113
+
114
+ if labels:
115
+ deduped = list(dict.fromkeys(labels))
116
+ if deduped:
117
+ payload["labels"] = deduped
118
+
119
+ if fingerprint:
120
+ _ensure_lock_dir()
121
+ lock_path = _fingerprint_path(fingerprint)
122
+ if _has_recent_marker(lock_path):
123
+ logger.info("Skipping GitHub issue for active fingerprint %s", fingerprint)
124
+ return None
125
+
126
+ lock_path.write_text(datetime.utcnow().isoformat(), encoding="utf-8")
127
+ digest = _fingerprint_digest(fingerprint)
128
+ payload["body"] = f"{body}\n\n<!-- fingerprint:{digest} -->"
129
+
130
+ return payload
131
+
132
+
133
+ def create_issue(
134
+ title: str,
135
+ body: str,
136
+ labels: Iterable[str] | None = None,
137
+ fingerprint: str | None = None,
138
+ ) -> requests.Response | None:
139
+ """Create a GitHub issue using the configured repository and token."""
140
+
141
+ payload = build_issue_payload(title, body, labels=labels, fingerprint=fingerprint)
142
+ if payload is None:
143
+ return None
144
+
145
+ owner, repo = resolve_repository()
146
+ token = get_github_token()
147
+
148
+ headers = {
149
+ "Accept": "application/vnd.github+json",
150
+ "Authorization": f"token {token}",
151
+ "User-Agent": "arthexis-runtime-reporter",
152
+ }
153
+ url = f"https://api.github.com/repos/{owner}/{repo}/issues"
154
+
155
+ response = requests.post(
156
+ url, json=payload, headers=headers, timeout=REQUEST_TIMEOUT
157
+ )
158
+ if not (200 <= response.status_code < 300):
159
+ logger.error(
160
+ "GitHub issue creation failed with status %s: %s",
161
+ response.status_code,
162
+ response.text,
163
+ )
164
+ response.raise_for_status()
165
+
166
+ logger.info(
167
+ "GitHub issue created for %s/%s with status %s",
168
+ owner,
169
+ repo,
170
+ response.status_code,
171
+ )
172
+ return response
core/lcd_screen.py CHANGED
@@ -6,6 +6,7 @@ characters the text scrolls horizontally. A third line in the lock file
6
6
  can define the scroll speed in milliseconds per character (default 1000
7
7
  ms).
8
8
  """
9
+
9
10
  from __future__ import annotations
10
11
 
11
12
  import logging
core/liveupdate.py ADDED
@@ -0,0 +1,25 @@
1
+ from functools import wraps
2
+
3
+
4
+ def live_update(interval=5):
5
+ """Decorator to mark function-based views for automatic refresh."""
6
+
7
+ def decorator(view):
8
+ @wraps(view)
9
+ def wrapped(request, *args, **kwargs):
10
+ setattr(request, "live_update_interval", interval)
11
+ return view(request, *args, **kwargs)
12
+
13
+ return wrapped
14
+
15
+ return decorator
16
+
17
+
18
+ class LiveUpdateMixin:
19
+ """Mixin to enable automatic refresh for class-based views."""
20
+
21
+ live_update_interval = 5
22
+
23
+ def dispatch(self, request, *args, **kwargs):
24
+ setattr(request, "live_update_interval", self.live_update_interval)
25
+ return super().dispatch(request, *args, **kwargs)
core/log_paths.py ADDED
@@ -0,0 +1,100 @@
1
+ """Helpers for selecting writable log directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import os
7
+ import sys
8
+
9
+
10
+ def _is_root() -> bool:
11
+ if hasattr(os, "geteuid"):
12
+ try:
13
+ return os.geteuid() == 0
14
+ except OSError: # pragma: no cover - defensive for unusual platforms
15
+ return False
16
+ return False
17
+
18
+
19
+ def _state_home(base_home: Path) -> Path:
20
+ state_home = os.environ.get("XDG_STATE_HOME")
21
+ if state_home:
22
+ return Path(state_home).expanduser()
23
+ return base_home / ".local" / "state"
24
+
25
+
26
+ def select_log_dir(base_dir: Path) -> Path:
27
+ """Choose a writable log directory for the current process."""
28
+
29
+ default = base_dir / "logs"
30
+ env_override = os.environ.get("ARTHEXIS_LOG_DIR")
31
+ is_root = _is_root()
32
+ sudo_user = os.environ.get("SUDO_USER")
33
+
34
+ candidates: list[Path] = []
35
+ if env_override:
36
+ candidates.append(Path(env_override).expanduser())
37
+
38
+ if is_root:
39
+ if not sudo_user or sudo_user == "root":
40
+ candidates.append(default)
41
+ candidates.append(Path("/var/log/arthexis"))
42
+ candidates.append(Path("/tmp/arthexis/logs"))
43
+ else:
44
+ home = Path.home()
45
+ state_home = _state_home(home)
46
+ candidates.extend(
47
+ [
48
+ default,
49
+ state_home / "arthexis" / "logs",
50
+ home / ".arthexis" / "logs",
51
+ Path("/tmp/arthexis/logs"),
52
+ ]
53
+ )
54
+
55
+ seen: set[Path] = set()
56
+ ordered_candidates: list[Path] = []
57
+ for candidate in candidates:
58
+ candidate = candidate.expanduser()
59
+ if candidate not in seen:
60
+ seen.add(candidate)
61
+ ordered_candidates.append(candidate)
62
+
63
+ attempted: list[Path] = []
64
+ chosen: Path | None = None
65
+ for candidate in ordered_candidates:
66
+ attempted.append(candidate)
67
+ try:
68
+ candidate.mkdir(parents=True, exist_ok=True)
69
+ except OSError:
70
+ continue
71
+ if os.access(candidate, os.W_OK | os.X_OK):
72
+ chosen = candidate
73
+ break
74
+
75
+ if chosen is None:
76
+ attempted_str = (
77
+ ", ".join(str(path) for path in attempted) if attempted else "none"
78
+ )
79
+ raise RuntimeError(
80
+ f"Unable to create a writable log directory. Tried: {attempted_str}"
81
+ )
82
+
83
+ if chosen != default:
84
+ if (
85
+ attempted
86
+ and attempted[0] == default
87
+ and not os.access(default, os.W_OK | os.X_OK)
88
+ ):
89
+ print(
90
+ f"Log directory {default} is not writable; using {chosen}",
91
+ file=sys.stderr,
92
+ )
93
+ elif is_root and sudo_user and sudo_user != "root" and not env_override:
94
+ print(
95
+ f"Running with elevated privileges; writing logs to {chosen}",
96
+ file=sys.stderr,
97
+ )
98
+
99
+ os.environ["ARTHEXIS_LOG_DIR"] = str(chosen)
100
+ return chosen
core/mailer.py ADDED
@@ -0,0 +1,83 @@
1
+ import logging
2
+ from typing import Sequence
3
+
4
+ from django.conf import settings
5
+ from django.core.mail import EmailMessage
6
+ from django.utils.module_loading import import_string
7
+
8
+ try: # pragma: no cover - import should always succeed but guard defensively
9
+ from django.core.mail.backends.dummy import (
10
+ EmailBackend as DummyEmailBackend,
11
+ )
12
+ except Exception: # pragma: no cover - fallback when dummy backend unavailable
13
+ DummyEmailBackend = None # type: ignore[assignment]
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def send(
19
+ subject: str,
20
+ message: str,
21
+ recipient_list: Sequence[str],
22
+ from_email: str | None = None,
23
+ *,
24
+ outbox=None,
25
+ attachments: Sequence[tuple[str, str, str]] | None = None,
26
+ content_subtype: str | None = None,
27
+ **kwargs,
28
+ ):
29
+ """Send an email using Django's email utilities.
30
+
31
+ If ``outbox`` is provided, its connection will be used when sending.
32
+ """
33
+ sender = (
34
+ from_email or getattr(outbox, "from_email", None) or settings.DEFAULT_FROM_EMAIL
35
+ )
36
+ connection = outbox.get_connection() if outbox is not None else None
37
+ fail_silently = kwargs.pop("fail_silently", False)
38
+ email = EmailMessage(
39
+ subject=subject,
40
+ body=message,
41
+ from_email=sender,
42
+ to=list(recipient_list),
43
+ connection=connection,
44
+ **kwargs,
45
+ )
46
+ if attachments:
47
+ for attachment in attachments:
48
+ if not isinstance(attachment, (list, tuple)) or len(attachment) != 3:
49
+ raise ValueError(
50
+ "attachments must contain (name, content, mimetype) tuples"
51
+ )
52
+ email.attach(*attachment)
53
+ if content_subtype:
54
+ email.content_subtype = content_subtype
55
+ email.send(fail_silently=fail_silently)
56
+ return email
57
+
58
+
59
+ def can_send_email() -> bool:
60
+ """Return ``True`` when at least one outbound email path is configured."""
61
+
62
+ from nodes.models import EmailOutbox # imported lazily to avoid circular deps
63
+
64
+ has_outbox = EmailOutbox.objects.exclude(host="").exists()
65
+ if has_outbox:
66
+ return True
67
+
68
+ backend_path = getattr(settings, "EMAIL_BACKEND", "")
69
+ if not backend_path:
70
+ return False
71
+ try:
72
+ backend_cls = import_string(backend_path)
73
+ except Exception: # pragma: no cover - misconfigured backend
74
+ logger.warning("Email backend %s could not be imported", backend_path)
75
+ return False
76
+
77
+ if DummyEmailBackend is None:
78
+ return True
79
+ try:
80
+ return not issubclass(backend_cls, DummyEmailBackend)
81
+ except TypeError: # pragma: no cover - backend not a class
82
+ logger.warning("Email backend %s is not a class", backend_path)
83
+ return False
core/middleware.py CHANGED
@@ -1,5 +1,12 @@
1
+ from django.contrib.auth import get_user_model
1
2
  from django.contrib.contenttypes.models import ContentType
3
+ from django.contrib.sites.models import Site
4
+ from django.urls import resolve
5
+
6
+ from nodes.models import Node, NodeRole
7
+
2
8
  from .models import AdminHistory
9
+ from .sigil_context import set_context, clear_context
3
10
 
4
11
 
5
12
  class AdminHistoryMiddleware:
@@ -32,3 +39,53 @@ class AdminHistoryMiddleware:
32
39
  defaults={"content_type": content_type},
33
40
  )
34
41
  return response
42
+
43
+
44
+ class SigilContextMiddleware:
45
+ """Capture model instance identifiers from resolved views."""
46
+
47
+ def __init__(self, get_response):
48
+ self.get_response = get_response
49
+
50
+ def __call__(self, request):
51
+ context = {}
52
+ if request.user.is_authenticated:
53
+ context[get_user_model()] = request.user.pk
54
+ try:
55
+ site = Site.objects.get_current(request)
56
+ context[Site] = site.pk
57
+ except Exception:
58
+ pass
59
+ if hasattr(request, "node") and getattr(request, "node", None):
60
+ context[Node] = request.node.pk
61
+ if hasattr(request, "role") and getattr(request, "role", None):
62
+ context[NodeRole] = request.role.pk
63
+ try:
64
+ match = resolve(request.path_info)
65
+ except Exception: # pragma: no cover - resolution errors
66
+ match = None
67
+ if match and hasattr(match, "func"):
68
+ view = match.func
69
+ model = None
70
+ if hasattr(view, "view_class"):
71
+ view_class = view.view_class
72
+ model = getattr(view_class, "model", None)
73
+ if model is None:
74
+ queryset = getattr(view_class, "queryset", None)
75
+ if queryset is not None:
76
+ model = queryset.model
77
+ if model is not None:
78
+ pk = match.kwargs.get("pk") or match.kwargs.get("id")
79
+ if pk is not None:
80
+ context[model] = pk
81
+ for field in model._meta.fields:
82
+ if field.is_relation:
83
+ for key in (field.name, field.attname):
84
+ if key in match.kwargs:
85
+ context[field.related_model] = match.kwargs[key]
86
+ break
87
+ set_context(context)
88
+ try:
89
+ return self.get_response(request)
90
+ finally:
91
+ clear_context()