arthexis 0.1.17__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.

ocpp/test_rfid.py CHANGED
@@ -519,6 +519,76 @@ class ValidateRfidValueTests(SimpleTestCase):
519
519
  mock_popen.assert_not_called()
520
520
  self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
521
521
 
522
+ @patch("ocpp.rfid.reader.timezone.now")
523
+ @patch("ocpp.rfid.reader.notify_async")
524
+ @patch("ocpp.rfid.reader.subprocess.Popen")
525
+ @patch("ocpp.rfid.reader.subprocess.run")
526
+ @patch("ocpp.rfid.reader.RFID.register_scan")
527
+ def test_external_command_strips_trailing_percent_tokens(
528
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
529
+ ):
530
+ mock_now.return_value = timezone.now()
531
+ tag = MagicMock()
532
+ tag.pk = 3
533
+ tag.label_id = 3
534
+ tag.allowed = True
535
+ tag.external_command = "echo weird"
536
+ tag.color = "Y"
537
+ tag.released = False
538
+ tag.reference = None
539
+ tag.kind = RFID.CLASSIC
540
+ tag.endianness = RFID.BIG_ENDIAN
541
+ mock_register.return_value = (tag, False)
542
+ mock_run.return_value = types.SimpleNamespace(
543
+ returncode=0,
544
+ stdout="first %\nsecond 50%\r\nthird % %\n",
545
+ stderr="oops %\n",
546
+ )
547
+
548
+ result = validate_rfid_value("abc3")
549
+
550
+ output = result.get("command_output")
551
+ self.assertIsNotNone(output)
552
+ self.assertEqual(
553
+ output.get("stdout"), "first\nsecond 50%\r\nthird\n"
554
+ )
555
+ self.assertEqual(output.get("stderr"), "oops\n")
556
+ self.assertEqual(output.get("returncode"), 0)
557
+ self.assertEqual(output.get("error"), "")
558
+ mock_popen.assert_not_called()
559
+
560
+ @patch("ocpp.rfid.reader.timezone.now")
561
+ @patch("ocpp.rfid.reader.notify_async")
562
+ @patch("ocpp.rfid.reader.subprocess.Popen")
563
+ @patch("ocpp.rfid.reader.subprocess.run")
564
+ @patch("ocpp.rfid.reader.RFID.register_scan")
565
+ def test_external_command_error_strips_trailing_percent_tokens(
566
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
567
+ ):
568
+ mock_now.return_value = timezone.now()
569
+ tag = MagicMock()
570
+ tag.pk = 4
571
+ tag.label_id = 4
572
+ tag.allowed = True
573
+ tag.external_command = "echo boom"
574
+ tag.color = "R"
575
+ tag.released = False
576
+ tag.reference = None
577
+ tag.kind = RFID.CLASSIC
578
+ tag.endianness = RFID.BIG_ENDIAN
579
+ mock_register.return_value = (tag, False)
580
+ mock_run.side_effect = RuntimeError("bad % %")
581
+
582
+ result = validate_rfid_value("abcd")
583
+
584
+ output = result.get("command_output")
585
+ self.assertIsInstance(output, dict)
586
+ self.assertEqual(output.get("stdout"), "")
587
+ self.assertEqual(output.get("stderr"), "")
588
+ self.assertEqual(output.get("error"), "bad")
589
+ self.assertFalse(result["allowed"])
590
+ mock_popen.assert_not_called()
591
+
522
592
  @patch("ocpp.rfid.reader.timezone.now")
523
593
  @patch("ocpp.rfid.reader.notify_async")
524
594
  @patch("ocpp.rfid.reader.subprocess.Popen")
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")
@@ -2949,6 +2962,44 @@ class SimulatorAdminTests(TransactionTestCase):
2949
2962
 
2950
2963
  await communicator.disconnect()
2951
2964
 
