arthexis 0.1.20__py3-none-any.whl → 0.1.22__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/views.py CHANGED
@@ -1,12 +1,13 @@
1
1
  import json
2
2
  import uuid
3
- from datetime import datetime, timedelta, timezone as dt_timezone
3
+ from datetime import datetime, time, timedelta, timezone as dt_timezone
4
4
  from types import SimpleNamespace
5
5
 
6
6
  from django.http import Http404, HttpResponse, JsonResponse
7
7
  from django.http.request import split_domain_port
8
8
  from django.views.decorators.csrf import csrf_exempt
9
9
  from django.shortcuts import get_object_or_404, render, resolve_url
10
+ from django.template.loader import render_to_string
10
11
  from django.core.paginator import Paginator
11
12
  from django.contrib.auth.decorators import login_required
12
13
  from django.contrib.auth.views import redirect_to_login
@@ -14,7 +15,7 @@ from django.utils.translation import gettext_lazy as _, gettext, ngettext
14
15
  from django.utils.text import slugify
15
16
  from django.urls import NoReverseMatch, reverse
16
17
  from django.conf import settings
17
- from django.utils import translation, timezone
18
+ from django.utils import translation, timezone, formats
18
19
  from django.core.exceptions import ValidationError
19
20
 
20
21
  from asgiref.sync import async_to_sync
@@ -26,6 +27,8 @@ from nodes.models import Node
26
27
  from pages.utils import landing
27
28
  from core.liveupdate import live_update
28
29
 
30
+ from django.utils.dateparse import parse_datetime
31
+
29
32
  from . import store
30
33
  from .models import Transaction, Charger, DataTransferMessage, RFID
