arthexis 0.1.8__py3-none-any.whl → 0.1.9__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 (81) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.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 +133 -16
  10. config/urls.py +65 -6
  11. core/admin.py +1226 -191
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1071 -264
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +358 -63
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +7 -3
  42. core/workgroup_views.py +43 -6
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +1 -1
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.8.dist-info/RECORD +0 -80
  77. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  78. config/workgroup_app.py +0 -7
  79. core/checks.py +0 -29
  80. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  81. {arthexis-0.1.8.dist-info → arthexis-0.1.9.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,11 @@ 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
6
7
  from .models import Module
7
8
 
8
- _favicon_path = (
9
- Path(settings.BASE_DIR) / "pages" / "fixtures" / "data" / "favicon.txt"
10
- )
9
+ _favicon_path = Path(settings.BASE_DIR) / "pages" / "fixtures" / "data" / "favicon.txt"
11
10
  try:
12
11
  _DEFAULT_FAVICON = f"data:image/png;base64,{_favicon_path.read_text().strip()}"
13
12
  except OSError:
@@ -21,7 +20,7 @@ def nav_links(request):
21
20
  role = node.role if node else None
22
21
  if role:
23
22
  modules = (
24
- Module.objects.filter(node_role=role)
23
+ Module.objects.filter(node_role=role, is_deleted=False)
25
24
  .select_related("application")
26
25
  .prefetch_related("landings")
27
26
  )
@@ -51,9 +50,20 @@ def nav_links(request):
51
50
  module.enabled_landings = landings
52
51
  valid_modules.append(module)
53
52
  if request.path.startswith(module.path):
54
- if current_module is None or len(module.path) > len(current_module.path):
53
+ if current_module is None or len(module.path) > len(
54
+ current_module.path
55
+ ):
55
56
  current_module = module
56
57
 
58
+ datasette_lock = Path(settings.BASE_DIR) / "locks" / "datasette.lck"
59
+ if datasette_lock.exists():
60
+ datasette_module = SimpleNamespace(
61
+ menu_label="Data",
62
+ path="/data/",
63
+ enabled_landings=[SimpleNamespace(path="/data/", label="Datasette")],
64
+ )
65
+ valid_modules.append(datasette_module)
66
+
57
67
  valid_modules.sort(key=lambda m: m.menu_label.lower())
58
68
 
59
69
  if current_module and current_module.favicon:
@@ -69,4 +79,7 @@ def nav_links(request):
69
79
  if not favicon_url:
70
80
  favicon_url = _DEFAULT_FAVICON
71
81
 
72
- return {"nav_modules": valid_modules, "favicon_url": favicon_url}
82
+ return {
83
+ "nav_modules": valid_modules,
84
+ "favicon_url": favicon_url,
85
+ }
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
+ )
pages/models.py CHANGED
@@ -47,10 +47,14 @@ class ModuleManager(models.Manager):
47
47
 
48
48
  class Module(Entity):
49
49
  node_role = models.ForeignKey(
50
- NodeRole, on_delete=models.CASCADE, related_name="modules",
50
+ NodeRole,
51
+ on_delete=models.CASCADE,
52
+ related_name="modules",
51
53
  )
52
54
  application = models.ForeignKey(
53
- Application, on_delete=models.CASCADE, related_name="modules",
55
+ Application,
56
+ on_delete=models.CASCADE,
57
+ related_name="modules",
54
58
  )
55
59
  path = models.CharField(
56
60
  max_length=100,
@@ -127,7 +131,9 @@ class Module(Entity):
127
131
  )
128
132
  created = True
129
133
  else:
130
- _walk(pattern.url_patterns, prefix=f"{prefix}{str(pattern.pattern)}")
134
+ _walk(
135
+ pattern.url_patterns, prefix=f"{prefix}{str(pattern.pattern)}"
136
+ )
131
137
 
132
138
  _walk(patterns)
133
139
 
@@ -141,7 +147,9 @@ class SiteBadge(Entity):
141
147
  site = models.OneToOneField(Site, on_delete=models.CASCADE, related_name="badge")
142
148
  badge_color = models.CharField(max_length=7, default="#28a745")
143
149
  favicon = models.ImageField(upload_to="sites/favicons/", blank=True)
144
- landing_override = models.ForeignKey('Landing', null=True, blank=True, on_delete=models.SET_NULL)
150
+ landing_override = models.ForeignKey(
151
+ "Landing", null=True, blank=True, on_delete=models.SET_NULL
152
+ )
145
153
 
146
154
  def __str__(self) -> str: # pragma: no cover - simple representation
147
155
  return f"Badge for {self.site.domain}"
@@ -185,11 +193,11 @@ class Landing(Entity):
185
193
 
186
194
  def save(self, *args, **kwargs):
187
195
  if not self.pk:
188
- existing = type(self).objects.filter(
189
- module=self.module, path=self.path
190
- ).first()
191
- if existing:
192
- self.pk = existing.pk
196
+ existing = (
197
+ type(self).objects.filter(module=self.module, path=self.path).first()
198
+ )
199
+ if existing:
200
+ self.pk = existing.pk
193
201
  super().save(*args, **kwargs)
194
202
 
195
203
  def natural_key(self): # pragma: no cover - simple representation
@@ -198,6 +206,26 @@ class Landing(Entity):
198
206
  natural_key.dependencies = ["nodes.NodeRole", "pages.Module"]
199
207
 
200
208
 
209
+ class ViewHistory(Entity):
210
+ """Record of public site visits."""
211
+
212
+ path = models.CharField(max_length=500)
213
+ method = models.CharField(max_length=10)
214
+ status_code = models.PositiveSmallIntegerField()
215
+ status_text = models.CharField(max_length=100, blank=True)
216
+ error_message = models.TextField(blank=True)
217
+ view_name = models.CharField(max_length=200, blank=True)
218
+ visited_at = models.DateTimeField(auto_now_add=True)
219
+
220
+ class Meta:
221
+ ordering = ["-visited_at"]
222
+ verbose_name = _("View History")
223
+ verbose_name_plural = _("View Histories")
224
+
225
+ def __str__(self) -> str: # pragma: no cover - simple representation
226
+ return f"{self.method} {self.path} ({self.status_code})"
227
+
228
+
201
229
  class Favorite(Entity):
202
230
  user = models.ForeignKey(
203
231
  settings.AUTH_USER_MODEL,