arthexis 0.1.9__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 (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
core/temp_passwords.py ADDED
@@ -0,0 +1,181 @@
1
+ """Utilities for temporary password lock files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import secrets
9
+ import string
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from django.conf import settings
16
+ from django.contrib.auth.hashers import check_password, make_password
17
+ from django.utils import timezone
18
+ from django.utils.dateparse import parse_datetime
19
+
20
+
21
+ DEFAULT_PASSWORD_LENGTH = 16
22
+ DEFAULT_EXPIRATION = timedelta(hours=1)
23
+ _SAFE_COMPONENT_RE = re.compile(r"[^A-Za-z0-9_.-]+")
24
+
25
+
26
+ def _base_lock_dir() -> Path:
27
+ """Return the root directory used for temporary password lock files."""
28
+
29
+ configured = getattr(settings, "TEMP_PASSWORD_LOCK_DIR", None)
30
+ if configured:
31
+ path = Path(configured)
32
+ else:
33
+ path = Path(settings.BASE_DIR) / "locks" / "temp-passwords"
34
+ path.mkdir(parents=True, exist_ok=True)
35
+ return path
36
+
37
+
38
+ def _safe_component(value: str) -> str:
39
+ """Return a filesystem safe component derived from ``value``."""
40
+
41
+ if not value:
42
+ return ""
43
+ safe = _SAFE_COMPONENT_RE.sub("_", value)
44
+ safe = safe.strip("._")
45
+ return safe[:64]
46
+
47
+
48
+ def _lockfile_name(username: str) -> str:
49
+ """Return the filename used for the provided ``username``."""
50
+
51
+ digest = hashlib.sha256(username.encode("utf-8")).hexdigest()[:12]
52
+ safe = _safe_component(username)
53
+ if safe:
54
+ return f"{safe}-{digest}.json"
55
+ return f"user-{digest}.json"
56
+
57
+
58
+ def _lockfile_path(username: str) -> Path:
59
+ """Return the lockfile path for ``username``."""
60
+
61
+ return _base_lock_dir() / _lockfile_name(username)
62
+
63
+
64
+ def _parse_timestamp(value: str | None) -> Optional[datetime]:
65
+ """Return a timezone aware datetime parsed from ``value``."""
66
+
67
+ if not value:
68
+ return None
69
+ parsed = parse_datetime(value)
70
+ if parsed is None:
71
+ return None
72
+ if timezone.is_naive(parsed):
73
+ parsed = timezone.make_aware(parsed)
74
+ return parsed
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class TempPasswordEntry:
79
+ """Details for a temporary password stored on disk."""
80
+
81
+ username: str
82
+ password_hash: str
83
+ expires_at: datetime
84
+ created_at: datetime
85
+ path: Path
86
+ allow_change: bool = False
87
+
88
+ @property
89
+ def is_expired(self) -> bool:
90
+ return timezone.now() >= self.expires_at
91
+
92
+ def check_password(self, raw_password: str) -> bool:
93
+ """Return ``True`` if ``raw_password`` matches this entry."""
94
+
95
+ return check_password(raw_password, self.password_hash)
96
+
97
+
98
+ def generate_password(length: int = DEFAULT_PASSWORD_LENGTH) -> str:
99
+ """Return a random password composed of letters and digits."""
100
+
101
+ if length <= 0:
102
+ raise ValueError("length must be a positive integer")
103
+ alphabet = string.ascii_letters + string.digits
104
+ return "".join(secrets.choice(alphabet) for _ in range(length))
105
+
106
+
107
+ def store_temp_password(
108
+ username: str,
109
+ raw_password: str,
110
+ expires_at: Optional[datetime] = None,
111
+ *,
112
+ allow_change: bool = False,
113
+ ) -> TempPasswordEntry:
114
+ """Persist a temporary password for ``username`` and return the entry."""
115
+
116
+ if expires_at is None:
117
+ expires_at = timezone.now() + DEFAULT_EXPIRATION
118
+ if timezone.is_naive(expires_at):
119
+ expires_at = timezone.make_aware(expires_at)
120
+ created_at = timezone.now()
121
+ path = _lockfile_path(username)
122
+ data = {
123
+ "username": username,
124
+ "password_hash": make_password(raw_password),
125
+ "expires_at": expires_at.isoformat(),
126
+ "created_at": created_at.isoformat(),
127
+ "allow_change": allow_change,
128
+ }
129
+ path.write_text(json.dumps(data, indent=2, sort_keys=True))
130
+ return TempPasswordEntry(
131
+ username=username,
132
+ password_hash=data["password_hash"],
133
+ expires_at=expires_at,
134
+ created_at=created_at,
135
+ path=path,
136
+ allow_change=allow_change,
137
+ )
138
+
139
+
140
+ def load_temp_password(username: str) -> Optional[TempPasswordEntry]:
141
+ """Return the stored temporary password for ``username``, if any."""
142
+
143
+ path = _lockfile_path(username)
144
+ if not path.exists():
145
+ return None
146
+ try:
147
+ data = json.loads(path.read_text())
148
+ except (json.JSONDecodeError, UnicodeDecodeError):
149
+ path.unlink(missing_ok=True)
150
+ return None
151
+
152
+ expires_at = _parse_timestamp(data.get("expires_at"))
153
+ created_at = _parse_timestamp(data.get("created_at")) or timezone.now()
154
+ password_hash = data.get("password_hash")
155
+ if not expires_at or not password_hash:
156
+ path.unlink(missing_ok=True)
157
+ return None
158
+
159
+ username = data.get("username") or username
160
+ allow_change_value = data.get("allow_change", False)
161
+ if isinstance(allow_change_value, str):
162
+ allow_change = allow_change_value.lower() in {"1", "true", "yes", "on"}
163
+ else:
164
+ allow_change = bool(allow_change_value)
165
+
166
+ return TempPasswordEntry(
167
+ username=username,
168
+ password_hash=password_hash,
169
+ expires_at=expires_at,
170
+ created_at=created_at,
171
+ path=path,
172
+ allow_change=allow_change,
173
+ )
174
+
175
+
176
+ def discard_temp_password(username: str) -> None:
177
+ """Remove any stored temporary password for ``username``."""
178
+
179
+ path = _lockfile_path(username)
180
+ path.unlink(missing_ok=True)
181
+
core/test_system_info.py CHANGED
@@ -1,43 +1,202 @@
1
- import os
2
- from pathlib import Path
3
-
4
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
5
-
6
- import django
7
-
8
- django.setup()
9
-
10
- from django.conf import settings
11
- from django.test import SimpleTestCase, override_settings
12
- from core.system import _gather_info
13
-
14
-
15
- class SystemInfoRoleTests(SimpleTestCase):
16
- @override_settings(NODE_ROLE="Terminal")
17
- def test_defaults_to_terminal(self):
18
- info = _gather_info()
19
- self.assertEqual(info["role"], "Terminal")
20
-
21
- @override_settings(NODE_ROLE="Satellite")
22
- def test_uses_settings_role(self):
23
- info = _gather_info()
24
- self.assertEqual(info["role"], "Satellite")
25
-
26
-
27
- class SystemInfoScreenModeTests(SimpleTestCase):
28
- def test_without_lockfile(self):
29
- info = _gather_info()
30
- self.assertEqual(info["screen_mode"], "")
31
-
32
- def test_with_lockfile(self):
33
- lock_dir = Path(settings.BASE_DIR) / "locks"
34
- lock_dir.mkdir(exist_ok=True)
35
- lock_file = lock_dir / "screen_mode.lck"
36
- lock_file.write_text("tft")
37
- try:
38
- info = _gather_info()
39
- self.assertEqual(info["screen_mode"], "tft")
40
- finally:
41
- lock_file.unlink()
42
- if not any(lock_dir.iterdir()):
43
- lock_dir.rmdir()
1
+ import json
2
+ import os
3
+ from datetime import timedelta
4
+ from pathlib import Path
5
+ from subprocess import CompletedProcess
6
+ from tempfile import TemporaryDirectory
7
+ from unittest.mock import Mock, patch
8
+
9
+
10
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
11
+
12
+ import django
13
+
14
+ django.setup()
15
+
16
+ from django.conf import settings
17
+ from django.test import SimpleTestCase, override_settings
18
+ from nodes.models import Node, NodeFeature, NodeRole
19
+ from core.system import (
20
+ _gather_info,
21
+ _load_auto_upgrade_log_entries,
22
+ _read_auto_upgrade_mode,
23
+ get_system_sigil_values,
24
+ )
25
+
26
+
27
+ class SystemInfoRoleTests(SimpleTestCase):
28
+ @override_settings(NODE_ROLE="Terminal")
29
+ def test_defaults_to_terminal(self):
30
+ info = _gather_info()
31
+ self.assertEqual(info["role"], "Terminal")
32
+
33
+ @override_settings(NODE_ROLE="Satellite")
34
+ def test_uses_settings_role(self):
35
+ info = _gather_info()
36
+ self.assertEqual(info["role"], "Satellite")
37
+
38
+
39
+ class SystemInfoScreenModeTests(SimpleTestCase):
40
+ def test_without_lockfile(self):
41
+ info = _gather_info()
42
+ self.assertEqual(info["screen_mode"], "")
43
+
44
+ def test_with_lockfile(self):
45
+ lock_dir = Path(settings.BASE_DIR) / "locks"
46
+ lock_dir.mkdir(exist_ok=True)
47
+ lock_file = lock_dir / "screen_mode.lck"
48
+ lock_file.write_text("tft")
49
+ try:
50
+ info = _gather_info()
51
+ self.assertEqual(info["screen_mode"], "tft")
52
+ finally:
53
+ lock_file.unlink()
54
+ if not any(lock_dir.iterdir()):
55
+ lock_dir.rmdir()
56
+
57
+
58
+ class SystemInfoModeTests(SimpleTestCase):
59
+ def test_public_mode_case_insensitive(self):
60
+ lock_dir = Path(settings.BASE_DIR) / "locks"
61
+ lock_dir.mkdir(exist_ok=True)
62
+ lock_file = lock_dir / "nginx_mode.lck"
63
+ lock_file.write_text("PUBLIC", encoding="utf-8")
64
+ try:
65
+ info = _gather_info()
66
+ self.assertEqual(info["mode"], "public")
67
+ self.assertEqual(info["port"], 8000)
68
+ finally:
69
+ lock_file.unlink()
70
+ if not any(lock_dir.iterdir()):
71
+ lock_dir.rmdir()
72
+
73
+
74
+ class SystemInfoRevisionTests(SimpleTestCase):
75
+ @patch("core.system.revision.get_revision", return_value="abcdef1234567890")
76
+ def test_includes_full_revision(self, mock_revision):
77
+ info = _gather_info()
78
+ self.assertEqual(info["revision"], "abcdef1234567890")
79
+ mock_revision.assert_called_once()
80
+
81
+
82
+ class SystemInfoDatabaseTests(SimpleTestCase):
83
+ def test_collects_database_definitions(self):
84
+ info = _gather_info()
85
+ self.assertIn("databases", info)
86
+ aliases = {entry["alias"] for entry in info["databases"]}
87
+ self.assertIn("default", aliases)
88
+
89
+ @override_settings(
90
+ DATABASES={
91
+ "default": {
92
+ "ENGINE": "django.db.backends.sqlite3",
93
+ "NAME": Path("/tmp/db.sqlite3"),
94
+ }
95
+ }
96
+ )
97
+ def test_serializes_path_database_names(self):
98
+ info = _gather_info()
99
+ databases = info["databases"]
100
+ self.assertEqual(databases[0]["name"], "/tmp/db.sqlite3")
101
+
102
+
103
+ class AutoUpgradeModeTests(SimpleTestCase):
104
+ def test_lock_file_read_error_marks_enabled(self):
105
+ mock_path = Mock()
106
+ mock_path.exists.return_value = True
107
+ mock_path.read_text.side_effect = OSError
108
+
109
+ with patch("core.system._auto_upgrade_mode_file", return_value=mock_path):
110
+ info = _read_auto_upgrade_mode(Path("/tmp"))
111
+
112
+ self.assertTrue(info["lock_exists"])
113
+ self.assertTrue(info["enabled"])
114
+ self.assertTrue(info["read_error"])
115
+
116
+
117
+ class AutoUpgradeLogParsingTests(SimpleTestCase):
118
+ def test_parses_zulu_timestamp_entries(self):
119
+ with TemporaryDirectory() as tmpdir:
120
+ base_dir = Path(tmpdir)
121
+ log_dir = base_dir / "logs"
122
+ log_dir.mkdir()
123
+ log_path = log_dir / "auto-upgrade.log"
124
+ log_path.write_text("2024-01-01T12:34:56Z Started\n", encoding="utf-8")
125
+
126
+ with patch("core.system._format_timestamp", return_value="formatted") as mock_format:
127
+ result = _load_auto_upgrade_log_entries(base_dir)
128
+
129
+ entries = result["entries"]
130
+ self.assertEqual(len(entries), 1)
131
+ entry = entries[0]
132
+ self.assertEqual(entry["message"], "Started")
133
+ self.assertEqual(entry["timestamp"], "formatted")
134
+
135
+ mock_format.assert_called_once()
136
+ parsed_dt = mock_format.call_args[0][0]
137
+ self.assertEqual(parsed_dt.year, 2024)
138
+ self.assertEqual(parsed_dt.month, 1)
139
+ self.assertEqual(parsed_dt.day, 1)
140
+ self.assertEqual(parsed_dt.utcoffset(), timedelta(0))
141
+
142
+
143
+ class SystemInfoRunserverDetectionTests(SimpleTestCase):
144
+ @patch("core.system.subprocess.run")
145
+ def test_detects_runserver_process_port(self, mock_run):
146
+ mock_run.return_value = CompletedProcess(
147
+ args=["pgrep"],
148
+ returncode=0,
149
+ stdout="123 python manage.py runserver 0.0.0.0:8000 --noreload\n",
150
+ )
151
+
152
+ info = _gather_info()
153
+
154
+ self.assertTrue(info["running"])
155
+ self.assertEqual(info["port"], 8000)
156
+
157
+ @patch("core.system._probe_ports", return_value=(True, 8000))
158
+ @patch("core.system.subprocess.run", side_effect=FileNotFoundError)
159
+ def test_falls_back_to_port_probe_when_pgrep_missing(self, mock_run, mock_probe):
160
+ info = _gather_info()
161
+
162
+ self.assertTrue(info["running"])
163
+ self.assertEqual(info["port"], 8000)
164
+
165
+
166
+ class SystemSigilValueTests(SimpleTestCase):
167
+ def test_exports_values_for_sigil_resolution(self):
168
+ sample_info = {
169
+ "installed": True,
170
+ "revision": "abcdef",
171
+ "service": "gunicorn",
172
+ "mode": "internal",
173
+ "port": 8888,
174
+ "role": "Terminal",
175
+ "screen_mode": "",
176
+ "features": [
177
+ {"display": "Feature", "expected": True, "actual": False, "slug": "feature"}
178
+ ],
179
+ "running": True,
180
+ "service_status": "active",
181
+ "hostname": "example.local",
182
+ "ip_addresses": ["127.0.0.1"],
183
+ "databases": [
184
+ {
185
+ "alias": "default",
186
+ "engine": "django.db.backends.sqlite3",
187
+ "name": "db.sqlite3",
188
+ }
189
+ ],
190
+ }
191
+ with patch("core.system._gather_info", return_value=sample_info):
192
+ values = get_system_sigil_values()
193
+
194
+ self.assertEqual(values["REVISION"], "abcdef")
195
+ self.assertEqual(values["RUNNING"], "True")
196
+ self.assertEqual(values["NGINX_MODE"], "internal (8888)")
197
+ self.assertEqual(values["IP_ADDRESSES"], "127.0.0.1")
198
+ features = json.loads(values["FEATURES"])
199
+ self.assertEqual(features[0]["display"], "Feature")
200
+ databases = json.loads(values["DATABASES"])
201
+ self.assertEqual(databases[0]["alias"], "default")
202
+