31
34
  from .evcs import (
@@ -336,6 +339,272 @@ def _connector_overview(
336
339
  return overview
337
340
 
338
341
 
342
+ def _normalize_timeline_status(value: str | None) -> str | None:
343
+ """Normalize raw charger status strings into timeline buckets."""
344
+
345
+ normalized = (value or "").strip().lower()
346
+ if not normalized:
347
+ return None
348
+ charging_states = {
349
+ "charging",
350
+ "finishing",
351
+ "suspendedev",
352
+ "suspendedevse",
353
+ "occupied",
354
+ }
355
+ available_states = {"available", "preparing", "reserved"}
356
+ offline_states = {"faulted", "unavailable", "outofservice"}
357
+ if normalized in charging_states:
358
+ return "charging"
359
+ if normalized in offline_states:
360
+ return "offline"
361
+ if normalized in available_states:
362
+ return "available"
363
+ # Treat other states as available for the initial implementation.
364
+ return "available"
365
+
366
+
367
+ def _timeline_labels() -> dict[str, str]:
368
+ """Return translated labels for timeline statuses."""
369
+
370
+ return {
371
+ "offline": gettext("Offline"),
372
+ "available": gettext("Available"),
373
+ "charging": gettext("Charging"),
374
+ }
375
+
376
+
377
+ def _format_segment_range(start: datetime, end: datetime) -> tuple[str, str]:
378
+ """Return localized display values for a timeline range."""
379
+
380
+ start_display = formats.date_format(
381
+ timezone.localtime(start), "SHORT_DATETIME_FORMAT"
382
+ )
383
+ end_display = formats.date_format(timezone.localtime(end), "SHORT_DATETIME_FORMAT")
384
+ return start_display, end_display
385
+
386
+
387
+ def _collect_status_events(
388
+ charger: Charger,
389
+ connector: Charger,
390
+ window_start: datetime,
391
+ window_end: datetime,
392
+ ) -> tuple[list[tuple[datetime, str]], tuple[datetime, str] | None]:
393
+ """Parse log entries into ordered status events for the connector."""
394
+
395
+ connector_id = connector.connector_id
396
+ serial = connector.charger_id
397
+ keys = [store.identity_key(serial, connector_id)]
398
+ if connector_id is not None:
399
+ keys.append(store.identity_key(serial, None))
400
+ keys.append(store.pending_key(serial))
401
+
402
+ seen_entries: set[str] = set()
403
+ events: list[tuple[datetime, str]] = []
404
+ latest_before_window: tuple[datetime, str] | None = None
405
+
406
+ for key in keys:
407
+ for entry in store.get_logs(key, log_type="charger"):
408
+ if entry in seen_entries:
409
+ continue
410
+ seen_entries.add(entry)
411
+ if len(entry) < 24:
412
+ continue
413
+ timestamp_raw = entry[:23]
414
+ message = entry[24:].strip()
415
+ try:
416
+ log_timestamp = datetime.strptime(
417
+ timestamp_raw, "%Y-%m-%d %H:%M:%S.%f"
418
+ ).replace(tzinfo=dt_timezone.utc)
419
+ except ValueError:
420
+ continue
421
+
422
+ event_time = log_timestamp
423
+ status_bucket: str | None = None
424
+
425
+ if message.startswith("StatusNotification processed:"):
426
+ payload_text = message.split(":", 1)[1].strip()
427
+ try:
428
+ payload = json.loads(payload_text)
429
+ except json.JSONDecodeError:
430
+ continue
431
+ target_id = payload.get("connectorId")
432
+ if connector_id is not None:
433
+ try:
434
+ normalized_target = int(target_id)
435
+ except (TypeError, ValueError):
436
+ normalized_target = None
437
+ if normalized_target not in {connector_id, None}:
438
+ continue
439
+ raw_status = payload.get("status")
440
+ status_bucket = _normalize_timeline_status(
441
+ raw_status if isinstance(raw_status, str) else None
442
+ )
443
+ payload_timestamp = payload.get("timestamp")
444
+ if isinstance(payload_timestamp, str):
445
+ parsed = parse_datetime(payload_timestamp)
446
+ if parsed is not None:
447
+ if timezone.is_naive(parsed):
448
+ parsed = timezone.make_aware(parsed, timezone=dt_timezone.utc)
449
+ event_time = parsed
450
+ elif message.startswith("Connected"):
451
+ status_bucket = "available"
452
+ elif message.startswith("Closed"):
453
+ status_bucket = "offline"
454
+
455
+ if not status_bucket:
456
+ continue
457
+
458
+ if event_time < window_start:
459
+ if (
460
+ latest_before_window is None
461
+ or event_time > latest_before_window[0]
462
+ ):
463
+ latest_before_window = (event_time, status_bucket)
464
+ continue
465
+ if event_time > window_end:
466
+ continue
467
+ events.append((event_time, status_bucket))
468
+
469
+ events.sort(key=lambda item: item[0])
470
+
471
+ deduped_events: list[tuple[datetime, str]] = []
472
+ for event_time, state in events:
473
+ if deduped_events and deduped_events[-1][1] == state:
474
+ continue
475
+ deduped_events.append((event_time, state))
476
+
477
+ return deduped_events, latest_before_window
478
+
479
+
480
+ def _usage_timeline(
481
+ charger: Charger,
482
+ connector_overview: list[dict],
483
+ *,
484
+ now: datetime | None = None,
485
+ ) -> tuple[list[dict], tuple[str, str] | None]:
486
+ """Build usage timeline data for inactive chargers."""
487
+
488
+ if now is None:
489
+ now = timezone.now()
490
+ window_end = now
491
+ window_start = now - timedelta(days=7)
492
+
493
+ if charger.connector_id is not None:
494
+ connectors = [charger]
495
+ else:
496
+ connectors = [
497
+ item["charger"]
498
+ for item in connector_overview
499
+ if item.get("charger") and item["charger"].connector_id is not None
500
+ ]
501
+ if not connectors:
502
+ connectors = [
503
+ sibling
504
+ for sibling in _connector_set(charger)
505
+ if sibling.connector_id is not None
506
+ ]
507
+
508
+ seen_ids: set[int] = set()
509
+ labels = _timeline_labels()
510
+ timeline_entries: list[dict] = []
511
+ window_display: tuple[str, str] | None = None
512
+
513
+ if window_start < window_end:
514
+ window_display = _format_segment_range(window_start, window_end)
515
+
516
+ for connector in connectors:
517
+ if connector.connector_id is None:
518
+ continue
519
+ if connector.connector_id in seen_ids:
520
+ continue
521
+ seen_ids.add(connector.connector_id)
522
+
523
+ events, prior_event = _collect_status_events(
524
+ charger, connector, window_start, window_end
525
+ )
526
+ fallback_state = _normalize_timeline_status(connector.last_status)
527
+ if fallback_state is None:
528
+ fallback_state = (
529
+ "available"
530
+ if store.is_connected(connector.charger_id, connector.connector_id)
531
+ else "offline"
532
+ )
533
+ current_state = fallback_state
534
+ if prior_event is not None:
535
+ current_state = prior_event[1]
536
+ segments: list[dict] = []
537
+ previous_time = window_start
538
+ total_seconds = (window_end - window_start).total_seconds()
539
+
540
+ for event_time, state in events:
541
+ if event_time <= window_start:
542
+ current_state = state
543
+ continue
544
+ if event_time > window_end:
545
+ break
546
+ if state == current_state:
547
+ continue
548
+ segment_start = max(previous_time, window_start)
549
+ segment_end = min(event_time, window_end)
550
+ if segment_end > segment_start:
551
+ duration = (segment_end - segment_start).total_seconds()
552
+ start_display, end_display = _format_segment_range(
553
+ segment_start, segment_end
554
+ )
555
+ segments.append(
556
+ {
557
+ "status": current_state,
558
+ "label": labels.get(current_state, current_state.title()),
559
+ "start_display": start_display,
560
+ "end_display": end_display,
561
+ "duration": max(duration, 1.0),
562
+ }
563
+ )
564
+ current_state = state
565
+ previous_time = max(event_time, window_start)
566
+
567
+ if previous_time < window_end:
568
+ segment_start = max(previous_time, window_start)
569
+ segment_end = window_end
570
+ if segment_end > segment_start:
571
+ duration = (segment_end - segment_start).total_seconds()
572
+ start_display, end_display = _format_segment_range(
573
+ segment_start, segment_end
574
+ )
575
+ segments.append(
576
+ {
577
+ "status": current_state,
578
+ "label": labels.get(current_state, current_state.title()),
579
+ "start_display": start_display,
580
+ "end_display": end_display,
581
+ "duration": max(duration, 1.0),
582
+ }
583
+ )
584
+
585
+ if not segments and total_seconds > 0:
586
+ start_display, end_display = _format_segment_range(window_start, window_end)
587
+ segments.append(
588
+ {
589
+ "status": current_state,
590
+ "label": labels.get(current_state, current_state.title()),
591
+ "start_display": start_display,
592
+ "end_display": end_display,
593
+ "duration": max(total_seconds, 1.0),
594
+ }
595
+ )
596
+
597
+ if segments:
598
+ timeline_entries.append(
599
+ {
600
+ "label": connector.connector_label,
601
+ "segments": segments,
602
+ }
603
+ )
604
+
605
+ return timeline_entries, window_display
606
+
607
+
339
608
  def _live_sessions(charger: Charger) -> list[tuple[Charger, Transaction]]:
340
609
  """Return active sessions grouped by connector for the charger."""
341
610
 
@@ -752,8 +1021,48 @@ def dashboard(request):
752
1021
  request.get_full_path(), login_url=reverse("pages:login")
753
1022
  )
754
1023
  is_watchtower = role_name in {"Watchtower", "Constellation"}
755
- chargers = []
756
- for charger in _visible_chargers(request.user):
1024
+ visible_chargers = (
1025
+ _visible_chargers(request.user)
1026
+ .select_related("location")
1027
+ .order_by("charger_id", "connector_id")
1028
+ )
1029
+ stats_cache: dict[int, dict[str, float]] = {}
1030
+
1031
+ def _charger_display_name(charger: Charger) -> str:
1032
+ if charger.display_name:
1033
+ return charger.display_name
1034
+ if charger.location:
1035
+ return charger.location.name
1036
+ return charger.charger_id
1037
+
1038
+ today = timezone.localdate()
1039
+ tz = timezone.get_current_timezone()
1040
+ day_start = datetime.combine(today, time.min)
1041
+ if timezone.is_naive(day_start):
1042
+ day_start = timezone.make_aware(day_start, tz)
1043
+ day_end = day_start + timedelta(days=1)
1044
+
1045
+ def _charger_stats(charger: Charger) -> dict[str, float]:
1046
+ cache_key = charger.pk or id(charger)
1047
+ if cache_key not in stats_cache:
1048
+ stats_cache[cache_key] = {
1049
+ "total_kw": charger.total_kw,
1050
+ "today_kw": charger.total_kw_for_range(day_start, day_end),
1051
+ }
1052
+ return stats_cache[cache_key]
1053
+
1054
+ def _status_url(charger: Charger) -> str:
1055
+ return _reverse_connector_url(
1056
+ "charger-status",
1057
+ charger.charger_id,
1058
+ charger.connector_slug,
1059
+ )
1060
+
1061
+ chargers: list[dict[str, object]] = []
1062
+ charger_groups: list[dict[str, object]] = []
1063
+ group_lookup: dict[str, dict[str, object]] = {}
1064
+
1065
+ for charger in visible_chargers:
757
1066
  tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
758
1067
  if not tx_obj:
759
1068
  tx_obj = (
@@ -761,17 +1070,63 @@ def dashboard(request):
761
1070
  .order_by("-start_time")
762
1071
  .first()
763
1072
  )
1073
+ has_session = _has_active_session(tx_obj)
764
1074
  state, color = _charger_state(charger, tx_obj)
765
- chargers.append({"charger": charger, "state": state, "color": color})
1075
+ if (
1076
+ charger.connector_id is not None
1077
+ and not has_session
1078
+ and (charger.last_status or "").strip().casefold() == "charging"
1079
+ ):
1080
+ state, color = STATUS_BADGE_MAP["charging"]
1081
+ entry = {
1082
+ "charger": charger,
1083
+ "state": state,
1084
+ "color": color,
1085
+ "display_name": _charger_display_name(charger),
1086
+ "stats": _charger_stats(charger),
1087
+ "status_url": _status_url(charger),
1088
+ }
1089
+ chargers.append(entry)
1090
+ if charger.connector_id is None:
1091
+ group = {"parent": entry, "children": []}
1092
+ charger_groups.append(group)
1093
+ group_lookup[charger.charger_id] = group
1094
+ else:
1095
+ group = group_lookup.get(charger.charger_id)
1096
+ if group is None:
1097
+ group = {"parent": None, "children": []}
1098
+ charger_groups.append(group)
1099
+ group_lookup[charger.charger_id] = group
1100
+ group["children"].append(entry)
1101
+
1102
+ for group in charger_groups:
1103
+ parent_entry = group.get("parent")
1104
+ if not parent_entry or not group["children"]:
1105
+ continue
1106
+ connector_statuses = [
1107
+ (child["charger"].last_status or "").strip().casefold()
1108
+ for child in group["children"]
1109
+ if child["charger"].connector_id is not None
1110
+ ]
1111
+ if connector_statuses and all(status == "charging" for status in connector_statuses):
1112
+ label, badge_color = STATUS_BADGE_MAP["charging"]
1113
+ parent_entry["state"] = label
1114
+ parent_entry["color"] = badge_color
766
1115
  scheme = "wss" if request.is_secure() else "ws"
767
1116
  host = request.get_host()
768
1117
  ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
769
1118
  context = {
770
1119
  "chargers": chargers,
1120
+ "charger_groups": charger_groups,
771
1121
  "show_demo_notice": is_watchtower,
772
1122
  "demo_ws_url": ws_url,
773
1123
  "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
774
1124
  }
1125
+ if request.headers.get("x-requested-with") == "XMLHttpRequest" or request.GET.get("partial") == "table":
1126
+ html = render_to_string(
1127
+ "ocpp/includes/dashboard_table_rows.html", context, request=request
1128
+ )
1129
+ return JsonResponse({"html": html})
775
1130
  return render(request, "ocpp/dashboard.html", context)
776
1131
 
777
1132
 
@@ -1132,6 +1487,9 @@ def charger_status(request, cid, connector=None):
1132
1487
  connector_overview = [
1133
1488
  item for item in overview if item["charger"].connector_id is not None
1134
1489
  ]
1490
+ usage_timeline, usage_timeline_window = _usage_timeline(
1491
+ charger, connector_overview
1492
+ )
1135
1493
  search_url = _reverse_connector_url("charger-session-search", cid, connector_slug)
1136
1494
  configuration_url = None
1137
1495
  if request.user.is_staff:
@@ -1198,6 +1556,8 @@ def charger_status(request, cid, connector=None):
1198
1556
  "pagination_query": pagination_query,
1199
1557
  "session_query": session_query,
1200
1558
  "chart_should_animate": chart_should_animate,
1559
+ "usage_timeline": usage_timeline,
1560
+ "usage_timeline_window": usage_timeline_window,
1201
1561
  },
