From 6ff0813f6f01243b0f008ca8ca1e3bde3b1542f9 Mon Sep 17 00:00:00 2001 From: samuelzielke Date: Wed, 30 Sep 2026 12:12:44 +0200 Subject: [PATCH] Admin-Dashboard: anonyme Nutzungsstatistik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neues Modell UsageEvent (device_id, Plattform/Browser aus User-Agent serverseitig geparst, Standalone-Flag, Zeitstempel) - komplett anonym, device_id ist eine zufällige, nur lokal im Browser gespeicherte ID - POST /app/track/: nimmt ein Nutzungssignal entgegen (max. 1x pro Gerät/Tag, siehe home01.js: pingUsageIfNeeded()) - GET /app/dashboard/: Übersicht für Admins (staff_member_required, nutzt bestehenden Django-Admin-Login) - Geräte heute/7/30 Tage, Plattform- und Browser-Verteilung, Standalone-Quote, letzte Signale - UsageEvent zusätzlich im normalen Django-Admin registriert - Migration bewusst nicht mitcommittet (Projekt-Konvention, siehe .gitignore) - muss auf jedem Environment einmalig per `manage.py makemigrations app && manage.py migrate` erzeugt werden Lokal getestet: Track-Endpoint, Dashboard-Rendering mit Testdaten, Admin-Login- Schutz (unauthenticated -> 302), Django-Admin-Listing. Co-Authored-By: Claude Sonnet 5 --- app/admin.py | 11 ++- app/models.py | 26 +++++- app/static/app/js/home01.js | 53 +++++++++++ app/templates/app/base.html | 2 +- app/templates/app/dashboard.html | 148 +++++++++++++++++++++++++++++++ app/urls.py | 2 + app/utils.py | 32 +++++++ app/views.py | 67 ++++++++++++++ 8 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 app/templates/app/dashboard.html create mode 100644 app/utils.py diff --git a/app/admin.py b/app/admin.py index 8c38f3f..0a09c31 100644 --- a/app/admin.py +++ b/app/admin.py @@ -1,3 +1,12 @@ from django.contrib import admin -# Register your models here. +from .models import UsageEvent + + +@admin.register(UsageEvent) +class UsageEventAdmin(admin.ModelAdmin): + list_display = ("device_id", "created_at", "app_version", "platform", "browser", "is_standalone") + list_filter = ("platform", "browser", "is_standalone", "app_version") + search_fields = ("device_id",) + date_hierarchy = "created_at" + ordering = ("-created_at",) diff --git a/app/models.py b/app/models.py index 71a8362..352baa7 100644 --- a/app/models.py +++ b/app/models.py @@ -1,3 +1,27 @@ from django.db import models -# Create your models here. + +class UsageEvent(models.Model): + """Anonymes Nutzungssignal für das Admin-Dashboard (siehe home01.js: pingUsageIfNeeded()). + + Ein Eintrag pro Gerät und Tag - device_id ist eine zufällige, nur lokal im Browser + (localStorage) gespeicherte ID ohne jeden Bezug zu einer echten Person. Plattform und + Browser werden serverseitig aus dem User-Agent-Header grob abgeleitet (siehe app/utils.py). + """ + + device_id = models.CharField(max_length=64, db_index=True) + app_version = models.CharField(max_length=20, blank=True) + user_agent = models.TextField(blank=True) + platform = models.CharField(max_length=40, blank=True) + browser = models.CharField(max_length=40, blank=True) + is_standalone = models.BooleanField( + default=False, + help_text="True, wenn als installierte PWA (Homescreen) geöffnet statt im Browser-Tab.", + ) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.device_id[:8]}… – {self.created_at:%d.%m.%Y %H:%M}" diff --git a/app/static/app/js/home01.js b/app/static/app/js/home01.js index f2e4fc4..4f2d0b6 100644 --- a/app/static/app/js/home01.js +++ b/app/static/app/js/home01.js @@ -52,8 +52,61 @@ var currentMonthEntries = []; // Trigger // +// +// ANONYME NUTZUNGSSTATISTIK (Admin-Dashboard) +// + +// Liest die zufällige Geräte-ID aus dem localStorage oder legt einmalig eine neue an. +// Kein Bezug zu einer Person - rein lokal generiert, dient nur der Dashboard-Zählung. +function getOrCreateDeviceId() { + try { + let id = localStorage.getItem('piomintDeviceId') + if (!id) { + id = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}` + localStorage.setItem('piomintDeviceId', id) + } + return id + } catch (error) { + return null + } +} + +// Meldet höchstens einmal pro Gerät und Tag ein anonymes "App genutzt"-Signal ans Backend. +// Darf die App unter keinen Umständen blockieren oder zum Absturz bringen - daher überall try/catch. +function pingUsageIfNeeded() { + try { + const today = new Date().toISOString().slice(0, 10) + if (localStorage.getItem('piomintLastPing') === today) { + return + } + const deviceId = getOrCreateDeviceId() + if (!deviceId) { + return + } + const standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || window.navigator.standalone === true + + fetch('/app/track/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_id: deviceId, + app_version: document.body.dataset.appVersion || '', + standalone: standalone, + }), + }).then(() => { + localStorage.setItem('piomintLastPing', today) + }).catch(() => { + // Kein Netz/Fehler beim Tracking - einfach beim nächsten Öffnen erneut versuchen + }) + } catch (error) { + console.error('Nutzungssignal konnte nicht gesendet werden:', error) + } +} + // Wenn das Dokument komplett geladen ist document.addEventListener('DOMContentLoaded', (event) => { + pingUsageIfNeeded() + initDB().then(async () => { console.log('IndexedDB erfolgreich initialisiert.'); // Hier kannst du die App weiter initialisieren, z.B. Daten laden oder die UI aufbauen diff --git a/app/templates/app/base.html b/app/templates/app/base.html index e765bf7..7040336 100644 --- a/app/templates/app/base.html +++ b/app/templates/app/base.html @@ -15,7 +15,7 @@ PioMint - +
diff --git a/app/templates/app/dashboard.html b/app/templates/app/dashboard.html new file mode 100644 index 0000000..a5f71bc --- /dev/null +++ b/app/templates/app/dashboard.html @@ -0,0 +1,148 @@ +{% load static %} + + + + + + + PioMint – Nutzung + + + +
+
+

