arthexis 0.1.8__py3-none-any.whl → 0.1.10__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.

Files changed (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
pages/admin.py CHANGED
@@ -3,10 +3,16 @@ from django.contrib.sites.admin import SiteAdmin as DjangoSiteAdmin
3
3
  from django.contrib.sites.models import Site
4
4
  from django import forms
5
5
  from django.db import models
6
- from app.widgets import CopyColorWidget
6
+ from core.widgets import CopyColorWidget
7
7
  from django.shortcuts import redirect, render, get_object_or_404
8
8
  from django.urls import path, reverse
9
9
  from django.utils.html import format_html
10
+ from django.template.response import TemplateResponse
11
+ from django.http import JsonResponse
12
+ from django.utils import timezone
13
+ from django.db.models import Count
14
+ from django.db.models.functions import TruncDate
15
+ from datetime import timedelta
10
16
  import ipaddress
11
17
  from django.apps import apps as django_apps
12
18
  from django.conf import settings
@@ -14,8 +20,17 @@ from django.conf import settings
14
20
  from nodes.models import Node
15
21
  from nodes.utils import capture_screenshot, save_screenshot
16
22
 
17
- from .models import SiteBadge, Application, SiteProxy, Module, Landing, Favorite
23
+ from .models import (
24
+ SiteBadge,
25
+ Application,
26
+ SiteProxy,
27
+ Module,
28
+ Landing,
29
+ Favorite,
30
+ ViewHistory,
31
+ )
18
32
  from django.contrib.contenttypes.models import ContentType
33
+ from core.user_data import EntityModelAdmin
19
34
 
20
35
 
21
36
  def get_local_app_choices():
@@ -65,9 +80,7 @@ class SiteAdmin(DjangoSiteAdmin):
65
80
  self.message_user(request, f"{site.domain}: {exc}", messages.ERROR)
66
81
  continue
67
82
  if screenshot:
68
- link = reverse(
69
- "admin:nodes_contentsample_change", args=[screenshot.pk]
70
- )
83
+ link = reverse("admin:nodes_contentsample_change", args=[screenshot.pk])
71
84
  self.message_user(
72
85
  request,
73
86
  format_html(
@@ -138,7 +151,7 @@ class ApplicationModuleInline(admin.TabularInline):
138
151
 
139
152
 
140
153
  @admin.register(Application)
141
- class ApplicationAdmin(admin.ModelAdmin):
154
+ class ApplicationAdmin(EntityModelAdmin):
142
155
  form = ApplicationForm
143
156
  list_display = ("name", "app_verbose_name", "installed")
144
157
  readonly_fields = ("installed",)
@@ -160,13 +173,151 @@ class LandingInline(admin.TabularInline):
160
173
 
161
174
 
162
175
  @admin.register(Module)
163
- class ModuleAdmin(admin.ModelAdmin):
176
+ class ModuleAdmin(EntityModelAdmin):
164
177
  list_display = ("application", "node_role", "path", "menu", "is_default")
165
178
  list_filter = ("node_role", "application")
166
179
  fields = ("node_role", "application", "path", "menu", "is_default", "favicon")
167
180
  inlines = [LandingInline]
168
181
 
169
182
 
183
+ @admin.register(ViewHistory)
184
+ class ViewHistoryAdmin(EntityModelAdmin):
185
+ date_hierarchy = "visited_at"
186
+ list_display = (
187
+ "path",
188
+ "status_code",
189
+ "status_text",
190
+ "method",
191
+ "visited_at",
192
+ )
193
+ list_filter = ("method", "status_code")
194
+ search_fields = ("path", "error_message", "view_name", "status_text")
195
+ readonly_fields = (
196
+ "path",
197
+ "method",
198
+ "status_code",
199
+ "status_text",
200
+ "error_message",
201
+ "view_name",
202
+ "visited_at",
203
+ )
204
+ ordering = ("-visited_at",)
205
+ change_list_template = "admin/pages/viewhistory/change_list.html"
206
+ actions = ["view_traffic_graph"]
207
+
208
+ def has_add_permission(self, request):
209
+ return False
210
+
211
+ @admin.action(description="View traffic graph")
212
+ def view_traffic_graph(self, request, queryset):
213
+ return redirect("admin:pages_viewhistory_traffic_graph")
214
+
215
+ def get_urls(self):
216
+ urls = super().get_urls()
217
+ custom = [
218
+ path(
219
+ "traffic-graph/",
220
+ self.admin_site.admin_view(self.traffic_graph_view),
221
+ name="pages_viewhistory_traffic_graph",
222
+ ),
223
+ path(
224
+ "traffic-data/",
225
+ self.admin_site.admin_view(self.traffic_data_view),
226
+ name="pages_viewhistory_traffic_data",
227
+ ),
228
+ ]
229
+ return custom + urls
230
+
231
+ def traffic_graph_view(self, request):
232
+ context = {
233
+ **self.admin_site.each_context(request),
234
+ "opts": self.model._meta,
235
+ "title": "Public site traffic",
236
+ "chart_endpoint": reverse("admin:pages_viewhistory_traffic_data"),
237
+ }
238
+ return TemplateResponse(
239
+ request,
240
+ "admin/pages/viewhistory/traffic_graph.html",
241
+ context,
242
+ )
243
+
244
+ def traffic_data_view(self, request):
245
+ return JsonResponse(self._build_chart_data())
246
+
247
+ def _build_chart_data(self, days: int = 30, max_pages: int = 8) -> dict:
248
+ end_date = timezone.localdate()
249
+ start_date = end_date - timedelta(days=days - 1)
250
+ queryset = ViewHistory.objects.filter(
251
+ visited_at__date__range=(start_date, end_date)
252
+ )
253
+
254
+ meta = {
255
+ "start": start_date.isoformat(),
256
+ "end": end_date.isoformat(),
257
+ }
258
+
259
+ if not queryset.exists():
260
+ meta["pages"] = []
261
+ return {"labels": [], "datasets": [], "meta": meta}
262
+
263
+ top_paths = list(
264
+ queryset.values("path")
265
+ .annotate(total=Count("id"))
266
+ .order_by("-total")[:max_pages]
267
+ )
268
+ paths = [entry["path"] for entry in top_paths]
269
+ meta["pages"] = paths
270
+
271
+ labels = [
272
+ (start_date + timedelta(days=offset)).isoformat() for offset in range(days)
273
+ ]
274
+
275
+ aggregates = (
276
+ queryset.filter(path__in=paths)
277
+ .annotate(day=TruncDate("visited_at"))
278
+ .values("day", "path")
279
+ .order_by("day")
280
+ .annotate(total=Count("id"))
281
+ )
282
+
283
+ counts: dict[str, dict[str, int]] = {
284
+ path: {label: 0 for label in labels} for path in paths
285
+ }
286
+ for row in aggregates:
287
+ day = row["day"].isoformat()
288
+ path = row["path"]
289
+ if day in counts.get(path, {}):
290
+ counts[path][day] = row["total"]
291
+
292
+ palette = [
293
+ "#1f77b4",
294
+ "#ff7f0e",
295
+ "#2ca02c",
296
+ "#d62728",
297
+ "#9467bd",
298
+ "#8c564b",
299
+ "#e377c2",
300
+ "#7f7f7f",
301
+ "#bcbd22",
302
+ "#17becf",
303
+ ]
304
+ datasets = []
305
+ for index, path in enumerate(paths):
306
+ color = palette[index % len(palette)]
307
+ datasets.append(
308
+ {
309
+ "label": path,
310
+ "data": [counts[path][label] for label in labels],
311
+ "borderColor": color,
312
+ "backgroundColor": color,
313
+ "fill": False,
314
+ "tension": 0.3,
315
+ }
316
+ )
317
+
318
+ return {"labels": labels, "datasets": datasets, "meta": meta}
319
+
320
+
170
321
  def favorite_toggle(request, ct_id):
171
322
  ct = get_object_or_404(ContentType, pk=ct_id)
172
323
  fav = Favorite.objects.filter(user=request.user, content_type=ct).first()
@@ -191,7 +342,9 @@ def favorite_toggle(request, ct_id):
191
342
 
192
343
 
193
344
  def favorite_list(request):
194
- favorites = Favorite.objects.filter(user=request.user).select_related("content_type")
345
+ favorites = Favorite.objects.filter(user=request.user).select_related(
346
+ "content_type"
347
+ )
195
348
  if request.method == "POST":
196
349
  selected = request.POST.getlist("user_data")
197
350
  for fav in favorites:
@@ -212,11 +365,18 @@ def favorite_clear(request):
212
365
  return redirect("admin:favorite_list")
213
366
 
214
367
 
215
- def get_admin_urls(urls):
368
+ def get_admin_urls(original_get_urls):
216
369
  def get_urls():
370
+ urls = original_get_urls()
217
371
  my_urls = [
218
- path("favorites/<int:ct_id>/", admin.site.admin_view(favorite_toggle), name="favorite_toggle"),
219
- path("favorites/", admin.site.admin_view(favorite_list), name="favorite_list"),
372
+ path(
373
+ "favorites/<int:ct_id>/",
374
+ admin.site.admin_view(favorite_toggle),
375
+ name="favorite_toggle",
376
+ ),
377
+ path(
378
+ "favorites/", admin.site.admin_view(favorite_list), name="favorite_list"
379
+ ),
220
380
  path(
221
381
  "favorites/delete/<int:pk>/",
222
382
  admin.site.admin_view(favorite_delete),
@@ -228,9 +388,9 @@ def get_admin_urls(urls):
228
388
  name="favorite_clear",
229
389
  ),
230
390
  ]
231
- return my_urls + urls
391
+ return my_urls + original_get_urls()
232
392
 
233
393
  return get_urls
234
394
 
235
395
 
236
- admin.site.get_urls = get_admin_urls(admin.site.get_urls())
396
+ admin.site.get_urls = get_admin_urls(admin.site.get_urls)
pages/checks.py CHANGED
@@ -38,4 +38,3 @@ def landing_views_have_no_args(app_configs, **kwargs):
38
38
  if isinstance(p, URLResolver):
39
39
  _collect_checks(p, errors, p.pattern._route)
40
40
  return errors
41
-
@@ -2,12 +2,13 @@ from utils.sites import get_site
2
2
  from django.urls import Resolver404, resolve
3
3
  from django.conf import settings
4
4
  from pathlib import Path
5
+ from types import SimpleNamespace
5
6
  from nodes.models import Node
7
+ from core.models import Reference
8
+ from core.reference_utils import filter_visible_references
6
9
  from .models import Module
7
10
 
8
- _favicon_path = (
9
- Path(settings.BASE_DIR) / "pages" / "fixtures" / "data" / "favicon.txt"
10
- )
11
+ _favicon_path = Path(settings.BASE_DIR) / "pages" / "fixtures" / "data" / "favicon.txt"
11
12
  try:
12
13
  _DEFAULT_FAVICON = f"data:image/png;base64,{_favicon_path.read_text().strip()}"
13
14
  except OSError:
@@ -21,7 +22,7 @@ def nav_links(request):
21
22
  role = node.role if node else None
22
23
  if role:
23
24
  modules = (
24
- Module.objects.filter(node_role=role)
25
+ Module.objects.filter(node_role=role, is_deleted=False)
25
26
  .select_related("application")
26
27
  .prefetch_related("landings")
27
28
  )
@@ -48,12 +49,28 @@ def nav_links(request):
48
49
  continue
49
50
  landings.append(landing)
50
51
  if landings:
52
+ app_name = getattr(module.application, "name", "").lower()
53
+ if app_name == "awg":
54
+ module.menu = "Calculate"
55
+ elif app_name == "man":
56
+ module.menu = "Manuals"
51
57
  module.enabled_landings = landings
52
58
  valid_modules.append(module)
53
59
  if request.path.startswith(module.path):
54
- if current_module is None or len(module.path) > len(current_module.path):
60
+ if current_module is None or len(module.path) > len(
61
+ current_module.path
62
+ ):
55
63
  current_module = module
56
64
 
65
+ datasette_lock = Path(settings.BASE_DIR) / "locks" / "datasette.lck"
66
+ if datasette_lock.exists():
67
+ datasette_module = SimpleNamespace(
68
+ menu_label="Data",
69
+ path="/data/",
70
+ enabled_landings=[SimpleNamespace(path="/data/", label="Datasette")],
71
+ )
72
+ valid_modules.append(datasette_module)
73
+
57
74
  valid_modules.sort(key=lambda m: m.menu_label.lower())
58
75
 
59
76
  if current_module and current_module.favicon:
@@ -69,4 +86,20 @@ def nav_links(request):
69
86
  if not favicon_url:
70
87
  favicon_url = _DEFAULT_FAVICON
71
88
 
72
- return {"nav_modules": valid_modules, "favicon_url": favicon_url}
89
+ header_refs_qs = (
90
+ Reference.objects.filter(show_in_header=True)
91
+ .exclude(value="")
92
+ .prefetch_related("roles", "features", "sites")
93
+ )
94
+ header_references = filter_visible_references(
95
+ header_refs_qs,
96
+ request=request,
97
+ site=site,
98
+ node=node,
99
+ )
100
+
101
+ return {
102
+ "nav_modules": valid_modules,
103
+ "favicon_url": favicon_url,
104
+ "header_references": header_references,
105
+ }
pages/forms.py ADDED
@@ -0,0 +1,131 @@
1
+ """Forms for the pages app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django import forms
6
+ from django.contrib.auth import authenticate
7
+ from django.contrib.auth.forms import AuthenticationForm
8
+ from django.core.exceptions import ValidationError
9
+ from django.utils.translation import gettext_lazy as _
10
+ from django.views.decorators.debug import sensitive_variables
11
+
12
+
13
+ class AuthenticatorLoginForm(AuthenticationForm):
14
+ """Authentication form that supports password or authenticator codes."""
15
+
16
+ otp_token = forms.CharField(
17
+ label=_("Authenticator code"),
18
+ required=False,
19
+ widget=forms.TextInput(
20
+ attrs={
21
+ "autocomplete": "one-time-code",
22
+ "inputmode": "numeric",
23
+ "pattern": "[0-9]*",
24
+ }
25
+ ),
26
+ )
27
+ auth_method = forms.CharField(required=False, widget=forms.HiddenInput(), initial="password")
28
+
29
+ error_messages = {
30
+ **AuthenticationForm.error_messages,
31
+ "invalid_token": _("The authenticator code is invalid or has expired."),
32
+ "token_required": _("Enter the code from your authenticator app."),
33
+ "password_required": _("Enter your password."),
34
+ }
35
+
36
+ def __init__(self, request=None, *args, **kwargs):
37
+ super().__init__(request=request, *args, **kwargs)
38
+ self.fields["password"].required = False
39
+ self.fields["otp_token"].strip = True
40
+ self.fields["auth_method"].initial = "password"
41
+ self.verified_device = None
42
+
43
+ def get_invalid_token_error(self) -> ValidationError:
44
+ return ValidationError(self.error_messages["invalid_token"], code="invalid_token")
45
+
46
+ def get_token_required_error(self) -> ValidationError:
47
+ return ValidationError(self.error_messages["token_required"], code="token_required")
48
+
49
+ def get_password_required_error(self) -> ValidationError:
50
+ return ValidationError(self.error_messages["password_required"], code="password_required")
51
+
52
+ @sensitive_variables()
53
+ def clean(self):
54
+ username = self.cleaned_data.get("username")
55
+ method = (self.cleaned_data.get("auth_method") or "password").lower()
56
+ if method not in {"password", "otp"}:
57
+ method = "password"
58
+ self.cleaned_data["auth_method"] = method
59
+
60
+ if username is not None:
61
+ if method == "otp":
62
+ token = (self.cleaned_data.get("otp_token") or "").strip().replace(" ", "")
63
+ if not token:
64
+ raise self.get_token_required_error()
65
+ self.user_cache = authenticate(
66
+ self.request,
67
+ username=username,
68
+ otp_token=token,
69
+ )
70
+ if self.user_cache is None:
71
+ raise self.get_invalid_token_error()
72
+ self.cleaned_data["otp_token"] = token
73
+ self.verified_device = getattr(self.user_cache, "otp_device", None)
74
+ else:
75
+ password = self.cleaned_data.get("password")
76
+ if not password:
77
+ raise self.get_password_required_error()
78
+ self.user_cache = authenticate(
79
+ self.request, username=username, password=password
80
+ )
81
+ if self.user_cache is None:
82
+ raise self.get_invalid_login_error()
83
+ self.confirm_login_allowed(self.user_cache)
84
+
85
+ return self.cleaned_data
86
+
87
+ def get_verified_device(self):
88
+ return self.verified_device
89
+
90
+
91
+ class AuthenticatorEnrollmentForm(forms.Form):
92
+ """Form used to confirm a pending authenticator enrollment."""
93
+
94
+ token = forms.CharField(
95
+ label=_("Authenticator code"),
96
+ min_length=6,
97
+ max_length=8,
98
+ widget=forms.TextInput(
99
+ attrs={
100
+ "autocomplete": "one-time-code",
101
+ "inputmode": "numeric",
102
+ "pattern": "[0-9]*",
103
+ }
104
+ ),
105
+ )
106
+
107
+ error_messages = {
108
+ "invalid_token": _("The provided code is invalid or has expired."),
109
+ "missing_device": _("Generate a new authenticator secret before confirming it."),
110
+ }
111
+
112
+ def __init__(self, *args, device=None, **kwargs):
113
+ self.device = device
114
+ super().__init__(*args, **kwargs)
115
+
116
+ def clean_token(self):
117
+ token = (self.cleaned_data.get("token") or "").strip().replace(" ", "")
118
+ if not token:
119
+ raise forms.ValidationError(self.error_messages["invalid_token"], code="invalid_token")
120
+ if self.device is None:
121
+ raise forms.ValidationError(self.error_messages["missing_device"], code="missing_device")
122
+ try:
123
+ verified = self.device.verify_token(token)
124
+ except Exception:
125
+ verified = False
126
+ if not verified:
127
+ raise forms.ValidationError(self.error_messages["invalid_token"], code="invalid_token")
128
+ return token
129
+
130
+ def get_verified_device(self):
131
+ return self.device
pages/middleware.py ADDED
@@ -0,0 +1,153 @@
1
+ """Middleware helpers for the pages application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import logging
7
+ from http import HTTPStatus
8
+
9
+ from django.conf import settings
10
+ from django.urls import Resolver404, resolve
11
+
12
+ from .models import ViewHistory
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class ViewHistoryMiddleware:
19
+ """Persist public site visits for analytics."""
20
+
21
+ _EXCLUDED_PREFIXES = ("/admin", "/__debug__", "/healthz", "/status")
22
+
23
+ def __init__(self, get_response):
24
+ self.get_response = get_response
25
+ static_url = getattr(settings, "STATIC_URL", "") or ""
26
+ media_url = getattr(settings, "MEDIA_URL", "") or ""
27
+ self._skipped_prefixes = tuple(
28
+ prefix.rstrip("/") for prefix in (static_url, media_url) if prefix
29
+ )
30
+
31
+ def __call__(self, request):
32
+ should_track = self._should_track(request)
33
+ if not should_track:
34
+ return self.get_response(request)
35
+
36
+ error_message = ""
37
+ try:
38
+ response = self.get_response(request)
39
+ except Exception as exc: # pragma: no cover - re-raised for Django
40
+ status_code = getattr(exc, "status_code", 500) or 500
41
+ error_message = str(exc)
42
+ self._record_visit(request, status_code, error_message)
43
+ raise
44
+ else:
45
+ status_code = getattr(response, "status_code", 0) or 0
46
+ self._record_visit(request, status_code, error_message)
47
+ return response
48
+
49
+ def _should_track(self, request) -> bool:
50
+ method = request.method.upper()
51
+ if method not in {"GET", "HEAD"}:
52
+ return False
53
+
54
+ path = request.path
55
+ if any(path.startswith(prefix) for prefix in self._EXCLUDED_PREFIXES):
56
+ return False
57
+
58
+ if any(path.startswith(prefix) for prefix in self._skipped_prefixes):
59
+ return False
60
+
61
+ if path.startswith("/favicon") or path.startswith("/robots.txt"):
62
+ return False
63
+
64
+ if "djdt" in request.GET:
65
+ return False
66
+
67
+ return True
68
+
69
+ def _record_visit(self, request, status_code: int, error_message: str) -> None:
70
+ try:
71
+ status = HTTPStatus(status_code)
72
+ status_text = status.phrase
73
+ except ValueError:
74
+ status_text = ""
75
+
76
+ view_name = self._resolve_view_name(request)
77
+ full_path = request.get_full_path()
78
+ if not error_message and status_code >= HTTPStatus.BAD_REQUEST:
79
+ error_message = status_text or f"HTTP {status_code}"
80
+
81
+ try:
82
+ ViewHistory.objects.create(
83
+ path=full_path,
84
+ method=request.method,
85
+ status_code=status_code,
86
+ status_text=status_text,
87
+ error_message=(error_message or "")[:1000],
88
+ view_name=view_name,
89
+ )
90
+ except Exception: # pragma: no cover - best effort logging
91
+ logger.debug(
92
+ "Failed to record ViewHistory for %s", full_path, exc_info=True
93
+ )
94
+ else:
95
+ self._update_user_last_visit_ip(request)
96
+
97
+ def _resolve_view_name(self, request) -> str:
98
+ match = getattr(request, "resolver_match", None)
99
+ if match is None:
100
+ try:
101
+ match = resolve(request.path_info)
102
+ except Resolver404:
103
+ return ""
104
+
105
+ if getattr(match, "view_name", ""):
106
+ return match.view_name
107
+
108
+ func = getattr(match, "func", None)
109
+ if func is None:
110
+ return ""
111
+
112
+ module = getattr(func, "__module__", "")
113
+ name = getattr(func, "__name__", "")
114
+ if module and name:
115
+ return f"{module}.{name}"
116
+ return name or module or ""
117
+
118
+ def _extract_client_ip(self, request) -> str:
119
+ forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
120
+ candidates = []
121
+ if forwarded:
122
+ candidates.extend(part.strip() for part in forwarded.split(","))
123
+ remote = request.META.get("REMOTE_ADDR", "").strip()
124
+ if remote:
125
+ candidates.append(remote)
126
+
127
+ for candidate in candidates:
128
+ if not candidate:
129
+ continue
130
+ try:
131
+ ipaddress.ip_address(candidate)
132
+ except ValueError:
133
+ continue
134
+ return candidate
135
+ return ""
136
+
137
+ def _update_user_last_visit_ip(self, request) -> None:
138
+ user = getattr(request, "user", None)
139
+ if not getattr(user, "is_authenticated", False) or not getattr(user, "pk", None):
140
+ return
141
+
142
+ ip_address = self._extract_client_ip(request)
143
+ if not ip_address or getattr(user, "last_visit_ip_address", None) == ip_address:
144
+ return
145
+
146
+ try:
147
+ user.last_visit_ip_address = ip_address
148
+ user.save(update_fields=["last_visit_ip_address"])
149
+ except Exception: # pragma: no cover - best effort logging
150
+ logger.debug(
151
+ "Failed to update last_visit_ip_address for user %s", user.pk,
152
+ exc_info=True,
153
+ )