1202
1562
  )
1203
1563
 
pages/admin.py CHANGED
@@ -595,7 +595,23 @@ class ViewHistoryAdmin(EntityModelAdmin):
595
595
  )
596
596
 
597
597
  def traffic_data_view(self, request):
598
- return JsonResponse(self._build_chart_data())
598
+ return JsonResponse(
599
+ self._build_chart_data(days=self._resolve_requested_days(request))
600
+ )
601
+
602
+ def _resolve_requested_days(self, request, default: int = 30) -> int:
603
+ raw_value = request.GET.get("days")
604
+ if raw_value in (None, ""):
605
+ return default
606
+
607
+ try:
608
+ days = int(raw_value)
609
+ except (TypeError, ValueError):
610
+ return default
611
+
612
+ minimum = 1
613
+ maximum = 90
614
+ return max(minimum, min(days, maximum))
599
615
 
600
616
  def _build_chart_data(self, days: int = 30, max_pages: int = 8) -> dict:
601
617
  end_date = timezone.localdate()
@@ -689,11 +705,13 @@ class UserStoryAdmin(EntityModelAdmin):
689
705
  actions = ["create_github_issues"]
690
706
  list_display = (
691
707
  "name",
708
+ "language_code",
692
709
  "rating",
693
710
  "path",
694
711
  "status",
695
712
  "submitted_at",
696
713
  "github_issue_display",
714
+ "screenshot_display",
697
715
  "take_screenshot",
698
716
  "owner",
699
717
  "assign_to",
@@ -703,6 +721,7 @@ class UserStoryAdmin(EntityModelAdmin):
703
721
  "name",
704
722
  "comments",
705
723
  "path",
724
+ "language_code",
706
725
  "referer",
707
726
  "github_issue_url",
708
727
  "ip_address",
@@ -715,6 +734,7 @@ class UserStoryAdmin(EntityModelAdmin):
715
734
  "path",
716
735
  "user",
717
736
  "owner",
737
+ "language_code",
718
738
  "referer",
719
739
  "user_agent",
720
740
  "ip_address",
@@ -722,6 +742,7 @@ class UserStoryAdmin(EntityModelAdmin):
722
742
  "submitted_at",
723
743
  "github_issue_number",
724
744
  "github_issue_url",
745
+ "screenshot_display",
725
746
  )