PIOMINT – Nutzung

+ Zur App +
+ +
+
+
+
{{ devices_today }}
+
Geräte heute
+
+
+
+
+
{{ devices_7d }}
+
Geräte (7 Tage)
+
+
+
+
+
{{ devices_30d }}
+
Geräte (30 Tage)
+
+
+
+
+
{{ unique_devices_total }}
+
Geräte insgesamt
+
+
+
+ +
+
+
+
Plattform (eindeutige Geräte)
+
    + {% for row in by_platform %} +
  • + {{ row.platform|default:"Unbekannt" }}{{ row.count }} +
  • + {% empty %} +
  • Noch keine Daten
  • + {% endfor %} +
+
+
+
+
+
Browser (eindeutige Geräte)
+
    + {% for row in by_browser %} +
  • + {{ row.browser|default:"Unbekannt" }}{{ row.count }} +
  • + {% empty %} +
  • Noch keine Daten
  • + {% endfor %} +
+
+
+
+ +
+
Als App installiert
+

+ {{ standalone_count }} von {{ unique_devices_total }} Geräten insgesamt haben + PioMint (mindestens einmal als) installierte PWA geöffnet statt im Browser-Tab. +

+
+ +
+
Letzte Signale
+
+ + + + + + + + + + + + + {% for event in recent_events %} + + + + + + + + + {% empty %} + + {% endfor %} + +
ZeitGerätVersionPlattformBrowserStandalone
{{ event.created_at|date:"d.m.Y H:i" }}{{ event.device_id|slice:":8" }}…{{ event.app_version|default:"–" }}{{ event.platform }}{{ event.browser }}{{ event.is_standalone|yesno:"Ja,Nein" }}
Noch keine Daten
+
+
+ +

+ Komplett anonym: device_id ist eine zufällige, nur lokal im Browser + gespeicherte ID ohne Bezug zu einer Person. Ein Signal pro Gerät und Tag. +

