arthexis 0.1.11__py3-none-any.whl → 0.1.12__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.11.dist-info → arthexis-0.1.12.dist-info}/METADATA +2 -2
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/RECORD +38 -35
- config/settings.py +7 -2
- core/admin.py +246 -68
- core/apps.py +21 -0
- core/models.py +41 -8
- core/reference_utils.py +1 -1
- core/release.py +4 -0
- core/system.py +6 -3
- core/tasks.py +92 -40
- core/tests.py +64 -0
- core/views.py +131 -17
- nodes/admin.py +316 -6
- nodes/feature_checks.py +133 -0
- nodes/models.py +83 -26
- nodes/reports.py +411 -0
- nodes/tests.py +365 -36
- nodes/utils.py +32 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +506 -8
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +234 -4
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/tests.py +789 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +225 -19
- pages/admin.py +135 -3
- pages/context_processors.py +15 -1
- pages/defaults.py +1 -2
- pages/forms.py +38 -0
- pages/models.py +136 -1
- pages/tests.py +262 -4
- pages/urls.py +1 -0
- pages/views.py +52 -3
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
ocpp/transactions_io.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
from typing import Iterable
|
|
5
5
|
|
|
6
|
+
from django.core.exceptions import ValidationError
|
|
6
7
|
from django.utils import timezone
|
|
7
8
|
from django.utils.dateparse import parse_datetime
|
|
8
9
|
|
|
@@ -107,23 +108,36 @@ def import_transactions(data: dict) -> int:
|
|
|
107
108
|
"""
|
|
108
109
|
charger_map: dict[str, Charger] = {}
|
|
109
110
|
for item in data.get("chargers", []):
|
|
111
|
+
try:
|
|
112
|
+
serial = Charger.validate_serial(item.get("charger_id"))
|
|
113
|
+
except ValidationError:
|
|
114
|
+
continue
|
|
110
115
|
connector_value = item.get("connector_id", None)
|
|
111
116
|
if connector_value in ("", None):
|
|
112
117
|
connector_value = None
|
|
113
118
|
elif isinstance(connector_value, str):
|
|
114
119
|
connector_value = int(connector_value)
|
|
115
120
|
charger, _ = Charger.objects.get_or_create(
|
|
116
|
-
charger_id=
|
|
121
|
+
charger_id=serial,
|
|
117
122
|
defaults={
|
|
118
123
|
"connector_id": connector_value,
|
|
119
124
|
"require_rfid": item.get("require_rfid", False),
|
|
120
125
|
},
|
|
121
126
|
)
|
|
122
|
-
charger_map[
|
|
127
|
+
charger_map[serial] = charger
|
|
123
128
|
|
|
124
129
|
imported = 0
|
|
125
130
|
for tx in data.get("transactions", []):
|
|
126
|
-
|
|
131
|
+
serial = Charger.normalize_serial(tx.get("charger"))
|
|
132
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
133
|
+
continue
|
|
134
|
+
charger = charger_map.get(serial)
|
|
135
|
+
if charger is None:
|
|
136
|
+
try:
|
|
137
|
+
charger, _ = Charger.objects.get_or_create(charger_id=serial)
|
|
138
|
+
except ValidationError:
|
|
139
|
+
continue
|
|
140
|
+
charger_map[serial] = charger
|
|
127
141
|
transaction = Transaction.objects.create(
|
|
128
142
|
charger=charger,
|
|
129
143
|
account_id=tx.get("account"),
|
ocpp/views.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import asyncio
|
|
2
1
|
import json
|
|
2
|
+
import uuid
|
|
3
3
|
from datetime import datetime, timedelta, timezone as dt_timezone
|
|
4
4
|
from types import SimpleNamespace
|
|
5
5
|
|
|
@@ -13,7 +13,10 @@ from django.contrib.auth.views import redirect_to_login
|
|
|
13
13
|
from django.utils.translation import gettext_lazy as _, gettext, ngettext
|
|
14
14
|
from django.urls import NoReverseMatch, reverse
|
|
15
15
|
from django.conf import settings
|
|
16
|
-
from django.utils import translation
|
|
16
|
+
from django.utils import translation, timezone
|
|
17
|
+
from django.core.exceptions import ValidationError
|
|
18
|
+
|
|
19
|
+
from asgiref.sync import async_to_sync
|
|
17
20
|
|
|
18
21
|
from utils.api import api_login_required
|
|
19
22
|
|
|
@@ -23,7 +26,7 @@ from pages.utils import landing
|
|
|
23
26
|
from core.liveupdate import live_update
|
|
24
27
|
|
|
25
28
|
from . import store
|
|
26
|
-
from .models import Transaction, Charger
|
|
29
|
+
from .models import Transaction, Charger, DataTransferMessage
|
|
27
30
|
from .evcs import (
|
|
28
31
|
_start_simulator,
|
|
29
32
|
_stop_simulator,
|
|
@@ -57,6 +60,10 @@ def _reverse_connector_url(name: str, serial: str, connector_slug: str) -> str:
|
|
|
57
60
|
def _get_charger(serial: str, connector_slug: str | None) -> tuple[Charger, str]:
|
|
58
61
|
"""Return charger for the requested identity, creating if necessary."""
|
|
59
62
|
|
|
63
|
+
try:
|
|
64
|
+
serial = Charger.validate_serial(serial)
|
|
65
|
+
except ValidationError as exc:
|
|
66
|
+
raise Http404("Charger not found") from exc
|
|
60
67
|
connector_value, normalized_slug = _normalize_connector_slug(connector_slug)
|
|
61
68
|
if connector_value is None:
|
|
62
69
|
charger, _ = Charger.objects.get_or_create(
|
|
@@ -79,11 +86,26 @@ def _connector_set(charger: Charger) -> list[Charger]:
|
|
|
79
86
|
return siblings
|
|
80
87
|
|
|
81
88
|
|
|
82
|
-
def
|
|
89
|
+
def _visible_chargers(user):
|
|
90
|
+
"""Return chargers visible to ``user`` on public dashboards."""
|
|
91
|
+
|
|
92
|
+
return Charger.visible_for_user(user).prefetch_related("owner_users", "owner_groups")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _ensure_charger_access(user, charger: Charger):
|
|
96
|
+
"""Raise 404 when the user cannot view the charger."""
|
|
97
|
+
|
|
98
|
+
if not charger.is_visible_to(user):
|
|
99
|
+
raise Http404("Charger not found")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _connector_overview(charger: Charger, user=None) -> list[dict]:
|
|
83
103
|
"""Return connector metadata used for navigation and summaries."""
|
|
84
104
|
|
|
85
105
|
overview: list[dict] = []
|
|
86
106
|
for sibling in _connector_set(charger):
|
|
107
|
+
if user is not None and not sibling.is_visible_to(user):
|
|
108
|
+
continue
|
|
87
109
|
tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
|
|
88
110
|
state, color = _charger_state(sibling, tx_obj)
|
|
89
111
|
overview.append(
|
|
@@ -232,7 +254,7 @@ def _diagnostics_payload(charger: Charger) -> dict[str, str | None]:
|
|
|
232
254
|
def charger_list(request):
|
|
233
255
|
"""Return a JSON list of known chargers and state."""
|
|
234
256
|
data = []
|
|
235
|
-
for charger in
|
|
257
|
+
for charger in _visible_chargers(request.user):
|
|
236
258
|
cid = charger.charger_id
|
|
237
259
|
sessions: list[tuple[Charger, Transaction]] = []
|
|
238
260
|
tx_obj = store.get_transaction(cid, charger.connector_id)
|
|
@@ -324,6 +346,7 @@ def charger_list(request):
|
|
|
324
346
|
@api_login_required
|
|
325
347
|
def charger_detail(request, cid, connector=None):
|
|
326
348
|
charger, connector_slug = _get_charger(cid, connector)
|
|
349
|
+
_ensure_charger_access(request.user, charger)
|
|
327
350
|
|
|
328
351
|
sessions: list[tuple[Charger, Transaction]] = []
|
|
329
352
|
tx_obj = store.get_transaction(cid, charger.connector_id)
|
|
@@ -413,7 +436,7 @@ def charger_detail(request, cid, connector=None):
|
|
|
413
436
|
return JsonResponse(payload)
|
|
414
437
|
|
|
415
438
|
|
|
416
|
-
@landing("
|
|
439
|
+
@landing("CPMS Online Dashboard")
|
|
417
440
|
@live_update()
|
|
418
441
|
def dashboard(request):
|
|
419
442
|
"""Landing page listing all known chargers and their status."""
|
|
@@ -425,7 +448,7 @@ def dashboard(request):
|
|
|
425
448
|
request.get_full_path(), login_url=reverse("pages:login")
|
|
426
449
|
)
|
|
427
450
|
chargers = []
|
|
428
|
-
for charger in
|
|
451
|
+
for charger in _visible_chargers(request.user):
|
|
429
452
|
tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
|
|
430
453
|
if not tx_obj:
|
|
431
454
|
tx_obj = (
|
|
@@ -447,7 +470,7 @@ def dashboard(request):
|
|
|
447
470
|
return render(request, "ocpp/dashboard.html", context)
|
|
448
471
|
|
|
449
472
|
|
|
450
|
-
@login_required
|
|
473
|
+
@login_required(login_url="pages:login")
|
|
451
474
|
@landing("Charge Point Simulator")
|
|
452
475
|
@live_update()
|
|
453
476
|
def cp_simulator(request):
|
|
@@ -551,7 +574,8 @@ def cp_simulator(request):
|
|
|
551
574
|
def charger_page(request, cid, connector=None):
|
|
552
575
|
"""Public landing page for a charger displaying usage guidance or progress."""
|
|
553
576
|
charger, connector_slug = _get_charger(cid, connector)
|
|
554
|
-
|
|
577
|
+
_ensure_charger_access(request.user, charger)
|
|
578
|
+
overview = _connector_overview(charger, request.user)
|
|
555
579
|
sessions = _live_sessions(charger)
|
|
556
580
|
tx = None
|
|
557
581
|
active_connector_count = 0
|
|
@@ -618,6 +642,7 @@ def charger_page(request, cid, connector=None):
|
|
|
618
642
|
@login_required
|
|
619
643
|
def charger_status(request, cid, connector=None):
|
|
620
644
|
charger, connector_slug = _get_charger(cid, connector)
|
|
645
|
+
_ensure_charger_access(request.user, charger)
|
|
621
646
|
session_id = request.GET.get("session")
|
|
622
647
|
sessions = _live_sessions(charger)
|
|
623
648
|
live_tx = None
|
|
@@ -721,7 +746,7 @@ def charger_status(request, cid, connector=None):
|
|
|
721
746
|
"connector_id": connector_id,
|
|
722
747
|
}
|
|
723
748
|
)
|
|
724
|
-
overview = _connector_overview(charger)
|
|
749
|
+
overview = _connector_overview(charger, request.user)
|
|
725
750
|
connector_links = [
|
|
726
751
|
{
|
|
727
752
|
"slug": item["slug"],
|
|
@@ -797,6 +822,7 @@ def charger_status(request, cid, connector=None):
|
|
|
797
822
|
@login_required
|
|
798
823
|
def charger_session_search(request, cid, connector=None):
|
|
799
824
|
charger, connector_slug = _get_charger(cid, connector)
|
|
825
|
+
_ensure_charger_access(request.user, charger)
|
|
800
826
|
date_str = request.GET.get("date")
|
|
801
827
|
transactions = None
|
|
802
828
|
if date_str:
|
|
@@ -814,7 +840,7 @@ def charger_session_search(request, cid, connector=None):
|
|
|
814
840
|
transactions = qs.order_by("-start_time")
|
|
815
841
|
except ValueError:
|
|
816
842
|
transactions = []
|
|
817
|
-
overview = _connector_overview(charger)
|
|
843
|
+
overview = _connector_overview(charger, request.user)
|
|
818
844
|
connector_links = [
|
|
819
845
|
{
|
|
820
846
|
"slug": item["slug"],
|
|
@@ -848,8 +874,9 @@ def charger_log_page(request, cid, connector=None):
|
|
|
848
874
|
status_url = None
|
|
849
875
|
if log_type == "charger":
|
|
850
876
|
charger, connector_slug = _get_charger(cid, connector)
|
|
877
|
+
_ensure_charger_access(request.user, charger)
|
|
851
878
|
log_key = store.identity_key(cid, charger.connector_id)
|
|
852
|
-
overview = _connector_overview(charger)
|
|
879
|
+
overview = _connector_overview(charger, request.user)
|
|
853
880
|
connector_links = [
|
|
854
881
|
{
|
|
855
882
|
"slug": item["slug"],
|
|
@@ -885,6 +912,30 @@ def charger_log_page(request, cid, connector=None):
|
|
|
885
912
|
@api_login_required
|
|
886
913
|
def dispatch_action(request, cid, connector=None):
|
|
887
914
|
connector_value, _ = _normalize_connector_slug(connector)
|
|
915
|
+
log_key = store.identity_key(cid, connector_value)
|
|
916
|
+
if connector_value is None:
|
|
917
|
+
charger_obj = (
|
|
918
|
+
Charger.objects.filter(charger_id=cid, connector_id__isnull=True)
|
|
919
|
+
.order_by("pk")
|
|
920
|
+
.first()
|
|
921
|
+
)
|
|
922
|
+
else:
|
|
923
|
+
charger_obj = (
|
|
924
|
+
Charger.objects.filter(charger_id=cid, connector_id=connector_value)
|
|
925
|
+
.order_by("pk")
|
|
926
|
+
.first()
|
|
927
|
+
)
|
|
928
|
+
if charger_obj is None:
|
|
929
|
+
if connector_value is None:
|
|
930
|
+
charger_obj, _ = Charger.objects.get_or_create(
|
|
931
|
+
charger_id=cid, connector_id=None
|
|
932
|
+
)
|
|
933
|
+
else:
|
|
934
|
+
charger_obj, _ = Charger.objects.get_or_create(
|
|
935
|
+
charger_id=cid, connector_id=connector_value
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
_ensure_charger_access(request.user, charger_obj)
|
|
888
939
|
ws = store.get_connection(cid, connector_value)
|
|
889
940
|
if ws is None:
|
|
890
941
|
return JsonResponse({"detail": "no connection"}, status=404)
|
|
@@ -897,15 +948,27 @@ def dispatch_action(request, cid, connector=None):
|
|
|
897
948
|
tx_obj = store.get_transaction(cid, connector_value)
|
|
898
949
|
if not tx_obj:
|
|
899
950
|
return JsonResponse({"detail": "no transaction"}, status=404)
|
|
951
|
+
message_id = uuid.uuid4().hex
|
|
900
952
|
msg = json.dumps(
|
|
901
953
|
[
|
|
902
954
|
2,
|
|
903
|
-
|
|
955
|
+
message_id,
|
|
904
956
|
"RemoteStopTransaction",
|
|
905
957
|
{"transactionId": tx_obj.pk},
|
|
906
958
|
]
|
|
907
959
|
)
|
|
908
|
-
|
|
960
|
+
async_to_sync(ws.send)(msg)
|
|
961
|
+
store.register_pending_call(
|
|
962
|
+
message_id,
|
|
963
|
+
{
|
|
964
|
+
"action": "RemoteStopTransaction",
|
|
965
|
+
"charger_id": cid,
|
|
966
|
+
"connector_id": connector_value,
|
|
967
|
+
"log_key": log_key,
|
|
968
|
+
"transaction_id": tx_obj.pk,
|
|
969
|
+
"requested_at": timezone.now(),
|
|
970
|
+
},
|
|
971
|
+
)
|
|
909
972
|
elif action == "remote_start":
|
|
910
973
|
id_tag = data.get("idTag")
|
|
911
974
|
if not isinstance(id_tag, str) or not id_tag.strip():
|
|
@@ -924,20 +987,163 @@ def dispatch_action(request, cid, connector=None):
|
|
|
924
987
|
payload["connectorId"] = connector_id
|
|
925
988
|
if "chargingProfile" in data and data["chargingProfile"] is not None:
|
|
926
989
|
payload["chargingProfile"] = data["chargingProfile"]
|
|
990
|
+
message_id = uuid.uuid4().hex
|
|
927
991
|
msg = json.dumps(
|
|
928
992
|
[
|
|
929
993
|
2,
|
|
930
|
-
|
|
994
|
+
message_id,
|
|
931
995
|
"RemoteStartTransaction",
|
|
932
996
|
payload,
|
|
933
997
|
]
|
|
934
998
|
)
|
|
935
|
-
|
|
999
|
+
async_to_sync(ws.send)(msg)
|
|
1000
|
+
store.register_pending_call(
|
|
1001
|
+
message_id,
|
|
1002
|
+
{
|
|
1003
|
+
"action": "RemoteStartTransaction",
|
|
1004
|
+
"charger_id": cid,
|
|
1005
|
+
"connector_id": connector_value,
|
|
1006
|
+
"log_key": log_key,
|
|
1007
|
+
"id_tag": id_tag,
|
|
1008
|
+
"requested_at": timezone.now(),
|
|
1009
|
+
},
|
|
1010
|
+
)
|
|
1011
|
+
elif action == "change_availability":
|
|
1012
|
+
availability_type = data.get("type")
|
|
1013
|
+
if availability_type not in {"Operative", "Inoperative"}:
|
|
1014
|
+
return JsonResponse({"detail": "invalid availability type"}, status=400)
|
|
1015
|
+
connector_payload = connector_value if connector_value is not None else 0
|
|
1016
|
+
if "connectorId" in data:
|
|
1017
|
+
candidate = data.get("connectorId")
|
|
1018
|
+
if candidate not in (None, ""):
|
|
1019
|
+
try:
|
|
1020
|
+
connector_payload = int(candidate)
|
|
1021
|
+
except (TypeError, ValueError):
|
|
1022
|
+
connector_payload = candidate
|
|
1023
|
+
message_id = uuid.uuid4().hex
|
|
1024
|
+
payload = {"connectorId": connector_payload, "type": availability_type}
|
|
1025
|
+
msg = json.dumps([2, message_id, "ChangeAvailability", payload])
|
|
1026
|
+
async_to_sync(ws.send)(msg)
|
|
1027
|
+
requested_at = timezone.now()
|
|
1028
|
+
store.register_pending_call(
|
|
1029
|
+
message_id,
|
|
1030
|
+
{
|
|
1031
|
+
"action": "ChangeAvailability",
|
|
1032
|
+
"charger_id": cid,
|
|
1033
|
+
"connector_id": connector_value,
|
|
1034
|
+
"availability_type": availability_type,
|
|
1035
|
+
"requested_at": requested_at,
|
|
1036
|
+
},
|
|
1037
|
+
)
|
|
1038
|
+
if charger_obj:
|
|
1039
|
+
updates = {
|
|
1040
|
+
"availability_requested_state": availability_type,
|
|
1041
|
+
"availability_requested_at": requested_at,
|
|
1042
|
+
"availability_request_status": "",
|
|
1043
|
+
"availability_request_status_at": None,
|
|
1044
|
+
"availability_request_details": "",
|
|
1045
|
+
}
|
|
1046
|
+
Charger.objects.filter(pk=charger_obj.pk).update(**updates)
|
|
1047
|
+
for field, value in updates.items():
|
|
1048
|
+
setattr(charger_obj, field, value)
|
|
1049
|
+
elif action == "data_transfer":
|
|
1050
|
+
vendor_id = data.get("vendorId")
|
|
1051
|
+
if not isinstance(vendor_id, str) or not vendor_id.strip():
|
|
1052
|
+
return JsonResponse({"detail": "vendorId required"}, status=400)
|
|
1053
|
+
vendor_id = vendor_id.strip()
|
|
1054
|
+
payload: dict[str, object] = {"vendorId": vendor_id}
|
|
1055
|
+
message_identifier = ""
|
|
1056
|
+
if "messageId" in data and data["messageId"] is not None:
|
|
1057
|
+
message_candidate = data["messageId"]
|
|
1058
|
+
if not isinstance(message_candidate, str):
|
|
1059
|
+
return JsonResponse({"detail": "messageId must be a string"}, status=400)
|
|
1060
|
+
message_identifier = message_candidate.strip()
|
|
1061
|
+
if message_identifier:
|
|
1062
|
+
payload["messageId"] = message_identifier
|
|
1063
|
+
if "data" in data:
|
|
1064
|
+
payload["data"] = data["data"]
|
|
1065
|
+
message_id = uuid.uuid4().hex
|
|
1066
|
+
msg = json.dumps([2, message_id, "DataTransfer", payload])
|
|
1067
|
+
record = DataTransferMessage.objects.create(
|
|
1068
|
+
charger=charger_obj,
|
|
1069
|
+
connector_id=connector_value,
|
|
1070
|
+
direction=DataTransferMessage.DIRECTION_CSMS_TO_CP,
|
|
1071
|
+
ocpp_message_id=message_id,
|
|
1072
|
+
vendor_id=vendor_id,
|
|
1073
|
+
message_id=message_identifier,
|
|
1074
|
+
payload=payload,
|
|
1075
|
+
status="Pending",
|
|
1076
|
+
)
|
|
1077
|
+
async_to_sync(ws.send)(msg)
|
|
1078
|
+
store.register_pending_call(
|
|
1079
|
+
message_id,
|
|
1080
|
+
{
|
|
1081
|
+
"action": "DataTransfer",
|
|
1082
|
+
"charger_id": cid,
|
|
1083
|
+
"connector_id": connector_value,
|
|
1084
|
+
"message_pk": record.pk,
|
|
1085
|
+
"log_key": log_key,
|
|
1086
|
+
},
|
|
1087
|
+
)
|
|
936
1088
|
elif action == "reset":
|
|
937
|
-
|
|
938
|
-
|
|
1089
|
+
message_id = uuid.uuid4().hex
|
|
1090
|
+
msg = json.dumps([2, message_id, "Reset", {"type": "Soft"}])
|
|
1091
|
+
async_to_sync(ws.send)(msg)
|
|
1092
|
+
store.register_pending_call(
|
|
1093
|
+
message_id,
|
|
1094
|
+
{
|
|
1095
|
+
"action": "Reset",
|
|
1096
|
+
"charger_id": cid,
|
|
1097
|
+
"connector_id": connector_value,
|
|
1098
|
+
"log_key": log_key,
|
|
1099
|
+
"requested_at": timezone.now(),
|
|
1100
|
+
},
|
|
1101
|
+
)
|
|
1102
|
+
elif action == "trigger_message":
|
|
1103
|
+
trigger_target = data.get("target") or data.get("triggerTarget")
|
|
1104
|
+
if not isinstance(trigger_target, str) or not trigger_target.strip():
|
|
1105
|
+
return JsonResponse({"detail": "target required"}, status=400)
|
|
1106
|
+
trigger_target = trigger_target.strip()
|
|
1107
|
+
allowed_targets = {
|
|
1108
|
+
"BootNotification",
|
|
1109
|
+
"DiagnosticsStatusNotification",
|
|
1110
|
+
"FirmwareStatusNotification",
|
|
1111
|
+
"Heartbeat",
|
|
1112
|
+
"MeterValues",
|
|
1113
|
+
"StatusNotification",
|
|
1114
|
+
}
|
|
1115
|
+
if trigger_target not in allowed_targets:
|
|
1116
|
+
return JsonResponse({"detail": "invalid target"}, status=400)
|
|
1117
|
+
payload: dict[str, object] = {"requestedMessage": trigger_target}
|
|
1118
|
+
trigger_connector = None
|
|
1119
|
+
connector_field = data.get("connectorId")
|
|
1120
|
+
if connector_field in (None, ""):
|
|
1121
|
+
connector_field = data.get("connector")
|
|
1122
|
+
if connector_field in (None, "") and connector_value is not None:
|
|
1123
|
+
connector_field = connector_value
|
|
1124
|
+
if connector_field not in (None, ""):
|
|
1125
|
+
try:
|
|
1126
|
+
trigger_connector = int(connector_field)
|
|
1127
|
+
except (TypeError, ValueError):
|
|
1128
|
+
return JsonResponse({"detail": "connectorId must be an integer"}, status=400)
|
|
1129
|
+
if trigger_connector <= 0:
|
|
1130
|
+
return JsonResponse({"detail": "connectorId must be positive"}, status=400)
|
|
1131
|
+
payload["connectorId"] = trigger_connector
|
|
1132
|
+
message_id = uuid.uuid4().hex
|
|
1133
|
+
msg = json.dumps([2, message_id, "TriggerMessage", payload])
|
|
1134
|
+
async_to_sync(ws.send)(msg)
|
|
1135
|
+
store.register_pending_call(
|
|
1136
|
+
message_id,
|
|
1137
|
+
{
|
|
1138
|
+
"action": "TriggerMessage",
|
|
1139
|
+
"charger_id": cid,
|
|
1140
|
+
"connector_id": connector_value,
|
|
1141
|
+
"log_key": log_key,
|
|
1142
|
+
"trigger_target": trigger_target,
|
|
1143
|
+
"trigger_connector": trigger_connector,
|
|
1144
|
+
"requested_at": timezone.now(),
|
|
1145
|
+
},
|
|
939
1146
|
)
|
|
940
|
-
asyncio.get_event_loop().create_task(ws.send(msg))
|
|
941
1147
|
else:
|
|
942
1148
|
return JsonResponse({"detail": "unknown action"}, status=400)
|
|
943
1149
|
log_key = store.identity_key(cid, connector_value)
|
pages/admin.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
1
3
|
from django.contrib import admin, messages
|
|
2
4
|
from django.contrib.sites.admin import SiteAdmin as DjangoSiteAdmin
|
|
3
5
|
from django.contrib.sites.models import Site
|
|
@@ -12,10 +14,11 @@ from django.http import JsonResponse
|
|
|
12
14
|
from django.utils import timezone
|
|
13
15
|
from django.db.models import Count
|
|
14
16
|
from django.db.models.functions import TruncDate
|
|
15
|
-
from datetime import timedelta
|
|
17
|
+
from datetime import datetime, time, timedelta
|
|
16
18
|
import ipaddress
|
|
17
19
|
from django.apps import apps as django_apps
|
|
18
20
|
from django.conf import settings
|
|
21
|
+
from django.utils.translation import gettext_lazy as _, ngettext
|
|
19
22
|
|
|
20
23
|
from nodes.models import Node
|
|
21
24
|
from nodes.utils import capture_screenshot, save_screenshot
|
|
@@ -29,11 +32,15 @@ from .models import (
|
|
|
29
32
|
Favorite,
|
|
30
33
|
ViewHistory,
|
|
31
34
|
UserManual,
|
|
35
|
+
UserStory,
|
|
32
36
|
)
|
|
33
37
|
from django.contrib.contenttypes.models import ContentType
|
|
34
38
|
from core.user_data import EntityModelAdmin
|
|
35
39
|
|
|
36
40
|
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
37
44
|
def get_local_app_choices():
|
|
38
45
|
choices = []
|
|
39
46
|
for app_label in getattr(settings, "LOCAL_APPS", []):
|
|
@@ -255,8 +262,20 @@ class ViewHistoryAdmin(EntityModelAdmin):
|
|
|
255
262
|
def _build_chart_data(self, days: int = 30, max_pages: int = 8) -> dict:
|
|
256
263
|
end_date = timezone.localdate()
|
|
257
264
|
start_date = end_date - timedelta(days=days - 1)
|
|
265
|
+
|
|
266
|
+
start_at = datetime.combine(start_date, time.min)
|
|
267
|
+
end_at = datetime.combine(end_date + timedelta(days=1), time.min)
|
|
268
|
+
|
|
269
|
+
if settings.USE_TZ:
|
|
270
|
+
current_tz = timezone.get_current_timezone()
|
|
271
|
+
start_at = timezone.make_aware(start_at, current_tz)
|
|
272
|
+
end_at = timezone.make_aware(end_at, current_tz)
|
|
273
|
+
trunc_expression = TruncDate("visited_at", tzinfo=current_tz)
|
|
274
|
+
else:
|
|
275
|
+
trunc_expression = TruncDate("visited_at")
|
|
276
|
+
|
|
258
277
|
queryset = ViewHistory.objects.filter(
|
|
259
|
-
|
|
278
|
+
visited_at__gte=start_at, visited_at__lt=end_at
|
|
260
279
|
)
|
|
261
280
|
|
|
262
281
|
meta = {
|
|
@@ -282,7 +301,7 @@ class ViewHistoryAdmin(EntityModelAdmin):
|
|
|
282
301
|
|
|
283
302
|
aggregates = (
|
|
284
303
|
queryset.filter(path__in=paths)
|
|
285
|
-
.annotate(day=
|
|
304
|
+
.annotate(day=trunc_expression)
|
|
286
305
|
.values("day", "path")
|
|
287
306
|
.order_by("day")
|
|
288
307
|
.annotate(total=Count("id"))
|
|
@@ -326,6 +345,119 @@ class ViewHistoryAdmin(EntityModelAdmin):
|
|
|
326
345
|
return {"labels": labels, "datasets": datasets, "meta": meta}
|
|
327
346
|
|
|
328
347
|
|
|
348
|
+
@admin.register(UserStory)
|
|
349
|
+
class UserStoryAdmin(EntityModelAdmin):
|
|
350
|
+
date_hierarchy = "submitted_at"
|
|
351
|
+
actions = ["create_github_issues"]
|
|
352
|
+
list_display = (
|
|
353
|
+
"name",
|
|
354
|
+
"rating",
|
|
355
|
+
"path",
|
|
356
|
+
"submitted_at",
|
|
357
|
+
"github_issue_display",
|
|
358
|
+
"take_screenshot",
|
|
359
|
+
"owner",
|
|
360
|
+
)
|
|
361
|
+
list_filter = ("rating", "submitted_at", "take_screenshot")
|
|
362
|
+
search_fields = ("name", "comments", "path", "github_issue_url")
|
|
363
|
+
readonly_fields = (
|
|
364
|
+
"name",
|
|
365
|
+
"rating",
|
|
366
|
+
"comments",
|
|
367
|
+
"take_screenshot",
|
|
368
|
+
"path",
|
|
369
|
+
"user",
|
|
370
|
+
"owner",
|
|
371
|
+
"submitted_at",
|
|
372
|
+
"github_issue_number",
|
|
373
|
+
"github_issue_url",
|
|
374
|
+
)
|
|
375
|
+
ordering = ("-submitted_at",)
|
|
376
|
+
fields = (
|
|
377
|
+
"name",
|
|
378
|
+
"rating",
|
|
379
|
+
"comments",
|
|
380
|
+
"take_screenshot",
|
|
381
|
+
"path",
|
|
382
|
+
"user",
|
|
383
|
+
"owner",
|
|
384
|
+
"submitted_at",
|
|
385
|
+
"github_issue_number",
|
|
386
|
+
"github_issue_url",
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
@admin.display(description=_("GitHub issue"), ordering="github_issue_number")
|
|
390
|
+
def github_issue_display(self, obj):
|
|
391
|
+
if obj.github_issue_url:
|
|
392
|
+
label = (
|
|
393
|
+
f"#{obj.github_issue_number}"
|
|
394
|
+
if obj.github_issue_number is not None
|
|
395
|
+
else obj.github_issue_url
|
|
396
|
+
)
|
|
397
|
+
return format_html(
|
|
398
|
+
'<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
|
|
399
|
+
obj.github_issue_url,
|
|
400
|
+
label,
|
|
401
|
+
)
|
|
402
|
+
if obj.github_issue_number is not None:
|
|
403
|
+
return f"#{obj.github_issue_number}"
|
|
404
|
+
return _("Not created")
|
|
405
|
+
|
|
406
|
+
@admin.action(description=_("Create GitHub issues"))
|
|
407
|
+
def create_github_issues(self, request, queryset):
|
|
408
|
+
created = 0
|
|
409
|
+
skipped = 0
|
|
410
|
+
|
|
411
|
+
for story in queryset:
|
|
412
|
+
if story.github_issue_url:
|
|
413
|
+
skipped += 1
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
issue_url = story.create_github_issue()
|
|
418
|
+
except Exception as exc: # pragma: no cover - network/runtime errors
|
|
419
|
+
logger.exception("Failed to create GitHub issue for UserStory %s", story.pk)
|
|
420
|
+
self.message_user(
|
|
421
|
+
request,
|
|
422
|
+
_("Unable to create a GitHub issue for %(story)s: %(error)s")
|
|
423
|
+
% {"story": story, "error": exc},
|
|
424
|
+
messages.ERROR,
|
|
425
|
+
)
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
if issue_url:
|
|
429
|
+
created += 1
|
|
430
|
+
else:
|
|
431
|
+
skipped += 1
|
|
432
|
+
|
|
433
|
+
if created:
|
|
434
|
+
self.message_user(
|
|
435
|
+
request,
|
|
436
|
+
ngettext(
|
|
437
|
+
"Created %(count)d GitHub issue.",
|
|
438
|
+
"Created %(count)d GitHub issues.",
|
|
439
|
+
created,
|
|
440
|
+
)
|
|
441
|
+
% {"count": created},
|
|
442
|
+
messages.SUCCESS,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
if skipped:
|
|
446
|
+
self.message_user(
|
|
447
|
+
request,
|
|
448
|
+
ngettext(
|
|
449
|
+
"Skipped %(count)d feedback item (issue already exists or was throttled).",
|
|
450
|
+
"Skipped %(count)d feedback items (issues already exist or were throttled).",
|
|
451
|
+
skipped,
|
|
452
|
+
)
|
|
453
|
+
% {"count": skipped},
|
|
454
|
+
messages.INFO,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def has_add_permission(self, request):
|
|
458
|
+
return False
|
|
459
|
+
|
|
460
|
+
|
|
329
461
|
def favorite_toggle(request, ct_id):
|
|
330
462
|
ct = get_object_or_404(ContentType, pk=ct_id)
|
|
331
463
|
fav = Favorite.objects.filter(user=request.user, content_type=ct).first()
|
pages/context_processors.py
CHANGED
|
@@ -9,11 +9,22 @@ from core.reference_utils import filter_visible_references
|
|
|
9
9
|
from .models import Module
|
|
10
10
|
|
|
11
11
|
_favicon_path = Path(settings.BASE_DIR) / "pages" / "fixtures" / "data" / "favicon.txt"
|
|
12
|
+
_control_favicon_path = (
|
|
13
|
+
Path(settings.BASE_DIR) / "pages" / "fixtures" / "data" / "favicon_control.txt"
|
|
14
|
+
)
|
|
15
|
+
|
|
12
16
|
try:
|
|
13
17
|
_DEFAULT_FAVICON = f"data:image/png;base64,{_favicon_path.read_text().strip()}"
|
|
14
18
|
except OSError:
|
|
15
19
|
_DEFAULT_FAVICON = ""
|
|
16
20
|
|
|
21
|
+
try:
|
|
22
|
+
_CONTROL_FAVICON = (
|
|
23
|
+
f"data:image/png;base64,{_control_favicon_path.read_text().strip()}"
|
|
24
|
+
)
|
|
25
|
+
except OSError:
|
|
26
|
+
_CONTROL_FAVICON = _DEFAULT_FAVICON
|
|
27
|
+
|
|
17
28
|
|
|
18
29
|
def nav_links(request):
|
|
19
30
|
"""Provide navigation links for the current site."""
|
|
@@ -84,7 +95,10 @@ def nav_links(request):
|
|
|
84
95
|
except Exception:
|
|
85
96
|
pass
|
|
86
97
|
if not favicon_url:
|
|
87
|
-
|
|
98
|
+
if node and getattr(node.role, "name", "") == "Control":
|
|
99
|
+
favicon_url = _CONTROL_FAVICON
|
|
100
|
+
else:
|
|
101
|
+
favicon_url = _DEFAULT_FAVICON
|
|
88
102
|
|
|
89
103
|
header_refs_qs = (
|
|
90
104
|
Reference.objects.filter(show_in_header=True)
|
pages/defaults.py
CHANGED
|
@@ -8,7 +8,6 @@ DEFAULT_APPLICATION_DESCRIPTIONS: Dict[str, str] = {
|
|
|
8
8
|
"core": "Support for Business Processes and monetization.",
|
|
9
9
|
"ocpp": "Compatibility with Standards and Good Practices.",
|
|
10
10
|
"nodes": "System and Node-level operations,",
|
|
11
|
-
"pages": "
|
|
11
|
+
"pages": "User QA, Continuity Design and Chaos Testing.",
|
|
12
12
|
"teams": "Identity, Entitlements and Access Controls.",
|
|
13
|
-
"man": "User QA, Continuity Design and Chaos Testing.",
|
|
14
13
|
}
|