726
747
  ordering = ("-submitted_at",)
727
748
  fields = (
@@ -729,7 +750,9 @@ class UserStoryAdmin(EntityModelAdmin):
729
750
  "rating",
730
751
  "comments",
731
752
  "take_screenshot",
753
+ "screenshot_display",
732
754
  "path",
755
+ "language_code",
733
756
  "user",
734
757
  "owner",
735
758
  "status",
@@ -758,6 +781,21 @@ class UserStoryAdmin(EntityModelAdmin):
758
781
  )
759
782
  if obj.github_issue_number is not None:
760
783
  return f"#{obj.github_issue_number}"
784
+ return ""
785
+
786
+ @admin.display(description=_("Screenshot"), ordering="screenshot")
787
+ def screenshot_display(self, obj):
788
+ if not obj.screenshot_id:
789
+ return ""
790
+ try:
791
+ url = reverse("admin:nodes_contentsample_change", args=[obj.screenshot_id])
792
+ except NoReverseMatch:
793
+ return obj.screenshot.path
794
+ return format_html(
795
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
796
+ url,
797
+ _("View screenshot"),
798
+ )
761
799
  return _("Not created")
762
800
 
763
801
  @admin.action(description=_("Create GitHub issues"))
@@ -842,34 +880,93 @@ def favorite_toggle(request, ct_id):
842
880
  ct = get_object_or_404(ContentType, pk=ct_id)