2965
+ async def test_authorize_requires_rfid_accepts_allowed_tag_without_account(self):
2966
+ charger_id = "AUTHWARN"
2967
+ tag_value = "WARN01"
2968
+ await database_sync_to_async(Charger.objects.create)(
2969
+ charger_id=charger_id, require_rfid=True
2970
+ )
2971
+ await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
2972
+
2973
+ pending_key = store.pending_key(charger_id)
2974
+ store.clear_log(pending_key, log_type="charger")
2975
+
2976
+ communicator = WebsocketCommunicator(application, f"/{charger_id}/")
2977
+ connected, _ = await communicator.connect()
2978
+ self.assertTrue(connected)
2979
+
2980
+ message_id = "auth-unlinked"
2981
+ await communicator.send_json_to(
2982
+ [2, message_id, "Authorize", {"idTag": tag_value}]
2983
+ )
2984
+ response = await communicator.receive_json_from()
2985
+ self.assertEqual(response[0], 3)
2986
+ self.assertEqual(response[1], message_id)
2987
+ self.assertEqual(response[2], {"idTagInfo": {"status": "Accepted"}})
2988
+
2989
+ log_entries = store.get_logs(pending_key, log_type="charger")
2990
+ self.assertTrue(
2991
+ any(
2992
+ "Authorized RFID" in entry
2993
+ and tag_value in entry
2994
+ and charger_id in entry
2995
+ for entry in log_entries
2996
+ ),
2997
+ log_entries,
2998
+ )
2999
+
3000
+ await communicator.disconnect()
3001
+ store.clear_log(pending_key, log_type="charger")
3002
+
2952
3003
  async def test_authorize_without_requirement_records_rfid(self):
2953
3004
  await database_sync_to_async(Charger.objects.create)(
2954
3005
  charger_id="AUTHOPT", require_rfid=False
@@ -3041,6 +3092,61 @@ class SimulatorAdminTests(TransactionTestCase):
3041
3092
  )
3042
3093
  self.assertEqual(tx.account_id, user.energy_account.id)
3043
3094
 
3095
+ async def test_start_transaction_allows_allowed_tag_without_account(self):
3096
+ charger_id = "STARTWARN"
3097
+ tag_value = "WARN02"
3098
+ await database_sync_to_async(Charger.objects.create)(
3099
+ charger_id=charger_id, require_rfid=True
3100
+ )
3101
+ await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
3102
+
3103
+ pending_key = store.pending_key(charger_id)
3104
+ store.clear_log(pending_key, log_type="charger")
3105
+
3106
+ communicator = WebsocketCommunicator(application, f"/{charger_id}/")
3107
+ connected, _ = await communicator.connect()
3108
+ self.assertTrue(connected)
3109
+
3110
+ start_payload = {
3111
+ "meterStart": 5,
3112
+ "idTag": tag_value,
3113
+ "connectorId": 1,
3114
+ }
3115
+ await communicator.send_json_to([2, "start-1", "StartTransaction", start_payload])
3116
+ response = await communicator.receive_json_from()
3117
+ self.assertEqual(response[0], 3)
3118
+ self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
3119
+ tx_id = response[2]["transactionId"]
3120
+
3121
+ tx = await database_sync_to_async(Transaction.objects.get)(
3122
+ pk=tx_id, charger__charger_id=charger_id
3123
+ )
3124
+ self.assertIsNone(tx.account_id)
3125
+
3126
+ log_entries = store.get_logs(pending_key, log_type="charger")
3127
+ self.assertTrue(
3128
+ any(
3129
+ "Authorized RFID" in entry
3130
+ and tag_value in entry
3131
+ and charger_id in entry
3132
+ for entry in log_entries
3133
+ ),
3134
+ log_entries,
3135
+ )
3136
+
3137
+ await communicator.send_json_to(
3138
+ [
3139
+ 2,
3140
+ "stop-1",
3141
+ "StopTransaction",
3142
+ {"transactionId": tx_id, "meterStop": 6},
3143
+ ]
3144
+ )
3145
+ await communicator.receive_json_from()
3146
+
3147
+ await communicator.disconnect()
3148
+ store.clear_log(pending_key, log_type="charger")
3149
+
3044
3150
  async def test_status_fields_updated(self):
3045
3151
  communicator = WebsocketCommunicator(application, "/STAT/")
3046
3152
  connected, _ = await communicator.connect()
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,17 +1235,65 @@ def charger_log_page(request, cid, connector=None):
1209
1235
  charger_id=cid
1210
1236
  )
1211
1237
  target_id = cid
1212
- log = store.get_logs(target_id, log_type=log_type)
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"
1242
+ limit_options = [
1243
+ {"value": "20", "label": "20"},
1244
+ {"value": "40", "label": "40"},
1245
+ {"value": "100", "label": "100"},
1246
+ {"value": "all", "label": gettext("All")},
1247
+ ]
1248
+ allowed_values = [item["value"] for item in limit_options]
1249
+ limit_choice = request.GET.get("limit", "20")
1250
+ if limit_choice not in allowed_values:
1251
+ limit_choice = "20"
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
1265
+ if limit_choice != "all":
1266
+ try:
1267
+ limit_value = int(limit_choice)
1268
+ except (TypeError, ValueError):
1269
+ limit_value = 20
1270
+ limit_choice = "20"
1271
+ limit_index = allowed_values.index(limit_choice)
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"]
1213
1281
  return render(
1214
1282
  request,
1215
1283
  "ocpp/charger_logs.html",
1216
1284
  {
1217
1285
  "charger": charger,
1218
- "log": log,
1286
+ "log": log_entries,
1219
1287
  "log_type": log_type,
1220
1288
  "connector_slug": connector_slug,
1221
1289
  "connector_links": connector_links,
1222
1290
  "status_url": status_url,
1291
+ "log_limit_options": limit_options,
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,
1223
1297
  },
1224
1298
  )
