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.

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.assertTrue(msg.confirmed_peers)
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
- "kw_max",
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.utcnow().strftime("%Y-%m-%d %H:%M:%S")
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.utcnow(),
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.utcnow().isoformat() + "Z",
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(entry, r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} hello$")
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
- for code in ("en", "es"):
313
- with translation.override(code):
314
- catalog[code] = {
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
- preferred_language = "es"
846
- supported_languages = {code for code, _ in settings.LANGUAGES}
847
- if preferred_language in supported_languages and not language_cookie:
848
- translation.activate(preferred_language)
849
- request.LANGUAGE_CODE = translation.get_language()
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
- log_entries = list(store.get_logs(target_id, log_type=log_type) or [])
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": allowed_values.index(limit_choice),
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 = ("enabled", "module__node_role", "module__application")
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
- log_content = selected_path.read_text(encoding="utf-8")
904
- except UnicodeDecodeError:
905
- log_content = selected_path.read_text(
906
- encoding="utf-8", errors="replace"
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)
@@ -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
@@ -126,6 +126,9 @@ class ViewHistoryMiddleware:
126
126
  if request.method.upper() != "GET":
127
127
  return
128
128
 
129
+ if not getattr(landing, "track_leads", False):
130
+ return
131
+
129
132
  if not landing_leads_supported():
130
133
  return
131
134
 
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."""