843
881
  fav = Favorite.objects.filter(user=request.user, content_type=ct).first()
844
882
  next_url = request.GET.get("next")
845
- if fav:
846
- return redirect(next_url or "admin:favorite_list")
847
883
  if request.method == "POST":
884
+ if fav and request.POST.get("remove"):
885
+ fav.delete()
886
+ return redirect(next_url or "admin:index")
848
887
  label = request.POST.get("custom_label", "").strip()
849
888
  user_data = request.POST.get("user_data") == "on"
850
- Favorite.objects.create(
851
- user=request.user,
852
- content_type=ct,
853
- custom_label=label,
854
- user_data=user_data,
855
- )
889
+ priority_raw = request.POST.get("priority", "").strip()
890
+ if fav:
891
+ default_priority = fav.priority
892
+ else:
893
+ default_priority = 0
894
+ if priority_raw:
895
+ try:
896
+ priority = int(priority_raw)
897
+ except (TypeError, ValueError):
898
+ priority = default_priority
899
+ else:
900
+ priority = default_priority
901
+
902
+ if fav:
903
+ update_fields = []
904
+ if fav.custom_label != label:
905
+ fav.custom_label = label
906
+ update_fields.append("custom_label")
907
+ if fav.user_data != user_data:
908
+ fav.user_data = user_data
909
+ update_fields.append("user_data")
910
+ if fav.priority != priority:
911
+ fav.priority = priority
912
+ update_fields.append("priority")
913
+ if update_fields:
914
+ fav.save(update_fields=update_fields)
915
+ else:
916
+ Favorite.objects.create(
917
+ user=request.user,
918
+ content_type=ct,
919
+ custom_label=label,
920
+ user_data=user_data,
921
+ priority=priority,
922
+ )
856
923
  return redirect(next_url or "admin:index")