+
+ + diff --git a/app/urls.py b/app/urls.py index 5d45856..497f0c0 100644 --- a/app/urls.py +++ b/app/urls.py @@ -20,4 +20,6 @@ urlpatterns = [ path("v1340/", views.v1340), path("v260/", views.v260), path("v261/", views.v261), + path("track/", views.track_usage), + path("dashboard/", views.usage_dashboard), ] diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..2e75080 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,32 @@ +def parse_user_agent(user_agent): + """Sehr einfache, dependency-freie Grobzuordnung von Plattform/Browser aus dem + User-Agent-String - reicht für die Dashboard-Statistik, muss nicht exakt sein.""" + ua = (user_agent or "").lower() + + if "iphone" in ua or "ipad" in ua or "ipod" in ua: + platform = "iOS" + elif "android" in ua: + platform = "Android" + elif "windows" in ua: + platform = "Windows" + elif "macintosh" in ua or "mac os x" in ua: + platform = "macOS" + elif "linux" in ua: + platform = "Linux" + else: + platform = "Unbekannt" + + if "edg/" in ua or "edga" in ua or "edgios" in ua: + browser = "Edge" + elif "opr/" in ua or "opera" in ua: + browser = "Opera" + elif "firefox" in ua or "fxios" in ua: + browser = "Firefox" + elif "crios" in ua or "chrome" in ua: + browser = "Chrome" + elif "safari" in ua: + browser = "Safari" + else: + browser = "Unbekannt" + + return platform, browser diff --git a/app/views.py b/app/views.py index e982ad6..dba7819 100644 --- a/app/views.py +++ b/app/views.py @@ -1,4 +1,17 @@ +import json + +from django.contrib.admin.views.decorators import staff_member_required +from django.db.models import Count +from django.http import JsonResponse from django.shortcuts import render, HttpResponse, redirect +from django.utils import timezone +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +from datetime import timedelta + +from .models import UsageEvent +from .utils import parse_user_agent # Create your views here. def index(request): @@ -42,3 +55,57 @@ def v260(request): def v261(request): return render(request, "app/home.html") + + +# --------------------------------------------------------------------------- +# Anonyme Nutzungsstatistik fürs Admin-Dashboard +# --------------------------------------------------------------------------- + +@csrf_exempt +@require_POST +def track_usage(request): + """Nimmt ein anonymes Nutzungssignal entgegen (ein Aufruf pro Gerät und Tag, + siehe home01.js: pingUsageIfNeeded()). Kein CSRF-Schutz nötig, da rein additiv + und ohne jeden Personenbezug - device_id kommt aus dem localStorage des Geräts.""" + try: + data = json.loads(request.body or "{}") + except json.JSONDecodeError: + data = {} + + device_id = str(data.get("device_id", "")).strip()[:64] + if not device_id: + return JsonResponse({"ok": False, "error": "device_id fehlt"}, status=400) + + user_agent = request.META.get("HTTP_USER_AGENT", "") + platform, browser = parse_user_agent(user_agent) + + UsageEvent.objects.create( + device_id=device_id, + app_version=str(data.get("app_version", ""))[:20], + user_agent=user_agent[:1000], + platform=platform, + browser=browser, + is_standalone=bool(data.get("standalone", False)), + ) + return JsonResponse({"ok": True}) + + +@staff_member_required +def usage_dashboard(request): + """Nutzungs-Dashboard für Admins - nur für angemeldete Staff-Nutzer (Django-Admin-Login).""" + now = timezone.now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + events = UsageEvent.objects.all() + + context = { + "unique_devices_total": events.values("device_id").distinct().count(), + "devices_today": events.filter(created_at__gte=today_start).values("device_id").distinct().count(), + "devices_7d": events.filter(created_at__gte=now - timedelta(days=7)).values("device_id").distinct().count(), + "devices_30d": events.filter(created_at__gte=now - timedelta(days=30)).values("device_id").distinct().count(), + "standalone_count": events.filter(is_standalone=True).values("device_id").distinct().count(), + "by_platform": events.values("platform").annotate(count=Count("device_id", distinct=True)).order_by("-count"), + "by_browser": events.values("browser").annotate(count=Count("device_id", distinct=True)).order_by("-count"), + "recent_events": events[:30], + } + return render(request, "app/dashboard.html", context)