1225
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
@@ -6,12 +7,14 @@ from django.contrib.sites.admin import SiteAdmin as DjangoSiteAdmin
6
7
  from django.contrib.sites.models import Site
7
8
  from django import forms
8
9
  from django.shortcuts import redirect, render, get_object_or_404
9
- from django.urls import path, reverse
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
17
+ from django.core.exceptions import FieldError
15
18
  from django.db.models.functions import TruncDate
16
19
  from datetime import datetime, time, timedelta
17
20
  import ipaddress
@@ -25,6 +28,7 @@ from nodes.utils import capture_screenshot, save_screenshot
25
28
 
26
29
  from .forms import UserManualAdminForm
27
30
  from .module_defaults import reload_default_modules as restore_default_modules
31
+ from .site_config import ensure_site_fields
28
32
  from .utils import landing_leads_supported
29
33
 
30
34
  from .models import (
@@ -41,6 +45,7 @@ from .models import (
41
45
  UserStory,
42
46
  )
43
47
  from django.contrib.contenttypes.models import ContentType
48
+ from core.models import ReleaseManager
44
49
  from core.user_data import EntityModelAdmin
45
50
 
46
51
 
@@ -73,12 +78,47 @@ class SiteForm(forms.ModelForm):
73
78
  fields = "__all__"
74
79
 
75
80
 
81
+ ensure_site_fields()
82
+
83
+
84
+ class _BooleanAttributeListFilter(admin.SimpleListFilter):
85
+ """Filter helper for boolean attributes on :class:`~django.contrib.sites.models.Site`."""
86
+
87
+ field_name: str
88
+
89
+ def lookups(self, request, model_admin): # pragma: no cover - admin UI
90
+ return (("1", _("Yes")), ("0", _("No")))
91
+
92
+ def queryset(self, request, queryset):
93
+ value = self.value()
94
+ if value not in {"0", "1"}:
95
+ return queryset
96
+ expected = value == "1"
97
+ try:
98
+ return queryset.filter(**{self.field_name: expected})
99
+ except FieldError: # pragma: no cover - defensive when fields missing
100
+ return queryset
101
+
102
+
103
+ class ManagedSiteListFilter(_BooleanAttributeListFilter):
104
+ title = _("Managed by local NGINX")
105
+ parameter_name = "managed"
106
+ field_name = "managed"
107
+
108
+
109
+ class RequireHttpsListFilter(_BooleanAttributeListFilter):
110
+ title = _("Require HTTPS")
111
+ parameter_name = "require_https"
112
+ field_name = "require_https"
113
+
114
+
76
115
  class SiteAdmin(DjangoSiteAdmin):
77
116
  form = SiteForm
78
117
  inlines = [SiteBadgeInline]
79
118
  change_list_template = "admin/sites/site/change_list.html"
80
- fields = ("domain", "name")
81
- list_display = ("domain", "name")
119
+ fields = ("domain", "name", "managed", "require_https")
120
+ list_display = ("domain", "name", "managed", "require_https")
121
+ list_filter = (ManagedSiteListFilter, RequireHttpsListFilter)
82
122
  actions = ["capture_screenshot"]
83
123
 
84
124
  @admin.action(description="Capture screenshot")
@@ -110,6 +150,27 @@ class SiteAdmin(DjangoSiteAdmin):
110
150
  messages.INFO,
111
151
  )
112
152
 
153
+ def save_model(self, request, obj, form, change):
154
+ super().save_model(request, obj, form, change)
155
+ if {"managed", "require_https"} & set(form.changed_data or []):
156
+ self.message_user(
157
+ request,
158
+ _(
159
+ "Managed NGINX configuration staged. Run network-setup.sh to apply changes."
160
+ ),
161
+ messages.INFO,
162
+ )
163
+
164
+ def delete_model(self, request, obj):
165
+ super().delete_model(request, obj)
166
+ self.message_user(
167
+ request,
168
+ _(
169
+ "Managed NGINX configuration staged. Run network-setup.sh to apply changes."
170
+ ),
171
+ messages.INFO,
172
+ )
173
+
113
174
  def _reload_site_fixtures(self, request):
