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.
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
- arthexis-0.1.9.dist-info/RECORD +92 -0
- arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +133 -16
- config/urls.py +65 -6
- core/admin.py +1226 -191
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +158 -3
- core/backends.py +46 -4
- core/entity.py +62 -48
- core/fields.py +6 -1
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1071 -264
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/release.py +27 -20
- core/sigil_builder.py +131 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +129 -10
- core/tasks.py +118 -19
- core/test_system_info.py +22 -0
- core/tests.py +358 -63
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +329 -167
- core/views.py +383 -57
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +159 -284
- nodes/apps.py +9 -15
- nodes/backends.py +53 -0
- nodes/lcd.py +24 -10
- nodes/models.py +375 -178
- nodes/tasks.py +1 -5
- nodes/tests.py +524 -129
- nodes/utils.py +13 -2
- nodes/views.py +66 -23
- ocpp/admin.py +150 -61
- ocpp/apps.py +1 -1
- ocpp/consumers.py +432 -69
- ocpp/evcs.py +25 -8
- ocpp/models.py +408 -68
- ocpp/simulator.py +13 -6
- ocpp/store.py +258 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1198 -135
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +654 -101
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +19 -6
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +759 -40
- pages/urls.py +3 -0
- pages/utils.py +0 -1
- pages/views.py +576 -25
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
- {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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
368
|
+
def get_admin_urls(original_get_urls):
|
|
216
369
|
def get_urls():
|
|
370
|
+
urls = original_get_urls()
|
|
217
371
|
my_urls = [
|
|
218
|
-
path(
|
|
219
|
-
|
|
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 +
|
|
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
pages/context_processors.py
CHANGED
|
@@ -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(
|
|
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 {
|
|
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,
|
|
50
|
+
NodeRole,
|
|
51
|
+
on_delete=models.CASCADE,
|
|
52
|
+
related_name="modules",
|
|
51
53
|
)
|
|
52
54
|
application = models.ForeignKey(
|
|
53
|
-
Application,
|
|
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(
|
|
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(
|
|
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 =
|
|
189
|
-
module=self.module, path=self.path
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
|
|
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,
|