"""Setzt die Art einer Sichtung richtig — samt allem, was daran hängt. 🔑 Warum ein eigener Befehl und nicht nur das Feld im Admin: An einer Bestimmung hängt mehr als der Name. Der Dex-Eintrag der falschen Art bleibt sonst stehen, der Erstfang wird nicht zurückgenommen, und die dreifachen Punkte bleiben vergeben. Wer nur das Feld ändert, hat danach eine Art in der Sammlung, die nie da war. Am 30.09.2026 hat das Modell eine Kohlmeise mit **94,5 % Sicherheit** als Blaumeise bestimmt. Am Foto war es zu sehen: gelbe Brust mit dunklem Streifen → Kohlmeise Kopf komplett verwischt → das entscheidende Merkmal fehlte ⚠️ **Hohe Sicherheit heißt nicht richtig.** Das Modell musste ohne die blaue Kappe entscheiden und war sich trotzdem sicher. manage.py sichtung_bestimmen 228 kohlmeise --trocken manage.py sichtung_bestimmen 228 kohlmeise 📌 Die Punkte werden neu gerechnet, nicht geschätzt — mit derselben Formel, die bei der Aufnahme gilt (``scoring``). Sonst laufen Vergabe und Korrektur auseinander. """ 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, Sighting, Species class Command(BaseCommand): help = "Setzt die Art einer Sichtung richtig und repariert die Sammlung" def add_arguments(self, parser): parser.add_argument("id", type=int, help="ID der Sichtung") parser.add_argument("art", help="Kürzel der richtigen Art, z.B. kohlmeise") parser.add_argument( "--trocken", action="store_true", help="Nur anzeigen, was passieren würde", ) def handle(self, *args, **optionen): trocken = optionen["trocken"] try: sichtung = Sighting.objects.select_related( "species", "confirmed_species", "camera").get(pk=optionen["id"]) except Sighting.DoesNotExist: raise CommandError(f"Sichtung {optionen['id']} gibt es nicht.") try: richtig = Species.objects.get(slug=optionen["art"]) except Species.DoesNotExist: raise CommandError( f"Art '{optionen['art']}' gibt es nicht. " f"Kürzel siehe Admin oder: manage.py shell") bisher = sichtung.effective_species if bisher is not None and bisher.pk == richtig.pk: self.stdout.write(f"Sichtung {sichtung.pk} ist bereits " f"{richtig.german} — nichts zu tun.") return self.stdout.write( f"Sichtung {sichtung.pk} · " f"{sichtung.started_at:%d.%m.%Y %H:%M:%S} · " f"{sichtung.camera.name if sichtung.camera else 'ohne Kamera'}" ) self.stdout.write( f" erkannt als {bisher.german if bisher else 'unbestimmt'} " f"({sichtung.confidence:.3f} Sicherheit)" ) self.stdout.write(f" richtig ist {richtig.german}") if sichtung.is_first_of_species: self.stdout.write(self.style.WARNING( "⚠ Das galt als ERSTFANG — dreifache Punkte. Die werden " "neu gerechnet." )) punkte_neu = self._punkte(sichtung, richtig) self.stdout.write(f" Punkte {sichtung.xp_awarded} → {punkte_neu}") if trocken: self.stdout.write(self.style.NOTICE( "[trocken] Es wurde nichts geändert.")) return with transaction.atomic(): alt = bisher sichtung.confirmed_species = richtig # 🔑 is_first_of_species neu bestimmen: Gibt es zu der # richtigen Art schon einen Eintrag, ist es kein Erstfang. war_erst = sichtung.is_first_of_species sichtung.is_first_of_species = not DexEntry.objects.filter( species=richtig).exclude( first_seen__gt=sichtung.started_at).exists() sichtung.xp_awarded = self._punkte(sichtung, richtig) sichtung.save(update_fields=["confirmed_species", "is_first_of_species", "xp_awarded"]) self._dex_reparieren(alt) self._dex_reparieren(richtig) if sichtung.camera: self._kamera_reparieren(sichtung.camera) if war_erst and not sichtung.is_first_of_species: self.stdout.write(" Erstfang zurückgenommen.") self.stdout.write(self.style.SUCCESS( f"Sichtung {sichtung.pk} ist jetzt {richtig.german}.")) # -- Rechnen ------------------------------------------------------ def _punkte(self, sichtung, art) -> int: """Dieselbe Formel wie bei der Aufnahme — siehe scoring.register. ⚠️ Bewusst nicht nachgebaut, sondern abgeleitet: Weicht die Korrektur von der Vergabe ab, stimmt der Punktestand nie wieder. """ erstfang = not DexEntry.objects.filter(species=art).exclude( first_seen__gt=sichtung.started_at).exists() punkte = art.xp * (scoring.FIRST_CATCH_MULTIPLIER if erstfang else 1) return int(round(punkte * (0.5 + 0.5 * min(1.0, sichtung.confidence)))) # -- Aufräumen ---------------------------------------------------- def _dex_reparieren(self, art): """Zählt den Eintrag einer Art neu — aus den Sichtungen. 🔑 Neu zählen statt herunterzählen: Ein Eintrag, der schon vorher falsch stand, wird dabei nebenbei richtig. """ if art is None: return # ⚠️ Über effective_species zählen, nicht über species -- sonst # zählt eine korrigierte Sichtung weiter bei der falschen Art. passend = [ s for s in Sighting.objects.select_related( "species", "confirmed_species") if s.effective_species and s.effective_species.pk == art.pk ] eintrag = DexEntry.objects.filter(species=art).first() if not passend: if eintrag: eintrag.delete() self.stdout.write(f" Dex-Eintrag {art.german} entfernt " f"(keine Sichtung mehr).") return passend.sort(key=lambda s: s.started_at) # 🔑 Bestes Foto nach Bildguete, nicht nach Sicherheit. beste = scoring.beste_sichtung(passend) if eintrag is None: DexEntry.objects.create( species=art, first_seen=passend[0].started_at, last_seen=passend[-1].started_at, count=len(passend), best_sighting=beste) self.stdout.write(f" Dex-Eintrag {art.german} angelegt " f"({len(passend)}×).") return eintrag.count = len(passend) eintrag.first_seen = passend[0].started_at eintrag.last_seen = passend[-1].started_at eintrag.best_sighting = beste eintrag.save() self.stdout.write(f" Dex-Eintrag {art.german}: jetzt " f"{eintrag.count}×, beste Sichtung {beste.pk}.") 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. """ zuletzt = Sighting.objects.filter(camera=kamera).aggregate( z=Max("ended_at"))["z"] Camera.objects.filter(pk=kamera.pk).update(last_seen=zuletzt)