Admin-Dashboard: anonyme Nutzungsstatistik

- 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 <noreply@anthropic.com>
main
Samuel Zielke 3 weeks ago
parent 722eacbee7
commit 6ff0813f6f

@ -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",)

@ -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}"

@ -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

@ -15,7 +15,7 @@
<title>PioMint</title>
</head>
<body id="body" style="overflow: hidden;" class="bg-white-color">
<body id="body" style="overflow: hidden;" class="bg-white-color" data-app-version="{{ STATIC_VERSION }}">
<!-- HEADER -->
<div class="container-fluid header-bg-block bg-white-color HeaderDefaultHeightbg" id="header-bg-block">
<div class="container-fluid header-color-block text-center bg-white-color HeaderDefaultHeightcolor" id="header-color-block" style="color: white; width: 100vw;">

@ -0,0 +1,148 @@
{% load static %}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'app/css/bootstrap.css' %}?v={{ STATIC_VERSION }}">
<title>PioMint – Nutzung</title>
<style>
body {
background-color: #f4f6f5;
padding: 2rem 1rem 3rem 1rem;
}
h1 b {
color: rgb(120, 170, 86);
}
.stat-card {
background: white;
border-radius: 1rem;
box-shadow: 0 0 0.5rem gray;
padding: 1.25rem;
}
.stat-card.text-center .stat-value {
font-size: 2rem;
font-weight: bold;
color: rgb(120, 170, 86);
}
.stat-card .stat-label {
color: #6c757d;
font-size: 0.9rem;
}
.stat-card table {
background: white;
}
</style>
</head>
<body>
<div class="container" style="max-width: 960px;">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="m-0">PIO<b>MINT</b> – Nutzung</h1>
<a href="/app/home/" class="btn btn-sm btn-light">Zur App</a>
</div>
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="stat-card text-center">
<div class="stat-value">{{ devices_today }}</div>
<div class="stat-label">Geräte heute</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card text-center">
<div class="stat-value">{{ devices_7d }}</div>
<div class="stat-label">Geräte (7 Tage)</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card text-center">
<div class="stat-value">{{ devices_30d }}</div>
<div class="stat-label">Geräte (30 Tage)</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card text-center">
<div class="stat-value">{{ unique_devices_total }}</div>
<div class="stat-label">Geräte insgesamt</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="stat-card">
<h5>Plattform <small class="text-muted">(eindeutige Geräte)</small></h5>
<ul class="list-unstyled mb-0">
{% for row in by_platform %}
<li class="d-flex justify-content-between border-bottom py-1">
<span>{{ row.platform|default:"Unbekannt" }}</span><b>{{ row.count }}</b>
</li>
{% empty %}
<li class="text-muted">Noch keine Daten</li>
{% endfor %}
</ul>
</div>
</div>
<div class="col-md-6">
<div class="stat-card">
<h5>Browser <small class="text-muted">(eindeutige Geräte)</small></h5>
<ul class="list-unstyled mb-0">
{% for row in by_browser %}
<li class="d-flex justify-content-between border-bottom py-1">
<span>{{ row.browser|default:"Unbekannt" }}</span><b>{{ row.count }}</b>
</li>
{% empty %}
<li class="text-muted">Noch keine Daten</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<div class="stat-card mb-3">
<h5 class="mb-1">Als App installiert</h5>
<p class="mb-0 text-muted">
{{ standalone_count }} von {{ unique_devices_total }} Geräten insgesamt haben
PioMint (mindestens einmal als) installierte PWA geöffnet statt im Browser-Tab.
</p>
</div>
<div class="stat-card">
<h5 class="mb-2">Letzte Signale</h5>
<div style="overflow-x: auto;">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Zeit</th>
<th>Gerät</th>
<th>Version</th>
<th>Plattform</th>
<th>Browser</th>
<th>Standalone</th>
</tr>
</thead>
<tbody>
{% for event in recent_events %}
<tr>
<td>{{ event.created_at|date:"d.m.Y H:i" }}</td>
<td><code>{{ event.device_id|slice:":8" }}…</code></td>
<td>{{ event.app_version|default:"–" }}</td>
<td>{{ event.platform }}</td>
<td>{{ event.browser }}</td>
<td>{{ event.is_standalone|yesno:"Ja,Nein" }}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-muted">Noch keine Daten</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="text-muted mt-4" style="font-size: 0.8rem;">
Komplett anonym: <code>device_id</code> ist eine zufällige, nur lokal im Browser
gespeicherte ID ohne Bezug zu einer Person. Ein Signal pro Gerät und Tag.
</p>
</div>
</body>
</html>

@ -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),
]

@ -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

@ -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)

Loading…
Cancel
Save

Powered by TurnKey Linux.