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,957 @@
|
||||
"""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}"
|
||||
Reference in new issue
Block a user