arthexis 0.1.18__py3-none-any.whl → 0.1.19__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.18.dist-info → arthexis-0.1.19.dist-info}/METADATA +37 -10
- {arthexis-0.1.18.dist-info → arthexis-0.1.19.dist-info}/RECORD +26 -26
- config/settings.py +1 -5
- core/system.py +125 -0
- core/tasks.py +0 -22
- core/views.py +35 -4
- nodes/admin.py +1 -2
- nodes/models.py +18 -23
- nodes/tests.py +42 -34
- nodes/urls.py +0 -1
- nodes/views.py +2 -15
- ocpp/admin.py +23 -2
- ocpp/models.py +7 -0
- ocpp/store.py +6 -4
- ocpp/tests.py +14 -1
- ocpp/views.py +65 -12
- pages/admin.py +63 -10
- pages/context_processors.py +11 -0
- pages/middleware.py +3 -0
- pages/models.py +35 -0
- pages/tests.py +177 -34
- pages/urls.py +2 -1
- pages/views.py +70 -23
- {arthexis-0.1.18.dist-info → arthexis-0.1.19.dist-info}/WHEEL +0 -0
- {arthexis-0.1.18.dist-info → arthexis-0.1.19.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.18.dist-info → arthexis-0.1.19.dist-info}/top_level.txt +0 -0
nodes/tests.py
CHANGED
|
@@ -223,6 +223,46 @@ class NodeGetLocalTests(TestCase):
|
|
|
223
223
|
node.refresh_from_db()
|
|
224
224
|
self.assertEqual(node.role.name, "Constellation")
|
|
225
225
|
|
|
226
|
+
def test_register_current_respects_node_hostname_env(self):
|
|
227
|
+
with TemporaryDirectory() as tmp:
|
|
228
|
+
base = Path(tmp)
|
|
229
|
+
with override_settings(BASE_DIR=base):
|
|
230
|
+
with (
|
|
231
|
+
patch.dict(os.environ, {"NODE_HOSTNAME": "gway-002"}, clear=False),
|
|
232
|
+
patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"),
|
|
233
|
+
patch("nodes.models.socket.gethostname", return_value="localhost"),
|
|
234
|
+
patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
|
|
235
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
236
|
+
patch.object(Node, "ensure_keys"),
|
|
237
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
238
|
+
):
|
|
239
|
+
node, created = Node.register_current()
|
|
240
|
+
self.assertTrue(created)
|
|
241
|
+
self.assertEqual(node.hostname, "gway-002")
|
|
242
|
+
self.assertEqual(node.public_endpoint, "gway-002")
|
|
243
|
+
|
|
244
|
+
def test_register_current_respects_public_endpoint_env(self):
|
|
245
|
+
with TemporaryDirectory() as tmp:
|
|
246
|
+
base = Path(tmp)
|
|
247
|
+
with override_settings(BASE_DIR=base):
|
|
248
|
+
with (
|
|
249
|
+
patch.dict(
|
|
250
|
+
os.environ,
|
|
251
|
+
{"NODE_HOSTNAME": "gway-alpha", "NODE_PUBLIC_ENDPOINT": "gway-002"},
|
|
252
|
+
clear=False,
|
|
253
|
+
),
|
|
254
|
+
patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:56"),
|
|
255
|
+
patch("nodes.models.socket.gethostname", return_value="localhost"),
|
|
256
|
+
patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
|
|
257
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
258
|
+
patch.object(Node, "ensure_keys"),
|
|
259
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
260
|
+
):
|
|
261
|
+
node, created = Node.register_current()
|
|
262
|
+
self.assertTrue(created)
|
|
263
|
+
self.assertEqual(node.hostname, "gway-alpha")
|
|
264
|
+
self.assertEqual(node.public_endpoint, "gway-002")
|
|
265
|
+
|
|
226
266
|
def test_register_and_list_node(self):
|
|
227
267
|
response = self.client.post(
|
|
228
268
|
reverse("register-node"),
|
|
@@ -2315,29 +2355,6 @@ class NetMessageAdminTests(TransactionTestCase):
|
|
|
2315
2355
|
self.assertEqual(form["subject"].value(), "Re: Ping")
|
|
2316
2356
|
self.assertEqual(str(form["filter_node"].value()), str(node.pk))
|
|
2317
2357
|
|
|
2318
|
-
|
|
2319
|
-
class LastNetMessageViewTests(TestCase):
|
|
2320
|
-
def setUp(self):
|
|
2321
|
-
self.client = Client()
|
|
2322
|
-
NodeRole.objects.get_or_create(name="Terminal")
|
|
2323
|
-
|
|
2324
|
-
def test_returns_latest_message(self):
|
|
2325
|
-
NetMessage.objects.create(subject="old", body="msg1")
|
|
2326
|
-
latest = NetMessage.objects.create(subject="new", body="msg2")
|
|
2327
|
-
resp = self.client.get(reverse("last-net-message"))
|
|
2328
|
-
self.assertEqual(resp.status_code, 200)
|
|
2329
|
-
self.assertEqual(
|
|
2330
|
-
resp.json(),
|
|
2331
|
-
{
|
|
2332
|
-
"subject": "new",
|
|
2333
|
-
"body": "msg2",
|
|
2334
|
-
"admin_url": reverse(
|
|
2335
|
-
"admin:nodes_netmessage_change", args=[latest.pk]
|
|
2336
|
-
),
|
|
2337
|
-
},
|
|
2338
|
-
)
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
2358
|
class NetMessageReachTests(TestCase):
|
|
2342
2359
|
def setUp(self):
|
|
2343
2360
|
self.roles = {}
|
|
@@ -2597,13 +2614,6 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2597
2614
|
self.assertNotIn(sender_addr, targets)
|
|
2598
2615
|
self.assertEqual(msg.propagated_to.count(), 4)
|
|
2599
2616
|
self.assertTrue(msg.complete)
|
|
2600
|
-
self.assertEqual(len(msg.confirmed_peers), mock_post.call_count)
|
|
2601
|
-
self.assertTrue(
|
|
2602
|
-
all(entry["status"] == "acknowledged" for entry in msg.confirmed_peers.values())
|
|
2603
|
-
)
|
|
2604
|
-
self.assertTrue(
|
|
2605
|
-
all(entry["status_code"] == 200 for entry in msg.confirmed_peers.values())
|
|
2606
|
-
)
|
|
2607
2617
|
|
|
2608
2618
|
@patch("requests.post")
|
|
2609
2619
|
@patch("core.notifications.notify", return_value=False)
|
|
@@ -2689,10 +2699,8 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2689
2699
|
):
|
|
2690
2700
|
msg.propagate()
|
|
2691
2701
|
|
|
2692
|
-
self.
|
|
2693
|
-
self.assertTrue(
|
|
2694
|
-
all(entry["status"] == "error" for entry in msg.confirmed_peers.values())
|
|
2695
|
-
)
|
|
2702
|
+
self.assertEqual(msg.propagated_to.count(), len(self.remotes))
|
|
2703
|
+
self.assertTrue(msg.complete)
|
|
2696
2704
|
|
|
2697
2705
|
|
|
2698
2706
|
class NetMessageSignatureTests(TestCase):
|
nodes/urls.py
CHANGED
|
@@ -8,7 +8,6 @@ urlpatterns = [
|
|
|
8
8
|
path("register/", views.register_node, name="register-node"),
|
|
9
9
|
path("screenshot/", views.capture, name="node-screenshot"),
|
|
10
10
|
path("net-message/", views.net_message, name="net-message"),
|
|
11
|
-
path("last-message/", views.last_net_message, name="last-net-message"),
|
|
12
11
|
path("rfid/export/", views.export_rfids, name="node-rfid-export"),
|
|
13
12
|
path("rfid/import/", views.import_rfids, name="node-rfid-import"),
|
|
14
13
|
path("<slug:endpoint>/", views.public_node_endpoint, name="node-public-endpoint"),
|
nodes/views.py
CHANGED
|
@@ -104,6 +104,8 @@ def _get_host_domain(request) -> str:
|
|
|
104
104
|
domain, _ = split_domain_port(host)
|
|
105
105
|
if not domain:
|
|
106
106
|
return ""
|
|
107
|
+
if domain.lower() == "localhost":
|
|
108
|
+
return ""
|
|
107
109
|
try:
|
|
108
110
|
ipaddress.ip_address(domain)
|
|
109
111
|
except ValueError:
|
|
@@ -666,18 +668,3 @@ def net_message(request):
|
|
|
666
668
|
msg.apply_attachments(attachments)
|
|
667
669
|
msg.propagate(seen=seen)
|
|
668
670
|
return JsonResponse({"status": "propagated", "complete": msg.complete})
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
def last_net_message(request):
|
|
672
|
-
"""Return the most recent :class:`NetMessage`."""
|
|
673
|
-
|
|
674
|
-
msg = NetMessage.objects.order_by("-created").first()
|
|
675
|
-
if not msg:
|
|
676
|
-
return JsonResponse({"subject": "", "body": "", "admin_url": ""})
|
|
677
|
-
return JsonResponse(
|
|
678
|
-
{
|
|
679
|
-
"subject": msg.subject,
|
|
680
|
-
"body": msg.body,
|
|
681
|
-
"admin_url": reverse("admin:nodes_netmessage_change", args=[msg.pk]),
|
|
682
|
-
}
|
|
683
|
-
)
|
ocpp/admin.py
CHANGED
|
@@ -6,7 +6,7 @@ from datetime import timedelta
|
|
|
6
6
|
import json
|
|
7
7
|
|
|
8
8
|
from django.shortcuts import redirect
|
|
9
|
-
from django.utils import timezone
|
|
9
|
+
from django.utils import formats, timezone, translation
|
|
10
10
|
from django.urls import path
|
|
11
11
|
from django.http import HttpResponse, HttpResponseRedirect
|
|
12
12
|
from django.template.response import TemplateResponse
|
|
@@ -163,6 +163,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
163
163
|
"charger_id",
|
|
164
164
|
"display_name",
|
|
165
165
|
"connector_id",
|
|
166
|
+
"language",
|
|
166
167
|
"location",
|
|
167
168
|
"last_path",
|
|
168
169
|
"last_heartbeat",
|
|
@@ -719,7 +720,7 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
|
|
|
719
720
|
"ws_port",
|
|
720
721
|
"ws_url",
|
|
721
722
|
"interval",
|
|
722
|
-
"
|
|
723
|
+
"kw_max_display",
|
|
723
724
|
"running",
|
|
724
725
|
"log_link",
|
|
725
726
|
)
|
|
@@ -759,6 +760,26 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
|
|
|
759
760
|
|
|
760
761
|
log_type = "simulator"
|
|
761
762
|
|
|
763
|
+
@admin.display(description="kW Max", ordering="kw_max")
|
|
764
|
+
def kw_max_display(self, obj):
|
|
765
|
+
"""Display ``kw_max`` with a dot decimal separator for Spanish locales."""
|
|
766
|
+
|
|
767
|
+
language = translation.get_language() or ""
|
|
768
|
+
if language.startswith("es"):
|
|
769
|
+
return formats.number_format(
|
|
770
|
+
obj.kw_max,
|
|
771
|
+
decimal_pos=2,
|
|
772
|
+
use_l10n=False,
|
|
773
|
+
force_grouping=False,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
return formats.number_format(
|
|
777
|
+
obj.kw_max,
|
|
778
|
+
decimal_pos=2,
|
|
779
|
+
use_l10n=True,
|
|
780
|
+
force_grouping=False,
|
|
781
|
+
)
|
|
782
|
+
|
|
762
783
|
def save_model(self, request, obj, form, change):
|
|
763
784
|
previous_door_open = False
|
|
764
785
|
if change and obj.pk:
|
ocpp/models.py
CHANGED
|
@@ -82,6 +82,13 @@ class Charger(Entity):
|
|
|
82
82
|
default=True,
|
|
83
83
|
help_text="Display this charger on the public status dashboard.",
|
|
84
84
|
)
|
|
85
|
+
language = models.CharField(
|
|
86
|
+
_("Language"),
|
|
87
|
+
max_length=12,
|
|
88
|
+
choices=settings.LANGUAGES,
|
|
89
|
+
default="es",
|
|
90
|
+
help_text=_("Preferred language for the public landing page."),
|
|
91
|
+
)
|
|
85
92
|
require_rfid = models.BooleanField(
|
|
86
93
|
_("Require RFID Authorization"),
|
|
87
94
|
default=False,
|
ocpp/store.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
from datetime import datetime
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
7
|
import json
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
import re
|
|
@@ -427,7 +427,7 @@ def _file_path(cid: str, log_type: str = "charger") -> Path:
|
|
|
427
427
|
def add_log(cid: str, entry: str, log_type: str = "charger") -> None:
|
|
428
428
|
"""Append a timestamped log entry for the given id and log type."""
|
|
429
429
|
|
|
430
|
-
timestamp = datetime.
|
|
430
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
431
431
|
entry = f"{timestamp} {entry}"
|
|
432
432
|
|
|
433
433
|
store = logs[log_type]
|
|
@@ -454,7 +454,7 @@ def start_session_log(cid: str, tx_id: int) -> None:
|
|
|
454
454
|
|
|
455
455
|
history[cid] = {
|
|
456
456
|
"transaction": tx_id,
|
|
457
|
-
"start": datetime.
|
|
457
|
+
"start": datetime.now(timezone.utc),
|
|
458
458
|
"messages": [],
|
|
459
459
|
}
|
|
460
460
|
|
|
@@ -467,7 +467,9 @@ def add_session_message(cid: str, message: str) -> None:
|
|
|
467
467
|
return
|
|
468
468
|
sess["messages"].append(
|
|
469
469
|
{
|
|
470
|
-
"timestamp": datetime.
|
|
470
|
+
"timestamp": datetime.now(timezone.utc)
|
|
471
|
+
.isoformat()
|
|
472
|
+
.replace("+00:00", "Z"),
|
|
471
473
|
"message": message,
|
|
472
474
|
}
|
|
473
475
|
)
|
ocpp/tests.py
CHANGED
|
@@ -1852,6 +1852,16 @@ class ChargerLandingTests(TestCase):
|
|
|
1852
1852
|
status_url = reverse("charger-status-connector", args=["PAGE1", "all"])
|
|
1853
1853
|
self.assertContains(response, status_url)
|
|
1854
1854
|
|
|
1855
|
+
def test_charger_page_respects_language_configuration(self):
|
|
1856
|
+
charger = Charger.objects.create(charger_id="PAGE-DE", language="de")
|
|
1857
|
+
|
|
1858
|
+
response = self.client.get(reverse("charger-page", args=["PAGE-DE"]))
|
|
1859
|
+
|
|
1860
|
+
self.assertEqual(response.status_code, 200)
|
|
1861
|
+
self.assertEqual(response.context["LANGUAGE_CODE"], "de")
|
|
1862
|
+
self.assertContains(response, 'lang="de"')
|
|
1863
|
+
self.assertContains(response, 'data-preferred-language="de"')
|
|
1864
|
+
|
|
1855
1865
|
def test_status_page_renders(self):
|
|
1856
1866
|
charger = Charger.objects.create(charger_id="PAGE2")
|
|
1857
1867
|
resp = self.client.get(reverse("charger-status", args=["PAGE2"]))
|
|
@@ -2078,7 +2088,10 @@ class ChargerLandingTests(TestCase):
|
|
|
2078
2088
|
log_id = store.identity_key("LOG1", None)
|
|
2079
2089
|
store.add_log(log_id, "hello", log_type="charger")
|
|
2080
2090
|
entry = store.get_logs(log_id, log_type="charger")[0]
|
|
2081
|
-
self.assertRegex(
|
|
2091
|
+
self.assertRegex(
|
|
2092
|
+
entry,
|
|
2093
|
+
r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} hello$",
|
|
2094
|
+
)
|
|
2082
2095
|
resp = self.client.get(reverse("charger-log", args=["LOG1"]) + "?type=charger")
|
|
2083
2096
|
self.assertEqual(resp.status_code, 200)
|
|
2084
2097
|
self.assertContains(resp, "hello")
|
ocpp/views.py
CHANGED
|
@@ -11,6 +11,7 @@ from django.core.paginator import Paginator
|
|
|
11
11
|
from django.contrib.auth.decorators import login_required
|
|
12
12
|
from django.contrib.auth.views import redirect_to_login
|
|
13
13
|
from django.utils.translation import gettext_lazy as _, gettext, ngettext
|
|
14
|
+
from django.utils.text import slugify
|
|
14
15
|
from django.urls import NoReverseMatch, reverse
|
|
15
16
|
from django.conf import settings
|
|
16
17
|
from django.utils import translation, timezone
|
|
@@ -309,9 +310,14 @@ def _landing_page_translations() -> dict[str, dict[str, str]]:
|
|
|
309
310
|
"""Return static translations used by the charger public landing page."""
|
|
310
311
|
|
|
311
312
|
catalog: dict[str, dict[str, str]] = {}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
313
|
+
seen_codes: set[str] = set()
|
|
314
|
+
for code, _name in settings.LANGUAGES:
|
|
315
|
+
normalized = str(code).strip()
|
|
316
|
+
if not normalized or normalized in seen_codes:
|
|
317
|
+
continue
|
|
318
|
+
seen_codes.add(normalized)
|
|
319
|
+
with translation.override(normalized):
|
|
320
|
+
catalog[normalized] = {
|
|
315
321
|
"serial_number_label": gettext("Serial Number"),
|
|
316
322
|
"connector_label": gettext("Connector"),
|
|
317
323
|
"advanced_view_label": gettext("Advanced View"),
|
|
@@ -842,11 +848,30 @@ def charger_page(request, cid, connector=None):
|
|
|
842
848
|
state_source = tx if charger.connector_id is not None else (sessions if sessions else None)
|
|
843
849
|
state, color = _charger_state(charger, state_source)
|
|
844
850
|
language_cookie = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
851
|
+
available_languages = [
|
|
852
|
+
str(code).strip()
|
|
853
|
+
for code, _ in settings.LANGUAGES
|
|
854
|
+
if str(code).strip()
|
|
855
|
+
]
|
|
856
|
+
supported_languages = set(available_languages)
|
|
857
|
+
charger_language = (charger.language or "es").strip()
|
|
858
|
+
if charger_language not in supported_languages:
|
|
859
|
+
fallback = "es" if "es" in supported_languages else ""
|
|
860
|
+
if not fallback and available_languages:
|
|
861
|
+
fallback = available_languages[0]
|
|
862
|
+
charger_language = fallback
|
|
863
|
+
if (
|
|
864
|
+
charger_language
|
|
865
|
+
and (
|
|
866
|
+
not language_cookie
|
|
867
|
+
or language_cookie not in supported_languages
|
|
868
|
+
or language_cookie != charger_language
|
|
869
|
+
)
|
|
870
|
+
):
|
|
871
|
+
translation.activate(charger_language)
|
|
872
|
+
current_language = translation.get_language()
|
|
873
|
+
request.LANGUAGE_CODE = current_language
|
|
874
|
+
preferred_language = charger_language or current_language
|
|
850
875
|
connector_links = [
|
|
851
876
|
{
|
|
852
877
|
"slug": item["slug"],
|
|
@@ -874,6 +899,7 @@ def charger_page(request, cid, connector=None):
|
|
|
874
899
|
"active_connector_count": active_connector_count,
|
|
875
900
|
"status_url": status_url,
|
|
876
901
|
"landing_translations": _landing_page_translations(),
|
|
902
|
+
"preferred_language": preferred_language,
|
|
877
903
|
"state": state,
|
|
878
904
|
"color": color,
|
|
879
905
|
},
|
|
@@ -1209,8 +1235,11 @@ def charger_log_page(request, cid, connector=None):
|
|
|
1209
1235
|
charger_id=cid
|
|
1210
1236
|
)
|
|
1211
1237
|
target_id = cid
|
|
1238
|
+
|
|
1239
|
+
slug_source = slugify(target_id) or slugify(cid) or "log"
|
|
1240
|
+
filename_parts = [log_type, slug_source]
|
|
1241
|
+
download_filename = f"{'-'.join(part for part in filename_parts if part)}.log"
|
|
1212
1242
|
limit_options = [
|
|
1213
|
-
{"value": "10", "label": "10"},
|
|
1214
1243
|
{"value": "20", "label": "20"},
|
|
1215
1244
|
{"value": "40", "label": "40"},
|
|
1216
1245
|
{"value": "100", "label": "100"},
|
|
@@ -1220,15 +1249,35 @@ def charger_log_page(request, cid, connector=None):
|
|
|
1220
1249
|
limit_choice = request.GET.get("limit", "20")
|
|
1221
1250
|
if limit_choice not in allowed_values:
|
|
1222
1251
|
limit_choice = "20"
|
|
1223
|
-
|
|
1224
|
-
|
|
1252
|
+
limit_index = allowed_values.index(limit_choice)
|
|
1253
|
+
|
|
1254
|
+
log_entries_all = list(store.get_logs(target_id, log_type=log_type) or [])
|
|
1255
|
+
download_requested = request.GET.get("download") == "1"
|
|
1256
|
+
if download_requested:
|
|
1257
|
+
download_content = "\n".join(log_entries_all)
|
|
1258
|
+
if download_content and not download_content.endswith("\n"):
|
|
1259
|
+
download_content = f"{download_content}\n"
|
|
1260
|
+
response = HttpResponse(download_content, content_type="text/plain; charset=utf-8")
|
|
1261
|
+
response["Content-Disposition"] = f'attachment; filename="{download_filename}"'
|
|
1262
|
+
return response
|
|
1263
|
+
|
|
1264
|
+
log_entries = log_entries_all
|
|
1225
1265
|
if limit_choice != "all":
|
|
1226
1266
|
try:
|
|
1227
1267
|
limit_value = int(limit_choice)
|
|
1228
1268
|
except (TypeError, ValueError):
|
|
1229
1269
|
limit_value = 20
|
|
1230
1270
|
limit_choice = "20"
|
|
1271
|
+
limit_index = allowed_values.index(limit_choice)
|
|
1231
1272
|
log_entries = log_entries[-limit_value:]
|
|
1273
|
+
|
|
1274
|
+
download_params = request.GET.copy()
|
|
1275
|
+
download_params["download"] = "1"
|
|
1276
|
+
download_params.pop("limit", None)
|
|
1277
|
+
download_query = download_params.urlencode()
|
|
1278
|
+
log_download_url = f"{request.path}?{download_query}" if download_query else request.path
|
|
1279
|
+
|
|
1280
|
+
limit_label = limit_options[limit_index]["label"]
|
|
1232
1281
|
return render(
|
|
1233
1282
|
request,
|
|
1234
1283
|
"ocpp/charger_logs.html",
|
|
@@ -1240,7 +1289,11 @@ def charger_log_page(request, cid, connector=None):
|
|
|
1240
1289
|
"connector_links": connector_links,
|
|
1241
1290
|
"status_url": status_url,
|
|
1242
1291
|
"log_limit_options": limit_options,
|
|
1243
|
-
"log_limit_index":
|
|
1292
|
+
"log_limit_index": limit_index,
|
|
1293
|
+
"log_limit_choice": limit_choice,
|
|
1294
|
+
"log_limit_label": limit_label,
|
|
1295
|
+
"log_download_url": log_download_url,
|
|
1296
|
+
"log_filename": download_filename,
|
|
1244
1297
|
},
|
|
1245
1298
|
)
|
|
1246
1299
|
|
pages/admin.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from collections import deque
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
4
5
|
from django.contrib import admin, messages
|
|
@@ -8,8 +9,9 @@ from django import forms
|
|
|
8
9
|
from django.shortcuts import redirect, render, get_object_or_404
|
|
9
10
|
from django.urls import NoReverseMatch, path, reverse
|
|
10
11
|
from django.utils.html import format_html
|
|
12
|
+
|
|
11
13
|
from django.template.response import TemplateResponse
|
|
12
|
-
from django.http import JsonResponse
|
|
14
|
+
from django.http import FileResponse, JsonResponse
|
|
13
15
|
from django.utils import timezone
|
|
14
16
|
from django.db.models import Count
|
|
15
17
|
from django.core.exceptions import FieldError
|
|
@@ -289,14 +291,19 @@ class ApplicationAdmin(EntityModelAdmin):
|
|
|
289
291
|
class LandingInline(admin.TabularInline):
|
|
290
292
|
model = Landing
|
|
291
293
|
extra = 0
|
|
292
|
-
fields = ("path", "label", "enabled")
|
|
294
|
+
fields = ("path", "label", "enabled", "track_leads")
|
|
293
295
|
show_change_link = True
|
|
294
296
|
|
|
295
297
|
|
|
296
298
|
@admin.register(Landing)
|
|
297
299
|
class LandingAdmin(EntityModelAdmin):
|
|
298
|
-
list_display = ("label", "path", "module", "enabled")
|
|
299
|
-
list_filter = (
|
|
300
|
+
list_display = ("label", "path", "module", "enabled", "track_leads")
|
|
301
|
+
list_filter = (
|
|
302
|
+
"enabled",
|
|
303
|
+
"track_leads",
|
|
304
|
+
"module__node_role",
|
|
305
|
+
"module__application",
|
|
306
|
+
)
|
|
300
307
|
search_fields = (
|
|
301
308
|
"label",
|
|
302
309
|
"path",
|
|
@@ -305,7 +312,7 @@ class LandingAdmin(EntityModelAdmin):
|
|
|
305
312
|
"module__application__name",
|
|
306
313
|
"module__node_role__name",
|
|
307
314
|
)
|
|
308
|
-
fields = ("module", "path", "label", "enabled", "description")
|
|
315
|
+
fields = ("module", "path", "label", "enabled", "track_leads", "description")
|
|
309
316
|
list_select_related = ("module", "module__application", "module__node_role")
|
|
310
317
|
|
|
311
318
|
|
|
@@ -878,6 +885,13 @@ def favorite_clear(request):
|
|
|
878
885
|
return redirect("admin:favorite_list")
|
|
879
886
|
|
|
880
887
|
|
|
888
|
+
def _read_log_tail(path: Path, limit: int) -> str:
|
|
889
|
+
"""Return the last ``limit`` lines from ``path`` preserving newlines."""
|
|
890
|
+
|
|
891
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
892
|
+
return "".join(deque(handle, maxlen=limit))
|
|
893
|
+
|
|
894
|
+
|
|
881
895
|
def log_viewer(request):
|
|
882
896
|
logs_dir = Path(settings.BASE_DIR) / "logs"
|
|
883
897
|
logs_exist = logs_dir.exists() and logs_dir.is_dir()
|
|
@@ -895,16 +909,50 @@ def log_viewer(request):
|
|
|
895
909
|
selected_log = request.GET.get("log", "")
|
|
896
910
|
log_content = ""
|
|
897
911
|
log_error = ""
|
|
912
|
+
limit_options = [
|
|
913
|
+
{"value": "20", "label": "20"},
|
|
914
|
+
{"value": "40", "label": "40"},
|
|
915
|
+
{"value": "100", "label": "100"},
|
|
916
|
+
{"value": "all", "label": _("All")},
|
|
917
|
+
]
|
|
918
|
+
allowed_limits = [item["value"] for item in limit_options]
|
|
919
|
+
limit_choice = request.GET.get("limit", "20")
|
|
920
|
+
if limit_choice not in allowed_limits:
|
|
921
|
+
limit_choice = "20"
|
|
922
|
+
limit_index = allowed_limits.index(limit_choice)
|
|
923
|
+
download_requested = request.GET.get("download") == "1"
|
|
898
924
|
|
|
899
925
|
if selected_log:
|
|
900
926
|
if selected_log in available_logs:
|
|
901
927
|
selected_path = logs_dir / selected_log
|
|
902
928
|
try:
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
929
|
+
if download_requested:
|
|
930
|
+
return FileResponse(
|
|
931
|
+
selected_path.open("rb"),
|
|
932
|
+
as_attachment=True,
|
|
933
|
+
filename=selected_log,
|
|
934
|
+
)
|
|
935
|
+
if limit_choice == "all":
|
|
936
|
+
try:
|
|
937
|
+
log_content = selected_path.read_text(encoding="utf-8")
|
|
938
|
+
except UnicodeDecodeError:
|
|
939
|
+
log_content = selected_path.read_text(
|
|
940
|
+
encoding="utf-8", errors="replace"
|
|
941
|
+
)
|
|
942
|
+
else:
|
|
943
|
+
try:
|
|
944
|
+
limit_value = int(limit_choice)
|
|
945
|
+
except (TypeError, ValueError):
|
|
946
|
+
limit_value = 20
|
|
947
|
+
limit_choice = "20"
|
|
948
|
+
limit_index = allowed_limits.index(limit_choice)
|
|
949
|
+
try:
|
|
950
|
+
log_content = _read_log_tail(selected_path, limit_value)
|
|
951
|
+
except UnicodeDecodeError:
|
|
952
|
+
with selected_path.open(
|
|
953
|
+
"r", encoding="utf-8", errors="replace"
|
|
954
|
+
) as handle:
|
|
955
|
+
log_content = "".join(deque(handle, maxlen=limit_value))
|
|
908
956
|
except OSError as exc: # pragma: no cover - filesystem edge cases
|
|
909
957
|
logger.warning("Unable to read log file %s", selected_path, exc_info=exc)
|
|
910
958
|
log_error = _(
|
|
@@ -922,6 +970,7 @@ def log_viewer(request):
|
|
|
922
970
|
else:
|
|
923
971
|
log_notice = ""
|
|
924
972
|
|
|
973
|
+
limit_label = limit_options[limit_index]["label"]
|
|
925
974
|
context = {**admin.site.each_context(request)}
|
|
926
975
|
context.update(
|
|
927
976
|
{
|
|
@@ -932,6 +981,10 @@ def log_viewer(request):
|
|
|
932
981
|
"log_error": log_error,
|
|
933
982
|
"log_notice": log_notice,
|
|
934
983
|
"logs_directory": logs_dir,
|
|
984
|
+
"log_limit_options": limit_options,
|
|
985
|
+
"log_limit_index": limit_index,
|
|
986
|
+
"log_limit_choice": limit_choice,
|
|
987
|
+
"log_limit_label": limit_label,
|
|
935
988
|
}
|
|
936
989
|
)
|
|
937
990
|
return TemplateResponse(request, "admin/log_viewer.html", context)
|
pages/context_processors.py
CHANGED
|
@@ -87,6 +87,17 @@ def nav_links(request):
|
|
|
87
87
|
continue
|
|
88
88
|
landings.append(landing)
|
|
89
89
|
if landings:
|
|
90
|
+
normalized_module_path = module.path.rstrip("/") or "/"
|
|
91
|
+
if normalized_module_path == "/read":
|
|
92
|
+
primary_landings = [
|
|
93
|
+
landing
|
|
94
|
+
for landing in landings
|
|
95
|
+
if landing.path.rstrip("/") == normalized_module_path
|
|
96
|
+
]
|
|
97
|
+
if primary_landings:
|
|
98
|
+
landings = primary_landings
|
|
99
|
+
else:
|
|
100
|
+
landings = [landings[0]]
|
|
90
101
|
app_name = getattr(module.application, "name", "").lower()
|
|
91
102
|
if app_name == "awg":
|
|
92
103
|
module.menu = "Calculate"
|
pages/middleware.py
CHANGED
pages/models.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
import logging
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
@@ -212,6 +213,7 @@ class Landing(Entity):
|
|
|
212
213
|
path = models.CharField(max_length=200)
|
|
213
214
|
label = models.CharField(max_length=100)
|
|
214
215
|
enabled = models.BooleanField(default=True)
|
|
216
|
+
track_leads = models.BooleanField(default=False)
|
|
215
217
|
description = models.TextField(blank=True)
|
|
216
218
|
|
|
217
219
|
objects = LandingManager()
|
|
@@ -435,6 +437,39 @@ class UserManual(Entity):
|
|
|
435
437
|
def natural_key(self): # pragma: no cover - simple representation
|
|
436
438
|
return (self.slug,)
|
|
437
439
|
|
|
440
|
+
def _ensure_pdf_is_base64(self) -> None:
|
|
441
|
+
"""Normalize ``content_pdf`` so stored values are base64 strings."""
|
|
442
|
+
|
|
443
|
+
value = self.content_pdf
|
|
444
|
+
if value in {None, ""}:
|
|
445
|
+
self.content_pdf = "" if value is None else value
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
449
|
+
self.content_pdf = base64.b64encode(bytes(value)).decode("ascii")
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
reader = getattr(value, "read", None)
|
|
453
|
+
if callable(reader):
|
|
454
|
+
data = reader()
|
|
455
|
+
if hasattr(value, "seek"):
|
|
456
|
+
try:
|
|
457
|
+
value.seek(0)
|
|
458
|
+
except Exception: # pragma: no cover - best effort reset
|
|
459
|
+
pass
|
|
460
|
+
self.content_pdf = base64.b64encode(data).decode("ascii")
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
if isinstance(value, str):
|
|
464
|
+
stripped = value.strip()
|
|
465
|
+
if stripped.startswith("data:"):
|
|
466
|
+
_, _, encoded = stripped.partition(",")
|
|
467
|
+
self.content_pdf = encoded.strip()
|
|
468
|
+
|
|
469
|
+
def save(self, *args, **kwargs):
|
|
470
|
+
self._ensure_pdf_is_base64()
|
|
471
|
+
super().save(*args, **kwargs)
|
|
472
|
+
|
|
438
473
|
|
|
439
474
|
class ViewHistory(Entity):
|
|
440
475
|
"""Record of public site visits."""
|