"""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.") )