Vogelalbum 1.0.0
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.
This commit is contained in:
commit
c11fe9ab04
189 files changed
+29806
No files matched your search
@@ -0,0 +1,579 @@
|
||||
"""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),
|
||||
}
|
||||
)
|
||||
Reference in new issue
Block a user