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