857
924
  return render(
858
925
  request,
859
926
  "admin/favorite_confirm.html",
860
- {"content_type": ct, "next": next_url},
927
+ {
928
+ "content_type": ct,
929
+ "favorite": fav,
930
+ "next": next_url,
931
+ "initial_label": fav.custom_label if fav else "",
932
+ "initial_priority": fav.priority if fav else 0,
933
+ "is_checked": fav.user_data if fav else True,
934
+ },
861
935
  )
862
936
 
863
937
 
864
938
  def favorite_list(request):
865
- favorites = Favorite.objects.filter(user=request.user).select_related(
866
- "content_type"
939
+ favorites = (
940
+ Favorite.objects.filter(user=request.user)
941
+ .select_related("content_type")
942
+ .order_by("priority", "pk")
867
943
  )
868
944
  if request.method == "POST":
869
- selected = request.POST.getlist("user_data")
945
+ selected = set(request.POST.getlist("user_data"))
870
946
  for fav in favorites:
871
- fav.user_data = str(fav.pk) in selected
872
- fav.save(update_fields=["user_data"])
947
+ update_fields = []
948
+ user_selected = str(fav.pk) in selected
949
+ if fav.user_data != user_selected:
950
+ fav.user_data = user_selected
951
+ update_fields.append("user_data")
952
+
953
+ priority_raw = request.POST.get(f"priority_{fav.pk}", "").strip()
954
+ if priority_raw:
955
+ try:
956
+ priority = int(priority_raw)
957
+ except (TypeError, ValueError):
958
+ priority = fav.priority
959
+ else:
960
+ if fav.priority != priority:
961
+ fav.priority = priority
962
+ update_fields.append("priority")
963
+ else:
964
+ if fav.priority != 0:
965
+ fav.priority = 0
966
+ update_fields.append("priority")
967
+
968
+ if update_fields:
969
+ fav.save(update_fields=update_fields)
873
970
  return redirect("admin:favorite_list")
874
971
  return render(request, "admin/favorite_list.html", {"favorites": favorites})
875
972
 
pages/apps.py CHANGED
@@ -1,13 +1,45 @@
1
+ import logging
2
+
1
3
  from django.apps import AppConfig
4
+ from django.db import DatabaseError
5
+ from django.db.backends.signals import connection_created
6
+
7
+
8
+ logger = logging.getLogger(__name__)
2
9
 
3
10
 
4
11
  class PagesConfig(AppConfig):
5
12
  default_auto_field = "django.db.models.BigAutoField"
6
13
  name = "pages"
7
14
  verbose_name = "7. Experience"
15
+ _view_history_purged = False
8
16
 
9
17
  def ready(self): # pragma: no cover - import for side effects
10
18
  from . import checks # noqa: F401
11
19
  from . import site_config
12
20
 
13
21
  site_config.ready()
22
+ connection_created.connect(
23
+ self._handle_connection_created,
24
+ dispatch_uid="pages_view_history_connection_created",
25
+ weak=False,
26
+ )
27
+
28
+ def _handle_connection_created(self, sender, connection, **kwargs):
29
+ if self._view_history_purged:
30
+ return
31
+ self._view_history_purged = True
32
+ self._purge_view_history()
33
+
34
+ def _purge_view_history(self, days: int = 15) -> None:
35
+ """Remove stale :class:`pages.models.ViewHistory` entries."""
36
+
37
+ from .models import ViewHistory
38
+
39
+ try:
40
+ deleted = ViewHistory.purge_older_than(days=days)
41
+ except DatabaseError:
42
+ logger.debug("Skipping view history purge; database unavailable", exc_info=True)
43
+ else:
44
+ if deleted:
45
+ logger.info("Purged %s view history entries older than %s days", deleted, days)