Files
vogelalbum/web/dex/management/commands/sichtung_entfernen.py
T
christian c11fe9ab04 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.
2026-10-15 14:30:02 +00:00

220 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.")
)