114
175
  fixtures_dir = Path(settings.BASE_DIR) / "core" / "fixtures"
115
176
  fixture_paths = sorted(fixtures_dir.glob("references__00_site_*.json"))
@@ -230,14 +291,19 @@ class ApplicationAdmin(EntityModelAdmin):
230
291
  class LandingInline(admin.TabularInline):
231
292
  model = Landing
232
293
  extra = 0
233
- fields = ("path", "label", "enabled")
294
+ fields = ("path", "label", "enabled", "track_leads")
234
295
  show_change_link = True
235
296
 
236
297
 
237
298
  @admin.register(Landing)
238
299
  class LandingAdmin(EntityModelAdmin):
239
- list_display = ("label", "path", "module", "enabled")
240
- 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
+ )
241
307
  search_fields = (
242
308
  "label",
243
309
  "path",
@@ -246,7 +312,7 @@ class LandingAdmin(EntityModelAdmin):
246
312
  "module__application__name",
247
313
  "module__node_role__name",
248
314
  )
249
- fields = ("module", "path", "label", "enabled", "description")
315
+ fields = ("module", "path", "label", "enabled", "track_leads", "description")
250
316
  list_select_related = ("module", "module__application", "module__node_role")
251
317
 
252
318
 
@@ -708,10 +774,33 @@ class UserStoryAdmin(EntityModelAdmin):
708
774
  issue_url = story.create_github_issue()
709
775
  except Exception as exc: # pragma: no cover - network/runtime errors
710
776
  logger.exception("Failed to create GitHub issue for UserStory %s", story.pk)
777
+ message = _("Unable to create a GitHub issue for %(story)s: %(error)s") % {
778
+ "story": story,
779
+ "error": exc,
780
+ }
781
+
782
+ if (
783
+ isinstance(exc, RuntimeError)
784
+ and "GitHub token is not configured" in str(exc)
785
+ ):
786
+ try:
787
+ opts = ReleaseManager._meta
788
+ config_url = reverse(
789
+ f"{self.admin_site.name}:{opts.app_label}_{opts.model_name}_changelist"
790
+ )
791
+ except NoReverseMatch: # pragma: no cover - defensive guard
792
+ config_url = None
793
+ if config_url:
794
+ message = format_html(
795
+ "{} <a href=\"{}\">{}</a>",
796
+ message,
797
+ config_url,
798
+ _("Configure GitHub credentials."),
799
+ )
800
+
711
801
  self.message_user(
712
802
  request,
713
- _("Unable to create a GitHub issue for %(story)s: %(error)s")
714
- % {"story": story, "error": exc},
803
+ message,
715
804
  messages.ERROR,
716
805
  )
717
806
  continue
@@ -796,6 +885,13 @@ def favorite_clear(request):
796
885
  return redirect("admin:favorite_list")
797
886
 
798
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
+
799
895
  def log_viewer(request):
800
896
  logs_dir = Path(settings.BASE_DIR) / "logs"
801
897
  logs_exist = logs_dir.exists() and logs_dir.is_dir()
@@ -813,16 +909,50 @@ def log_viewer(request):
813
909
  selected_log = request.GET.get("log", "")
814
910
  log_content = ""
815
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"
816
924
 
817
925
  if selected_log:
818
926
  if selected_log in available_logs:
819
927
  selected_path = logs_dir / selected_log
820
928
  try:
821
- log_content = selected_path.read_text(encoding="utf-8")
822
- except UnicodeDecodeError:
823
- log_content = selected_path.read_text(
824
- encoding="utf-8", errors="replace"
825
- )
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))
826
956
  except OSError as exc: # pragma: no cover - filesystem edge cases
827
957
  logger.warning("Unable to read log file %s", selected_path, exc_info=exc)
828
958
  log_error = _(
@@ -840,6 +970,7 @@ def log_viewer(request):
840
970
  else:
841
971
  log_notice = ""
842
972
 
973
+ limit_label = limit_options[limit_index]["label"]
843
974
  context = {**admin.site.each_context(request)}
844
975
  context.update(
845
976
  {
@@ -850,6 +981,10 @@ def log_viewer(request):
850
981
  "log_error": log_error,
851
982
  "log_notice": log_notice,
852
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,
853
988
  }
854
989
  )
855
990
  return TemplateResponse(request, "admin/log_viewer.html", context)
pages/apps.py CHANGED
@@ -8,3 +8,6 @@ class PagesConfig(AppConfig):
8
8
 
9
9
  def ready(self): # pragma: no cover - import for side effects
10
10
  from . import checks # noqa: F401
11
+ from . import site_config
12
+
13
+ site_config.ready()
@@ -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