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.
220 lines
9.1 KiB
Python
220 lines
9.1 KiB
Python
"""Entfernt eine Sichtung — samt allem, was aus ihr abgeleitet wurde.
|
||
|
||
🔑 Warum das ein eigener Befehl ist und kein Löschen im Admin: An einer
|
||
Sichtung hängt mehr als ihre eigene Zeile. Der Dex-Eintrag zählt sie mit,
|
||
die Kamera merkt sich den Zeitpunkt, die Punkte stecken im Datensatz, und
|
||
zwei Bilddateien liegen auf der Platte. Wer nur die Zeile löscht, lässt
|
||
einen Dex-Eintrag zurück, der eine Sichtung zu viel zählt.
|
||
|
||
Gedacht für Fehlerkennungen. Die erste war am 24.09.2026: Die Kamera für
|
||
die Vogeltränke lag noch auf dem Schreibtisch und hat Teile der
|
||
Befestigung für eine Kohlmeise gehalten — ein einziges von 73 Bildern
|
||
hatte einen Vogel-Wert über der Schwelle, und der lag bei 0,098.
|
||
|
||
⚠ Die Punkte der Sichtung verrechnet niemand gesondert: Der Gesamtstand
|
||
summiert ``xp_awarded``, also verschwinden sie mit der Sichtung von selbst.
|
||
|
||
🔴 **Für die Punkte eines Abzeichens gilt das NICHT** — und bis zum
|
||
11.10.2026 stand hier das Gegenteil. ``scoring.total_xp()`` zählt
|
||
``Sighting.xp_awarded`` **und** ``EarnedBadge.badge__xp`` zusammen. Ein
|
||
Abzeichen, das aus der entfernten Sichtung stammt, lässt seine Punkte also
|
||
stehen. An jenem Tag waren das **200 Punkte für „Seltener Gast"** aus einer
|
||
Fehlerkennung — mehr als die 144 der Sichtung selbst, und sie haben eine
|
||
Stufe getragen, die es nicht gab.
|
||
|
||
🔑 Abzeichen bleiben trotzdem die Vorgabe: Ein einmal verdientes Abzeichen
|
||
nimmt man einem Kind nicht wieder weg. Aber **stillschweigend darf das nicht
|
||
passieren** — der Befehl nennt sie jetzt samt Punkten, und ``--abzeichen``
|
||
nimmt sie mit, wenn sie nie verdient waren.
|
||
|
||
manage.py sichtung_entfernen 199 --trocken # erst schauen
|
||
manage.py sichtung_entfernen 199 # dann löschen
|
||
manage.py sichtung_entfernen 199 --abzeichen # samt Abzeichen daraus
|
||
"""
|
||
|
||
from django.core.management.base import BaseCommand, CommandError
|
||
from django.db import transaction
|
||
from django.db.models import Max
|
||
|
||
from dex import scoring
|
||
from dex.models import Camera, DexEntry, EarnedBadge, Sighting
|
||
|
||
|
||
class Command(BaseCommand):
|
||
help = "Entfernt eine Sichtung und repariert, was aus ihr abgeleitet wurde"
|
||
|
||
def add_arguments(self, parser):
|
||
parser.add_argument("id", type=int, help="ID der Sichtung")
|
||
parser.add_argument(
|
||
"--trocken", action="store_true",
|
||
help="Nur anzeigen, was passieren würde",
|
||
)
|
||
parser.add_argument(
|
||
"--abzeichen", action="store_true",
|
||
help="Abzeichen aus dieser Sichtung mitnehmen (samt ihrer Punkte)",
|
||
)
|
||
|
||
def handle(self, *args, **optionen):
|
||
trocken = optionen["trocken"]
|
||
abzeichen_mit = optionen["abzeichen"]
|
||
|
||
try:
|
||
sichtung = Sighting.objects.select_related(
|
||
"species", "camera").get(pk=optionen["id"])
|
||
except Sighting.DoesNotExist:
|
||
raise CommandError(f"Sichtung {optionen['id']} gibt es nicht.")
|
||
|
||
art = sichtung.species
|
||
kamera = sichtung.camera
|
||
|
||
self.stdout.write(
|
||
f"Sichtung {sichtung.pk}: "
|
||
f"{art.german if art else 'unbestimmt'} · "
|
||
f"{kamera.name if kamera else 'ohne Kamera'} · "
|
||
f"{sichtung.started_at:%d.%m.%Y %H:%M:%S} · "
|
||
f"{sichtung.confidence:.3f} Sicherheit · "
|
||
f"{sichtung.frames_with_bird} von {sichtung.frames_total} Bildern "
|
||
f"mit Vogel · {sichtung.xp_awarded} Punkte"
|
||
)
|
||
|
||
# ⚠️ Eine Erstsichtung wiegt schwerer: Mit ihr fällt der Tag, an dem
|
||
# die Art zum ersten Mal da war. Deshalb ausdrücklich benennen statt
|
||
# stillschweigend mitlöschen.
|
||
if sichtung.is_first_of_species:
|
||
self.stdout.write(self.style.WARNING(
|
||
"⚠ Das ist die ERSTSICHTUNG dieser Art. Der Dex-Eintrag "
|
||
"rückt auf die nächstältere Sichtung nach."
|
||
))
|
||
|
||
dateien = [f for f in (sichtung.image, sichtung.crop) if f]
|
||
for datei in dateien:
|
||
self.stdout.write(f" Datei: {datei.name}")
|
||
|
||
# ⚠️ VOR dem Löschen einsammeln: ``EarnedBadge.sighting`` ist
|
||
# SET_NULL. Nach dem Löschen ist die Spur zur Sichtung weg, und dann
|
||
# weiß niemand mehr, welches Abzeichen aus ihr stammte.
|
||
verdiente = list(EarnedBadge.objects.filter(
|
||
sighting=sichtung).select_related("badge"))
|
||
self._abzeichen_nennen(verdiente, abzeichen_mit)
|
||
|
||
if trocken:
|
||
self.stdout.write(self.style.NOTICE(
|
||
"[trocken] Es wurde nichts geändert."))
|
||
self._vorschau(sichtung, art, kamera)
|
||
return
|
||
|
||
with transaction.atomic():
|
||
sichtung_id = sichtung.pk
|
||
for datei in dateien:
|
||
datei.delete(save=False) # Platte, nicht nur Datensatz
|
||
sichtung.delete()
|
||
|
||
if abzeichen_mit:
|
||
for verdient in verdiente:
|
||
# Nur das Verdienst, nie der Katalogeintrag: Das Abzeichen
|
||
# als solches bleibt natürlich erreichbar.
|
||
verdient.delete()
|
||
|
||
self._dex_reparieren(art)
|
||
self._kamera_reparieren(kamera)
|
||
|
||
self.stdout.write(self.style.SUCCESS(
|
||
f"Sichtung {sichtung_id} entfernt."))
|
||
|
||
# -- Aufräumen ----------------------------------------------------
|
||
|
||
def _dex_reparieren(self, art):
|
||
"""Zählt den Dex-Eintrag neu — aus den Sichtungen, nicht per -1.
|
||
|
||
🔑 Neu zählen statt herunterzählen: Ein Eintrag, der schon vorher
|
||
falsch stand, wird dadurch nebenbei richtig. Ein '-1' würde den
|
||
Fehler mitschleppen.
|
||
"""
|
||
if art is None:
|
||
return
|
||
eintrag = DexEntry.objects.filter(species=art).first()
|
||
if eintrag is None:
|
||
return
|
||
|
||
uebrig = Sighting.objects.filter(species=art)
|
||
if not uebrig.exists():
|
||
eintrag.delete()
|
||
self.stdout.write(" Dex-Eintrag entfernt (keine Sichtung mehr).")
|
||
return
|
||
|
||
eintrag.count = uebrig.count()
|
||
eintrag.first_seen = uebrig.earliest("started_at").started_at
|
||
eintrag.last_seen = uebrig.latest("started_at").started_at
|
||
# 🔑 Bestes Foto nach Bildguete, nicht nach Sicherheit -- siehe
|
||
# scoring.bildguete.
|
||
eintrag.best_sighting = scoring.beste_sichtung(uebrig)
|
||
eintrag.save()
|
||
self.stdout.write(
|
||
f" Dex-Eintrag: jetzt {eintrag.count}×, zuletzt "
|
||
f"{eintrag.last_seen:%d.%m.%Y %H:%M}, beste Sichtung "
|
||
f"{eintrag.best_sighting_id}."
|
||
)
|
||
|
||
def _kamera_reparieren(self, kamera):
|
||
"""`last_seen` ist die letzte SICHTUNG, nicht der letzte Kontakt.
|
||
|
||
⚠️ Nicht mit `letzter_kontakt` verwechseln — das ist der Herzschlag
|
||
und bleibt unberührt. Genau diese Verwechslung hatte am 23.09.2026
|
||
einen Fehlalarm ausgelöst.
|
||
"""
|
||
if kamera is None:
|
||
return
|
||
zuletzt = Sighting.objects.filter(camera=kamera).aggregate(
|
||
z=Max("ended_at"))["z"]
|
||
Camera.objects.filter(pk=kamera.pk).update(last_seen=zuletzt)
|
||
self.stdout.write(
|
||
f" Kamera {kamera.name}: letzte Sichtung "
|
||
+ (f"{zuletzt:%d.%m.%Y %H:%M}." if zuletzt else "keine mehr.")
|
||
)
|
||
|
||
def _abzeichen_nennen(self, verdiente, mitnehmen):
|
||
"""Sagt, welche Abzeichen an der Sichtung hängen — und was sie wiegen.
|
||
|
||
🔑 Dass hier überhaupt etwas steht, ist der eigentliche Fix vom
|
||
11.10.2026: Vorher blieben Abzeichen **und ihre Punkte** stumm
|
||
zurück, während die Beschreibung des Befehls das Gegenteil
|
||
behauptete. Wer eine Fehlerkennung entfernt und danach dieselbe
|
||
Stufe sieht, sucht den Fehler an der falschen Stelle.
|
||
"""
|
||
if not verdiente:
|
||
return
|
||
|
||
punkte = sum(v.badge.xp for v in verdiente)
|
||
for verdient in verdiente:
|
||
self.stdout.write(
|
||
f" Abzeichen: {verdient.badge.emoji} "
|
||
f"{verdient.badge.name} ({verdient.badge.xp} Punkte)"
|
||
)
|
||
if mitnehmen:
|
||
self.stdout.write(self.style.WARNING(
|
||
f"⚠ Diese Abzeichen werden mit entfernt — {punkte} Punkte "
|
||
f"weniger."
|
||
))
|
||
else:
|
||
self.stdout.write(self.style.WARNING(
|
||
f"⚠ Abzeichen BLEIBEN, samt ihren {punkte} Punkten. War die "
|
||
f"Sichtung eine Fehlerkennung, waren sie nie verdient: "
|
||
f"--abzeichen nimmt sie mit."
|
||
))
|
||
|
||
def _vorschau(self, sichtung, art, kamera):
|
||
if art is not None:
|
||
uebrig = Sighting.objects.filter(species=art).exclude(
|
||
pk=sichtung.pk)
|
||
self.stdout.write(
|
||
f" Dex-Eintrag {art.german}: würde auf {uebrig.count()}× "
|
||
+ ("sinken." if uebrig.exists() else "0 fallen und wegfallen.")
|
||
)
|
||
if kamera is not None:
|
||
zuletzt = Sighting.objects.filter(camera=kamera).exclude(
|
||
pk=sichtung.pk).aggregate(z=Max("ended_at"))["z"]
|
||
self.stdout.write(
|
||
f" Kamera {kamera.name}: letzte Sichtung wäre "
|
||
+ (f"{zuletzt:%d.%m.%Y %H:%M}." if zuletzt else "keine mehr.")
|
||
)
|