"""Datenmodell des Vogelalbum. Drei Ebenen: * ``Species`` — der Katalog. Was es theoretisch zu finden gibt. * ``Sighting`` — ein einzelner Besuch vor der Kamera. Rohdaten, unveränderlich. * ``DexEntry`` — der Sammel-Fortschritt pro Art. Abgeleitet, aber gespeichert, damit die Dex-Seite nicht bei jedem Aufruf über alle Sichtungen aggregieren muss. """ from __future__ import annotations import hashlib import secrets from datetime import timedelta from django.conf import settings from django.db import models from django.db.models.functions import Coalesce from django.utils import timezone def hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() class Camera(models.Model): """Eine Kamera, die Sichtungen meldet. Der Token identifiziert die Kamera — die Web-App muss sie deshalb nie erreichen können. Der Detector meldet ausgehend, es gibt keinen Port, der von außen offen sein müsste. Genau das erlaubt es, die Erkennung im Heimnetz laufen zu lassen und die Sammlung öffentlich zu hosten. Gespeichert wird nur der SHA-256 des Tokens. Ein hochentropischer Token braucht kein Salt, und wer die Datenbank in die Hand bekommt, kann damit trotzdem keine Sichtungen einliefern. """ name = models.CharField(max_length=80) slug = models.SlugField(max_length=48, unique=True) location = models.CharField("Standort", max_length=120, blank=True) token_hash = models.CharField(max_length=64, unique=True, editable=False) active = models.BooleanField("Aktiv", default=True) created_at = models.DateTimeField(default=timezone.now) last_seen = models.DateTimeField("Zuletzt gemeldet", null=True, blank=True) # 🔑 Getrennt von last_seen, und das ist der Kern der Sache: last_seen # heisst "zuletzt einen Vogel gesehen". Daran zu messen, ob die Kamera # lebt, waere falsch -- an einem ruhigen Nachmittag kommt stundenlang # nichts, und die Anlage ist trotzdem in Ordnung. # # Hier steht, wann sich der Detector zuletzt gemeldet hat. Er fragt # ohnehin alle fuenf Minuten die Konfiguration ab; dieser Abruf ist das # Lebenszeichen. Kein zusaetzlicher Verkehr, kein neuer Endpunkt. letzter_kontakt = models.DateTimeField( "Letztes Lebenszeichen", null=True, blank=True) # ⚠ Hier und nicht im Prozessspeicher, und das ist der ganze Witz: # `stoerungen_pruefen` ist ein Einmal-Lauf. Ein Modul-Set lebt genau # einen Durchlauf lang und ist beim naechsten wieder leer -- die # Entprellung griff deshalb NIE. Am 01.10.2026 kam der Beweis aufs # Handy: nach dem Stromausfall meldete der Waechter beide Kameras alle # zehn Minuten neu, viermal in zwei Laeufen, "schon gemeldet" kein # einziges Mal. Genau das Summen, das der Befehl vermeiden wollte. # # Ein Zeitstempel statt eines Ja/Nein: so steht da auch, SEIT WANN # gemeldet ist. Wer spaeter eine Erinnerung nach einigen Stunden will, # braucht dafuer kein neues Feld. stoerung_gemeldet_seit = models.DateTimeField( "Störung gemeldet seit", null=True, blank=True, editable=False) # Was der Detector von hier holt statt von seiner Kommandozeile. Bis diese # Felder existierten, stand die Einstellung nur im Startskript auf dem # Rechner, auf dem der Detector läuft — wer die Kamera umstellen wollte, # brauchte eine Shell. stream_url = models.URLField( "Stream-Adresse", blank=True, help_text="MJPEG- oder Snapshot-Adresse der Kamera, z.B. " "http://vogelkamera.local:8080/stream. Leer heißt: der Detector " "nimmt, was auf seiner Kommandozeile steht.", ) habitats = models.CharField( "Lebensräume", max_length=120, default="garten", help_text="Kommagetrennt: garten, wald, offenland, wasser, gebirge. " "Schränkt ein, welche Arten der Klassifikator überhaupt " "erwägt — eine Kamera am Futterhaus soll gar nicht erst " "überlegen, ob da ein Kranich sitzt. Leer lässt alle zu.", ) detect_threshold = models.FloatField( "Schwelle Vogelerkennung", default=0.4, help_text="Ab welcher Sicherheit etwas als Vogel gilt (0–1).", ) min_bird_score = models.FloatField( "Mindestanteil heimischer Arten", default=0.05, help_text="Darunter gilt die Sichtung als unbestimmt. Gemessen ist " "0.05 richtig: bei 0.10 fliegen richtige Bestimmungen raus, " "weil bei manchen Arten global die amerikanische " "Schwesterart gewinnt.", ) # ⚠️ **Nicht zu verwechseln mit ``min_bird_score`` eine Zeile höher.** Die # Namen ähneln sich, die Bedeutung ist verschieden: # # min_bird_score gilt je EINZELBILD # min_mean_bird_score gilt für den DURCHSCHNITT des ganzen Besuchs # # 🔑 Genau dieser Durchschnitt war die Lücke. Mit ``--min-bird-frames 1`` # genügte ein einziger Zufallstreffer unter 73 Bildern, um einen Besuch # anzulegen — so entstand am 24.09.2026 eine „Kohlmeise" aus einer # Befestigungsschraube (1 von 73 Bildern, Wert 0,098), und am 27.09. # vierundzwanzig Besuche aus Spiegelungen des Mondes (Wert 0,000). # # 📌 **Vorgabe 0, und das ist Absicht.** Alles bei exakt null wird ohnehin # abgewiesen; darüber hinaus will der Wert gemessen und nicht geraten sein. # Aus den Daten vom 01.10.2026: echte Sichtungen lagen bei 0,269 bis 0,600, # die Schraube bei 0,098, die Spiegelungen bei 0,000. Eine Grenze um 0,15 # fienge beide Fehler ab — belegt ist das aber nur durch sieben echte # Sichtungen, und das ist zu wenig. Wer mehr Daten hat, dreht hier hoch. min_mean_bird_score = models.FloatField( "Mindest-Vogelwert über den Besuch", default=0.0, help_text="Liegt der mittlere Vogelwert eines Besuchs darunter, " "entsteht gar keine Sichtung. 0 heißt: nur Meldungen ohne " "jeden Vogelwert werden abgewiesen. Erst hochdrehen, wenn " "genug eigene Sichtungen vorliegen — sonst wirft man " "richtige Bestimmungen weg.", ) # 🔑 Ein Handy ist auch eine Kamera — es hängt nur nicht am Haus. Es lädt # Fotos hoch statt zu streamen, bringt seinen Ort je Aufnahme mit und hat # weder Live-Bild noch Bildeinstellungen. # # ⚠️ **Entscheidend für den Störungswächter.** `stoerungen_pruefen` meldet # eine Kamera, die sich länger als `stille_minuten` nicht gemeldet hat. Ein # Vogelhaus meldet sich alle fünf Minuten, ein Handy alle paar Tage — ohne # dieses Kennzeichen käme nach dem ersten ruhigen Wochenende „Kamera still: # Handy" aufs Telefon. Das ist dieselbe Fehlerklasse wie das Abzeichen für # Mondspiegelungen am 01.10.2026: eine Regel, die für den einen Gerätetyp # richtig ist und beim anderen Fehlalarm erzeugt. mobil = models.BooleanField( "Mobiles Gerät", default=False, help_text="Lädt Fotos hoch statt zu streamen (Handy, Tablet). Solche " "Geräte werden nicht überwacht — sie melden sich nur, wenn " "jemand unterwegs fotografiert.", ) # Nicht die Kamera selbst, sondern der Live-Verteiler des Detectors # (`python -m detector watch --live-port`). Die Kamera liefert nur einen # Stream; wer direkt bei ihr zuschaut, nimmt ihn der Erkennung weg. live_bildrate = models.FloatField( "Bilder je Sekunde in der Vorschau", default=2.0, help_text=( "Gilt NUR für Zuschauer — die Erkennung bekommt immer jedes Bild. " "Gemessen am 24.09.2026: ohne Begrenzung schickt eine Kamera in " "HD rund 6 bis 10 Mbit/s. Zwei Kameras nebeneinander sind über 16 " "Mbit/s, und auf dem Handy dauert es entsprechend, bis das erste " "Bild erscheint. 2 Bilder/s sind flüssig genug fürs Zuschauen und " "brauchen etwa ein Achtel davon. Höher setzen heißt: ruckelfreier, " "aber träger Start und mehr Funklast. 0 schaltet die Begrenzung ab." ), ) live_url = models.URLField( "Live-Adresse", blank=True, help_text="MJPEG-Adresse des Live-Verteilers, z.B. " "http://erkennung.local:8131/live — NICHT die Kamera selbst, " "sonst fehlt der Erkennung das Bild.", ) live_enabled = models.BooleanField( "Live-Bild zeigen", default=False, help_text="Zeigt das Livebild dieser Kamera auf der Seite /live/. " "Bewusst standardmäßig aus: ein Livebild aus dem eigenen " "Garten gehört nicht auf eine öffentliche Seite.", ) class Meta: verbose_name = "Kamera" verbose_name_plural = "Kameras" ordering = ["name"] def __str__(self) -> str: return self.name @staticmethod def new_token() -> str: return secrets.token_urlsafe(32) def set_token(self, token: str) -> None: self.token_hash = hash_token(token) @classmethod def by_token(cls, token: str) -> "Camera | None": return cls.objects.filter(token_hash=hash_token(token), active=True).first() @property def is_stale(self) -> bool: """Kein Lebenszeichen seit über einer Stunde — da hängt vermutlich etwas. ⚠️ **Gemessen wird ``letzter_kontakt``, NICHT ``last_seen``.** Die beiden Felder sehen ähnlich aus und bedeuten Verschiedenes: ``last_seen`` heißt „zuletzt einen Vogel gesehen", ``letzter_kontakt`` heißt „die Kamera hat sich gemeldet". An ``last_seen`` zu messen hieße zu warnen, wenn nichts los ist, statt wenn etwas kaputt ist. 🔑 **Genau dieser Fehler stand hier bis zum 01.10.2026.** Die Dev-Server zeigte das Warnzeichen vor beiden Kameras, während beide tadellos liefen: Lebenszeichen vor einer Minute, letzter Vogel aber 1810 bzw. 5435 Minuten her. Ein Zeichen, das fast immer steht, sagt nichts mehr. ⚠️ Dass es ein bekannter Fehler ist, macht es schlimmer: In ``stoerungen_pruefen`` steht seit jeher der Kommentar „an last_seen zu messen waere der naheliegende Fehler -- an einem ruhigen Nachmittag kommt stundenlang kein Vogel". Dort wurde er erkannt und behoben, hier blieb er stehen. Eine Lehre gilt für alle Stellen, nicht nur für die, an der sie aufgefallen ist. ⚠️ **Mobile Geräte sind nie „still".** Ein Handy meldet sich, wenn jemand spazieren geht; „seit einer Stunde nichts" ist dort der Normalfall. Dieselbe Unterscheidung trifft ``stoerungen_pruefen``. """ if self.mobil: return False # 📌 Noch nie gemeldet bleibt auffällig. Der Störungswächter wertet das # anders — er meldet eine frisch eingerichtete Kamera nicht aufs # Telefon, und das ist dort richtig: Es ist keine Störung. Auf der # Seite ist es trotzdem die Wahrheit, nämlich „von hier kam noch # nichts". Dieses Verhalten stand schon vorher so im Test # ``test_camera_without_contact_is_stale`` und bleibt unverändert; # korrigiert wurde am 01.10.2026 allein die Messgröße. if self.letzter_kontakt is None: return True return timezone.now() - self.letzter_kontakt > timedelta(hours=1) @property def shows_live(self) -> bool: """Ob diese Kamera ein Livebild zeigen darf. Drei Bedingungen, und die Umgebung hat das letzte Wort: auf einer öffentlichen Instanz steht ``VOGELALBUM_LIVE_ERLAUBT=0``, und dann nützt auch ein Häkchen im Admin nichts. Ein Livebild aus einem Privatgarten soll nicht an einem Fehlklick hängen. """ return bool( settings.LIVE_ALLOWED and self.active and self.live_enabled and self.live_url ) @property def habitat_set(self) -> set[str]: """Die Lebensräume als Menge. Leer heißt: alle Arten zulassen.""" return {h.strip() for h in self.habitats.split(",") if h.strip()} @property def live_bild_url(self) -> str: """Einzelbild statt Dauerstrom. 🔑 Warum nicht der MJPEG-Strom in der Übersicht: Am 24.09.2026 gemessen — eine Kamera allein erscheint sofort, zwei nebeneinander lassen den Browser hängen. Ein Dauerstrom belegt seine Verbindung für immer, und mit zweien kommt mancher Browser (besonders auf dem Handy) durcheinander. Einzelbilder sind kurze, gewöhnliche Anfragen: Sie blockieren nichts, holen sich nach einem Fehler von selbst wieder, und die Bildrate bestimmt schlicht, wie oft gefragt wird. Für EINE Kamera bleibt der Strom die bessere Wahl -- deshalb ist live_url weiterhin da. """ if not self.live_url: return "" return self.live_url.rstrip("/").removesuffix("/live") + "/bild.jpg" def konfiguration(self) -> dict: """Was der Detector über ``/api/config/`` bekommt. Die Web-App ist damit die Wahrheit darüber, welche Arten zählen und ab welcher Sicherheit — nicht mehr die Kommandozeile auf dem Rechner, auf dem der Detector läuft. Die Artenliste kommt aus der Datenbank, eingeschränkt auf Region und Lebensräume dieser Kamera. """ arten = Species.objects.all() if self.habitat_set: arten = arten.filter(lebensraum_typ__in=self.habitat_set) # Die Region wird in Python geprüft: SQLite kann kein 'contains' auf # einem JSON-Feld, und ein Textvergleich wäre falsch — bei # zweibuchstabigen Kürzeln findet 'de' auch 'den'. Bei einigen hundert # Arten kostet die Schleife nichts. region = settings.REGION arten = [a for a in arten.order_by("scientific") if region in (a.regions or [])] return { "kamera": self.slug, "stream_url": self.stream_url, "habitats": sorted(self.habitat_set), "live_bildrate": self.live_bildrate, "detect_threshold": self.detect_threshold, "min_bird_score": self.min_bird_score, # ⚠️ Zwei ähnlich klingende Werte, verschiedene Bedeutung: # min_bird_score gilt je Einzelbild, min_mean_bird_score für den # Durchschnitt des ganzen Besuchs. Der Detector braucht beide — # den zweiten, um Besuche gar nicht erst zu senden, die die # Web-App ohnehin abweisen würde. Das spart vor allem Funk: Ein # Bild hochzuladen und dann verworfen zu bekommen, kostet eine # Kamera am Gartenzaun echte Bandbreite. "min_mean_bird_score": self.min_mean_bird_score, "region": settings.REGION, # Der wissenschaftliche Name ist gleichzeitig der Schlüssel ins # Label-File des Klassifikators — deshalb braucht es hier kein # zweites Mapping. "arten": [ { "scientific": a.scientific, "german": a.german, "english": a.english, "rarity": a.rarity, "lebensraum": a.lebensraum_typ, } for a in arten ], } @classmethod def live_cameras(cls): if not settings.LIVE_ALLOWED: return cls.objects.none() return cls.objects.filter(active=True, live_enabled=True).exclude(live_url="") RARITY_CHOICES = [ ("haeufig", "Häufig"), ("gelegentlich", "Gelegentlich"), ("selten", "Selten"), ("episch", "Episch"), ("legendaer", "Legendär"), ] RARITY_ORDER = {k: i for i, (k, _) in enumerate(RARITY_CHOICES)} class Species(models.Model): slug = models.SlugField(max_length=64, unique=True) german = models.CharField("Deutscher Name", max_length=80) scientific = models.CharField("Wissenschaftlicher Name", max_length=120) english = models.CharField("Englischer Name", max_length=120, blank=True) rarity = models.CharField(max_length=16, choices=RARITY_CHOICES) xp = models.PositiveIntegerField(default=10) fact = models.TextField("Kurzfakt", blank=True) size_cm = models.CharField(max_length=16, blank=True) # Steckbrief im Stil einer Sammelkarte. Alles optional, damit neue # Arten in den Katalog können, bevor der Text dazu geschrieben ist. number = models.PositiveIntegerField("Dex-Nummer", default=0, db_index=True) typ = models.CharField("Typ", max_length=24, blank=True) kategorie = models.CharField("Kategorie", max_length=80, blank=True) beschreibung = models.TextField("Beschreibung", blank=True) gewicht_g = models.FloatField("Gewicht (g)", null=True, blank=True) spannweite_cm = models.FloatField("Spannweite (cm)", null=True, blank=True) zug = models.CharField("Zugverhalten", max_length=24, blank=True) nahrung = models.CharField("Nahrung", max_length=200, blank=True) lebensraum = models.CharField("Lebensraum", max_length=200, blank=True) stimme = models.CharField("Stimme", max_length=200, blank=True) verwechslung = models.JSONField("Verwechslungsgefahr", default=list, blank=True) # Wo die Art vorkommt und wo sie lebt. Die Region entscheidet, welcher # Katalog überhaupt gilt; der Lebensraum teilt ihn in Portionen, die man # noch überblickt. regions = models.JSONField("Regionen", default=list, blank=True) lebensraum_typ = models.CharField( "Lebensraum-Gruppe", max_length=16, default="garten", db_index=True ) # Englische Fassung der Texte. Bewusst eigene Felder statt einer # Übersetzungs-Bibliothek: es sind fünf Felder in zwei Sprachen, und die # Texte sind geschrieben, nicht übersetzt — eine wörtliche Übertragung # liest sich in beiden Richtungen schlecht. kategorie_en = models.CharField("Kategorie (en)", max_length=80, blank=True) beschreibung_en = models.TextField("Beschreibung (en)", blank=True) nahrung_en = models.CharField("Nahrung (en)", max_length=200, blank=True) lebensraum_en = models.CharField("Lebensraum (en)", max_length=200, blank=True) stimme_en = models.CharField("Stimme (en)", max_length=200, blank=True) # Referenzfoto aus Wikipedia: zeigt, wonach man Ausschau hält, bevor die # Art zum ersten Mal selbst vor der Kamera saß. Lizenz und Urheber werden # mitgespeichert — die Bilder stehen unter CC und wollen genannt werden. reference_image = models.ImageField( "Referenzfoto", upload_to="referenz/", blank=True ) reference_credit = models.CharField("Urheber", max_length=250, blank=True) reference_license = models.CharField("Lizenz", max_length=80, blank=True) reference_source = models.URLField("Quelle", max_length=400, blank=True) class Meta: verbose_name = "Art" verbose_name_plural = "Arten" ordering = ["number", "german"] def __str__(self) -> str: return self.german @property def dex_nr(self) -> str: return f"#{self.number:03d}" if self.number else "#—" def _lang(self, feld: str) -> str: """Feld in der aktiven Sprache, mit Rückfall auf Deutsch. Der Rückfall ist Absicht: der Katalog wächst, und eine Art ohne englischen Text soll den deutschen zeigen statt gar nichts. """ from django.utils.translation import get_language if (get_language() or "de").startswith("en"): return getattr(self, f"{feld}_en", "") or getattr(self, feld, "") return getattr(self, feld, "") @property def name(self) -> str: """Artname in der aktiven Sprache.""" from django.utils.translation import get_language if (get_language() or "de").startswith("en") and self.english: return self.english return self.german @property def zweitname(self) -> str: """Der jeweils andere Name — steht klein unter dem Titel.""" from django.utils.translation import get_language if (get_language() or "de").startswith("en") and self.english: return self.german return self.english @property def kategorie_lokal(self) -> str: return self._lang("kategorie") @property def nahrung_lokal(self) -> str: return self._lang("nahrung") @property def lebensraum_lokal(self) -> str: return self._lang("lebensraum") @property def stimme_lokal(self) -> str: return self._lang("stimme") @property def zug_label(self) -> str: from django.utils.translation import get_language from .species_meta import ZUG_LABEL, ZUG_LABEL_EN table = ( ZUG_LABEL_EN if (get_language() or "de").startswith("en") else ZUG_LABEL ) return table.get(self.zug, self.zug) @property def typ_label(self) -> str: from django.utils.translation import get_language from .species_meta import TYPE_LABEL_EN, TYPE_STYLE if (get_language() or "de").startswith("en"): return TYPE_LABEL_EN.get(self.typ, self.typ or "—") return TYPE_STYLE.get(self.typ, ("—", "#7f9b6a", "#101a14"))[0] @property def rarity_label(self) -> str: from django.utils.translation import get_language from .species_meta import RARITY_LABEL_EN if (get_language() or "de").startswith("en"): return RARITY_LABEL_EN.get(self.rarity, self.rarity) return self.get_rarity_display() @property def typ_color(self) -> str: from .species_meta import TYPE_STYLE return TYPE_STYLE.get(self.typ, ("—", "#7f9b6a", "#101a14"))[1] @property def typ_ink(self) -> str: """Schriftfarbe auf der Typfläche — die Farben sind zu hell für Weiß.""" from .species_meta import TYPE_STYLE return TYPE_STYLE.get(self.typ, ("—", "#7f9b6a", "#101a14"))[2] @property def typ_vars(self) -> str: """Alle abgeleiteten Typfarben als fertige CSS-Variablen. Vorberechnet statt per ``color-mix()`` im Stylesheet: das gibt es erst ab Safari 16.2, und auf älteren Geräten fällt jede betroffene Regel ersatzlos weg. """ from .species_meta import type_vars return type_vars(self.typ) @property def absaetze(self) -> list[str]: text = self._lang("beschreibung") return [p.strip() for p in text.split("\n\n") if p.strip()] def balken(self) -> list[dict]: """Statuswerte als Balken, wie die Werte auf einer Sammelkarte. Die Bezugsgrößen sind die Extreme unseres eigenen Katalogs: der Zaunkönig mit 9 g gegen den Kolkraben mit 1200 g. Ein Balken zeigt also „wo steht diese Art unter unseren Arten", nicht einen absoluten Anteil — genau das macht ihn auf einen Blick lesbar. """ from django.utils.translation import gettext as _ rows = [] def add(label: str, value, maximum: float, einheit: str, log: bool = False): if value in (None, "", 0): return import math if log: pct = math.log10(value + 1) / math.log10(maximum + 1) * 100 else: pct = value / maximum * 100 wert = f"{value:g} {einheit}".strip() rows.append({"label": label, "wert": wert, "pct": max(4, min(100, round(pct)))}) # Gewicht logarithmisch: linear würde alles unter 100 g zu einem # ununterscheidbaren Strich zusammenfallen. add(_("Gewicht"), self.gewicht_g, 1200, "g", log=True) add(_("Spannweite"), self.spannweite_cm, 125, "cm") try: groesse = float(str(self.size_cm).split()[0]) if self.size_cm else None except (ValueError, IndexError): groesse = None add(_("Größe"), groesse, 64, "cm") seltenheit = _("Seltenheit") add(seltenheit, (self.rarity_rank + 1) * 20, 100, "") if rows and rows[-1]["label"] == seltenheit: rows[-1]["wert"] = self.rarity_label return rows @property def habitat_label(self) -> str: from django.utils.translation import get_language from .species_meta import HABITATS paar = HABITATS.get(self.lebensraum_typ) if not paar: return self.lebensraum_typ return paar[1] if (get_language() or "de").startswith("en") else paar[0] @property def rarity_rank(self) -> int: return RARITY_ORDER.get(self.rarity, 0) @property def rarity_color(self) -> str: from .species_meta import RARITY_COLOR return RARITY_COLOR.get(self.rarity, "#7f9b6a") @property def rarity_glow(self) -> str: from .species_meta import RARITY_GLOW return RARITY_GLOW.get(self.rarity, "none") class SichtungsQuerySet(models.QuerySet): """Was als Besuch zählt — an EINER Stelle festgelegt. 🔑 Vorher stand diese Entscheidung siebenmal im Code, jedes Mal etwas anders: mal ``Sighting.objects.count()`` (zählt alles), mal ``filter(species__isnull=False)`` (zählt die rohe Erkennung). Am 30.09.2026 war das Ergebnis zu sehen — die Übersicht meldete „31 Besuche insgesamt" bei 7 echten Vögeln und „2 Arten heute" bei einer. Beides waren keine Rechenfehler, sondern zwei verschiedene Vorstellungen davon, was ein Besuch ist. ⚠️ ``effective_species`` ist eine Python-Property und in der Datenbank nicht vorhanden. Wer danach filtern will, muss ``Coalesce`` nehmen — sonst fällt es erst auf, wenn eine korrigierte Sichtung weiter bei der falschen Art mitzählt. """ def mit_art(self): """Hängt ``art_id`` an: die bestätigte Art, sonst die erkannte.""" return self.annotate( art_id=Coalesce("confirmed_species_id", "species_id") ) def bestimmt(self): """Nur Sichtungen mit einer Art — das sind die echten Besuche. 📌 Unbestimmte Sichtungen bleiben in der Liste sichtbar (sie zeigen, dass die Kamera arbeitet), aber sie sind kein Besuch: Am 30.09.2026 waren 24 von 31 Spiegelungen des Mondes auf der Vogeltränke. 📌 **Hochgeladene Fotos, die noch auf den Detector warten, fallen hier von selbst heraus** — sie haben noch keine Art. Das ist kein Zufall, auf den man sich verlässt, sondern dieselbe Regel: Was keine Art hat, ist kein Besuch. Deshalb springt der Besuchszähler beim Hochladen nicht hoch, um nach der Bestimmung wieder zurückzufallen. """ return self.filter( models.Q(confirmed_species__isnull=False) | models.Q(species__isnull=False) ) def wartend(self): """Hochgeladene Fotos, die der Detector noch nicht bearbeitet hat. Älteste zuerst — wer zuerst hochgeladen hat, wartet am längsten. """ return self.filter(wartet_auf_bestimmung=True).order_by("started_at") def arten_anzahl(self) -> int: """Wie viele VERSCHIEDENE Arten — nach Bestätigung, nicht nach Modell.""" return ( self.bestimmt().mit_art() .values("art_id").distinct().count() ) class Sighting(models.Model): """Ein Besuch. Auch unbestimmte Besuche werden gespeichert — sie zeigen, dass die Kamera arbeitet, und lassen sich später nachbestimmen. ⚠️ Zum Zählen immer ``objects.bestimmt()`` nehmen, nie ``objects.count()`` — siehe :class:`SichtungsQuerySet`. """ objects = SichtungsQuerySet.as_manager() species = models.ForeignKey( Species, null=True, blank=True, on_delete=models.SET_NULL, related_name="sightings", ) camera = models.ForeignKey( Camera, null=True, blank=True, on_delete=models.SET_NULL, related_name="sightings", verbose_name="Kamera", ) confidence = models.FloatField(default=0.0) started_at = models.DateTimeField(db_index=True) ended_at = models.DateTimeField() image = models.ImageField(upload_to="sightings/%Y/%m/", blank=True) crop = models.ImageField(upload_to="crops/%Y/%m/", blank=True) # Wo der Vogel war. Leer bei allem, was aus einem Vogelhaus kommt: diese # Kamera hängt fest, ihr Standort steht am Gerät (``Camera.location``) und # gehört nicht an jede einzelne Sichtung. # # 🔑 Gefüllt wird das von unterwegs — ein Foto vom Spaziergang hat keinen # festen Platz, und erst der Ort macht daraus mehr als ein Bild. # # ⚠️ **Koordinaten sind personenbezogene Daten.** Wer im eigenen Garten # fotografiert, legt damit seine Wohnadresse in die Datenbank. Deshalb wird # schon beim Einliefern auf rund einen Kilometer gerundet (``ORT_STELLEN`` # in ``api.py``) — die genaue Position entsteht gar nicht erst. Angezeigt # wird sie zudem nicht von selbst. Dieselbe Überlegung wie beim Livebild # (siehe ``Camera.live_enabled``): Was aus dem Privaten nach außen geht, # darf nicht die Voreinstellung sein. latitude = models.FloatField("Breitengrad", null=True, blank=True) longitude = models.FloatField("Längengrad", null=True, blank=True) # 🔑 **Noch nicht bestimmt ist etwas anderes als nicht bestimmbar.** Beides # sieht an `species` gleich aus — leer. Ohne diese Unterscheidung könnte # der Detector nicht wissen, welche Fotos auf ihn warten, und die # Oberfläche nicht zwischen „gleich fertig" und „da war nichts" trennen. # # Gesetzt wird es beim Hochladen aus der App oder über das Formular, und # vom Detector wieder entfernt, sobald er das Bild bearbeitet hat — egal ob # mit Ergebnis oder ohne. # # ⚠️ Wartende Sichtungen sind KEIN Besuch (siehe ``SichtungsQuerySet``): # sonst springt der Zähler beim Hochladen hoch und nach der Bestimmung # wieder zurück, wenn gar kein Vogel drauf war. wartet_auf_bestimmung = models.BooleanField( "Wartet auf Bestimmung", default=False, db_index=True) frames_total = models.PositiveIntegerField(default=0) frames_with_bird = models.PositiveIntegerField(default=0) votes = models.JSONField(default=dict, blank=True) mean_bird_score = models.FloatField(default=0.0) # 🔑 **Wo der Vogel im Bild sitzt — als vier Zahlen, nicht als zweites # Bild.** # # Bis zum 14.10.2026 speicherte das Projekt neben dem Foto einen fertig # geschnittenen Ausschnitt. Der Zuschnitt war damit für immer festgelegt: # Eine bessere Regel half nur neuen Sichtungen, und alle vorhandenen # mussten einzeln nachgezogen werden. # # Aufgefallen ist es an Handyfotos. Dort ist der Vogel klein im Bild, der # enge Ausschnitt entsprechend schmal und hoch — und in einer quadratischen # Kachel blieb davon die Bauchmitte übrig, formatfüllend und unscharf. # Das Urteil darüber, und es war das richtige: *„Mir geht es darum, dass ein # Nutzer das so nicht verwenden kann."* # # Mit den Koordinaten ist der Zuschnitt eine **Regel statt einer Datei**: # Die Anzeige schneidet bei jedem Aufruf neu aus dem Original. Eine # geänderte Regel wirkt sofort auf den gesamten Bestand, Website und App # dürfen verschieden schneiden, und niemand braucht den Detector dafür. # # Werte sind relativ (0..1), damit sie eine Verkleinerung des Originals # überleben. Leer heißt: von vor dieser Änderung — dann gilt weiter das # gespeicherte ``crop``. box_x0 = models.FloatField("Vogel: links", null=True, blank=True) box_y0 = models.FloatField("Vogel: oben", null=True, blank=True) box_x1 = models.FloatField("Vogel: rechts", null=True, blank=True) box_y1 = models.FloatField("Vogel: unten", null=True, blank=True) is_first_of_species = models.BooleanField(default=False) xp_awarded = models.PositiveIntegerField(default=0) # Wer die Sammlung betreut, kann eine Bestimmung überstimmen. Das ist später die # Trainingsmenge für ein eigenes Modell auf genau dieser Kamera. confirmed_species = models.ForeignKey( Species, null=True, blank=True, on_delete=models.SET_NULL, related_name="confirmations", verbose_name="Von Hand bestätigt als", ) class Meta: verbose_name = "Sichtung" verbose_name_plural = "Sichtungen" ordering = ["-started_at"] indexes = [models.Index(fields=["-started_at", "species"])] def __str__(self) -> str: who = self.effective_species.german if self.effective_species else "unbestimmt" return f"{who} am {timezone.localtime(self.started_at):%d.%m. %H:%M}" @property def effective_species(self) -> Species | None: """Handkorrektur schlägt Modell.""" return self.confirmed_species or self.species @property def vogel_box(self) -> tuple[float, float, float, float] | None: """Wo der Vogel sitzt, relativ — oder ``None`` bei Altbestand.""" werte = (self.box_x0, self.box_y0, self.box_x1, self.box_y1) if any(w is None for w in werte): return None x0, y0, x1, y1 = werte if x1 <= x0 or y1 <= y0: return None return (x0, y0, x1, y1) @property def kachel_url(self) -> str: """Das Bild für eine Kachel: aus dem Original geschnitten. 🔑 Rückfall auf das gespeicherte ``crop`` für Sichtungen von vor der Umstellung, danach auf das Original. So bleibt jede alte Sammlung anzeigbar, ohne dass jemand etwas nachziehen muss. """ from .zuschnitt import adresse neu = adresse(self) if neu: return neu if self.crop: return self.crop.url return self.image.url if self.image else "" @property def duration_s(self) -> float: return (self.ended_at - self.started_at).total_seconds() class DexEntry(models.Model): """Sammel-Fortschritt für eine Art.""" species = models.OneToOneField( Species, on_delete=models.CASCADE, related_name="entry" ) first_seen = models.DateTimeField() last_seen = models.DateTimeField() count = models.PositiveIntegerField(default=0) best_sighting = models.ForeignKey( Sighting, null=True, blank=True, on_delete=models.SET_NULL, related_name="+" ) class Meta: verbose_name = "Dex-Eintrag" verbose_name_plural = "Dex-Einträge" ordering = ["-count"] def __str__(self) -> str: return f"{self.species.german} ×{self.count}" @property def is_new(self) -> bool: return timezone.now() - self.first_seen < timedelta(days=3) class Badge(models.Model): """Abzeichen. ``code`` wird von der Auswertung in ``scoring.py`` gesetzt.""" code = models.SlugField(max_length=48, unique=True) name = models.CharField(max_length=80) description = models.CharField(max_length=200) emoji = models.CharField(max_length=8, default="🏅") xp = models.PositiveIntegerField(default=50) sort = models.PositiveIntegerField(default=0) class Meta: verbose_name = "Abzeichen" verbose_name_plural = "Abzeichen" ordering = ["sort", "name"] def __str__(self) -> str: return self.name # Abzeichen sind ein fester, im Code definierter Satz. Deshalb liegt in der # Datenbank der deutsche Text und die Übersetzung passiert bei der Ausgabe # — anders als bei den Arten, wo der Katalog wächst und eigene Felder # nötig sind. Ein Sprachwechsel braucht so keine Migration. @property def name_lokal(self) -> str: from django.utils.translation import gettext as _ return _(self.name) @property def description_lokal(self) -> str: from django.utils.translation import gettext as _ return _(self.description) class EarnedBadge(models.Model): badge = models.OneToOneField(Badge, on_delete=models.CASCADE, related_name="earned") earned_at = models.DateTimeField(default=timezone.now) sighting = models.ForeignKey( Sighting, null=True, blank=True, on_delete=models.SET_NULL, related_name="+" ) class Meta: verbose_name = "Verdientes Abzeichen" verbose_name_plural = "Verdiente Abzeichen" ordering = ["-earned_at"] def __str__(self) -> str: return f"{self.badge.name} ({self.earned_at:%d.%m.%Y})" class Benachrichtigung(models.Model): """Einstellungen für Meldungen über ntfy. Es gibt genau einen Datensatz. Warum als Modell und nicht als Angabe in den Einstellungsdateien: Man soll im Backend umstellen können, ohne dass jemand die Anwendung neu startet -- und ohne dass er dafür jemanden fragen muss. """ class Sichtungen(models.TextChoices): AUS = "aus", "keine Sichtungsmeldungen" NEUE = "neue", "nur neue Arten (Erstfang)" ALLE = "alle", "alle Sichtungen" aktiv = models.BooleanField("Meldungen einschalten", default=False) server = models.URLField( "ntfy-Server", default="https://ntfy.sh", help_text="Eigener Server oder ntfy.sh", ) thema = models.CharField( "Thema", max_length=100, blank=True, help_text="Der Name, den du in der ntfy-App abonnierst. " "⚠ Wer das Thema kennt, kann mitlesen — nimm etwas, das " "niemand errät.", ) sichtungen = models.CharField( "Sichtungen", max_length=8, choices=Sichtungen.choices, default=Sichtungen.NEUE, help_text="Ein gut besuchtes Futterhaus erzeugt schnell Dutzende " "Meldungen am Tag. 'Nur neue Arten' bleibt dauerhaft " "interessant.", ) stoerungen = models.BooleanField( "Störungen melden", default=True, help_text="Kamera meldet sich nicht mehr, Erkennung steht.", ) stille_minuten = models.PositiveIntegerField( "Störung nach … Minuten Stille", default=30, help_text="So lange darf eine Kamera schweigen, bevor es eine " "Meldung gibt. Zu knapp bedeutet Fehlalarm bei jedem " "Router-Neustart.", ) # ⚠️ Merker für die Meldung „Fotos warten auf Bestimmung" — dasselbe # Muster wie ``Camera.stoerung_gemeldet_seit`` und aus demselben Grund in # der Datenbank statt im Prozess: ``stoerungen_pruefen`` ist ein Einmal- # Lauf, ein Merker im Speicher lebt genau einen Durchlauf. Am 01.10.2026 # hat das eine Störung alle zehn Minuten neu aufs Telefon geschickt, bis # der Merker hier gelandet ist. Ohne dieses Feld wäre derselbe Fehler mit # der neuen Meldung sofort wieder da. fotos_gemeldet_seit = models.DateTimeField( "Wartende Fotos gemeldet seit", null=True, blank=True, editable=False) class Meta: verbose_name = "Benachrichtigung" verbose_name_plural = "Benachrichtigungen" def __str__(self): return "Benachrichtigungen" + ("" if self.aktiv else " (aus)") @classmethod def laden(cls): """Den einen Datensatz holen, notfalls anlegen.""" eintrag, _ = cls.objects.get_or_create(pk=1) return eintrag @property def ziel(self): """Vollständige Adresse, oder leer wenn nichts eingerichtet ist.""" if not self.thema: return "" return f"{self.server.rstrip('/')}/{self.thema}"