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
cc9e0786c2
189 files changed
+29792
No files matched your search
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,128 @@
|
||||
"""Kameras anlegen, auflisten, Token erneuern.
|
||||
|
||||
manage.py camera add vogelhaus --name "Futterhaus Garten"
|
||||
manage.py camera list
|
||||
manage.py camera rotate vogelhaus
|
||||
manage.py camera disable vogelhaus
|
||||
|
||||
Der Token wird genau einmal ausgegeben — danach liegt nur noch sein Hash in
|
||||
der Datenbank. Wer ihn verliert, erzeugt mit ``rotate`` einen neuen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from dex.models import Camera
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Kameras verwalten."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
sub = parser.add_subparsers(dest="action", required=True)
|
||||
|
||||
add = sub.add_parser("add", help="Neue Kamera anlegen")
|
||||
add.add_argument("slug")
|
||||
add.add_argument("--name", help="Anzeigename (Standard: aus dem Slug)")
|
||||
add.add_argument("--location", default="", help="Standort, z.B. 'Garten Nord'")
|
||||
# 🔑 Ein Handy ist eine Kamera, die DARF schweigen. Ohne diese Angabe
|
||||
# trägt es im Kamerafilter und im Störungswächter dauerhaft ein ⚠,
|
||||
# weil beide "seit Stunden nichts geliefert" als Defekt lesen — richtig
|
||||
# für ein Vogelhaus, falsch für ein Telefon in der Tasche. Bisher gab
|
||||
# es das Feld nur im Admin; wer eine Handy-Kamera auf der
|
||||
# Kommandozeile anlegt, hat es zwangsläufig falsch.
|
||||
add.add_argument(
|
||||
"--mobil",
|
||||
action="store_true",
|
||||
help="Handy o.ä.: liefert nur gelegentlich, gilt nie als gestört.",
|
||||
)
|
||||
|
||||
sub.add_parser("list", help="Alle Kameras anzeigen")
|
||||
|
||||
rot = sub.add_parser("rotate", help="Neuen Token erzeugen")
|
||||
rot.add_argument("slug")
|
||||
|
||||
dis = sub.add_parser("disable", help="Kamera stilllegen")
|
||||
dis.add_argument("slug")
|
||||
|
||||
ena = sub.add_parser("enable", help="Kamera wieder aktivieren")
|
||||
ena.add_argument("slug")
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
action = opts["action"]
|
||||
if action == "add":
|
||||
return self._add(opts)
|
||||
if action == "list":
|
||||
return self._list()
|
||||
return self._toggle(action, opts["slug"])
|
||||
|
||||
def _add(self, opts):
|
||||
slug = slugify(opts["slug"])
|
||||
if Camera.objects.filter(slug=slug).exists():
|
||||
raise CommandError(f"Kamera '{slug}' gibt es schon.")
|
||||
|
||||
token = Camera.new_token()
|
||||
camera = Camera(
|
||||
slug=slug,
|
||||
name=opts.get("name") or slug.replace("-", " ").title(),
|
||||
location=opts.get("location") or "",
|
||||
mobil=bool(opts.get("mobil")),
|
||||
)
|
||||
camera.set_token(token)
|
||||
camera.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"\nKamera '{camera.name}' angelegt.\n"))
|
||||
self.stdout.write("Token (wird nur dieses eine Mal angezeigt):\n")
|
||||
self.stdout.write(self.style.WARNING(f" {token}\n"))
|
||||
if camera.mobil:
|
||||
# Eine mobile Kamera hat keinen Erkennungs-Host — der Token geht
|
||||
# in die App. Der Hinweis unten wäre hier eine falsche Fährte.
|
||||
self.stdout.write("In der App unter 'Server verbinden' eintragen.\n")
|
||||
else:
|
||||
self.stdout.write(
|
||||
"Auf dem Erkennungs-Host in /etc/vogelalbum.env eintragen:\n"
|
||||
)
|
||||
self.stdout.write(f" VOGELALBUM_TOKEN={token}\n")
|
||||
|
||||
def _list(self):
|
||||
cameras = Camera.objects.all()
|
||||
if not cameras:
|
||||
self.stdout.write("Keine Kameras angelegt.")
|
||||
return
|
||||
|
||||
self.stdout.write(f"\n{'Slug':<18} {'Name':<24} {'Aktiv':<6} {'Zuletzt':<18} Sichtungen")
|
||||
self.stdout.write("-" * 82)
|
||||
for c in cameras:
|
||||
last = (
|
||||
timezone.localtime(c.last_seen).strftime("%d.%m. %H:%M:%S")
|
||||
if c.last_seen
|
||||
else "nie"
|
||||
)
|
||||
flag = "ja" if c.active else "nein"
|
||||
warn = " ⚠ hängt?" if c.active and c.is_stale else ""
|
||||
self.stdout.write(
|
||||
f"{c.slug:<18} {c.name:<24} {flag:<6} {last:<18} "
|
||||
f"{c.sightings.count()}{warn}"
|
||||
)
|
||||
|
||||
def _toggle(self, action: str, slug: str):
|
||||
camera = Camera.objects.filter(slug=slug).first()
|
||||
if camera is None:
|
||||
raise CommandError(f"Keine Kamera '{slug}'.")
|
||||
|
||||
if action == "rotate":
|
||||
token = Camera.new_token()
|
||||
camera.set_token(token)
|
||||
camera.save(update_fields=["token_hash"])
|
||||
self.stdout.write(self.style.SUCCESS(f"Neuer Token für '{camera.name}':"))
|
||||
self.stdout.write(self.style.WARNING(f" {token}"))
|
||||
self.stdout.write("Der alte Token ist ab sofort ungültig.")
|
||||
return
|
||||
|
||||
camera.active = action == "enable"
|
||||
camera.save(update_fields=["active"])
|
||||
state = "aktiviert" if camera.active else "stillgelegt"
|
||||
self.stdout.write(self.style.SUCCESS(f"'{camera.name}' {state}."))
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Erzeugt plausible Beispiel-Sichtungen, damit sich die Oberfläche vor dem
|
||||
ersten echten Vogel beurteilen lässt.
|
||||
|
||||
Bewusst kein Teil des Normalbetriebs: ``--clear`` räumt alles wieder weg.
|
||||
Die Bilder sind die Referenzfotos aus Wikipedia, keine echten Kameraaufnahmen
|
||||
— die Sichtungen sind also erkennbar Attrappe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from dex import scoring
|
||||
from dex.models import DexEntry, EarnedBadge, Sighting, Species
|
||||
|
||||
# Wie wahrscheinlich taucht eine Seltenheitsstufe auf? Grob an einem echten
|
||||
# Garten orientiert: Kohlmeisen im Dauerbetrieb, der Sperber einmal im Monat.
|
||||
RARITY_WEIGHT = {
|
||||
"haeufig": 60,
|
||||
"gelegentlich": 22,
|
||||
"selten": 6,
|
||||
"episch": 1.2,
|
||||
"legendaer": 0.3,
|
||||
}
|
||||
|
||||
# Vögel am Futterhaus haben zwei Stoßzeiten: kurz nach Sonnenaufgang und am
|
||||
# späten Nachmittag.
|
||||
HOUR_WEIGHT = [
|
||||
0.1, 0.1, 0.1, 0.2, 0.6, 2.5, 6.0, 9.0, 8.0, 6.0, 4.5, 3.5,
|
||||
3.0, 3.2, 4.0, 5.5, 7.0, 6.5, 4.0, 1.5, 0.5, 0.2, 0.1, 0.1,
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Legt Beispiel-Sichtungen an (nur zum Ansehen der Oberfläche)."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--tage", type=int, default=21)
|
||||
parser.add_argument("--pro-tag", type=int, default=9)
|
||||
parser.add_argument(
|
||||
"--clear",
|
||||
action="store_true",
|
||||
help="Alle Sichtungen, Dex-Einträge und Abzeichen löschen.",
|
||||
)
|
||||
parser.add_argument("--seed", type=int, default=1312)
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
if opts["clear"]:
|
||||
n = Sighting.objects.count()
|
||||
Sighting.objects.all().delete()
|
||||
DexEntry.objects.all().delete()
|
||||
EarnedBadge.objects.all().delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"{n} Sichtungen gelöscht."))
|
||||
return
|
||||
|
||||
if Sighting.objects.exists():
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"Es gibt bereits Sichtungen. Erst 'demo_data --clear' ausführen, "
|
||||
"sonst mischen sich Demo- und Echtdaten."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
species = list(Species.objects.all())
|
||||
if not species:
|
||||
self.stdout.write(self.style.ERROR("Katalog leer — erst 'seed_dex'."))
|
||||
return
|
||||
|
||||
rng = random.Random(opts["seed"])
|
||||
weights = [RARITY_WEIGHT.get(s.rarity, 1) for s in species]
|
||||
|
||||
# Chronologisch anlegen, damit Erstfänge, XP und Abzeichen in der
|
||||
# richtigen Reihenfolge verbucht werden.
|
||||
now = timezone.localtime()
|
||||
events = []
|
||||
for day_offset in range(opts["tage"], -1, -1):
|
||||
day = (now - timedelta(days=day_offset)).date()
|
||||
for _ in range(rng.randint(max(1, opts["pro_tag"] - 4), opts["pro_tag"] + 4)):
|
||||
hour = rng.choices(range(24), weights=HOUR_WEIGHT)[0]
|
||||
start = timezone.make_aware(
|
||||
timezone.datetime.combine(day, timezone.datetime.min.time())
|
||||
) + timedelta(hours=hour, minutes=rng.randint(0, 59), seconds=rng.randint(0, 59))
|
||||
if start > timezone.now():
|
||||
continue
|
||||
events.append(start)
|
||||
|
||||
events.sort()
|
||||
created = 0
|
||||
for start in events:
|
||||
sp = rng.choices(species, weights=weights)[0]
|
||||
|
||||
# Ein Teil der Besuche bleibt unbestimmt — so sieht es im Betrieb
|
||||
# auch aus, und die Oberfläche muss den Fall aushalten.
|
||||
unbestimmt = rng.random() < 0.12
|
||||
confidence = 0.0 if unbestimmt else rng.uniform(0.55, 0.99)
|
||||
duration = rng.uniform(3, 75)
|
||||
|
||||
sighting = Sighting(
|
||||
species=None if unbestimmt else sp,
|
||||
confidence=confidence,
|
||||
started_at=start,
|
||||
ended_at=start + timedelta(seconds=duration),
|
||||
frames_total=int(duration * 4),
|
||||
frames_with_bird=int(duration * 4 * rng.uniform(0.5, 0.95)),
|
||||
votes={sp.slug: round(confidence, 3)} if not unbestimmt else {},
|
||||
mean_bird_score=rng.uniform(0.4, 0.95),
|
||||
)
|
||||
|
||||
if not unbestimmt and sp.reference_image:
|
||||
try:
|
||||
sp.reference_image.open("rb")
|
||||
data = sp.reference_image.read()
|
||||
sp.reference_image.close()
|
||||
sighting.image.save(f"demo-{sp.slug}.jpg", ContentFile(data), save=False)
|
||||
sighting.crop.save(f"demo-{sp.slug}.jpg", ContentFile(data), save=False)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
sighting.save()
|
||||
scoring.register(sighting)
|
||||
created += 1
|
||||
|
||||
p = scoring.progress()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"{created} Beispiel-Sichtungen angelegt. "
|
||||
f"Level {p.level} ({p.rank}), {p.xp} XP, "
|
||||
f"{p.species_seen}/{p.species_total} Arten, {p.badges} Abzeichen."
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Holt für jede Art ein Referenzfoto aus der deutschen Wikipedia.
|
||||
|
||||
Damit weiß man, wonach man Ausschau hält, bevor eine Art zum ersten Mal selbst
|
||||
vor der Kamera saß. Urheber und Lizenz werden mitgespeichert und in der
|
||||
Oberfläche angezeigt — die Bilder stehen fast alle unter CC-BY-SA und die
|
||||
Namensnennung ist Bedingung, nicht Höflichkeit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management.base import BaseCommand
|
||||
from PIL import Image
|
||||
|
||||
from dex.models import Species
|
||||
|
||||
UA = "VogelalbumBot/1.0 (https://gaertner.onl; webmaster@gaertner.onl)"
|
||||
WP_API = "https://de.wikipedia.org/w/api.php"
|
||||
COMMONS_API = "https://commons.wikimedia.org/w/api.php"
|
||||
|
||||
MAX_WIDTH = 900
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _get(url: str, params: dict) -> dict:
|
||||
full = f"{url}?{urllib.parse.urlencode(params)}"
|
||||
req = urllib.request.Request(full, headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def _strip_html(value: str) -> str:
|
||||
"""Der Artist kommt als HTML-Schnipsel mit Link. Wir wollen nur den Namen."""
|
||||
text = TAG_RE.sub("", value or "")
|
||||
return " ".join(text.split())[:250]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Lädt Referenzfotos samt Lizenzangaben aus Wikipedia/Commons."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Auch Arten neu laden, die schon ein Referenzfoto haben.",
|
||||
)
|
||||
parser.add_argument("--only", help="Nur diese Art (Slug).")
|
||||
parser.add_argument(
|
||||
"--delay",
|
||||
type=float,
|
||||
default=0.6,
|
||||
help="Pause zwischen Anfragen; Wikipedia bittet um Zurückhaltung.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
qs = Species.objects.all()
|
||||
if opts["only"]:
|
||||
qs = qs.filter(slug=opts["only"])
|
||||
if not opts["force"]:
|
||||
qs = qs.filter(reference_image="")
|
||||
|
||||
ok = skipped = failed = 0
|
||||
for species in qs:
|
||||
try:
|
||||
if self._fetch(species, opts["delay"]):
|
||||
ok += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" ✓ {species.german}"))
|
||||
else:
|
||||
skipped += 1
|
||||
self.stdout.write(f" – {species.german}: kein Bild gefunden")
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
self.stdout.write(self.style.WARNING(f" ✗ {species.german}: {exc}"))
|
||||
time.sleep(opts["delay"])
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\n{ok} geladen, {skipped} ohne Bild, {failed} Fehler.")
|
||||
)
|
||||
|
||||
def _lead_image(self, title: str) -> dict | None:
|
||||
page = _get(
|
||||
WP_API,
|
||||
{
|
||||
"action": "query",
|
||||
"titles": title,
|
||||
"prop": "pageimages",
|
||||
"piprop": "original|name",
|
||||
"format": "json",
|
||||
"formatversion": "2",
|
||||
"redirects": "1",
|
||||
},
|
||||
)
|
||||
pages = page.get("query", {}).get("pages", [])
|
||||
if not pages or "original" not in pages[0]:
|
||||
return None
|
||||
return pages[0]
|
||||
|
||||
def _fetch(self, species: Species, delay: float) -> bool:
|
||||
# 1. Artikelbild der deutschen Wikipedia finden. Der deutsche Name ist
|
||||
# meist das Artikellemma — außer bei Gimpel, Kleiber, Sperber, Star:
|
||||
# das sind Begriffsklärungsseiten ohne Bild. Der wissenschaftliche
|
||||
# Name leitet dort zuverlässig auf den Artikel weiter.
|
||||
page = self._lead_image(species.german)
|
||||
if page is None:
|
||||
time.sleep(delay)
|
||||
page = self._lead_image(species.scientific)
|
||||
if page is None:
|
||||
return False
|
||||
|
||||
img_url = page["original"]["source"].split("?", 1)[0]
|
||||
file_name = page.get("pageimage")
|
||||
|
||||
# 2. Lizenz und Urheber von Commons nachschlagen.
|
||||
credit = license_name = source = ""
|
||||
if file_name:
|
||||
time.sleep(delay)
|
||||
meta = _get(
|
||||
COMMONS_API,
|
||||
{
|
||||
"action": "query",
|
||||
"titles": f"File:{file_name}",
|
||||
"prop": "imageinfo",
|
||||
"iiprop": "extmetadata|url",
|
||||
"format": "json",
|
||||
"formatversion": "2",
|
||||
},
|
||||
)
|
||||
info = meta.get("query", {}).get("pages", [{}])[0].get("imageinfo")
|
||||
if info:
|
||||
ext = info[0].get("extmetadata", {})
|
||||
credit = _strip_html(ext.get("Artist", {}).get("value", ""))
|
||||
license_name = _strip_html(
|
||||
ext.get("LicenseShortName", {}).get("value", "")
|
||||
)
|
||||
source = info[0].get("descriptionurl", "")
|
||||
|
||||
# 3. Bild holen und auf vernünftige Größe bringen.
|
||||
req = urllib.request.Request(img_url, headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(req, timeout=45) as resp:
|
||||
raw = resp.read()
|
||||
|
||||
img = Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
if img.width > MAX_WIDTH:
|
||||
img = img.resize(
|
||||
(MAX_WIDTH, round(img.height * MAX_WIDTH / img.width)), Image.LANCZOS
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85, optimize=True)
|
||||
|
||||
species.reference_image.save(
|
||||
f"{species.slug}.jpg", ContentFile(buf.getvalue()), save=False
|
||||
)
|
||||
species.reference_credit = credit
|
||||
species.reference_license = license_name
|
||||
species.reference_source = source
|
||||
species.save(
|
||||
update_fields=[
|
||||
"reference_image",
|
||||
"reference_credit",
|
||||
"reference_license",
|
||||
"reference_source",
|
||||
]
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Füllt Artenkatalog und Abzeichen. Idempotent — beliebig oft ausführbar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from dex import scoring
|
||||
from dex.models import Species
|
||||
|
||||
def _steckbrief(z) -> dict:
|
||||
"""Steckbrief-Felder aus einem Zusatz-Eintrag, oder leer."""
|
||||
if z is None:
|
||||
return {}
|
||||
return {
|
||||
"kategorie": z.kategorie, "beschreibung": z.text,
|
||||
"nahrung": z.nahrung, "lebensraum": z.lebensraum, "stimme": z.stimme,
|
||||
"kategorie_en": z.kategorie_en, "beschreibung_en": z.text_en,
|
||||
"nahrung_en": z.nahrung_en, "lebensraum_en": z.lebensraum_en,
|
||||
"stimme_en": z.stimme_en,
|
||||
}
|
||||
|
||||
|
||||
# Muss zu RARITY_XP in detector/species_de.py passen.
|
||||
RARITY_XP = {
|
||||
"haeufig": 10, "gelegentlich": 25, "selten": 60, "episch": 150, "legendaer": 400,
|
||||
}
|
||||
|
||||
def find_catalog_root() -> Path | None:
|
||||
"""Sucht das Verzeichnis, das ``detector/species_de.py`` enthält.
|
||||
|
||||
Lokal liegt es eine Ebene über ``web/``, im Container direkt neben
|
||||
``manage.py`` — statt beide Fälle zu raten, einfach aufwärts suchen.
|
||||
"""
|
||||
for base in Path(__file__).resolve().parents:
|
||||
if (base / "detector" / "species_de.py").exists():
|
||||
return base
|
||||
return None
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Legt Arten und Abzeichen an bzw. aktualisiert sie."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# detector.species_de ist die einzige Quelle der Wahrheit für den
|
||||
# Katalog. Das Modul hängt nur an der Stdlib, der Import kostet die
|
||||
# Web-App also keine schweren Abhängigkeiten.
|
||||
root = find_catalog_root()
|
||||
if root is None:
|
||||
self.stderr.write(
|
||||
self.style.ERROR(
|
||||
"detector/species_de.py nicht gefunden — "
|
||||
"weder neben manage.py noch in einem übergeordneten Verzeichnis."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
sys.path.insert(0, str(root))
|
||||
try:
|
||||
from detector.species_de import SPECIES
|
||||
except ImportError as exc:
|
||||
self.stderr.write(self.style.ERROR(f"Artenkatalog nicht ladbar: {exc}"))
|
||||
return
|
||||
|
||||
try:
|
||||
from detector.species_extra import EXTRA
|
||||
except ImportError:
|
||||
EXTRA = []
|
||||
|
||||
try:
|
||||
from detector.species_info import INFO
|
||||
except ImportError:
|
||||
INFO = {}
|
||||
self.stdout.write(
|
||||
self.style.WARNING("Keine Steckbriefe gefunden — Arten bleiben knapp.")
|
||||
)
|
||||
|
||||
try:
|
||||
from detector.species_info_en import INFO_EN
|
||||
except ImportError:
|
||||
INFO_EN = {}
|
||||
|
||||
# Steckbriefe fuer die erweiterten Arten — beide Sprachen in einem
|
||||
# Datensatz, weil sie zusammen geschrieben werden.
|
||||
try:
|
||||
from detector.profiles import INFO_EXTRA
|
||||
except ImportError:
|
||||
INFO_EXTRA = {}
|
||||
|
||||
# Die Dex-Nummer soll stabil bleiben, damit #012 immer dieselbe Art
|
||||
# ist. Sortiert nach Seltenheit, dann alphabetisch — neue Arten
|
||||
# verschieben also Nummern, aber nur innerhalb ihrer Stufe.
|
||||
order = {"haeufig": 0, "gelegentlich": 1, "selten": 2, "episch": 3, "legendaer": 4}
|
||||
# Kernarten zuerst, dann die Erweiterung — so behalten die Gartenvögel
|
||||
# ihre niedrigen Dex-Nummern, auch wenn der Katalog wächst.
|
||||
geordnet = sorted(SPECIES, key=lambda s: (order.get(s.rarity, 9), s.german))
|
||||
geordnet_extra = sorted(EXTRA, key=lambda e: (order.get(e.rarity, 9), e.german))
|
||||
|
||||
created = updated = mit_text = 0
|
||||
for nummer, s in enumerate(geordnet, start=1):
|
||||
info = INFO.get(s.slug)
|
||||
defaults = {
|
||||
"german": s.german,
|
||||
"scientific": s.scientific,
|
||||
"english": s.english,
|
||||
"rarity": s.rarity,
|
||||
"xp": s.xp,
|
||||
"fact": s.fact,
|
||||
"size_cm": s.size_cm,
|
||||
"number": nummer,
|
||||
"lebensraum_typ": s.lebensraum,
|
||||
"regions": ["de"],
|
||||
}
|
||||
if info is not None:
|
||||
mit_text += 1
|
||||
defaults |= {
|
||||
"typ": info.typ,
|
||||
"kategorie": info.kategorie,
|
||||
"beschreibung": info.text,
|
||||
"gewicht_g": info.gewicht_g,
|
||||
"spannweite_cm": info.spannweite_cm,
|
||||
"zug": info.zug,
|
||||
"nahrung": info.nahrung,
|
||||
"lebensraum": info.lebensraum,
|
||||
"stimme": info.stimme,
|
||||
# Verweise auf Arten, die es (noch) nicht gibt, fliegen raus —
|
||||
# sonst zeigt die Seite Links ins Leere.
|
||||
"verwechslung": [
|
||||
v for v in info.verwechslung
|
||||
if any(x.slug == v for x in SPECIES)
|
||||
],
|
||||
}
|
||||
|
||||
info_en = INFO_EN.get(s.slug)
|
||||
if info_en is not None:
|
||||
defaults |= {
|
||||
"kategorie_en": info_en.kategorie,
|
||||
"beschreibung_en": info_en.text,
|
||||
"nahrung_en": info_en.nahrung,
|
||||
"lebensraum_en": info_en.lebensraum,
|
||||
"stimme_en": info_en.stimme,
|
||||
}
|
||||
|
||||
_, was_created = Species.objects.update_or_create(slug=s.slug, defaults=defaults)
|
||||
created += was_created
|
||||
updated += not was_created
|
||||
|
||||
# Erweiterte Arten: nur Strukturdaten, kein Erzähltext. Vorhandene
|
||||
# Texte werden nicht überschrieben — wer einen Steckbrief nachträgt,
|
||||
# verliert ihn beim nächsten Seed nicht.
|
||||
naechste = len(geordnet)
|
||||
for offset, e in enumerate(geordnet_extra, start=1):
|
||||
obj, was_created = Species.objects.update_or_create(
|
||||
slug=e.slug,
|
||||
defaults={
|
||||
"german": e.german,
|
||||
"scientific": e.scientific,
|
||||
"english": e.english,
|
||||
"rarity": e.rarity,
|
||||
"xp": RARITY_XP.get(e.rarity, 10),
|
||||
"size_cm": e.size_cm,
|
||||
"number": naechste + offset,
|
||||
"typ": e.typ,
|
||||
"gewicht_g": e.gewicht_g,
|
||||
"spannweite_cm": e.spannweite_cm,
|
||||
"zug": e.zug,
|
||||
"lebensraum_typ": e.lebensraum,
|
||||
"regions": ["de"],
|
||||
}
|
||||
| _steckbrief(INFO_EXTRA.get(e.slug)),
|
||||
)
|
||||
created += was_created
|
||||
updated += not was_created
|
||||
|
||||
badges = scoring.sync_badges()
|
||||
gesamt = len(SPECIES) + len(EXTRA)
|
||||
mit_text += sum(1 for e in EXTRA if e.slug in INFO_EXTRA)
|
||||
mit_en = sum(1 for s in SPECIES if s.slug in INFO_EN)
|
||||
mit_en += sum(1 for e in EXTRA if e.slug in INFO_EXTRA)
|
||||
ohne_text = gesamt - mit_text
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Arten: {created} neu, {updated} aktualisiert, "
|
||||
f"{mit_text} von {gesamt} mit Steckbrief ({mit_en} auch auf Englisch). "
|
||||
f"Abzeichen: {badges} neu."
|
||||
)
|
||||
)
|
||||
if ohne_text:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"{ohne_text} Arten noch ohne Steckbrief")
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""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.")
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Prüft, ob die Kameras noch melden — und schlägt Alarm, wenn nicht.
|
||||
|
||||
🔑 Warum das hier liegt und nicht im Detector: Ein abgestürzter Prozess kann
|
||||
seinen eigenen Tod nicht melden. Wer das Ausbleiben von Meldungen bemerken
|
||||
soll, muss von aussen schauen. Diese Seite hier läuft ohnehin.
|
||||
|
||||
Gedacht als regelmässiger Aufruf (systemd-Zeitgeber oder cron):
|
||||
|
||||
*/10 * * * * .../manage.py stoerungen_pruefen
|
||||
|
||||
⚠ Es wird pro Kamera nur EINMAL gemeldet, nicht bei jedem Durchlauf. Eine
|
||||
Störung, die alle zehn Minuten aufs Handy summt, schaltet man nach einer
|
||||
Stunde ab — und dann merkt man die nächste nicht mehr.
|
||||
|
||||
🔑 **Der Merker steht in der Datenbank, nicht im Prozess.** Hier stand
|
||||
einmal ein Modul-Set `_gemeldet`. In einem Einmal-Lauf ist das wirkungslos:
|
||||
jeder Aufruf ist ein frischer Prozess, das Set beim Start wieder leer. Die
|
||||
Zusage eine Zeile weiter oben war damit acht Monate lang unwahr, und am
|
||||
01.10.2026 hat es sich gezeigt -- nach dem Stromausfall summten beide
|
||||
Kameras im Zehnminutentakt.
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from dex import benachrichtigung as bn
|
||||
from dex.models import Benachrichtigung, Camera, Sighting
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Meldet Kameras, die sich länger nicht gemeldet haben"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--trocken", action="store_true",
|
||||
help="Nur anzeigen, nichts senden",
|
||||
)
|
||||
|
||||
def handle(self, *args, **optionen):
|
||||
einstellung = Benachrichtigung.laden()
|
||||
grenze = timezone.now() - timezone.timedelta(
|
||||
minutes=einstellung.stille_minuten)
|
||||
|
||||
# ⚠️ Mobile Geräte bleiben außen vor. Die Prüfung misst, ob sich ein
|
||||
# Gerät länger als erlaubt nicht gemeldet hat — das ergibt nur bei
|
||||
# Dauerbetrieb einen Sinn. Ein Handy meldet sich, wenn jemand
|
||||
# spazieren geht, und nach dem ersten ruhigen Wochenende stünde
|
||||
# sonst „Kamera still: Handy" auf dem Telefon. Genau so entstehen
|
||||
# Fehlalarme, die man abschaltet — und dann bemerkt man den echten
|
||||
# Ausfall nicht mehr.
|
||||
for kamera in Camera.objects.filter(active=True, mobil=False):
|
||||
# ⚠ Gemessen wird das LEBENSZEICHEN, nicht die letzte Sichtung.
|
||||
# An last_seen zu messen waere der naheliegende Fehler: An
|
||||
# einem ruhigen Nachmittag kommt stundenlang kein Vogel, und
|
||||
# es gaebe jeden Tag Fehlalarm. (Genau so hatte ich es zuerst
|
||||
# gebaut -- der Trockenlauf meldete prompt "still seit 824
|
||||
# min" fuer eine tadellos laufende Kamera.)
|
||||
if kamera.letzter_kontakt is None:
|
||||
# Noch nie gemeldet. Das ist keine Stoerung, sondern eine
|
||||
# Kamera, die gerade erst eingerichtet wurde.
|
||||
self.stdout.write(f"{kamera.name}: noch kein Lebenszeichen")
|
||||
continue
|
||||
|
||||
still_seit = timezone.now() - kamera.letzter_kontakt
|
||||
minuten = int(still_seit.total_seconds() // 60)
|
||||
|
||||
if kamera.letzter_kontakt < grenze:
|
||||
if kamera.stoerung_gemeldet_seit is not None:
|
||||
self.stdout.write(f"{kamera.name}: still ({minuten} min), "
|
||||
"schon gemeldet")
|
||||
continue
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f"{kamera.name}: STILL seit {minuten} min"))
|
||||
if not optionen["trocken"]:
|
||||
bn.melde_stoerung(
|
||||
f"Kamera still: {kamera.name}",
|
||||
f"Seit {minuten} Minuten kein Lebenszeichen. "
|
||||
f"Läuft die Erkennung noch?")
|
||||
# ⚠ Erst nach dem Senden vermerken. melde_stoerung
|
||||
# sendet synchron und schluckt Fehler nur im Log --
|
||||
# wer vorher vermerkt, verliert bei einem Aussetzer
|
||||
# des Meldewegs die Stoerung still und fuer immer.
|
||||
kamera.stoerung_gemeldet_seit = timezone.now()
|
||||
kamera.save(update_fields=["stoerung_gemeldet_seit"])
|
||||
else:
|
||||
if kamera.stoerung_gemeldet_seit is not None:
|
||||
# Wieder da — das gehört auch gemeldet, sonst weiss
|
||||
# niemand, ob das Problem noch besteht.
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"{kamera.name}: wieder da"))
|
||||
if not optionen["trocken"]:
|
||||
bn.melde_stoerung(
|
||||
f"Kamera wieder da: {kamera.name}",
|
||||
"Sie meldet sich wieder.")
|
||||
kamera.stoerung_gemeldet_seit = None
|
||||
kamera.save(update_fields=["stoerung_gemeldet_seit"])
|
||||
else:
|
||||
self.stdout.write(f"{kamera.name}: ok ({minuten} min her)")
|
||||
|
||||
self._wartende_fotos(einstellung, optionen["trocken"])
|
||||
|
||||
def _wartende_fotos(self, einstellung, trocken: bool) -> None:
|
||||
"""Meldet, wenn hochgeladene Fotos zu lange auf den Detector warten.
|
||||
|
||||
🔑 **Das ist die Stillstandsprüfung für den Detector selbst.** Die
|
||||
Kameraprüfung oben merkt, wenn ein Gerät nichts mehr liefert — sie
|
||||
merkt aber nicht, wenn der Detector steht, denn der meldet sich bei
|
||||
niemandem an. Liegengebliebene Fotos sind das einzige sichtbare
|
||||
Zeichen dafür, und sie sagen es unmissverständlich: Jemand hat
|
||||
hochgeladen, und seitdem ist nichts passiert.
|
||||
|
||||
📌 Verworfen wird nichts. Das Foto vom Spaziergang gibt es kein
|
||||
zweites Mal, und stilles Löschen wäre der schlechteste Umgang damit.
|
||||
"""
|
||||
wartend = Sighting.objects.wartend()
|
||||
aeltestes = wartend.first()
|
||||
|
||||
if aeltestes is None:
|
||||
if einstellung.fotos_gemeldet_seit is not None:
|
||||
self.stdout.write(self.style.SUCCESS("Fotos: alle bearbeitet"))
|
||||
if not trocken:
|
||||
bn.melde_stoerung(
|
||||
"Fotos wieder bearbeitet",
|
||||
"Die liegengebliebenen Fotos sind bestimmt.")
|
||||
einstellung.fotos_gemeldet_seit = None
|
||||
einstellung.save(update_fields=["fotos_gemeldet_seit"])
|
||||
return
|
||||
|
||||
minuten = int(
|
||||
(timezone.now() - aeltestes.started_at).total_seconds() // 60)
|
||||
anzahl = wartend.count()
|
||||
|
||||
if minuten < einstellung.stille_minuten:
|
||||
self.stdout.write(
|
||||
f"{anzahl} Foto(s) warten, ältestes {minuten} min — ok")
|
||||
return
|
||||
|
||||
if einstellung.fotos_gemeldet_seit is not None:
|
||||
self.stdout.write(
|
||||
f"{anzahl} Foto(s) warten ({minuten} min), schon gemeldet")
|
||||
return
|
||||
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f"{anzahl} Foto(s) warten auf Bestimmung, "
|
||||
f"ältestes seit {minuten} min"))
|
||||
if not trocken:
|
||||
bn.melde_stoerung(
|
||||
"Fotos warten auf Bestimmung",
|
||||
f"{anzahl} hochgeladene Foto(s) liegen seit {minuten} Minuten "
|
||||
f"unbearbeitet. Läuft der Detector noch?")
|
||||
# Erst nach dem Senden vermerken — siehe oben bei den Kameras.
|
||||
einstellung.fotos_gemeldet_seit = timezone.now()
|
||||
einstellung.save(update_fields=["fotos_gemeldet_seit"])
|
||||
Reference in new issue
Block a user