arthexis 0.1.22__py3-none-any.whl → 0.1.24__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.22.dist-info → arthexis-0.1.24.dist-info}/METADATA +6 -5
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/RECORD +26 -26
- config/settings.py +4 -0
- core/admin.py +200 -16
- core/models.py +878 -118
- core/release.py +0 -5
- core/tasks.py +25 -0
- core/tests.py +29 -1
- core/user_data.py +42 -2
- core/views.py +33 -26
- nodes/admin.py +153 -132
- nodes/models.py +9 -1
- nodes/tests.py +106 -81
- nodes/urls.py +6 -0
- nodes/views.py +620 -48
- ocpp/admin.py +543 -166
- ocpp/models.py +57 -2
- ocpp/tasks.py +336 -1
- ocpp/tests.py +123 -0
- ocpp/views.py +19 -3
- pages/tests.py +25 -6
- pages/urls.py +5 -0
- pages/views.py +117 -11
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/WHEEL +0 -0
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/top_level.txt +0 -0
core/release.py
CHANGED
|
@@ -520,11 +520,6 @@ def build(
|
|
|
520
520
|
if not version_path.exists():
|
|
521
521
|
raise ReleaseError("VERSION file not found")
|
|
522
522
|
version = version_path.read_text().strip()
|
|
523
|
-
if bump:
|
|
524
|
-
major, minor, patch = map(int, version.split("."))
|
|
525
|
-
patch += 1
|
|
526
|
-
version = f"{major}.{minor}.{patch}"
|
|
527
|
-
version_path.write_text(version + "\n")
|
|
528
523
|
else:
|
|
529
524
|
# Ensure the VERSION file reflects the provided release version
|
|
530
525
|
if version_path.parent != Path("."):
|
core/tasks.py
CHANGED
|
@@ -405,3 +405,28 @@ def run_client_report_schedule(schedule_id: int) -> None:
|
|
|
405
405
|
except Exception:
|
|
406
406
|
logger.exception("ClientReportSchedule %s failed", schedule_id)
|
|
407
407
|
raise
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@shared_task
|
|
411
|
+
def ensure_recurring_client_reports() -> None:
|
|
412
|
+
"""Ensure scheduled consumer reports run for the current period."""
|
|
413
|
+
|
|
414
|
+
from core.models import ClientReportSchedule
|
|
415
|
+
|
|
416
|
+
reference = timezone.localdate()
|
|
417
|
+
schedules = ClientReportSchedule.objects.filter(
|
|
418
|
+
periodicity__in=[
|
|
419
|
+
ClientReportSchedule.PERIODICITY_DAILY,
|
|
420
|
+
ClientReportSchedule.PERIODICITY_WEEKLY,
|
|
421
|
+
ClientReportSchedule.PERIODICITY_MONTHLY,
|
|
422
|
+
]
|
|
423
|
+
).prefetch_related("chargers")
|
|
424
|
+
|
|
425
|
+
for schedule in schedules:
|
|
426
|
+
try:
|
|
427
|
+
schedule.generate_missing_reports(reference=reference)
|
|
428
|
+
except Exception:
|
|
429
|
+
logger.exception(
|
|
430
|
+
"Automatic consumer report generation failed for schedule %s",
|
|
431
|
+
schedule.pk,
|
|
432
|
+
)
|
core/tests.py
CHANGED
|
@@ -2138,6 +2138,25 @@ class PackageReleaseCurrentTests(TestCase):
|
|
|
2138
2138
|
self.package.save()
|
|
2139
2139
|
self.assertFalse(self.release.is_current)
|
|
2140
2140
|
|
|
2141
|
+
def test_is_current_false_when_version_has_plus(self):
|
|
2142
|
+
self.version_path.write_text("1.0.0+")
|
|
2143
|
+
self.assertFalse(self.release.is_current)
|
|
2144
|
+
|
|
2145
|
+
|
|
2146
|
+
class PackageReleaseRevisionTests(TestCase):
|
|
2147
|
+
def setUp(self):
|
|
2148
|
+
self.package = Package.objects.get(name="arthexis")
|
|
2149
|
+
self.release = PackageRelease.objects.create(
|
|
2150
|
+
package=self.package,
|
|
2151
|
+
version="1.0.0",
|
|
2152
|
+
revision="abcdef123456",
|
|
2153
|
+
)
|
|
2154
|
+
|
|
2155
|
+
def test_matches_revision_ignores_plus_suffix(self):
|
|
2156
|
+
self.assertTrue(
|
|
2157
|
+
PackageRelease.matches_revision("1.0.0+", "abcdef123456")
|
|
2158
|
+
)
|
|
2159
|
+
|
|
2141
2160
|
def test_is_current_false_when_version_differs(self):
|
|
2142
2161
|
self.release.version = "2.0.0"
|
|
2143
2162
|
self.release.save()
|
|
@@ -2188,13 +2207,22 @@ class PackageAdminPrepareNextReleaseTests(TestCase):
|
|
|
2188
2207
|
|
|
2189
2208
|
def test_prepare_next_release_active_creates_release(self):
|
|
2190
2209
|
PackageRelease.all_objects.filter(package=self.package).delete()
|
|
2191
|
-
request = self.factory.
|
|
2210
|
+
request = self.factory.post("/admin/core/package/prepare-next-release/")
|
|
2192
2211
|
response = self.admin.prepare_next_release_active(request)
|
|
2193
2212
|
self.assertEqual(response.status_code, 302)
|
|
2194
2213
|
self.assertEqual(
|
|
2195
2214
|
PackageRelease.all_objects.filter(package=self.package).count(), 1
|
|
2196
2215
|
)
|
|
2197
2216
|
|
|
2217
|
+
def test_prepare_next_release_active_get_creates_release(self):
|
|
2218
|
+
PackageRelease.all_objects.filter(package=self.package).delete()
|
|
2219
|
+
request = self.factory.get("/admin/core/package/prepare-next-release/")
|
|
2220
|
+
response = self.admin.prepare_next_release_active(request)
|
|
2221
|
+
self.assertEqual(response.status_code, 302)
|
|
2222
|
+
self.assertTrue(
|
|
2223
|
+
PackageRelease.all_objects.filter(package=self.package).exists()
|
|
2224
|
+
)
|
|
2225
|
+
|
|
2198
2226
|
|
|
2199
2227
|
class PackageAdminChangeViewTests(TestCase):
|
|
2200
2228
|
def setUp(self):
|
core/user_data.py
CHANGED
|
@@ -201,9 +201,49 @@ def dump_user_fixture(instance, user=None) -> None:
|
|
|
201
201
|
|
|
202
202
|
def delete_user_fixture(instance, user=None) -> None:
|
|
203
203
|
target_user = user or _resolve_fixture_user(instance)
|
|
204
|
-
|
|
204
|
+
filename = (
|
|
205
|
+
f"{instance._meta.app_label}_{instance._meta.model_name}_{instance.pk}.json"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _remove_for_user(candidate) -> None:
|
|
209
|
+
if candidate is None:
|
|
210
|
+
return
|
|
211
|
+
base_path = Path(
|
|
212
|
+
getattr(candidate, "data_path", "") or Path(settings.BASE_DIR) / "data"
|
|
213
|
+
)
|
|
214
|
+
username = _username_for(candidate)
|
|
215
|
+
if not username:
|
|
216
|
+
return
|
|
217
|
+
user_dir = base_path / username
|
|
218
|
+
if user_dir.exists():
|
|
219
|
+
(user_dir / filename).unlink(missing_ok=True)
|
|
220
|
+
|
|
221
|
+
if target_user is not None:
|
|
222
|
+
_remove_for_user(target_user)
|
|
205
223
|
return
|
|
206
|
-
|
|
224
|
+
|
|
225
|
+
root = Path(settings.BASE_DIR) / "data"
|
|
226
|
+
if root.exists():
|
|
227
|
+
(root / filename).unlink(missing_ok=True)
|
|
228
|
+
for path in root.iterdir():
|
|
229
|
+
if path.is_dir():
|
|
230
|
+
(path / filename).unlink(missing_ok=True)
|
|
231
|
+
|
|
232
|
+
UserModel = get_user_model()
|
|
233
|
+
manager = getattr(UserModel, "all_objects", UserModel._default_manager)
|
|
234
|
+
for candidate in manager.all():
|
|
235
|
+
data_path = getattr(candidate, "data_path", "")
|
|
236
|
+
if not data_path:
|
|
237
|
+
continue
|
|
238
|
+
base_path = Path(data_path)
|
|
239
|
+
if not base_path.exists():
|
|
240
|
+
continue
|
|
241
|
+
username = _username_for(candidate)
|
|
242
|
+
if not username:
|
|
243
|
+
continue
|
|
244
|
+
user_dir = base_path / username
|
|
245
|
+
if user_dir.exists():
|
|
246
|
+
(user_dir / filename).unlink(missing_ok=True)
|
|
207
247
|
|
|
208
248
|
|
|
209
249
|
def _mark_fixture_user_data(path: Path) -> None:
|
core/views.py
CHANGED
|
@@ -690,16 +690,17 @@ def _ensure_origin_main_unchanged(log_path: Path) -> None:
|
|
|
690
690
|
def _next_patch_version(version: str) -> str:
|
|
691
691
|
from packaging.version import InvalidVersion, Version
|
|
692
692
|
|
|
693
|
+
cleaned = version.rstrip("+")
|
|
693
694
|
try:
|
|
694
|
-
parsed = Version(
|
|
695
|
+
parsed = Version(cleaned)
|
|
695
696
|
except InvalidVersion:
|
|
696
|
-
parts =
|
|
697
|
+
parts = cleaned.split(".") if cleaned else []
|
|
697
698
|
for index in range(len(parts) - 1, -1, -1):
|
|
698
699
|
segment = parts[index]
|
|
699
700
|
if segment.isdigit():
|
|
700
701
|
parts[index] = str(int(segment) + 1)
|
|
701
702
|
return ".".join(parts)
|
|
702
|
-
return version
|
|
703
|
+
return cleaned or version
|
|
703
704
|
return f"{parsed.major}.{parsed.minor}.{parsed.micro + 1}"
|
|
704
705
|
|
|
705
706
|
|
|
@@ -768,7 +769,9 @@ def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
|
|
|
768
769
|
version_path = Path("VERSION")
|
|
769
770
|
if version_path.exists():
|
|
770
771
|
try:
|
|
771
|
-
|
|
772
|
+
raw_version = version_path.read_text(encoding="utf-8").strip()
|
|
773
|
+
cleaned_version = raw_version.rstrip("+") or "0.0.0"
|
|
774
|
+
repo_version = Version(cleaned_version)
|
|
772
775
|
except InvalidVersion:
|
|
773
776
|
repo_version = None
|
|
774
777
|
|
|
@@ -935,7 +938,7 @@ def _refresh_changelog_once(ctx, log_path: Path) -> None:
|
|
|
935
938
|
ctx["changelog_refreshed"] = True
|
|
936
939
|
|
|
937
940
|
|
|
938
|
-
def _step_check_todos(release, ctx, log_path: Path) -> None:
|
|
941
|
+
def _step_check_todos(release, ctx, log_path: Path, *, user=None) -> None:
|
|
939
942
|
_refresh_changelog_once(ctx, log_path)
|
|
940
943
|
|
|
941
944
|
pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
|
|
@@ -975,7 +978,7 @@ def _step_check_todos(release, ctx, log_path: Path) -> None:
|
|
|
975
978
|
ctx["todos_ack"] = True
|
|
976
979
|
|
|
977
980
|
|
|
978
|
-
def _step_check_version(release, ctx, log_path: Path) -> None:
|
|
981
|
+
def _step_check_version(release, ctx, log_path: Path, *, user=None) -> None:
|
|
979
982
|
from . import release as release_utils
|
|
980
983
|
from packaging.version import InvalidVersion, Version
|
|
981
984
|
|
|
@@ -1110,10 +1113,12 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
|
|
|
1110
1113
|
version_path = Path("VERSION")
|
|
1111
1114
|
if version_path.exists():
|
|
1112
1115
|
current = version_path.read_text(encoding="utf-8").strip()
|
|
1113
|
-
if current
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1116
|
+
if current:
|
|
1117
|
+
current_clean = current.rstrip("+") or "0.0.0"
|
|
1118
|
+
if Version(release.version) < Version(current_clean):
|
|
1119
|
+
raise Exception(
|
|
1120
|
+
f"Version {release.version} is older than existing {current}"
|
|
1121
|
+
)
|
|
1117
1122
|
|
|
1118
1123
|
_append_log(log_path, f"Checking if version {release.version} exists on PyPI")
|
|
1119
1124
|
if release_utils.network_available():
|
|
@@ -1163,17 +1168,17 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
|
|
|
1163
1168
|
_append_log(log_path, "Network unavailable, skipping PyPI check")
|
|
1164
1169
|
|
|
1165
1170
|
|
|
1166
|
-
def _step_handle_migrations(release, ctx, log_path: Path) -> None:
|
|
1171
|
+
def _step_handle_migrations(release, ctx, log_path: Path, *, user=None) -> None:
|
|
1167
1172
|
_append_log(log_path, "Freeze, squash and approve migrations")
|
|
1168
1173
|
_append_log(log_path, "Migration review acknowledged (manual step)")
|
|
1169
1174
|
|
|
1170
1175
|
|
|
1171
|
-
def _step_changelog_docs(release, ctx, log_path: Path) -> None:
|
|
1176
|
+
def _step_changelog_docs(release, ctx, log_path: Path, *, user=None) -> None:
|
|
1172
1177
|
_append_log(log_path, "Compose CHANGELOG and documentation")
|
|
1173
1178
|
_append_log(log_path, "CHANGELOG and documentation review recorded")
|
|
1174
1179
|
|
|
1175
1180
|
|
|
1176
|
-
def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
|
|
1181
|
+
def _step_pre_release_actions(release, ctx, log_path: Path, *, user=None) -> None:
|
|
1177
1182
|
_append_log(log_path, "Execute pre-release actions")
|
|
1178
1183
|
if ctx.get("dry_run"):
|
|
1179
1184
|
_append_log(log_path, "Dry run: skipping pre-release actions")
|
|
@@ -1249,12 +1254,12 @@ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
|
|
|
1249
1254
|
_append_log(log_path, "Pre-release actions complete")
|
|
1250
1255
|
|
|
1251
1256
|
|
|
1252
|
-
def _step_run_tests(release, ctx, log_path: Path) -> None:
|
|
1257
|
+
def _step_run_tests(release, ctx, log_path: Path, *, user=None) -> None:
|
|
1253
1258
|
_append_log(log_path, "Complete test suite with --all flag")
|
|
1254
1259
|
_append_log(log_path, "Test suite completion acknowledged")
|
|
1255
1260
|
|
|
1256
1261
|
|
|
1257
|
-
def _step_promote_build(release, ctx, log_path: Path) -> None:
|
|
1262
|
+
def _step_promote_build(release, ctx, log_path: Path, *, user=None) -> None:
|
|
1258
1263
|
from . import release as release_utils
|
|
1259
1264
|
|
|
1260
1265
|
_append_log(log_path, "Generating build files")
|
|
@@ -1266,7 +1271,7 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
|
|
|
1266
1271
|
release_utils.promote(
|
|
1267
1272
|
package=release.to_package(),
|
|
1268
1273
|
version=release.version,
|
|
1269
|
-
creds=release.to_credentials(),
|
|
1274
|
+
creds=release.to_credentials(user=user),
|
|
1270
1275
|
)
|
|
1271
1276
|
_append_log(
|
|
1272
1277
|
log_path,
|
|
@@ -1314,8 +1319,10 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
|
|
|
1314
1319
|
_append_log(new_log, "Build complete")
|
|
1315
1320
|
|
|
1316
1321
|
|
|
1317
|
-
def _step_release_manager_approval(
|
|
1318
|
-
|
|
1322
|
+
def _step_release_manager_approval(
|
|
1323
|
+
release, ctx, log_path: Path, *, user=None
|
|
1324
|
+
) -> None:
|
|
1325
|
+
if release.to_credentials(user=user) is None:
|
|
1319
1326
|
ctx.pop("release_approval", None)
|
|
1320
1327
|
if not ctx.get("approval_credentials_missing"):
|
|
1321
1328
|
_append_log(log_path, "Release manager publishing credentials missing")
|
|
@@ -1349,14 +1356,14 @@ def _step_release_manager_approval(release, ctx, log_path: Path) -> None:
|
|
|
1349
1356
|
raise ApprovalRequired()
|
|
1350
1357
|
|
|
1351
1358
|
|
|
1352
|
-
def _step_publish(release, ctx, log_path: Path) -> None:
|
|
1359
|
+
def _step_publish(release, ctx, log_path: Path, *, user=None) -> None:
|
|
1353
1360
|
from . import release as release_utils
|
|
1354
1361
|
|
|
1355
1362
|
if ctx.get("dry_run"):
|
|
1356
1363
|
test_repository_url = os.environ.get(
|
|
1357
1364
|
"PYPI_TEST_REPOSITORY_URL", "https://test.pypi.org/legacy/"
|
|
1358
1365
|
)
|
|
1359
|
-
test_creds = release.to_credentials()
|
|
1366
|
+
test_creds = release.to_credentials(user=user)
|
|
1360
1367
|
if not (test_creds and test_creds.has_auth()):
|
|
1361
1368
|
test_creds = release_utils.Credentials(
|
|
1362
1369
|
token=os.environ.get("PYPI_TEST_API_TOKEN"),
|
|
@@ -1396,7 +1403,7 @@ def _step_publish(release, ctx, log_path: Path) -> None:
|
|
|
1396
1403
|
release_utils.build(
|
|
1397
1404
|
package=package,
|
|
1398
1405
|
version=release.version,
|
|
1399
|
-
creds=release.to_credentials(),
|
|
1406
|
+
creds=release.to_credentials(user=user),
|
|
1400
1407
|
dist=True,
|
|
1401
1408
|
tests=False,
|
|
1402
1409
|
twine=False,
|
|
@@ -1425,13 +1432,13 @@ def _step_publish(release, ctx, log_path: Path) -> None:
|
|
|
1425
1432
|
release_utils.publish(
|
|
1426
1433
|
package=release.to_package(),
|
|
1427
1434
|
version=release.version,
|
|
1428
|
-
creds=target.credentials or release.to_credentials(),
|
|
1435
|
+
creds=target.credentials or release.to_credentials(user=user),
|
|
1429
1436
|
repositories=[target],
|
|
1430
1437
|
)
|
|
1431
1438
|
_append_log(log_path, "Dry run: skipped release metadata updates")
|
|
1432
1439
|
return
|
|
1433
1440
|
|
|
1434
|
-
targets = release.build_publish_targets()
|
|
1441
|
+
targets = release.build_publish_targets(user=user)
|
|
1435
1442
|
repo_labels = []
|
|
1436
1443
|
for target in targets:
|
|
1437
1444
|
label = target.name
|
|
@@ -1450,7 +1457,7 @@ def _step_publish(release, ctx, log_path: Path) -> None:
|
|
|
1450
1457
|
release_utils.publish(
|
|
1451
1458
|
package=release.to_package(),
|
|
1452
1459
|
version=release.version,
|
|
1453
|
-
creds=release.to_credentials(),
|
|
1460
|
+
creds=release.to_credentials(user=user),
|
|
1454
1461
|
repositories=targets,
|
|
1455
1462
|
)
|
|
1456
1463
|
except release_utils.PostPublishWarning as warning:
|
|
@@ -1819,7 +1826,7 @@ def release_progress(request, pk: int, action: str):
|
|
|
1819
1826
|
return redirect(request.path)
|
|
1820
1827
|
|
|
1821
1828
|
manager = release.release_manager or release.package.release_manager
|
|
1822
|
-
credentials_ready = bool(release.to_credentials())
|
|
1829
|
+
credentials_ready = bool(release.to_credentials(user=request.user))
|
|
1823
1830
|
if credentials_ready and ctx.get("approval_credentials_missing"):
|
|
1824
1831
|
ctx.pop("approval_credentials_missing", None)
|
|
1825
1832
|
|
|
@@ -1990,7 +1997,7 @@ def release_progress(request, pk: int, action: str):
|
|
|
1990
1997
|
if to_run == step_count:
|
|
1991
1998
|
name, func = steps[to_run]
|
|
1992
1999
|
try:
|
|
1993
|
-
func(release, ctx, log_path)
|
|
2000
|
+
func(release, ctx, log_path, user=request.user)
|
|
1994
2001
|
except PendingTodos:
|
|
1995
2002
|
pass
|
|
1996
2003
|
except ApprovalRequired:
|
nodes/admin.py
CHANGED
|
@@ -19,7 +19,7 @@ from django.utils.html import format_html, format_html_join
|
|
|
19
19
|
from django.utils.translation import gettext_lazy as _
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from types import SimpleNamespace
|
|
22
|
-
from urllib.parse import urlparse, urlsplit, urlunsplit
|
|
22
|
+
from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit
|
|
23
23
|
import base64
|
|
24
24
|
import ipaddress
|
|
25
25
|
import json
|
|
@@ -60,6 +60,7 @@ from .models import (
|
|
|
60
60
|
)
|
|
61
61
|
from . import dns as dns_utils
|
|
62
62
|
from core.models import RFID
|
|
63
|
+
from ocpp.models import Charger, Location
|
|
63
64
|
from core.user_data import EntityModelAdmin
|
|
64
65
|
|
|
65
66
|
|
|
@@ -237,7 +238,6 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
237
238
|
"relation",
|
|
238
239
|
"last_seen",
|
|
239
240
|
"visit_link",
|
|
240
|
-
"proxy_link",
|
|
241
241
|
)
|
|
242
242
|
search_fields = ("hostname", "address", "mac_address")
|
|
243
243
|
change_list_template = "admin/nodes/node/change_list.html"
|
|
@@ -287,6 +287,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
287
287
|
"register_visitor",
|
|
288
288
|
"run_task",
|
|
289
289
|
"take_screenshots",
|
|
290
|
+
"discover_charge_points",
|
|
290
291
|
"import_rfids_from_selected",
|
|
291
292
|
"export_rfids_to_selected",
|
|
292
293
|
]
|
|
@@ -296,16 +297,6 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
296
297
|
def relation(self, obj):
|
|
297
298
|
return obj.get_current_relation_display()
|
|
298
299
|
|
|
299
|
-
@admin.display(description=_("Proxy"))
|
|
300
|
-
def proxy_link(self, obj):
|
|
301
|
-
if not obj or obj.is_local:
|
|
302
|
-
return ""
|
|
303
|
-
try:
|
|
304
|
-
url = reverse("admin:nodes_node_proxy", args=[obj.pk])
|
|
305
|
-
except NoReverseMatch:
|
|
306
|
-
return ""
|
|
307
|
-
return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
|
|
308
|
-
|
|
309
300
|
@admin.display(description=_("Visit"))
|
|
310
301
|
def visit_link(self, obj):
|
|
311
302
|
if not obj:
|
|
@@ -372,11 +363,6 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
372
363
|
self.admin_site.admin_view(self.update_selected_progress),
|
|
373
364
|
name="nodes_node_update_selected_progress",
|
|
374
365
|
),
|
|
375
|
-
path(
|
|
376
|
-
"<int:node_id>/proxy/",
|
|
377
|
-
self.admin_site.admin_view(self.proxy_node),
|
|
378
|
-
name="nodes_node_proxy",
|
|
379
|
-
),
|
|
380
366
|
]
|
|
381
367
|
return custom + urls
|
|
382
368
|
|
|
@@ -409,121 +395,6 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
409
395
|
)
|
|
410
396
|
return response
|
|
411
397
|
|
|
412
|
-
def _load_local_private_key(self, node):
|
|
413
|
-
security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
|
|
414
|
-
priv_path = security_dir / f"{node.public_endpoint}"
|
|
415
|
-
if not priv_path.exists():
|
|
416
|
-
return None, _("Local node private key not found.")
|
|
417
|
-
try:
|
|
418
|
-
return (
|
|
419
|
-
serialization.load_pem_private_key(
|
|
420
|
-
priv_path.read_bytes(), password=None
|
|
421
|
-
),
|
|
422
|
-
"",
|
|
423
|
-
)
|
|
424
|
-
except Exception as exc: # pragma: no cover - unexpected errors
|
|
425
|
-
return None, str(exc)
|
|
426
|
-
|
|
427
|
-
def _build_proxy_payload(self, request, local_node):
|
|
428
|
-
user = request.user
|
|
429
|
-
payload = {
|
|
430
|
-
"requester": str(local_node.uuid),
|
|
431
|
-
"user": {
|
|
432
|
-
"username": user.get_username(),
|
|
433
|
-
"email": user.email or "",
|
|
434
|
-
"first_name": user.first_name or "",
|
|
435
|
-
"last_name": user.last_name or "",
|
|
436
|
-
"is_staff": user.is_staff,
|
|
437
|
-
"is_superuser": user.is_superuser,
|
|
438
|
-
"groups": list(user.groups.values_list("name", flat=True)),
|
|
439
|
-
"permissions": sorted(user.get_all_permissions()),
|
|
440
|
-
},
|
|
441
|
-
"target": reverse("admin:index"),
|
|
442
|
-
}
|
|
443
|
-
return payload
|
|
444
|
-
|
|
445
|
-
def _start_proxy_session(self, request, node):
|
|
446
|
-
if node.is_local:
|
|
447
|
-
return {"ok": False, "message": _("Local node cannot be proxied.")}
|
|
448
|
-
|
|
449
|
-
local_node = Node.get_local()
|
|
450
|
-
if local_node is None:
|
|
451
|
-
try:
|
|
452
|
-
local_node, _ = Node.register_current()
|
|
453
|
-
except Exception as exc: # pragma: no cover - unexpected errors
|
|
454
|
-
return {"ok": False, "message": str(exc)}
|
|
455
|
-
|
|
456
|
-
private_key, error = self._load_local_private_key(local_node)
|
|
457
|
-
if private_key is None:
|
|
458
|
-
return {"ok": False, "message": error}
|
|
459
|
-
|
|
460
|
-
payload = self._build_proxy_payload(request, local_node)
|
|
461
|
-
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
462
|
-
try:
|
|
463
|
-
signature = private_key.sign(
|
|
464
|
-
body.encode(),
|
|
465
|
-
padding.PKCS1v15(),
|
|
466
|
-
hashes.SHA256(),
|
|
467
|
-
)
|
|
468
|
-
except Exception as exc: # pragma: no cover - unexpected errors
|
|
469
|
-
return {"ok": False, "message": str(exc)}
|
|
470
|
-
|
|
471
|
-
headers = {
|
|
472
|
-
"Content-Type": "application/json",
|
|
473
|
-
"X-Signature": base64.b64encode(signature).decode(),
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
last_error = ""
|
|
477
|
-
for url in self._iter_remote_urls(node, "/nodes/proxy/session/"):
|
|
478
|
-
try:
|
|
479
|
-
response = requests.post(url, data=body, headers=headers, timeout=5)
|
|
480
|
-
except RequestException as exc:
|
|
481
|
-
last_error = str(exc)
|
|
482
|
-
continue
|
|
483
|
-
if not response.ok:
|
|
484
|
-
last_error = f"{response.status_code} {response.text}"
|
|
485
|
-
continue
|
|
486
|
-
try:
|
|
487
|
-
data = response.json()
|
|
488
|
-
except ValueError:
|
|
489
|
-
last_error = "Invalid JSON response"
|
|
490
|
-
continue
|
|
491
|
-
login_url = data.get("login_url")
|
|
492
|
-
if not login_url:
|
|
493
|
-
last_error = "login_url missing"
|
|
494
|
-
continue
|
|
495
|
-
return {
|
|
496
|
-
"ok": True,
|
|
497
|
-
"login_url": login_url,
|
|
498
|
-
"expires": data.get("expires"),
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
return {
|
|
502
|
-
"ok": False,
|
|
503
|
-
"message": last_error or "Unable to initiate proxy.",
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
def proxy_node(self, request, node_id):
|
|
507
|
-
node = self.get_queryset(request).filter(pk=node_id).first()
|
|
508
|
-
if not node:
|
|
509
|
-
raise Http404
|
|
510
|
-
if not self.has_view_permission(request):
|
|
511
|
-
raise PermissionDenied
|
|
512
|
-
result = self._start_proxy_session(request, node)
|
|
513
|
-
if not result.get("ok"):
|
|
514
|
-
message = result.get("message") or _("Unable to proxy node.")
|
|
515
|
-
self.message_user(request, message, messages.ERROR)
|
|
516
|
-
return redirect("admin:nodes_node_changelist")
|
|
517
|
-
|
|
518
|
-
context = {
|
|
519
|
-
**self.admin_site.each_context(request),
|
|
520
|
-
"opts": self.model._meta,
|
|
521
|
-
"node": node,
|
|
522
|
-
"frame_url": result.get("login_url"),
|
|
523
|
-
"expires": result.get("expires"),
|
|
524
|
-
}
|
|
525
|
-
return TemplateResponse(request, "admin/nodes/node/proxy.html", context)
|
|
526
|
-
|
|
527
398
|
@admin.action(description="Register Visitor")
|
|
528
399
|
def register_visitor(self, request, queryset=None):
|
|
529
400
|
return self.register_visitor_view(request)
|
|
@@ -1189,6 +1060,156 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
1189
1060
|
|
|
1190
1061
|
return self._render_rfid_sync(request, "export", results)
|
|
1191
1062
|
|
|
1063
|
+
@admin.action(description=_("Discover Charge Points"))
|
|
1064
|
+
def discover_charge_points(self, request, queryset):
|
|
1065
|
+
local_node, private_key, error = self._load_local_node_credentials()
|
|
1066
|
+
if error:
|
|
1067
|
+
self.message_user(request, error, level=messages.ERROR)
|
|
1068
|
+
return
|
|
1069
|
+
|
|
1070
|
+
nodes = [node for node in queryset if not local_node.pk or node.pk != local_node.pk]
|
|
1071
|
+
if not nodes:
|
|
1072
|
+
self.message_user(request, _("No remote nodes selected."), level=messages.WARNING)
|
|
1073
|
+
return
|
|
1074
|
+
|
|
1075
|
+
payload = json.dumps(
|
|
1076
|
+
{"requester": str(local_node.uuid)},
|
|
1077
|
+
separators=(",", ":"),
|
|
1078
|
+
sort_keys=True,
|
|
1079
|
+
)
|
|
1080
|
+
signature = self._sign_payload(private_key, payload)
|
|
1081
|
+
headers = {
|
|
1082
|
+
"Content-Type": "application/json",
|
|
1083
|
+
"X-Signature": signature,
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
created = 0
|
|
1087
|
+
updated = 0
|
|
1088
|
+
errors: list[str] = []
|
|
1089
|
+
|
|
1090
|
+
for node in nodes:
|
|
1091
|
+
url = f"http://{node.address}:{node.port}/nodes/network/chargers/"
|
|
1092
|
+
try:
|
|
1093
|
+
response = requests.post(url, data=payload, headers=headers, timeout=5)
|
|
1094
|
+
except RequestException as exc:
|
|
1095
|
+
errors.append(f"{node}: {exc}")
|
|
1096
|
+
continue
|
|
1097
|
+
|
|
1098
|
+
if response.status_code != 200:
|
|
1099
|
+
errors.append(f"{node}: {response.status_code} {response.text}")
|
|
1100
|
+
continue
|
|
1101
|
+
|
|
1102
|
+
try:
|
|
1103
|
+
data = response.json()
|
|
1104
|
+
except ValueError:
|
|
1105
|
+
errors.append(f"{node}: invalid JSON response")
|
|
1106
|
+
continue
|
|
1107
|
+
|
|
1108
|
+
for entry in data.get("chargers", []):
|
|
1109
|
+
applied = self._apply_remote_charger_payload(node, entry)
|
|
1110
|
+
if applied == "created":
|
|
1111
|
+
created += 1
|
|
1112
|
+
elif applied == "updated":
|
|
1113
|
+
updated += 1
|
|
1114
|
+
|
|
1115
|
+
if created or updated:
|
|
1116
|
+
summary = _("Imported %(created)s new and %(updated)s existing charge point(s).") % {
|
|
1117
|
+
"created": created,
|
|
1118
|
+
"updated": updated,
|
|
1119
|
+
}
|
|
1120
|
+
self.message_user(request, summary, level=messages.SUCCESS)
|
|
1121
|
+
if errors:
|
|
1122
|
+
for error in errors:
|
|
1123
|
+
self.message_user(request, error, level=messages.ERROR)
|
|
1124
|
+
|
|
1125
|
+
def _apply_remote_charger_payload(self, node, payload: Mapping) -> str | None:
|
|
1126
|
+
serial = Charger.normalize_serial(payload.get("charger_id"))
|
|
1127
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
1128
|
+
return None
|
|
1129
|
+
|
|
1130
|
+
connector_value = payload.get("connector_id")
|
|
1131
|
+
if connector_value in ("", None):
|
|
1132
|
+
connector_value = None
|
|
1133
|
+
elif isinstance(connector_value, str):
|
|
1134
|
+
try:
|
|
1135
|
+
connector_value = int(connector_value)
|
|
1136
|
+
except ValueError:
|
|
1137
|
+
connector_value = None
|
|
1138
|
+
|
|
1139
|
+
charger, created = Charger.objects.get_or_create(
|
|
1140
|
+
charger_id=serial,
|
|
1141
|
+
connector_id=connector_value,
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
location_obj = None
|
|
1145
|
+
location_payload = payload.get("location")
|
|
1146
|
+
if isinstance(location_payload, Mapping):
|
|
1147
|
+
name = location_payload.get("name")
|
|
1148
|
+
if name:
|
|
1149
|
+
location_obj, _ = Location.objects.get_or_create(name=name)
|
|
1150
|
+
simple_fields = [
|
|
1151
|
+
"latitude",
|
|
1152
|
+
"longitude",
|
|
1153
|
+
"zone",
|
|
1154
|
+
"contract_type",
|
|
1155
|
+
]
|
|
1156
|
+
for field in simple_fields:
|
|
1157
|
+
value = location_payload.get(field)
|
|
1158
|
+
setattr(location_obj, field, value)
|
|
1159
|
+
location_obj.save()
|
|
1160
|
+
|
|
1161
|
+
datetime_fields = [
|
|
1162
|
+
"firmware_timestamp",
|
|
1163
|
+
"last_heartbeat",
|
|
1164
|
+
"availability_state_updated_at",
|
|
1165
|
+
"availability_requested_at",
|
|
1166
|
+
"availability_request_status_at",
|
|
1167
|
+
"diagnostics_timestamp",
|
|
1168
|
+
"last_status_timestamp",
|
|
1169
|
+
]
|
|
1170
|
+
|
|
1171
|
+
updates: dict[str, object] = {
|
|
1172
|
+
"node_origin": node,
|
|
1173
|
+
"allow_remote": bool(payload.get("allow_remote", False)),
|
|
1174
|
+
"export_transactions": bool(payload.get("export_transactions", False)),
|
|
1175
|
+
"last_online_at": timezone.now(),
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
simple_fields = [
|
|
1179
|
+
"display_name",
|
|
1180
|
+
"language",
|
|
1181
|
+
"public_display",
|
|
1182
|
+
"require_rfid",
|
|
1183
|
+
"firmware_status",
|
|
1184
|
+
"firmware_status_info",
|
|
1185
|
+
"last_status",
|
|
1186
|
+
"last_error_code",
|
|
1187
|
+
"last_status_vendor_info",
|
|
1188
|
+
"availability_state",
|
|
1189
|
+
"availability_requested_state",
|
|
1190
|
+
"availability_request_status",
|
|
1191
|
+
"availability_request_details",
|
|
1192
|
+
"temperature",
|
|
1193
|
+
"temperature_unit",
|
|
1194
|
+
"diagnostics_status",
|
|
1195
|
+
"diagnostics_location",
|
|
1196
|
+
]
|
|
1197
|
+
for field in simple_fields:
|
|
1198
|
+
updates[field] = payload.get(field)
|
|
1199
|
+
|
|
1200
|
+
if location_obj is not None:
|
|
1201
|
+
updates["location"] = location_obj
|
|
1202
|
+
|
|
1203
|
+
for field in datetime_fields:
|
|
1204
|
+
value = payload.get(field)
|
|
1205
|
+
updates[field] = parse_datetime(value) if value else None
|
|
1206
|
+
|
|
1207
|
+
for field in ("last_meter_values",):
|
|
1208
|
+
updates[field] = payload.get(field) or {}
|
|
1209
|
+
|
|
1210
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
1211
|
+
return "created" if created else "updated"
|
|
1212
|
+
|
|
1192
1213
|
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
1193
1214
|
extra_context = extra_context or {}
|
|
1194
1215
|
if object_id:
|