arthexis 0.1.20__py3-none-any.whl → 0.1.21__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.
- {arthexis-0.1.20.dist-info → arthexis-0.1.21.dist-info}/METADATA +3 -4
- {arthexis-0.1.20.dist-info → arthexis-0.1.21.dist-info}/RECORD +25 -27
- config/asgi.py +1 -15
- config/settings.py +0 -26
- config/urls.py +0 -1
- core/admin.py +1 -233
- core/apps.py +0 -6
- core/environment.py +29 -10
- core/models.py +8 -77
- core/tests.py +1 -7
- core/views.py +0 -96
- nodes/admin.py +29 -6
- nodes/tests.py +49 -0
- nodes/views.py +60 -1
- ocpp/admin.py +48 -6
- ocpp/models.py +48 -0
- ocpp/tests.py +83 -0
- ocpp/views.py +85 -3
- pages/context_processors.py +0 -12
- pages/tests.py +0 -41
- pages/urls.py +0 -1
- pages/views.py +0 -5
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.20.dist-info → arthexis-0.1.21.dist-info}/WHEEL +0 -0
- {arthexis-0.1.20.dist-info → arthexis-0.1.21.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.20.dist-info → arthexis-0.1.21.dist-info}/top_level.txt +0 -0
core/models.py
CHANGED
|
@@ -17,12 +17,12 @@ from django.dispatch import receiver
|
|
|
17
17
|
from django.views.decorators.debug import sensitive_variables
|
|
18
18
|
from datetime import time as datetime_time, timedelta
|
|
19
19
|
import logging
|
|
20
|
+
import json
|
|
20
21
|
from django.contrib.contenttypes.models import ContentType
|
|
21
22
|
import hashlib
|
|
22
23
|
import hmac
|
|
23
24
|
import os
|
|
24
25
|
import subprocess
|
|
25
|
-
import secrets
|
|
26
26
|
import re
|
|
27
27
|
from io import BytesIO
|
|
28
28
|
from django.core.files.base import ContentFile
|
|
@@ -518,18 +518,10 @@ class User(Entity, AbstractUser):
|
|
|
518
518
|
def odoo_profile(self):
|
|
519
519
|
return self._direct_profile("OdooProfile")
|
|
520
520
|
|
|
521
|
-
@property
|
|
522
|
-
def assistant_profile(self):
|
|
523
|
-
return self._direct_profile("AssistantProfile")
|
|
524
|
-
|
|
525
521
|
@property
|
|
526
522
|
def social_profile(self):
|
|
527
523
|
return self._direct_profile("SocialProfile")
|
|
528
524
|
|
|
529
|
-
@property
|
|
530
|
-
def chat_profile(self):
|
|
531
|
-
return self.assistant_profile
|
|
532
|
-
|
|
533
525
|
|
|
534
526
|
class UserPhoneNumber(Entity):
|
|
535
527
|
"""Store phone numbers associated with a user."""
|
|
@@ -3423,7 +3415,13 @@ class PackageRelease(Entity):
|
|
|
3423
3415
|
for release in cls.objects.all():
|
|
3424
3416
|
name = f"releases__packagerelease_{release.version.replace('.', '_')}.json"
|
|
3425
3417
|
path = base / name
|
|
3426
|
-
data = serializers.serialize(
|
|
3418
|
+
data = serializers.serialize(
|
|
3419
|
+
"json",
|
|
3420
|
+
[release],
|
|
3421
|
+
use_natural_foreign_keys=True,
|
|
3422
|
+
use_natural_primary_keys=True,
|
|
3423
|
+
)
|
|
3424
|
+
data = json.dumps(json.loads(data), indent=2) + "\n"
|
|
3427
3425
|
expected.add(name)
|
|
3428
3426
|
try:
|
|
3429
3427
|
current = path.read_text(encoding="utf-8")
|
|
@@ -3703,73 +3701,6 @@ def _rfid_unique_energy_account(
|
|
|
3703
3701
|
"RFID tags may only be assigned to one energy account."
|
|
3704
3702
|
)
|
|
3705
3703
|
|
|
3706
|
-
|
|
3707
|
-
def hash_key(key: str) -> str:
|
|
3708
|
-
"""Return a SHA-256 hash for ``key``."""
|
|
3709
|
-
|
|
3710
|
-
return hashlib.sha256(key.encode()).hexdigest()
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
class AssistantProfile(Profile):
|
|
3714
|
-
"""Stores a hashed user key used by the assistant for authentication.
|
|
3715
|
-
|
|
3716
|
-
The plain-text ``user_key`` is generated server-side and shown only once.
|
|
3717
|
-
Users must supply this key in the ``Authorization: Bearer <user_key>``
|
|
3718
|
-
header when requesting protected endpoints. Only the hash is stored.
|
|
3719
|
-
"""
|
|
3720
|
-
|
|
3721
|
-
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
3722
|
-
profile_fields = ("assistant_name", "user_key_hash", "scopes", "is_active")
|
|
3723
|
-
assistant_name = models.CharField(max_length=100, default="Assistant")
|
|
3724
|
-
user_key_hash = models.CharField(max_length=64, unique=True)
|
|
3725
|
-
scopes = models.JSONField(default=list, blank=True)
|
|
3726
|
-
created_at = models.DateTimeField(auto_now_add=True)
|
|
3727
|
-
last_used_at = models.DateTimeField(null=True, blank=True)
|
|
3728
|
-
is_active = models.BooleanField(default=True)
|
|
3729
|
-
|
|
3730
|
-
class Meta:
|
|
3731
|
-
db_table = "workgroup_assistantprofile"
|
|
3732
|
-
verbose_name = "Assistant Profile"
|
|
3733
|
-
verbose_name_plural = "Assistant Profiles"
|
|
3734
|
-
constraints = [
|
|
3735
|
-
models.CheckConstraint(
|
|
3736
|
-
check=(
|
|
3737
|
-
(Q(user__isnull=False) & Q(group__isnull=True))
|
|
3738
|
-
| (Q(user__isnull=True) & Q(group__isnull=False))
|
|
3739
|
-
),
|
|
3740
|
-
name="assistantprofile_requires_owner",
|
|
3741
|
-
)
|
|
3742
|
-
]
|
|
3743
|
-
|
|
3744
|
-
@classmethod
|
|
3745
|
-
def issue_key(cls, user) -> tuple["AssistantProfile", str]:
|
|
3746
|
-
"""Create or update a profile and return it with a new plain key."""
|
|
3747
|
-
|
|
3748
|
-
key = secrets.token_hex(32)
|
|
3749
|
-
key_hash = hash_key(key)
|
|
3750
|
-
if user is None:
|
|
3751
|
-
raise ValueError("Assistant profiles require a user instance")
|
|
3752
|
-
|
|
3753
|
-
profile, _ = cls.objects.update_or_create(
|
|
3754
|
-
user=user,
|
|
3755
|
-
defaults={
|
|
3756
|
-
"user_key_hash": key_hash,
|
|
3757
|
-
"last_used_at": None,
|
|
3758
|
-
"is_active": True,
|
|
3759
|
-
},
|
|
3760
|
-
)
|
|
3761
|
-
return profile, key
|
|
3762
|
-
|
|
3763
|
-
def touch(self) -> None:
|
|
3764
|
-
"""Record that the key was used."""
|
|
3765
|
-
|
|
3766
|
-
self.last_used_at = timezone.now()
|
|
3767
|
-
self.save(update_fields=["last_used_at"])
|
|
3768
|
-
|
|
3769
|
-
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
3770
|
-
return self.assistant_name or "AssistantProfile"
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
3704
|
def validate_relative_url(value: str) -> None:
|
|
3774
3705
|
if not value:
|
|
3775
3706
|
return
|
core/tests.py
CHANGED
|
@@ -1290,11 +1290,10 @@ class ReleaseProcessTests(TestCase):
|
|
|
1290
1290
|
run.assert_any_call(["git", "clean", "-fd"], check=False)
|
|
1291
1291
|
|
|
1292
1292
|
@mock.patch("core.views.PackageRelease.dump_fixture")
|
|
1293
|
-
@mock.patch("core.views._ensure_release_todo")
|
|
1294
1293
|
@mock.patch("core.views._sync_with_origin_main")
|
|
1295
1294
|
@mock.patch("core.views.subprocess.run")
|
|
1296
1295
|
def test_pre_release_syncs_with_main(
|
|
1297
|
-
self, run, sync_main,
|
|
1296
|
+
self, run, sync_main, dump_fixture
|
|
1298
1297
|
):
|
|
1299
1298
|
import subprocess as sp
|
|
1300
1299
|
|
|
@@ -1306,11 +1305,6 @@ class ReleaseProcessTests(TestCase):
|
|
|
1306
1305
|
return sp.CompletedProcess(cmd, 0)
|
|
1307
1306
|
|
|
1308
1307
|
run.side_effect = fake_run
|
|
1309
|
-
ensure_todo.return_value = (
|
|
1310
|
-
mock.Mock(request="Create release pkg 1.0.1", url="", request_details=""),
|
|
1311
|
-
Path("core/fixtures/todos__next_release.json"),
|
|
1312
|
-
)
|
|
1313
|
-
|
|
1314
1308
|
version_path = Path("VERSION")
|
|
1315
1309
|
original_version = version_path.read_text(encoding="utf-8")
|
|
1316
1310
|
|
core/views.py
CHANGED
|
@@ -19,7 +19,6 @@ from django.shortcuts import get_object_or_404, redirect, render, resolve_url
|
|
|
19
19
|
from django.template.response import TemplateResponse
|
|
20
20
|
from django.utils import timezone
|
|
21
21
|
from django.utils.html import strip_tags
|
|
22
|
-
from django.utils.text import slugify
|
|
23
22
|
from django.utils.translation import gettext as _
|
|
24
23
|
from django.urls import NoReverseMatch, reverse
|
|
25
24
|
from django.views.decorators.csrf import csrf_exempt
|
|
@@ -693,29 +692,6 @@ def _next_patch_version(version: str) -> str:
|
|
|
693
692
|
return f"{parsed.major}.{parsed.minor}.{parsed.micro + 1}"
|
|
694
693
|
|
|
695
694
|
|
|
696
|
-
def _write_todo_fixture(todo: Todo) -> Path:
|
|
697
|
-
safe_request = todo.request.replace(".", " ")
|
|
698
|
-
slug = slugify(safe_request).replace("-", "_")
|
|
699
|
-
if not slug:
|
|
700
|
-
slug = "todo"
|
|
701
|
-
path = TODO_FIXTURE_DIR / f"todos__{slug}.json"
|
|
702
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
703
|
-
data = [
|
|
704
|
-
{
|
|
705
|
-
"model": "core.todo",
|
|
706
|
-
"fields": {
|
|
707
|
-
"request": todo.request,
|
|
708
|
-
"url": todo.url,
|
|
709
|
-
"request_details": todo.request_details,
|
|
710
|
-
"generated_for_version": todo.generated_for_version,
|
|
711
|
-
"generated_for_revision": todo.generated_for_revision,
|
|
712
|
-
},
|
|
713
|
-
}
|
|
714
|
-
]
|
|
715
|
-
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
716
|
-
return path
|
|
717
|
-
|
|
718
|
-
|
|
719
695
|
def _should_use_python_changelog(exc: OSError) -> bool:
|
|
720
696
|
winerror = getattr(exc, "winerror", None)
|
|
721
697
|
if winerror in {193}:
|
|
@@ -736,46 +712,6 @@ def _generate_changelog_with_python(log_path: Path) -> None:
|
|
|
736
712
|
_append_log(log_path, "Regenerated CHANGELOG.rst using Python fallback")
|
|
737
713
|
|
|
738
714
|
|
|
739
|
-
def _ensure_release_todo(
|
|
740
|
-
release, *, previous_version: str | None = None
|
|
741
|
-
) -> tuple[Todo, Path]:
|
|
742
|
-
previous_version = (previous_version or "").strip()
|
|
743
|
-
target_version = _next_patch_version(release.version)
|
|
744
|
-
if previous_version:
|
|
745
|
-
try:
|
|
746
|
-
from packaging.version import InvalidVersion, Version
|
|
747
|
-
|
|
748
|
-
parsed_previous = Version(previous_version)
|
|
749
|
-
parsed_target = Version(target_version)
|
|
750
|
-
except InvalidVersion:
|
|
751
|
-
pass
|
|
752
|
-
else:
|
|
753
|
-
if parsed_target <= parsed_previous:
|
|
754
|
-
target_version = _next_patch_version(previous_version)
|
|
755
|
-
request = f"Create release {release.package.name} {target_version}"
|
|
756
|
-
try:
|
|
757
|
-
url = reverse("admin:core_packagerelease_changelist")
|
|
758
|
-
except NoReverseMatch:
|
|
759
|
-
url = ""
|
|
760
|
-
todo, _ = Todo.all_objects.update_or_create(
|
|
761
|
-
request__iexact=request,
|
|
762
|
-
defaults={
|
|
763
|
-
"request": request,
|
|
764
|
-
"url": url,
|
|
765
|
-
"request_details": "",
|
|
766
|
-
"generated_for_version": release.version or "",
|
|
767
|
-
"generated_for_revision": release.revision or "",
|
|
768
|
-
"is_seed_data": True,
|
|
769
|
-
"is_deleted": False,
|
|
770
|
-
"is_user_data": False,
|
|
771
|
-
"done_on": None,
|
|
772
|
-
"on_done_condition": "",
|
|
773
|
-
},
|
|
774
|
-
)
|
|
775
|
-
fixture_path = _write_todo_fixture(todo)
|
|
776
|
-
return todo, fixture_path
|
|
777
|
-
|
|
778
|
-
|
|
779
715
|
def _todo_blocks_publish(todo: Todo, release: PackageRelease) -> bool:
|
|
780
716
|
"""Return ``True`` when ``todo`` should block the release workflow."""
|
|
781
717
|
|
|
@@ -1226,36 +1162,6 @@ def _step_changelog_docs(release, ctx, log_path: Path) -> None:
|
|
|
1226
1162
|
_append_log(log_path, "CHANGELOG and documentation review recorded")
|
|
1227
1163
|
|
|
1228
1164
|
|
|
1229
|
-
def _record_release_todo(
|
|
1230
|
-
release, ctx, log_path: Path, *, previous_version: str | None = None
|
|
1231
|
-
) -> None:
|
|
1232
|
-
previous_version = previous_version or ctx.pop(
|
|
1233
|
-
"release_todo_previous_version",
|
|
1234
|
-
getattr(release, "_repo_version_before_sync", ""),
|
|
1235
|
-
)
|
|
1236
|
-
todo, fixture_path = _ensure_release_todo(
|
|
1237
|
-
release, previous_version=previous_version
|
|
1238
|
-
)
|
|
1239
|
-
fixture_display = _format_path(fixture_path)
|
|
1240
|
-
_append_log(log_path, f"Added TODO: {todo.request}")
|
|
1241
|
-
_append_log(log_path, f"Wrote TODO fixture {fixture_display}")
|
|
1242
|
-
subprocess.run(["git", "add", str(fixture_path)], check=True)
|
|
1243
|
-
_append_log(log_path, f"Staged TODO fixture {fixture_display}")
|
|
1244
|
-
fixture_diff = subprocess.run(
|
|
1245
|
-
["git", "diff", "--cached", "--quiet", "--", str(fixture_path)],
|
|
1246
|
-
check=False,
|
|
1247
|
-
)
|
|
1248
|
-
if fixture_diff.returncode != 0:
|
|
1249
|
-
commit_message = f"chore: add release TODO for {release.package.name}"
|
|
1250
|
-
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
|
1251
|
-
_append_log(log_path, f"Committed TODO fixture {fixture_display}")
|
|
1252
|
-
else:
|
|
1253
|
-
_append_log(
|
|
1254
|
-
log_path,
|
|
1255
|
-
f"No changes detected for TODO fixture {fixture_display}; skipping commit",
|
|
1256
|
-
)
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
1165
|
def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
|
|
1260
1166
|
_append_log(log_path, "Execute pre-release actions")
|
|
1261
1167
|
if ctx.get("dry_run"):
|
|
@@ -1329,7 +1235,6 @@ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
|
|
|
1329
1235
|
for path in staged_release_fixtures:
|
|
1330
1236
|
subprocess.run(["git", "reset", "HEAD", str(path)], check=False)
|
|
1331
1237
|
_append_log(log_path, f"Unstaged release fixture {_format_path(path)}")
|
|
1332
|
-
ctx["release_todo_previous_version"] = repo_version_before_sync
|
|
1333
1238
|
_append_log(log_path, "Pre-release actions complete")
|
|
1334
1239
|
|
|
1335
1240
|
|
|
@@ -1383,7 +1288,6 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
|
|
|
1383
1288
|
_push_release_changes(log_path)
|
|
1384
1289
|
PackageRelease.dump_fixture()
|
|
1385
1290
|
_append_log(log_path, "Updated release fixtures")
|
|
1386
|
-
_record_release_todo(release, ctx, log_path)
|
|
1387
1291
|
except Exception:
|
|
1388
1292
|
_clean_repo()
|
|
1389
1293
|
raise
|
nodes/admin.py
CHANGED
|
@@ -11,6 +11,7 @@ from django.db.models import Count
|
|
|
11
11
|
from django.http import Http404, HttpResponse, JsonResponse
|
|
12
12
|
from django.shortcuts import redirect, render
|
|
13
13
|
from django.template.response import TemplateResponse
|
|
14
|
+
from django.test import signals
|
|
14
15
|
from django.urls import NoReverseMatch, path, reverse
|
|
15
16
|
from django.utils import timezone
|
|
16
17
|
from django.utils.dateparse import parse_datetime
|
|
@@ -283,6 +284,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
283
284
|
"register_visitor",
|
|
284
285
|
"run_task",
|
|
285
286
|
"take_screenshots",
|
|
287
|
+
"fetch_rfids_from_selected",
|
|
286
288
|
"import_rfids_from_selected",
|
|
287
289
|
"export_rfids_to_selected",
|
|
288
290
|
]
|
|
@@ -347,7 +349,20 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
347
349
|
"token": token,
|
|
348
350
|
"register_url": reverse("register-node"),
|
|
349
351
|
}
|
|
350
|
-
|
|
352
|
+
response = TemplateResponse(
|
|
353
|
+
request, "admin/nodes/node/register_remote.html", context
|
|
354
|
+
)
|
|
355
|
+
response.render()
|
|
356
|
+
template = response.resolve_template(response.template_name)
|
|
357
|
+
if getattr(template, "name", None) in (None, ""):
|
|
358
|
+
template.name = response.template_name
|
|
359
|
+
signals.template_rendered.send(
|
|
360
|
+
sender=template.__class__,
|
|
361
|
+
template=template,
|
|
362
|
+
context=response.context_data,
|
|
363
|
+
request=request,
|
|
364
|
+
)
|
|
365
|
+
return response
|
|
351
366
|
|
|
352
367
|
def _load_local_private_key(self, node):
|
|
353
368
|
security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
|
|
@@ -874,6 +889,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
874
889
|
def _render_rfid_sync(self, request, operation, results, setup_error=None):
|
|
875
890
|
titles = {
|
|
876
891
|
"import": _("Import RFID results"),
|
|
892
|
+
"fetch": _("Fetch RFID results"),
|
|
877
893
|
"export": _("Export RFID results"),
|
|
878
894
|
}
|
|
879
895
|
summary = self._summarize_rfid_results(results)
|
|
@@ -983,18 +999,17 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
983
999
|
result["status"] = self._status_from_result(result)
|
|
984
1000
|
return result
|
|
985
1001
|
|
|
986
|
-
|
|
987
|
-
def import_rfids_from_selected(self, request, queryset):
|
|
1002
|
+
def _run_rfid_fetch(self, request, queryset, *, operation):
|
|
988
1003
|
nodes = list(queryset)
|
|
989
1004
|
local_node, private_key, error = self._load_local_node_credentials()
|
|
990
1005
|
if error:
|
|
991
1006
|
results = [self._skip_result(node, error) for node in nodes]
|
|
992
|
-
return self._render_rfid_sync(request,
|
|
1007
|
+
return self._render_rfid_sync(request, operation, results, setup_error=error)
|
|
993
1008
|
|
|
994
1009
|
if not nodes:
|
|
995
1010
|
return self._render_rfid_sync(
|
|
996
1011
|
request,
|
|
997
|
-
|
|
1012
|
+
operation,
|
|
998
1013
|
[],
|
|
999
1014
|
setup_error=_("No nodes selected."),
|
|
1000
1015
|
)
|
|
@@ -1017,7 +1032,15 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
1017
1032
|
continue
|
|
1018
1033
|
results.append(self._process_import_from_node(node, payload, headers))
|
|
1019
1034
|
|
|
1020
|
-
return self._render_rfid_sync(request,
|
|
1035
|
+
return self._render_rfid_sync(request, operation, results)
|
|
1036
|
+
|
|
1037
|
+
@admin.action(description=_("Fetch RFIDs from selected"))
|
|
1038
|
+
def fetch_rfids_from_selected(self, request, queryset):
|
|
1039
|
+
return self._run_rfid_fetch(request, queryset, operation="fetch")
|
|
1040
|
+
|
|
1041
|
+
@admin.action(description=_("Import RFIDs from selected"))
|
|
1042
|
+
def import_rfids_from_selected(self, request, queryset):
|
|
1043
|
+
return self._run_rfid_fetch(request, queryset, operation="import")
|
|
1021
1044
|
|
|
1022
1045
|
@admin.action(description=_("Export RFIDs to selected"))
|
|
1023
1046
|
def export_rfids_to_selected(self, request, queryset):
|
nodes/tests.py
CHANGED
|
@@ -742,6 +742,55 @@ class NodeGetLocalTests(TestCase):
|
|
|
742
742
|
self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
|
|
743
743
|
|
|
744
744
|
|
|
745
|
+
class NodeInfoViewTests(TestCase):
|
|
746
|
+
def setUp(self):
|
|
747
|
+
self.mac = "02:00:00:00:00:01"
|
|
748
|
+
self.patcher = patch("nodes.models.Node.get_current_mac", return_value=self.mac)
|
|
749
|
+
self.patcher.start()
|
|
750
|
+
self.addCleanup(self.patcher.stop)
|
|
751
|
+
self.node = Node.objects.create(
|
|
752
|
+
hostname="local",
|
|
753
|
+
address="10.0.0.10",
|
|
754
|
+
port=8000,
|
|
755
|
+
mac_address=self.mac,
|
|
756
|
+
public_endpoint="local",
|
|
757
|
+
current_relation=Node.Relation.SELF,
|
|
758
|
+
)
|
|
759
|
+
self.url = reverse("node-info")
|
|
760
|
+
|
|
761
|
+
def test_returns_https_port_for_secure_domain_request(self):
|
|
762
|
+
with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
|
|
763
|
+
response = self.client.get(
|
|
764
|
+
self.url,
|
|
765
|
+
secure=True,
|
|
766
|
+
HTTP_HOST="arthexis.com",
|
|
767
|
+
)
|
|
768
|
+
self.assertEqual(response.status_code, 200)
|
|
769
|
+
payload = response.json()
|
|
770
|
+
self.assertEqual(payload["port"], 443)
|
|
771
|
+
|
|
772
|
+
def test_returns_http_port_for_plain_domain_request(self):
|
|
773
|
+
with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
|
|
774
|
+
response = self.client.get(
|
|
775
|
+
self.url,
|
|
776
|
+
HTTP_HOST="arthexis.com",
|
|
777
|
+
)
|
|
778
|
+
self.assertEqual(response.status_code, 200)
|
|
779
|
+
payload = response.json()
|
|
780
|
+
self.assertEqual(payload["port"], 80)
|
|
781
|
+
|
|
782
|
+
def test_preserves_explicit_port_in_host_header(self):
|
|
783
|
+
with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
|
|
784
|
+
response = self.client.get(
|
|
785
|
+
self.url,
|
|
786
|
+
secure=True,
|
|
787
|
+
HTTP_HOST="arthexis.com:8443",
|
|
788
|
+
)
|
|
789
|
+
self.assertEqual(response.status_code, 200)
|
|
790
|
+
payload = response.json()
|
|
791
|
+
self.assertEqual(payload["port"], 8443)
|
|
792
|
+
|
|
793
|
+
|
|
745
794
|
class RegisterVisitorNodeMessageTests(TestCase):
|
|
746
795
|
def setUp(self):
|
|
747
796
|
self.client = Client()
|
nodes/views.py
CHANGED
|
@@ -204,6 +204,60 @@ def _get_host_domain(request) -> str:
|
|
|
204
204
|
return ""
|
|
205
205
|
|
|
206
206
|
|
|
207
|
+
def _normalize_port(value: str | int | None) -> int | None:
|
|
208
|
+
"""Return ``value`` as an integer port number when valid."""
|
|
209
|
+
|
|
210
|
+
if value in (None, ""):
|
|
211
|
+
return None
|
|
212
|
+
try:
|
|
213
|
+
port = int(value)
|
|
214
|
+
except (TypeError, ValueError):
|
|
215
|
+
return None
|
|
216
|
+
if port <= 0 or port > 65535:
|
|
217
|
+
return None
|
|
218
|
+
return port
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _get_host_port(request) -> int | None:
|
|
222
|
+
"""Return the port implied by the current request if available."""
|
|
223
|
+
|
|
224
|
+
forwarded_port = request.headers.get("X-Forwarded-Port") or request.META.get(
|
|
225
|
+
"HTTP_X_FORWARDED_PORT"
|
|
226
|
+
)
|
|
227
|
+
port = _normalize_port(forwarded_port)
|
|
228
|
+
if port:
|
|
229
|
+
return port
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
host = request.get_host()
|
|
233
|
+
except Exception: # pragma: no cover - defensive
|
|
234
|
+
host = ""
|
|
235
|
+
if host:
|
|
236
|
+
_, host_port = split_domain_port(host)
|
|
237
|
+
port = _normalize_port(host_port)
|
|
238
|
+
if port:
|
|
239
|
+
return port
|
|
240
|
+
|
|
241
|
+
forwarded_proto = request.headers.get("X-Forwarded-Proto", "")
|
|
242
|
+
if forwarded_proto:
|
|
243
|
+
scheme = forwarded_proto.split(",")[0].strip().lower()
|
|
244
|
+
if scheme == "https":
|
|
245
|
+
return 443
|
|
246
|
+
if scheme == "http":
|
|
247
|
+
return 80
|
|
248
|
+
|
|
249
|
+
if request.is_secure():
|
|
250
|
+
return 443
|
|
251
|
+
|
|
252
|
+
scheme = getattr(request, "scheme", "")
|
|
253
|
+
if scheme.lower() == "https":
|
|
254
|
+
return 443
|
|
255
|
+
if scheme.lower() == "http":
|
|
256
|
+
return 80
|
|
257
|
+
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
207
261
|
def _get_advertised_address(request, node) -> str:
|
|
208
262
|
"""Return the best address for the client to reach this node."""
|
|
209
263
|
|
|
@@ -245,6 +299,11 @@ def node_info(request):
|
|
|
245
299
|
token = request.GET.get("token", "")
|
|
246
300
|
host_domain = _get_host_domain(request)
|
|
247
301
|
advertised_address = _get_advertised_address(request, node)
|
|
302
|
+
advertised_port = node.port
|
|
303
|
+
if host_domain:
|
|
304
|
+
host_port = _get_host_port(request)
|
|
305
|
+
if host_port:
|
|
306
|
+
advertised_port = host_port
|
|
248
307
|
if host_domain:
|
|
249
308
|
hostname = host_domain
|
|
250
309
|
if advertised_address and advertised_address != node.address:
|
|
@@ -257,7 +316,7 @@ def node_info(request):
|
|
|
257
316
|
data = {
|
|
258
317
|
"hostname": hostname,
|
|
259
318
|
"address": address,
|
|
260
|
-
"port":
|
|
319
|
+
"port": advertised_port,
|
|
261
320
|
"mac_address": node.mac_address,
|
|
262
321
|
"public_key": node.public_key,
|
|
263
322
|
"features": list(node.features.values_list("slug", flat=True)),
|
ocpp/admin.py
CHANGED
|
@@ -2,11 +2,12 @@ from django.contrib import admin, messages
|
|
|
2
2
|
from django import forms
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
|
-
from datetime import timedelta
|
|
5
|
+
from datetime import datetime, time, timedelta
|
|
6
6
|
import json
|
|
7
7
|
|
|
8
8
|
from django.shortcuts import redirect
|
|
9
9
|
from django.utils import formats, timezone, translation
|
|
10
|
+
from django.utils.html import format_html
|
|
10
11
|
from django.urls import path
|
|
11
12
|
from django.http import HttpResponse, HttpResponseRedirect
|
|
12
13
|
from django.template.response import TemplateResponse
|
|
@@ -318,7 +319,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
318
319
|
list_display = (
|
|
319
320
|
"display_name_with_fallback",
|
|
320
321
|
"connector_number",
|
|
321
|
-
"
|
|
322
|
+
"charger_name_display",
|
|
322
323
|
"require_rfid_display",
|
|
323
324
|
"public_display",
|
|
324
325
|
"last_heartbeat",
|
|
@@ -429,16 +430,19 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
429
430
|
|
|
430
431
|
@admin.display(description="Display Name", ordering="display_name")
|
|
431
432
|
def display_name_with_fallback(self, obj):
|
|
433
|
+
return self._charger_display_name(obj)
|
|
434
|
+
|
|
435
|
+
@admin.display(description="Charger", ordering="display_name")
|
|
436
|
+
def charger_name_display(self, obj):
|
|
437
|
+
return self._charger_display_name(obj)
|
|
438
|
+
|
|
439
|
+
def _charger_display_name(self, obj):
|
|
432
440
|
if obj.display_name:
|
|
433
441
|
return obj.display_name
|
|
434
442
|
if obj.location:
|
|
435
443
|
return obj.location.name
|
|
436
444
|
return obj.charger_id
|
|
437
445
|
|
|
438
|
-
@admin.display(description="Serial Number", ordering="charger_id")
|
|
439
|
-
def serial_number_display(self, obj):
|
|
440
|
-
return obj.charger_id
|
|
441
|
-
|
|
442
446
|
def location_name(self, obj):
|
|
443
447
|
return obj.location.name if obj.location else ""
|
|
444
448
|
|
|
@@ -800,6 +804,44 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
800
804
|
|
|
801
805
|
session_kw.short_description = "Session kW"
|
|
802
806
|
|
|
807
|
+
def changelist_view(self, request, extra_context=None):
|
|
808
|
+
response = super().changelist_view(request, extra_context=extra_context)
|
|
809
|
+
if hasattr(response, "context_data"):
|
|
810
|
+
cl = response.context_data.get("cl")
|
|
811
|
+
if cl is not None:
|
|
812
|
+
response.context_data.update(
|
|
813
|
+
self._charger_quick_stats_context(cl.queryset)
|
|
814
|
+
)
|
|
815
|
+
return response
|
|
816
|
+
|
|
817
|
+
def _charger_quick_stats_context(self, queryset):
|
|
818
|
+
chargers = list(queryset)
|
|
819
|
+
stats = {"total_kw": 0.0, "today_kw": 0.0}
|
|
820
|
+
if not chargers:
|
|
821
|
+
return {"charger_quick_stats": stats}
|
|
822
|
+
|
|
823
|
+
parent_ids = {c.charger_id for c in chargers if c.connector_id is None}
|
|
824
|
+
start, end = self._today_range()
|
|
825
|
+
|
|
826
|
+
for charger in chargers:
|
|
827
|
+
include_totals = True
|
|
828
|
+
if charger.connector_id is not None and charger.charger_id in parent_ids:
|
|
829
|
+
include_totals = False
|
|
830
|
+
if include_totals:
|
|
831
|
+
stats["total_kw"] += charger.total_kw
|
|
832
|
+
stats["today_kw"] += charger.total_kw_for_range(start, end)
|
|
833
|
+
|
|
834
|
+
stats = {key: round(value, 2) for key, value in stats.items()}
|
|
835
|
+
return {"charger_quick_stats": stats}
|
|
836
|
+
|
|
837
|
+
def _today_range(self):
|
|
838
|
+
today = timezone.localdate()
|
|
839
|
+
start = datetime.combine(today, time.min)
|
|
840
|
+
if timezone.is_naive(start):
|
|
841
|
+
start = timezone.make_aware(start, timezone.get_current_timezone())
|
|
842
|
+
end = start + timedelta(days=1)
|
|
843
|
+
return start, end
|
|
844
|
+
|
|
803
845
|
|
|
804
846
|
@admin.register(Simulator)
|
|
805
847
|
class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin):
|
ocpp/models.py
CHANGED
|
@@ -544,6 +544,20 @@ class Charger(Entity):
|
|
|
544
544
|
return qs
|
|
545
545
|
return qs.filter(pk=self.pk)
|
|
546
546
|
|
|
547
|
+
def total_kw_for_range(
|
|
548
|
+
self,
|
|
549
|
+
start=None,
|
|
550
|
+
end=None,
|
|
551
|
+
) -> float:
|
|
552
|
+
"""Return total energy delivered within ``start``/``end`` window."""
|
|
553
|
+
|
|
554
|
+
from . import store
|
|
555
|
+
|
|
556
|
+
total = 0.0
|
|
557
|
+
for charger in self._target_chargers():
|
|
558
|
+
total += charger._total_kw_range_single(store, start, end)
|
|
559
|
+
return total
|
|
560
|
+
|
|
547
561
|
def _total_kw_single(self, store_module) -> float:
|
|
548
562
|
"""Return total kW for this specific charger identity."""
|
|
549
563
|
|
|
@@ -564,6 +578,40 @@ class Charger(Entity):
|
|
|
564
578
|
total += kw
|
|
565
579
|
return total
|
|
566
580
|
|
|
581
|
+
def _total_kw_range_single(self, store_module, start=None, end=None) -> float:
|
|
582
|
+
"""Return total kW for a date range for this charger."""
|
|
583
|
+
|
|
584
|
+
tx_active = None
|
|
585
|
+
if self.connector_id is not None:
|
|
586
|
+
tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
|
|
587
|
+
|
|
588
|
+
qs = self.transactions.all()
|
|
589
|
+
if start is not None:
|
|
590
|
+
qs = qs.filter(start_time__gte=start)
|
|
591
|
+
if end is not None:
|
|
592
|
+
qs = qs.filter(start_time__lt=end)
|
|
593
|
+
if tx_active and tx_active.pk is not None:
|
|
594
|
+
qs = qs.exclude(pk=tx_active.pk)
|
|
595
|
+
|
|
596
|
+
total = 0.0
|
|
597
|
+
for tx in qs:
|
|
598
|
+
kw = tx.kw
|
|
599
|
+
if kw:
|
|
600
|
+
total += kw
|
|
601
|
+
|
|
602
|
+
if tx_active:
|
|
603
|
+
start_time = getattr(tx_active, "start_time", None)
|
|
604
|
+
include = True
|
|
605
|
+
if start is not None and start_time and start_time < start:
|
|
606
|
+
include = False
|
|
607
|
+
if end is not None and start_time and start_time >= end:
|
|
608
|
+
include = False
|
|
609
|
+
if include:
|
|
610
|
+
kw = tx_active.kw
|
|
611
|
+
if kw:
|
|
612
|
+
total += kw
|
|
613
|
+
return total
|
|
614
|
+
|
|
567
615
|
def purge(self):
|
|
568
616
|
from . import store
|
|
569
617
|
|