arthexis 0.1.10__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.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
- arthexis-0.1.12.dist-info/RECORD +102 -0
- config/context_processors.py +1 -0
- config/settings.py +31 -5
- config/urls.py +5 -4
- core/admin.py +430 -90
- core/apps.py +48 -2
- core/backends.py +38 -0
- core/environment.py +23 -5
- core/mailer.py +3 -1
- core/models.py +303 -31
- core/reference_utils.py +20 -9
- core/release.py +4 -0
- core/sigil_builder.py +7 -2
- core/sigil_resolver.py +35 -4
- core/system.py +250 -1
- core/tasks.py +92 -40
- core/temp_passwords.py +181 -0
- core/test_system_info.py +62 -2
- core/tests.py +169 -3
- core/user_data.py +51 -8
- core/views.py +371 -20
- nodes/admin.py +453 -8
- nodes/backends.py +21 -6
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +374 -31
- nodes/reports.py +411 -0
- nodes/tests.py +677 -38
- nodes/utils.py +32 -0
- nodes/views.py +14 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +517 -16
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +237 -4
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +819 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +233 -19
- pages/admin.py +144 -4
- pages/context_processors.py +21 -7
- pages/defaults.py +13 -0
- pages/forms.py +38 -0
- pages/models.py +189 -15
- pages/tests.py +281 -8
- pages/urls.py +4 -0
- pages/views.py +137 -21
- arthexis-0.1.10.dist-info/RECORD +0 -95
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.10.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):
|
|
@@ -464,6 +487,7 @@ def cp_simulator(request):
|
|
|
464
487
|
default_vins = ["WP0ZZZ00000000000", "WAUZZZ00000000000"]
|
|
465
488
|
|
|
466
489
|
message = ""
|
|
490
|
+
dashboard_link: str | None = None
|
|
467
491
|
if request.method == "POST":
|
|
468
492
|
cp_idx = int(request.POST.get("cp") or 1)
|
|
469
493
|
action = request.POST.get("action")
|
|
@@ -500,6 +524,12 @@ def cp_simulator(request):
|
|
|
500
524
|
started, status, log_file = _start_simulator(sim_params, cp=cp_idx)
|
|
501
525
|
if started:
|
|
502
526
|
message = f"CP{cp_idx} started: {status}. Logs: {log_file}"
|
|
527
|
+
try:
|
|
528
|
+
dashboard_link = reverse(
|
|
529
|
+
"charger-status", args=[sim_params["cp_path"]]
|
|
530
|
+
)
|
|
531
|
+
except NoReverseMatch: # pragma: no cover - defensive
|
|
532
|
+
dashboard_link = None
|
|
503
533
|
else:
|
|
504
534
|
message = f"CP{cp_idx} {status}. Logs: {log_file}"
|
|
505
535
|
except Exception as exc: # pragma: no cover - unexpected
|
|
@@ -526,6 +556,7 @@ def cp_simulator(request):
|
|
|
526
556
|
|
|
527
557
|
context = {
|
|
528
558
|
"message": message,
|
|
559
|
+
"dashboard_link": dashboard_link,
|
|
529
560
|
"states": state_list,
|
|
530
561
|
"default_host": default_host,
|
|
531
562
|
"default_ws_port": default_ws_port,
|
|
@@ -543,7 +574,8 @@ def cp_simulator(request):
|
|
|
543
574
|
def charger_page(request, cid, connector=None):
|
|
544
575
|
"""Public landing page for a charger displaying usage guidance or progress."""
|
|
545
576
|
charger, connector_slug = _get_charger(cid, connector)
|
|
546
|
-
|
|
577
|
+
_ensure_charger_access(request.user, charger)
|
|
578
|
+
overview = _connector_overview(charger, request.user)
|
|
547
579
|
sessions = _live_sessions(charger)
|
|
548
580
|
tx = None
|
|
549
581
|
active_connector_count = 0
|
|
@@ -610,6 +642,7 @@ def charger_page(request, cid, connector=None):
|
|
|
610
642
|
@login_required
|
|
611
643
|
def charger_status(request, cid, connector=None):
|
|
612
644
|
charger, connector_slug = _get_charger(cid, connector)
|
|
645
|
+
_ensure_charger_access(request.user, charger)
|
|
613
646
|
session_id = request.GET.get("session")
|
|
614
647
|
sessions = _live_sessions(charger)
|
|
615
648
|
live_tx = None
|
|
@@ -713,7 +746,7 @@ def charger_status(request, cid, connector=None):
|
|
|
713
746
|
"connector_id": connector_id,
|
|
714
747
|
}
|
|
715
748
|
)
|
|
716
|
-
overview = _connector_overview(charger)
|
|
749
|
+
overview = _connector_overview(charger, request.user)
|
|
717
750
|
connector_links = [
|
|
718
751
|
{
|
|
719
752
|
"slug": item["slug"],
|
|
@@ -789,6 +822,7 @@ def charger_status(request, cid, connector=None):
|
|
|
789
822
|
@login_required
|
|
790
823
|
def charger_session_search(request, cid, connector=None):
|
|
791
824
|
charger, connector_slug = _get_charger(cid, connector)
|
|
825
|
+
_ensure_charger_access(request.user, charger)
|
|
792
826
|
date_str = request.GET.get("date")
|
|
793
827
|
transactions = None
|
|
794
828
|
if date_str:
|
|
@@ -806,7 +840,7 @@ def charger_session_search(request, cid, connector=None):
|
|
|
806
840
|
transactions = qs.order_by("-start_time")
|
|
807
841
|
except ValueError:
|
|
808
842
|
transactions = []
|
|
809
|
-
overview = _connector_overview(charger)
|
|
843
|
+
overview = _connector_overview(charger, request.user)
|
|
810
844
|
connector_links = [
|
|
811
845
|
{
|
|
812
846
|
"slug": item["slug"],
|
|
@@ -840,8 +874,9 @@ def charger_log_page(request, cid, connector=None):
|
|
|
840
874
|
status_url = None
|
|
841
875
|
if log_type == "charger":
|
|
842
876
|
charger, connector_slug = _get_charger(cid, connector)
|
|
877
|
+
_ensure_charger_access(request.user, charger)
|
|
843
878
|
log_key = store.identity_key(cid, charger.connector_id)
|
|
844
|
-
overview = _connector_overview(charger)
|
|
879
|
+
overview = _connector_overview(charger, request.user)
|
|
845
880
|
connector_links = [
|
|
846
881
|
{
|
|
847
882
|
"slug": item["slug"],
|
|
@@ -877,6 +912,30 @@ def charger_log_page(request, cid, connector=None):
|
|
|
877
912
|
@api_login_required
|
|
878
913
|
def dispatch_action(request, cid, connector=None):
|
|
879
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)
|
|
880
939
|
ws = store.get_connection(cid, connector_value)
|
|
881
940
|
if ws is None:
|
|
882
941
|
return JsonResponse({"detail": "no connection"}, status=404)
|
|
@@ -889,15 +948,27 @@ def dispatch_action(request, cid, connector=None):
|
|
|
889
948
|
tx_obj = store.get_transaction(cid, connector_value)
|
|
890
949
|
if not tx_obj:
|
|
891
950
|
return JsonResponse({"detail": "no transaction"}, status=404)
|
|
951
|
+
message_id = uuid.uuid4().hex
|
|
892
952
|
msg = json.dumps(
|
|
893
953
|
[
|
|
894
954
|
2,
|
|
895
|
-
|
|
955
|
+
message_id,
|
|
896
956
|
"RemoteStopTransaction",
|
|
897
957
|
{"transactionId": tx_obj.pk},
|
|
898
958
|
]
|
|
899
959
|
)
|
|
900
|
-
|
|
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
|
+
)
|
|
901
972
|
elif action == "remote_start":
|
|
902
973
|
id_tag = data.get("idTag")
|
|
903
974
|
if not isinstance(id_tag, str) or not id_tag.strip():
|
|
@@ -916,20 +987,163 @@ def dispatch_action(request, cid, connector=None):
|
|
|
916
987
|
payload["connectorId"] = connector_id
|
|
917
988
|
if "chargingProfile" in data and data["chargingProfile"] is not None:
|
|
918
989
|
payload["chargingProfile"] = data["chargingProfile"]
|
|
990
|
+
message_id = uuid.uuid4().hex
|
|
919
991
|
msg = json.dumps(
|
|
920
992
|
[
|
|
921
993
|
2,
|
|
922
|
-
|
|
994
|
+
message_id,
|
|
923
995
|
"RemoteStartTransaction",
|
|
924
996
|
payload,
|
|
925
997
|
]
|
|
926
998
|
)
|
|
927
|
-
|
|
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
|
+
)
|
|
928
1088
|
elif action == "reset":
|
|
929
|
-
|
|
930
|
-
|
|
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
|
+
},
|
|
931
1146
|
)
|
|
932
|
-
asyncio.get_event_loop().create_task(ws.send(msg))
|
|
933
1147
|
else:
|
|
934
1148
|
return JsonResponse({"detail": "unknown action"}, status=400)
|
|
935
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
|
|
@@ -28,11 +31,16 @@ from .models import (
|
|
|
28
31
|
Landing,
|
|
29
32
|
Favorite,
|
|
30
33
|
ViewHistory,
|
|
34
|
+
UserManual,
|
|
35
|
+
UserStory,
|
|
31
36
|
)
|
|
32
37
|
from django.contrib.contenttypes.models import ContentType
|
|
33
38
|
from core.user_data import EntityModelAdmin
|
|
34
39
|
|
|
35
40
|
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
36
44
|
def get_local_app_choices():
|
|
37
45
|
choices = []
|
|
38
46
|
for app_label in getattr(settings, "LOCAL_APPS", []):
|
|
@@ -153,7 +161,7 @@ class ApplicationModuleInline(admin.TabularInline):
|
|
|
153
161
|
@admin.register(Application)
|
|
154
162
|
class ApplicationAdmin(EntityModelAdmin):
|
|
155
163
|
form = ApplicationForm
|
|
156
|
-
list_display = ("name", "app_verbose_name", "installed")
|
|
164
|
+
list_display = ("name", "app_verbose_name", "description", "installed")
|
|
157
165
|
readonly_fields = ("installed",)
|
|
158
166
|
inlines = [ApplicationModuleInline]
|
|
159
167
|
|
|
@@ -180,6 +188,13 @@ class ModuleAdmin(EntityModelAdmin):
|
|
|
180
188
|
inlines = [LandingInline]
|
|
181
189
|
|
|
182
190
|
|
|
191
|
+
@admin.register(UserManual)
|
|
192
|
+
class UserManualAdmin(EntityModelAdmin):
|
|
193
|
+
list_display = ("title", "slug", "languages", "is_seed_data", "is_user_data")
|
|
194
|
+
search_fields = ("title", "slug", "description")
|
|
195
|
+
list_filter = ("is_seed_data", "is_user_data")
|
|
196
|
+
|
|
197
|
+
|
|
183
198
|
@admin.register(ViewHistory)
|
|
184
199
|
class ViewHistoryAdmin(EntityModelAdmin):
|
|
185
200
|
date_hierarchy = "visited_at"
|
|
@@ -247,8 +262,20 @@ class ViewHistoryAdmin(EntityModelAdmin):
|
|
|
247
262
|
def _build_chart_data(self, days: int = 30, max_pages: int = 8) -> dict:
|
|
248
263
|
end_date = timezone.localdate()
|
|
249
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
|
+
|
|
250
277
|
queryset = ViewHistory.objects.filter(
|
|
251
|
-
|
|
278
|
+
visited_at__gte=start_at, visited_at__lt=end_at
|
|
252
279
|
)
|
|
253
280
|
|
|
254
281
|
meta = {
|
|
@@ -274,7 +301,7 @@ class ViewHistoryAdmin(EntityModelAdmin):
|
|
|
274
301
|
|
|
275
302
|
aggregates = (
|
|
276
303
|
queryset.filter(path__in=paths)
|
|
277
|
-
.annotate(day=
|
|
304
|
+
.annotate(day=trunc_expression)
|
|
278
305
|
.values("day", "path")
|
|
279
306
|
.order_by("day")
|
|
280
307
|
.annotate(total=Count("id"))
|
|
@@ -318,6 +345,119 @@ class ViewHistoryAdmin(EntityModelAdmin):
|
|
|
318
345
|
return {"labels": labels, "datasets": datasets, "meta": meta}
|
|
319
346
|
|
|
320
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
|
+
|
|
321
461
|
def favorite_toggle(request, ct_id):
|
|
322
462
|
ct = get_object_or_404(ContentType, pk=ct_id)
|
|
323
463
|
fav = Favorite.objects.filter(user=request.user, content_type=ct).first()
|