Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine Sammlung, die sich von allein fuellt. Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert — ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten, gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte, Stufen und 15 Abzeichen. Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto, keine Telemetrie; die Bilder verlassen das eigene Netz nicht. Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und braucht deshalb weder eine erreichbare Adresse noch einen offenen Port — er laeuft hinter einem gewoehnlichen Router. Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs. AGPL-3.0.
580 lines
21 KiB
Python
580 lines
21 KiB
Python
"""Lese-Schnittstelle für die Handy-App.
|
|
|
|
🔑 **Warum ein eigenes Modul und nicht mehr Endpunkte in ``api.py``.** Dort
|
|
steht der Verkehr mit den *Kameras*: einliefern, Konfiguration abholen,
|
|
Warteschlange abarbeiten. Das hier ist der umgekehrte Fall — ein Mensch
|
|
blättert in seinem Album. Die beiden haben verschiedene Leser, verschiedene
|
|
Antwortformen und ändern sich aus verschiedenen Gründen; sie in eine Datei zu
|
|
legen hieße, bei jeder App-Änderung den Kameraverkehr mit zu lesen.
|
|
|
|
⚠️ **Es ist absichtlich keine neue Wahrheit.** Jede Zahl hier kommt aus
|
|
derselben Quelle wie die Website: ``scoring.progress()``, ``DexEntry``,
|
|
``Sighting``. Wer die Punktevergabe ändert, ändert beides zugleich — sonst
|
|
stünde auf dem Telefon Stufe 3 und im Browser Stufe 2, und niemand wüsste,
|
|
welche der beiden lügt.
|
|
|
|
🔑 **Die Sprache kommt aus ``Accept-Language``.** Djangos LocaleMiddleware
|
|
steht schon in der Kette, also liefern die ``*_lokal``-Eigenschaften der
|
|
Modelle von selbst Deutsch oder Englisch. Die App muss nichts übersetzen und
|
|
wir pflegen die Texte nicht zweimal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
|
|
from django.conf import settings
|
|
from django.db.models import Q
|
|
from django.http import HttpRequest, JsonResponse
|
|
from django.utils import timezone
|
|
from django.views.decorators.http import require_GET
|
|
|
|
from . import scoring
|
|
from .models import Badge, Camera, DexEntry, Sighting, Species
|
|
from .species_meta import HABITATS
|
|
|
|
# Höchstens so viele Sichtungen je Seite. Ein Feed auf dem Telefon lädt
|
|
# nach; eine Antwort mit allen 300 Sichtungen und ihren Bildadressen wäre
|
|
# auf einer Mobilfunkverbindung die längste Wartezeit der ganzen App.
|
|
SEITE_MAX = 60
|
|
SEITE_VORGABE = 24
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Zugang
|
|
# --------------------------------------------------------------------------
|
|
#
|
|
# 🔑 **Die App kommt mit EINEM Token aus.** Sie muss zwei Dinge können:
|
|
# Fotos einliefern (braucht einen Kamera-Token, siehe ``api.hochladen``) und
|
|
# das Album lesen. Hätte das Lesen einen eigenen Token, müsste man beim
|
|
# Einrichten zwei Geheimnisse abtippen und wir hätten einen Fehlerfall mehr
|
|
# ("hochladen geht, Album bleibt leer"). Deshalb gilt hier **beides**: der
|
|
# Lese-Token wie bei Home Assistant, oder der Token der eigenen Kamera.
|
|
#
|
|
# ⚠️ Dass ein Kamera-Token hier LESEN darf, ist kein Dammbruch: Das Album
|
|
# steht ohnehin öffentlich im Netz — ``/sammlung/``, ``/sichtungen/`` und
|
|
# ``/abzeichen/`` sind ohne Anmeldung erreichbar. Diese Schnittstelle gibt
|
|
# exakt dieselben Angaben in anderer Verpackung heraus und **nichts darüber
|
|
# hinaus**: insbesondere keine Koordinaten (die zeigt die Website nicht), kein
|
|
# Live-Bild und keine Kamera-Token.
|
|
|
|
|
|
def _zugang(request: HttpRequest) -> bool:
|
|
header = request.headers.get("Authorization", "")
|
|
if not header.startswith("Bearer "):
|
|
return False
|
|
token = header[7:]
|
|
|
|
if Camera.by_token(token) is not None:
|
|
return True
|
|
|
|
# Konstantzeit-Vergleich, und leer heißt ZU — dieselbe Regel wie in
|
|
# ``api._lesen_erlaubt``. Ein vergessenes Geheimnis darf nicht die Tür
|
|
# aufmachen, sondern muss sie zuhalten.
|
|
if settings.LESE_TOKEN:
|
|
return hmac.compare_digest(token, settings.LESE_TOKEN)
|
|
return False
|
|
|
|
|
|
def _abgelehnt() -> JsonResponse:
|
|
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Bausteine, die mehrere Endpunkte brauchen
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _bild(request: HttpRequest, feld) -> str | None:
|
|
"""Absolute Adresse eines Bildfelds, oder ``None``.
|
|
|
|
⚠️ Absolut, nicht relativ. Eine App hat keine Basisadresse, von der aus
|
|
``/media/…`` etwas bedeuten würde — sie kennt nur die URL, die ihr der
|
|
Nutzer eingetragen hat, und die zeigt womöglich auf einen anderen Host als
|
|
die Medien.
|
|
"""
|
|
if not feld:
|
|
return None
|
|
return request.build_absolute_uri(feld.url)
|
|
|
|
|
|
def _art_kurz(request: HttpRequest, art: Species, eintrag: DexEntry | None) -> dict:
|
|
"""Eine Albumseite, wie die App sie im Raster braucht.
|
|
|
|
``gefunden=False`` heißt Schatten: Name und Nummer bleiben sichtbar, das
|
|
Bild nicht — genau wie im Klebebildchen-Album, wo die leere Stelle die
|
|
Umrisse zeigt. Die App soll diese Entscheidung nicht selbst treffen
|
|
müssen, deshalb steht sie hier als Feld.
|
|
"""
|
|
daten = {
|
|
"slug": art.slug,
|
|
"nummer": art.number,
|
|
"nummer_text": art.dex_nr,
|
|
"name": art.name,
|
|
"zweitname": art.zweitname,
|
|
"wissenschaftlich": art.scientific,
|
|
"seltenheit": art.rarity,
|
|
"seltenheit_label": art.rarity_label,
|
|
"seltenheit_farbe": art.rarity_color,
|
|
"typ": art.typ,
|
|
"typ_label": art.typ_label,
|
|
"typ_farbe": art.typ_color,
|
|
"typ_schrift": art.typ_ink,
|
|
"lebensraum": art.lebensraum_typ,
|
|
"punkte": art.xp,
|
|
"gefunden": eintrag is not None,
|
|
# Das Referenzbild gehört zum Katalog und ist auch bei ungefundenen
|
|
# Arten da. Die App zeigt es erst nach dem Fang — aber die
|
|
# Entscheidung trifft sie, nicht wir: fürs Nachschlagen ("wie sieht
|
|
# ein Gartenrotschwanz aus?") braucht sie es vorher.
|
|
"referenzbild": _bild(request, art.reference_image),
|
|
}
|
|
if eintrag is not None:
|
|
daten |= {
|
|
"anzahl": eintrag.count,
|
|
"erstmals": eintrag.first_seen.isoformat(),
|
|
"zuletzt": eintrag.last_seen.isoformat(),
|
|
"neu": eintrag.is_new,
|
|
"foto": (
|
|
request.build_absolute_uri(eintrag.best_sighting.kachel_url)
|
|
if eintrag.best_sighting and eintrag.best_sighting.kachel_url
|
|
else None
|
|
),
|
|
}
|
|
return daten
|
|
|
|
|
|
def _sichtung_kurz(request: HttpRequest, s: Sighting) -> dict:
|
|
"""Eine Zeile im Feed.
|
|
|
|
⚠️ ``effective_species`` statt ``species``: Eine Handbestimmung im Admin
|
|
gewinnt gegen das Modell. Wer hier ``species`` nähme, zeigte in der App
|
|
weiter die falsche Art, die längst korrigiert wurde — und zwar nur
|
|
in der App, was die Fehlersuche teuer macht.
|
|
"""
|
|
art = s.effective_species
|
|
return {
|
|
"id": s.id,
|
|
"zeitpunkt": s.started_at.isoformat(),
|
|
"dauer_s": round(s.duration_s, 1),
|
|
"wartet": s.wartet_auf_bestimmung,
|
|
"art": (
|
|
{
|
|
"slug": art.slug,
|
|
"name": art.name,
|
|
"wissenschaftlich": art.scientific,
|
|
"seltenheit": art.rarity,
|
|
"seltenheit_farbe": art.rarity_color,
|
|
"typ_farbe": art.typ_color,
|
|
# 🔑 Das Referenzbild gehört an die Sichtung, obwohl es zur Art
|
|
# gehört: Nicht jede Sichtung hat ein eigenes Foto (der
|
|
# Detector verwirft schlechte Bilder, und ältere Einträge haben
|
|
# gar keins). Ohne diesen Rückfall steht in der App ein
|
|
# Fragezeichen neben einer Art, die sie kennt und abbilden
|
|
# könnte — gefunden am gerenderten Feed, nicht im Test.
|
|
"referenzbild": _bild(request, art.reference_image),
|
|
}
|
|
if art
|
|
else None
|
|
),
|
|
"sicherheit": round(s.confidence, 3),
|
|
"vogelwert": round(s.mean_bird_score, 3),
|
|
"handbestimmt": s.confirmed_species_id is not None,
|
|
"neue_art": s.is_first_of_species,
|
|
"punkte": s.xp_awarded,
|
|
"kamera": (
|
|
{"kennung": s.camera.slug, "name": s.camera.name, "mobil": s.camera.mobil}
|
|
if s.camera
|
|
else None
|
|
),
|
|
"foto": _bild(request, s.image),
|
|
# 🔑 Der Ausschnitt wird aus dem Original gerechnet, nicht als
|
|
# zweites Bild gespeichert — siehe dex/zuschnitt.py. Damit schneidet
|
|
# eine geänderte Regel sofort den ganzen Bestand neu, und die App
|
|
# bekommt dieselbe Kachel wie die Website.
|
|
"ausschnitt": (request.build_absolute_uri(s.kachel_url)
|
|
if s.kachel_url else None),
|
|
}
|
|
|
|
|
|
def _fortschritt() -> dict:
|
|
"""Punkte, Stufe, Rang — die Kopfzeile jeder App-Ansicht."""
|
|
p = scoring.progress()
|
|
return {
|
|
"punkte": p.xp,
|
|
"stufe": p.level,
|
|
"rang": p.rank,
|
|
"punkte_stufe_start": p.xp_this_level,
|
|
"punkte_stufe_ziel": p.xp_next_level,
|
|
"punkte_bis_aufstieg": p.xp_to_next,
|
|
"anteil_stufe": round(p.level_progress, 3),
|
|
"arten_gesammelt": p.species_seen,
|
|
"arten_gesamt": p.species_total,
|
|
"sichtungen": p.sightings,
|
|
"abzeichen": p.badges,
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Endpunkte
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@require_GET
|
|
def start(request: HttpRequest) -> JsonResponse:
|
|
"""Der Startbildschirm in einer Antwort.
|
|
|
|
🔑 Bewusst ein Sammelabruf statt vier einzelner. Beim Öffnen der App
|
|
wartet ein Mensch — vier Anfragen hintereinander über Mobilfunk sind
|
|
viermal die Latenz, und drei davon zeigen Dinge, die ohnehin zusammen auf
|
|
einen Bildschirm gehören.
|
|
"""
|
|
if not _zugang(request):
|
|
return _abgelehnt()
|
|
|
|
jetzt = timezone.now()
|
|
heute = jetzt.astimezone().date()
|
|
|
|
letzte = (
|
|
Sighting.objects.bestimmt()
|
|
.select_related("species", "confirmed_species", "camera")
|
|
.order_by("-started_at")
|
|
.first()
|
|
)
|
|
|
|
neueste = (
|
|
DexEntry.objects.select_related("species", "best_sighting")
|
|
.order_by("-first_seen")[:6]
|
|
)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"fortschritt": _fortschritt(),
|
|
"sichtungen_heute": Sighting.objects.bestimmt()
|
|
.filter(started_at__date=heute)
|
|
.count(),
|
|
"letzte_sichtung": _sichtung_kurz(request, letzte) if letzte else None,
|
|
# Die letzten Erstfänge — das ist der Teil, den man jemandem
|
|
# zeigt. Eine Liste aller Sichtungen beantwortet "war was los",
|
|
# die Erstfänge beantworten "ist die Sammlung gewachsen".
|
|
"neueste_arten": [
|
|
_art_kurz(request, e.species, e) for e in neueste
|
|
],
|
|
"wartende_fotos": Sighting.objects.wartend().count(),
|
|
"kameras": [
|
|
{
|
|
"kennung": k.slug,
|
|
"name": k.name,
|
|
"standort": k.location,
|
|
"mobil": k.mobil,
|
|
"aktiv": k.active,
|
|
"still": k.is_stale,
|
|
"letzte_sichtung": (
|
|
k.last_seen.isoformat() if k.last_seen else None
|
|
),
|
|
}
|
|
for k in Camera.objects.order_by("name")
|
|
],
|
|
"stand": jetzt.isoformat(),
|
|
}
|
|
)
|
|
|
|
|
|
@require_GET
|
|
def album(request: HttpRequest) -> JsonResponse:
|
|
"""Das ganze Album eines Lebensraums, gefundene und fehlende.
|
|
|
|
⚠️ **Vorgabe ``garten``, wie auf der Website.** 148 Arten auf einmal sind
|
|
kein Ziel, sondern eine Wand; ``?lebensraum=alle`` holt sie trotzdem, wenn
|
|
die App einen Nachschlage-Modus anbietet.
|
|
"""
|
|
if not _zugang(request):
|
|
return _abgelehnt()
|
|
|
|
lebensraum = request.GET.get("lebensraum", "garten")
|
|
if lebensraum == "alle":
|
|
lebensraum = ""
|
|
|
|
arten = Species.objects.all()
|
|
if lebensraum:
|
|
arten = arten.filter(lebensraum_typ=lebensraum)
|
|
|
|
eintraege = {
|
|
e.species_id: e
|
|
for e in DexEntry.objects.select_related("best_sighting")
|
|
}
|
|
|
|
seiten = [_art_kurz(request, a, eintraege.get(a.id)) for a in arten]
|
|
# Nach Albumnummer, nicht nach Fundstatus. Die Nummer ist die Seitenzahl:
|
|
# Sie bleibt stehen, wo sie ist, auch wenn die Lücke daneben sich füllt.
|
|
seiten.sort(key=lambda s: s["nummer"] or 9999)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"arten": seiten,
|
|
"gefunden": sum(1 for s in seiten if s["gefunden"]),
|
|
"gesamt": len(seiten),
|
|
"lebensraum": lebensraum,
|
|
"lebensraeume": _lebensraeume(),
|
|
}
|
|
)
|
|
|
|
|
|
def _lebensraeume() -> list[dict]:
|
|
"""Die Reiter über dem Album — nur die, in denen Arten stehen."""
|
|
from django.utils.translation import get_language
|
|
|
|
englisch = (get_language() or "de").startswith("en")
|
|
raus = []
|
|
for code, paar in HABITATS.items():
|
|
anzahl = Species.objects.filter(lebensraum_typ=code).count()
|
|
if not anzahl:
|
|
continue
|
|
gefunden = DexEntry.objects.filter(species__lebensraum_typ=code).count()
|
|
raus.append(
|
|
{
|
|
"code": code,
|
|
"label": paar[1] if englisch else paar[0],
|
|
"gesamt": anzahl,
|
|
"gefunden": gefunden,
|
|
}
|
|
)
|
|
return raus
|
|
|
|
|
|
@require_GET
|
|
def art(request: HttpRequest, slug: str) -> JsonResponse:
|
|
"""Eine Albumseite in voller Länge: Steckbrief, Besuche, Tagesverlauf."""
|
|
if not _zugang(request):
|
|
return _abgelehnt()
|
|
|
|
species = Species.objects.filter(slug=slug).first()
|
|
if species is None:
|
|
return JsonResponse({"fehler": f"unbekannte Art '{slug}'"}, status=404)
|
|
|
|
eintrag = (
|
|
DexEntry.objects.filter(species=species)
|
|
.select_related("best_sighting")
|
|
.first()
|
|
)
|
|
|
|
passend = Q(species=species) | Q(confirmed_species=species)
|
|
besuche = (
|
|
Sighting.objects.filter(passend)
|
|
.select_related("species", "confirmed_species", "camera")
|
|
.order_by("-started_at")[:24]
|
|
)
|
|
|
|
# Wann ist die Art aktiv? Über die Stunden der bisherigen Besuche,
|
|
# in Ortszeit — eine Sichtung um 6:31 gehört in die 6-Uhr-Säule, auch
|
|
# wenn sie in der Datenbank als 04:31 UTC steht.
|
|
stunden = [0] * 24
|
|
for dt in Sighting.objects.filter(passend).values_list("started_at", flat=True):
|
|
stunden[timezone.localtime(dt).hour] += 1
|
|
spitze = max(stunden) or 1
|
|
|
|
aehnliche = list(Species.objects.filter(slug__in=species.verwechslung or []))
|
|
reihenfolge = species.verwechslung or []
|
|
aehnliche.sort(key=lambda s: reihenfolge.index(s.slug))
|
|
|
|
vorher = (
|
|
Species.objects.filter(number__lt=species.number).order_by("-number").first()
|
|
)
|
|
nachher = (
|
|
Species.objects.filter(number__gt=species.number).order_by("number").first()
|
|
)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"art": _art_kurz(request, species, eintrag)
|
|
| {
|
|
"kategorie": species.kategorie_lokal,
|
|
"beschreibung": species.absaetze,
|
|
"kurzfakt": species.fact,
|
|
"groesse_cm": species.size_cm,
|
|
"gewicht_g": species.gewicht_g,
|
|
"spannweite_cm": species.spannweite_cm,
|
|
"zug": species.zug_label,
|
|
"nahrung": species.nahrung_lokal,
|
|
"lebensraum_text": species.lebensraum_lokal,
|
|
"stimme": species.stimme_lokal,
|
|
"lebensraum_label": species.habitat_label,
|
|
"bildnachweis": {
|
|
"urheber": species.reference_credit,
|
|
"lizenz": species.reference_license,
|
|
"quelle": species.reference_source,
|
|
},
|
|
# Fertig gerechnete Balken, wie auf der Website. Die
|
|
# Bezugsgrößen stecken in ``Species.balken()``; sie in der App
|
|
# nachzubauen hieße, zwei Wahrheiten über denselben Katalog zu
|
|
# haben.
|
|
"balken": species.balken(),
|
|
},
|
|
"besuche": [_sichtung_kurz(request, s) for s in besuche],
|
|
"tagesverlauf": [
|
|
{"stunde": h, "anzahl": n, "anteil": round(n / spitze, 3)}
|
|
for h, n in enumerate(stunden)
|
|
],
|
|
"verwechslung": [
|
|
_art_kurz(request, s, None) for s in aehnliche
|
|
],
|
|
"blaettern": {
|
|
"vorher": vorher.slug if vorher else None,
|
|
"nachher": nachher.slug if nachher else None,
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@require_GET
|
|
def sichtungen(request: HttpRequest) -> JsonResponse:
|
|
"""Der Feed. Jüngste zuerst, seitenweise.
|
|
|
|
``?nur_bestimmt=1`` blendet aus, was keine Art hat — das ist die Ansicht
|
|
für jemanden, der *sammelt*; die vollständige Liste ist die für jemanden,
|
|
der *prüft*.
|
|
"""
|
|
if not _zugang(request):
|
|
return _abgelehnt()
|
|
|
|
qs = Sighting.objects.select_related(
|
|
"species", "confirmed_species", "camera"
|
|
).order_by("-started_at")
|
|
|
|
if request.GET.get("nur_bestimmt"):
|
|
qs = qs.bestimmt()
|
|
|
|
kamera = request.GET.get("kamera", "")
|
|
if kamera:
|
|
qs = qs.filter(camera__slug=kamera)
|
|
|
|
art_slug = request.GET.get("art", "")
|
|
if art_slug:
|
|
qs = qs.filter(Q(species__slug=art_slug) | Q(confirmed_species__slug=art_slug))
|
|
|
|
seite = _ganzzahl(request.GET.get("seite"), 1, mindestens=1)
|
|
pro_seite = _ganzzahl(request.GET.get("anzahl"), SEITE_VORGABE, mindestens=1)
|
|
pro_seite = min(pro_seite, SEITE_MAX)
|
|
|
|
gesamt = qs.count()
|
|
ab = (seite - 1) * pro_seite
|
|
|
|
return JsonResponse(
|
|
{
|
|
"sichtungen": [
|
|
_sichtung_kurz(request, s) for s in qs[ab : ab + pro_seite]
|
|
],
|
|
"seite": seite,
|
|
"pro_seite": pro_seite,
|
|
"gesamt": gesamt,
|
|
"weiter": ab + pro_seite < gesamt,
|
|
}
|
|
)
|
|
|
|
|
|
def _ganzzahl(wert, vorgabe: int, mindestens: int = 0) -> int:
|
|
try:
|
|
return max(mindestens, int(wert))
|
|
except (TypeError, ValueError):
|
|
return vorgabe
|
|
|
|
|
|
@require_GET
|
|
def sichtung(request: HttpRequest, sichtung_id: int) -> JsonResponse:
|
|
"""Eine einzelne Sichtung.
|
|
|
|
🔑 **Das ist der Endpunkt, mit dem die App nach einem Upload nachfragt.**
|
|
``api.hochladen`` legt eine wartende Sichtung an und gibt ihre Kennung
|
|
zurück; bestimmt wird sie erst, wenn der Detector sie abgeholt hat. Die
|
|
App fragt also hier nach, bis ``wartet`` auf ``false`` springt, und zeigt
|
|
dann das Ergebnis.
|
|
|
|
⚠️ ``wartet: false`` **und** ``art: null`` ist kein Fehler, sondern die
|
|
Antwort "auf dem Foto war kein Vogel". Die App muss beide Fälle
|
|
unterscheiden können, sonst dreht sie für ein Foto vom eigenen Daumen
|
|
endlos die Warteschleife.
|
|
"""
|
|
if not _zugang(request):
|
|
return _abgelehnt()
|
|
|
|
s = (
|
|
Sighting.objects.select_related("species", "confirmed_species", "camera")
|
|
.filter(id=sichtung_id)
|
|
.first()
|
|
)
|
|
if s is None:
|
|
return JsonResponse({"fehler": "keine Sichtung mit dieser Kennung"}, status=404)
|
|
|
|
daten = _sichtung_kurz(request, s)
|
|
art = s.effective_species
|
|
if art is not None:
|
|
eintrag = DexEntry.objects.filter(species=art).first()
|
|
daten["album"] = _art_kurz(request, art, eintrag)
|
|
# Nach einem Erstfang will die App die neue Punktzahl zeigen, ohne noch
|
|
# einmal den Startbildschirm zu holen.
|
|
daten["fortschritt"] = _fortschritt()
|
|
return JsonResponse(daten)
|
|
|
|
|
|
@require_GET
|
|
def abzeichen(request: HttpRequest) -> JsonResponse:
|
|
"""Alle Abzeichen mit Fortschritt — verdiente und offene.
|
|
|
|
🔑 Nur die *zählbaren* Abzeichen haben einen Balken. "Ein Specht war da"
|
|
passiert oder passiert nicht; ein Balken auf 0 % wäre dort keine
|
|
Information, sondern nur entmutigend. Dieselbe Regel wie auf der Website,
|
|
und sie kommt aus derselben Funktion.
|
|
"""
|
|
if not _zugang(request):
|
|
return _abgelehnt()
|
|
|
|
from .templatetags.dex_extras import abzeichen_bild
|
|
|
|
verdient = {
|
|
b.code: b.earned
|
|
for b in Badge.objects.filter(earned__isnull=False).select_related("earned")
|
|
}
|
|
stand = scoring.fortschritt_je_abzeichen()
|
|
|
|
zeilen = []
|
|
for badge in Badge.objects.all():
|
|
erworben = verdient.get(badge.code)
|
|
# 🔑 **Der Pin, nicht das Emoji.** Die Website zeigt die gezeichneten
|
|
# Emaille-Pins aus `static/img/pins/`; eine App mit Emoji daneben wäre
|
|
# eine zweite Gestaltung derselben Sache. Dazu kommt der Grund, aus
|
|
# dem die Pins überhaupt entstanden sind: Auf einem System ohne
|
|
# Emoji-Schrift steht statt des Abzeichens ein leeres Rechteck — und
|
|
# zwar ausgerechnet auf der Seite, die Belohnungen zeigt.
|
|
#
|
|
# ⚠️ Das Emoji bleibt trotzdem im Feld `symbol`: Ein später
|
|
# hinzugefügtes Abzeichen hat noch keinen Pin, und dann ist das Emoji
|
|
# besser als eine Lücke. Die App entscheidet, was sie nimmt.
|
|
pin = abzeichen_bild(badge.code)
|
|
zeile = {
|
|
"code": badge.code,
|
|
"name": badge.name_lokal,
|
|
"beschreibung": badge.description_lokal,
|
|
"symbol": badge.emoji,
|
|
"bild": request.build_absolute_uri(pin) if pin else None,
|
|
"punkte": badge.xp,
|
|
"verdient": erworben is not None,
|
|
"am": erworben.earned_at.isoformat() if erworben else None,
|
|
}
|
|
messbar = stand.get(badge.code)
|
|
if messbar and erworben is None:
|
|
ist, ziel = messbar
|
|
zeile |= {
|
|
"ist": ist,
|
|
"ziel": ziel,
|
|
"anteil": round(ist / ziel, 3) if ziel else 0.0,
|
|
}
|
|
zeilen.append(zeile)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"abzeichen": zeilen,
|
|
"verdient": sum(1 for z in zeilen if z["verdient"]),
|
|
"gesamt": len(zeilen),
|
|
}
|
|
)
|