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:
christian committed 2026-10-14 18:55:23 +00:00
commit cc9e0786c2
189 files changed
+29792

No files matched your search

View File
Whitespace-only changes.
+158
View File
@@ -0,0 +1,158 @@
from django.conf import settings
from django.contrib import admin, messages
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from . import benachrichtigung as bn
from .models import (Badge, Benachrichtigung, Camera, DexEntry, EarnedBadge,
Sighting, Species)
@admin.register(Camera)
class CameraAdmin(admin.ModelAdmin):
list_display = (
"name", "slug", "location", "active", "last_seen", "health", "live",
)
list_filter = ("active", "live_enabled")
# Der Token existiert hier nicht im Klartext — nur sein Hash. Neuen Token
# gibt es über 'manage.py camera rotate <slug>'.
readonly_fields = ("token_hash", "created_at", "last_seen",
"letzter_kontakt")
prepopulated_fields = {"slug": ("name",)}
fieldsets = (
(None, {"fields": ("name", "slug", "location", "active")}),
("Erkennung", {
"fields": ("stream_url", "habitats", "detect_threshold",
"min_bird_score"),
"description": _(
"Der Detector holt sich diese Werte selbst — er fragt "
"<code>/api/config/</code> mit seinem Token und übernimmt "
"Änderungen im laufenden Betrieb, ohne Neustart. Eine "
"Stream-Adresse, die auf seiner Kommandozeile steht, behält "
"aber Vorrang."
),
}),
("Live-Bild", {
"fields": ("live_url", "live_enabled", "live_bildrate"),
"description": _(
"Das Livebild kommt vom Live-Verteiler des Detectors, nicht "
"von der Kamera selbst — die liefert nur einen Stream, und den "
"braucht die Erkennung. Starten mit "
"<code>python -m detector watch … --live-port 8131</code>."
),
}),
("Technisches", {
"fields": ("token_hash", "created_at", "last_seen",
"letzter_kontakt"),
"classes": ("collapse",),
}),
)
@admin.display(description="Zustand")
def health(self, obj: Camera) -> str:
if not obj.active:
return "stillgelegt"
return "meldet sich nicht" if obj.is_stale else "ok"
@admin.display(description="Live", boolean=False)
def live(self, obj: Camera) -> str:
"""Warum das Livebild (nicht) erscheint — geraten wird hier nicht."""
if not settings.LIVE_ALLOWED:
return _("gesperrt (VOGELALBUM_LIVE_ERLAUBT=0)")
if not obj.live_enabled:
return _("aus")
if not obj.live_url:
return _("an, aber keine Adresse")
if not obj.active:
return _("an, aber Kamera stillgelegt")
return _("an")
@admin.register(Species)
class SpeciesAdmin(admin.ModelAdmin):
list_display = ("german", "scientific", "rarity", "xp", "size_cm")
list_filter = ("rarity",)
search_fields = ("german", "scientific", "english")
prepopulated_fields = {"slug": ("german",)}
@admin.register(Sighting)
class SightingAdmin(admin.ModelAdmin):
list_display = (
"started_at", "camera", "species", "confirmed_species", "confidence_pct", "thumb",
)
list_filter = ("camera", "species", "is_first_of_species")
date_hierarchy = "started_at"
readonly_fields = ("preview", "votes", "xp_awarded", "is_first_of_species")
# Handkorrektur ist der häufigste Grund, hier reinzuschauen — deshalb direkt
# aus der Liste heraus editierbar.
list_editable = ("confirmed_species",)
@admin.display(description="Sicherheit", ordering="confidence")
def confidence_pct(self, obj: Sighting) -> str:
return f"{obj.confidence:.0%}"
@admin.display(description="Bild")
def thumb(self, obj: Sighting):
if not obj.crop:
return "—"
return format_html('<img src="{}" style="height:48px;border-radius:4px">', obj.crop.url)
@admin.display(description="Vorschau")
def preview(self, obj: Sighting):
if not obj.image:
return "—"
return format_html('<img src="{}" style="max-width:520px">', obj.image.url)
@admin.register(DexEntry)
class DexEntryAdmin(admin.ModelAdmin):
list_display = ("species", "count", "first_seen", "last_seen")
readonly_fields = ("species", "first_seen", "last_seen", "count", "best_sighting")
@admin.register(Badge)
class BadgeAdmin(admin.ModelAdmin):
list_display = ("emoji", "name", "code", "xp", "sort")
@admin.register(EarnedBadge)
class EarnedBadgeAdmin(admin.ModelAdmin):
list_display = ("badge", "earned_at")
@admin.register(Benachrichtigung)
class BenachrichtigungAdmin(admin.ModelAdmin):
"""Ein einziger Datensatz — deshalb kein Hinzufügen und kein Löschen."""
fieldsets = (
(None, {"fields": ("aktiv",)}),
("Wohin", {"fields": ("server", "thema")}),
("Was", {"fields": ("sichtungen", "stoerungen", "stille_minuten")}),
)
actions = ["probe_senden"]
def has_add_permission(self, request):
# Der Datensatz wird beim ersten Aufruf angelegt, siehe changelist_view.
return False
def has_delete_permission(self, request, obj=None):
return False
def changelist_view(self, request, extra_context=None):
# Sicherstellen, dass es den einen Eintrag gibt — sonst steht hier
# eine leere Liste und niemand weiss, wo man anfaengt.
Benachrichtigung.laden()
return super().changelist_view(request, extra_context)
@admin.action(description="Probemeldung senden")
def probe_senden(self, request, queryset):
"""⚠ Wartet bewusst auf die Antwort. Eine Probe, die still im
Hintergrund scheitert, taugt nichts — man will ja gerade wissen,
ob die Einstellungen stimmen."""
geklappt, text = bn.melde_probe()
if geklappt:
self.message_user(request, f"Probemeldung: {text}", messages.SUCCESS)
else:
self.message_user(request, f"Probemeldung fehlgeschlagen: {text}",
messages.ERROR)
+614
View File
@@ -0,0 +1,614 @@
"""Ingest-Endpunkt: der Detector meldet fertige Sichtungen hierher.
Absichtlich ein simples multipart-POST statt DRF — es gibt genau eine
Schreiboperation, und die Web-App soll ohne zusätzliche Abhängigkeiten laufen.
"""
from __future__ import annotations
import hmac
import json
from datetime import datetime
from django.conf import settings
from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_POST
from . import benachrichtigung, scoring
from .bilder import KeinBild, saeubern
from .models import Camera, Sighting, Species
# Marker für "Token stimmt, gehört aber zu keiner registrierten Kamera".
# Das ist der Einzelkamera-Betrieb über VOGELALBUM_INGEST_TOKEN aus den Settings.
LEGACY_CAMERA = object()
def _authenticate(request: HttpRequest):
"""Gibt die Kamera zurück, ``LEGACY_CAMERA`` oder ``None`` bei Ablehnung.
Jede Kamera hat ihren eigenen Token — damit ist klar, wer geliefert hat,
und ein kompromittierter Standort lässt sich einzeln stilllegen, ohne die
anderen anzufassen.
"""
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return None
token = header[7:]
camera = Camera.by_token(token)
if camera is not None:
return camera
# Konstantzeit-Vergleich: der Token ist ein Geheimnis, kein Benutzername.
# Ein leerer INGEST_TOKEN schaltet diesen Weg ab — dann geht nur noch über
# registrierte Kameras etwas rein. Ohne die Prüfung auf leer würde ein
# leerer Authorization-Header durchgehen.
if settings.INGEST_TOKEN and hmac.compare_digest(token, settings.INGEST_TOKEN):
return LEGACY_CAMERA
return None
@require_GET
def config(request: HttpRequest) -> JsonResponse:
"""Was für die anfragende Kamera gilt: Schwellen, Lebensräume, Artenliste.
Damit ist die Web-App die Wahrheit über die Erkennung, und nicht mehr die
Kommandozeile auf dem Rechner, auf dem der Detector läuft. Wer eine Kamera
umstellen will, braucht keine Shell mehr.
Im Einzelkamera-Betrieb über ``VOGELALBUM_INGEST_TOKEN`` gibt es keine
Kamera-Zeile in der Datenbank. Dann gelten die Vorgaben des Modells —
sonst müsste man erst eine Kamera anlegen, um überhaupt etwas zu bekommen.
"""
auth = _authenticate(request)
if auth is None:
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
kamera = auth if isinstance(auth, Camera) else Camera(slug="", habitats="garten")
# Der Abruf selbst ist das Lebenszeichen — siehe Camera.letzter_kontakt.
if isinstance(auth, Camera) and auth.pk:
Camera.objects.filter(pk=auth.pk).update(letzter_kontakt=timezone.now())
return JsonResponse(kamera.konfiguration())
def _box_uebernehmen(sighting, payload) -> None:
"""Wo der Vogel im Bild sitzt, als vier relative Zahlen.
🔑 Damit ist der Zuschnitt eine Regel statt einer Datei: Die Anzeige
schneidet aus dem Original, und eine bessere Regel wirkt auf den ganzen
Bestand. Siehe ``dex/zuschnitt.py``.
⚠️ Nur vollständige und plausible Vierecke übernehmen. Ein halbes Viereck
ist keins, und eine vertauschte Kante ergäbe einen leeren Ausschnitt —
sichtbar würde das erst als graue Kachel, weit weg von hier.
"""
box = payload.get("box")
if not isinstance(box, (list, tuple)) or len(box) != 4:
return
try:
x0, y0, x1, y1 = (float(w) for w in box)
except (TypeError, ValueError):
return
if not (0.0 <= x0 < x1 <= 1.0 and 0.0 <= y0 < y1 <= 1.0):
return
sighting.box_x0, sighting.box_y0 = x0, y0
sighting.box_x1, sighting.box_y1 = x1, y1
def _parse_dt(value: str | None) -> datetime | None:
if not value:
return None
return parse_datetime(value)
@csrf_exempt
@require_POST
def ingest(request: HttpRequest) -> JsonResponse:
auth = _authenticate(request)
if auth is None:
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
camera = auth if isinstance(auth, Camera) else None
raw = request.POST.get("data")
if not raw:
return JsonResponse({"fehler": "Feld 'data' fehlt"}, status=400)
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
return JsonResponse({"fehler": f"ungültiges JSON: {exc}"}, status=400)
started = _parse_dt(payload.get("started_at"))
ended = _parse_dt(payload.get("ended_at"))
if started is None or ended is None:
return JsonResponse(
{"fehler": "started_at/ended_at fehlen oder sind unlesbar"}, status=400
)
species = None
slug = payload.get("species")
if slug:
species = Species.objects.filter(slug=slug).first()
if species is None:
return JsonResponse(
{"fehler": f"unbekannte Art '{slug}' — Katalog nicht geseedet?"},
status=400,
)
vogelwert = float(payload.get("mean_bird_score") or 0.0)
# ⚠️ Ohne Vogel keine Sichtung. Die Grenze liegt am VOGELWERT, nicht an der
# Artsicherheit -- die beiden werden leicht verwechselt und bedeuten
# Verschiedenes: Artsicherheit 0 heißt "ich weiß nicht, welche Art",
# was bei einem echten Vogel vorkommt, den man später nachbestimmen
# will. Vogelwert 0 heißt "auf keinem einzigen Bild war ein Vogel".
#
# 🔑 Der Anlass: Am 27.09.2026 meldete die Vogeltränke abends 24 Besuche
# in vierzig Minuten -- Spiegelungen des Mondes auf dem Wasser, je ein
# Bild von 31 mit vermeintlicher Bewegung. Gezählt haben sie nie, aber
# sie standen in der Liste und brachten das Abzeichen "Nachtschicht"
# ein. Hier abzuweisen ist billiger, als die Folgen überall zu filtern.
#
# Kein Fehler für den Detector: er hat richtig gearbeitet und soll das
# nicht als Störung protokollieren. Deshalb 200 und nicht 400.
#
# ⚠️ Nur abweisen, wenn das Feld auch GESCHICKT wurde. Web-App und Detector
# sind getrennt deploybar (so ist dieses Projekt gebaut), die Fassungen
# laufen also auseinander: Web auf dem Server aktualisiert, Detector zu
# Hause noch alt. Eine Fassung, die den Wert gar nicht kennt, würde
# sonst ab dem Deploy stumm keine Sichtung mehr loswerden — ein Ausfall,
# der wie ein totes Vogelhaus aussieht und dessen Ursache auf einer
# anderen Maschine liegt.
# Je Kamera einstellbare Untergrenze. Ohne registrierte Kamera (Betrieb
# über den Sammel-Token) bleibt es beim harten Boden von 0.
grenze = camera.min_mean_bird_score if camera is not None else 0.0
if "mean_bird_score" in payload and vogelwert <= max(grenze, 0.0):
return JsonResponse(
{
"ignoriert": "kein Vogel erkannt",
"mean_bird_score": vogelwert,
"grenze": grenze,
},
status=200,
)
# Ort, falls die Quelle einen kennt — ein Handyfoto vom Spaziergang tut
# das, ein Vogelhaus nicht.
#
# ⚠️ Beide Werte nur zusammen übernehmen und nur im gültigen Bereich. Eine
# halbe Koordinate ist keine, und ein verrutschtes Vorzeichen oder eine
# 0/0-Vorgabe aus einem Formular landet sonst als Sichtung im Golf von
# Guinea, ohne dass irgendwo etwas auffällt.
breite = _koordinate(payload.get("latitude"), 90.0)
laenge = _koordinate(payload.get("longitude"), 180.0)
if breite is None or laenge is None:
breite = laenge = None
sighting = Sighting(
species=species,
camera=camera,
confidence=float(payload.get("confidence") or 0.0),
started_at=started,
ended_at=ended,
frames_total=int(payload.get("frames_total") or 0),
frames_with_bird=int(payload.get("frames_with_bird") or 0),
votes=payload.get("votes") or {},
mean_bird_score=vogelwert,
latitude=breite,
longitude=laenge,
)
# 🔑 Auch hier neu schreiben, obwohl der Detector vertrauenswürdig ist.
# Der Token EINER Kamera genügt, um Dateien unter der öffentlichen
# Medienadresse abzulegen — und ein kompromittierter Standort ist genau
# der Fall, für den die Token je Kamera getrennt sind. Siehe bilder.py.
_box_uebernehmen(sighting, payload)
try:
if "image" in request.FILES:
sighting.image = saeubern(request.FILES["image"])
if "crop" in request.FILES:
sighting.crop = saeubern(request.FILES["crop"])
except KeinBild as fehler:
return JsonResponse({"fehler": str(fehler)}, status=400)
sighting.save()
if camera is not None:
# 🔑 Beides, und das ist kein Doppel: ``last_seen`` heißt „zuletzt
# einen Vogel gesehen", ``letzter_kontakt`` heißt „die Kamera lebt".
# Eine eingelieferte Sichtung beweist beides. Bis zum 01.10.2026 stand
# hier nur ``last_seen``, und das Lebenszeichen kam ausschließlich vom
# Konfigurationsabruf — wer den abschaltet (``--konfig-alle 0``), hätte
# eine fleißig liefernde Kamera gehabt, die als still gilt.
jetzt = timezone.now()
camera.last_seen = jetzt
camera.letzter_kontakt = jetzt
camera.save(update_fields=["last_seen", "letzter_kontakt"])
reward = scoring.register(sighting)
# Meldung erst NACH dem Speichern und Bewerten: Erst hier steht fest,
# ob es eine neue Art ist. Laeuft im Hintergrund und kann die Antwort
# an den Detector nicht aufhalten.
benachrichtigung.melde_sichtung(sighting, reward.is_first)
return JsonResponse(
{
"id": sighting.id,
"kamera": camera.name if camera else None,
"art": species.german if species else None,
"xp": reward.xp,
"erstfang": reward.is_first,
"level": reward.level_after,
"level_up": reward.leveled_up,
"abzeichen": [b.name for b in reward.badges],
},
status=201,
)
# --------------------------------------------------------------------------
# Lesen nach außen
# --------------------------------------------------------------------------
#
# 🔑 Bis hierher konnte Vogelalbum nur ZUHÖREN: Kameras liefern ein, sonst
# passiert nichts. Für Home Assistant — und später eine HACS-Integration —
# braucht es den umgekehrten Weg.
#
# Eine einzige Antwort mit allem statt vieler kleiner Endpunkte. Wer alle
# zehn Sekunden fragt, soll nicht sechsmal fragen müssen, und die Brücke
# nach MQTT will ohnehin den ganzen Zustand auf einmal.
# 🔑 **Gerundet wird beim SPEICHERN, nicht beim Anzeigen.** Zwei Nachkommastellen
# sind rund ein Kilometer: genug für „am Waldrand bei X", zu grob für ein Haus.
# Eine Stelle weniger wären elf Kilometer und für die Frage, wo der Vogel war,
# wertlos.
#
# ⚠️ Der Unterschied ist nicht kosmetisch. Wer genau speichert und nur ungenau
# anzeigt, hat die Wohnadresse trotzdem in der Datenbank — und damit in jeder
# Sicherung, jedem Auszug und jeder späteren Auswertung, die von dieser
# Anzeigeregel nichts weiß. Was nie gespeichert wurde, kann auch nicht
# versehentlich herausfallen.
ORT_STELLEN = 2
def _koordinate(wert, grenze: float) -> float | None:
"""Eine gerundete Koordinate oder ``None``. Unbrauchbares wird verworfen.
⚠️ Auch die exakte 0 gilt als unbrauchbar. Das ist streng genommen ein
gültiger Ort im Atlantik — aber als Wert aus einer App ist es praktisch
immer ein nicht gefülltes Feld, und eine Sammlung voller Sichtungen vor
Westafrika fällt niemandem rechtzeitig auf.
"""
if wert is None:
return None
try:
zahl = float(wert)
except (TypeError, ValueError):
return None
if zahl == 0.0 or not -grenze <= zahl <= grenze:
return None
return round(zahl, ORT_STELLEN)
def _lesen_erlaubt(request: HttpRequest) -> bool:
"""Eigener Token fürs Lesen, getrennt vom Einliefern.
⚠️ Leerer Token heißt ZU, nicht offen. Ohne diese Prüfung stünde die
ganze Sammlung im Netz, sobald jemand die Umgebungsvariable vergisst.
"""
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return False
if not settings.LESE_TOKEN:
return False
return hmac.compare_digest(header[7:], settings.LESE_TOKEN)
def _kamera_zustand(kamera: Camera, grenze_min: int, jetzt) -> dict:
still_seit = None
if kamera.letzter_kontakt is not None:
still_seit = round((jetzt - kamera.letzter_kontakt).total_seconds() / 60, 1)
return {
"name": kamera.name,
"kennung": kamera.slug,
"standort": kamera.location,
"aktiv": kamera.active,
# 🔑 Die zwei Zeitpunkte bedeuten verschiedene Dinge und werden
# deshalb beide geliefert: 'letzter_kontakt' heißt "der Detector
# lebt", 'letzte_sichtung' heißt "zuletzt war ein Vogel da". An
# einem ruhigen Nachmittag ist das eine frisch und das andere alt.
"letzter_kontakt": (kamera.letzter_kontakt.isoformat()
if kamera.letzter_kontakt else None),
"still_seit_min": still_seit,
"online": still_seit is not None and still_seit < grenze_min,
"letzte_sichtung": (kamera.last_seen.isoformat()
if kamera.last_seen else None),
# ⚠️ Das Einzelbild vom DETECTOR, nicht von der Kamera. Die Kamera
# erlaubt genau einen Zuschauer; klinkt sich Home Assistant dort
# ein, verliert der Detector sein Bild und die Erkennung steht.
"bild_url": kamera.live_bild_url if kamera.shows_live else None,
# 🔑 Die Adresse des GERÄTS, nicht des Detectors. Signalstärke,
# Temperatur und Bildrate kennt nur die Kamera selbst; wer sie
# haben will, muss sie dort abholen. Über die Datenbank zu gehen
# hieße, sie erst zu speichern — und dann wären sie alt.
"geraet_url": kamera.stream_url or None,
"erkennungsschwelle": kamera.detect_threshold,
"mindest_vogelwert": kamera.min_bird_score,
}
@require_GET
def zustand(request: HttpRequest) -> JsonResponse:
"""Alles, was Vogelalbum über sich weiß — in einer Antwort.
Was hier bewusst FEHLT: Signalstärke, Chiptemperatur und Bildrate der
Kameras. Die weiß nur das Gerät selbst, und es beantwortet sie unter
``/status`` auf seinem Diagnose-Port. Sie hier durchzureichen hieße,
sie erst in die Datenbank zu schreiben — und dann wären sie alt.
"""
if not _lesen_erlaubt(request):
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
from .models import Benachrichtigung, DexEntry, EarnedBadge
jetzt = timezone.now()
grenze_min = Benachrichtigung.laden().stille_minuten
fortschritt = scoring.progress()
heute = jetzt.astimezone().date()
letzte = (
Sighting.objects.select_related("species", "confirmed_species", "camera")
.exclude(species__isnull=True, confirmed_species__isnull=True)
.order_by("-started_at")
.first()
)
letztes_abzeichen = (
EarnedBadge.objects.select_related("badge")
.order_by("-earned_at")
.first()
)
def bild(feld):
return request.build_absolute_uri(feld.url) if feld else None
return JsonResponse({
"kameras": [
_kamera_zustand(k, grenze_min, jetzt)
for k in Camera.objects.order_by("name")
],
"sammlung": {
"arten_gesammelt": fortschritt.species_seen,
"arten_gesamt": fortschritt.species_total,
"sichtungen_gesamt": fortschritt.sightings,
# ⚠️ bestimmt(): sonst meldet Home Assistant Spiegelungen als Besuch.
"sichtungen_heute": Sighting.objects.bestimmt().filter(
started_at__date=heute).count(),
"abzeichen": fortschritt.badges,
"punkte": fortschritt.xp,
"stufe": fortschritt.level,
"rang": fortschritt.rank,
"fortschritt_stufe": round(fortschritt.level_progress, 3),
},
"letzte_sichtung": ({
"art": letzte.effective_species.german,
"art_wissenschaftlich": letzte.effective_species.scientific,
"kamera": letzte.camera.slug if letzte.camera else None,
"zeitpunkt": letzte.started_at.isoformat(),
"sicherheit": round(letzte.confidence, 3),
"neue_art": letzte.is_first_of_species,
"bild": bild(letzte.crop) or bild(letzte.image),
} if letzte else None),
"letztes_abzeichen": ({
"name": letztes_abzeichen.badge.name,
"symbol": letztes_abzeichen.badge.emoji,
"zeitpunkt": letztes_abzeichen.earned_at.isoformat(),
} if letztes_abzeichen else None),
"stand": jetzt.isoformat(),
})
# --------------------------------------------------------------------------
# Fotos von unterwegs: hochladen, abholen, Ergebnis nachtragen
# --------------------------------------------------------------------------
#
# 🔑 **Warum nicht über `ingest`.** Der Ingest nimmt eine FERTIGE Sichtung
# entgegen: Art, Vogelwert, Bildanzahl — alles schon bestimmt. Ein Foto vom
# Spaziergang hat nichts davon, es wartet ja gerade erst auf die Bestimmung.
# Beides in einen Endpunkt zu zwingen hieße, überall Sonderfälle zu prüfen;
# insbesondere würde die Regel „Vogelwert 0 wird abgewiesen" jedes frisch
# hochgeladene Foto verwerfen, weil es den Wert zwangsläufig noch nicht hat.
#
# 🔑 **Die Richtung bleibt, wie sie ist.** Der Detector holt sich die Arbeit
# ab, sie wird ihm nicht zugestellt. Damit braucht er weiterhin keine
# erreichbare Adresse und keinen offenen Port — dieselbe Mechanik wie bei
# `fernkonfig.py`, die ihn alle fünf Minuten nach seiner Konfiguration fragen
# lässt.
@csrf_exempt
@require_POST
def hochladen(request: HttpRequest) -> JsonResponse:
"""Ein Foto zur Bestimmung annehmen. Von der App oder vom Formular.
Es entsteht eine Sichtung mit ``wartet_auf_bestimmung``, ohne Art und ohne
Vogelwert. Den trägt der Detector nach, wenn er sie abgeholt hat.
"""
auth = _authenticate(request)
if auth is None:
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
camera = auth if isinstance(auth, Camera) else None
bilder = request.FILES.getlist("bilder") or request.FILES.getlist("bild")
if not bilder:
return JsonResponse({"fehler": "kein Bild dabei"}, status=400)
try:
angelegt = fotos_annehmen(
bilder,
camera=camera,
aufgenommen=_parse_dt(request.POST.get("aufgenommen_am")),
latitude=request.POST.get("latitude"),
longitude=request.POST.get("longitude"),
)
except KeinBild as fehler:
return JsonResponse({"fehler": str(fehler)}, status=400)
return JsonResponse(
{"angelegt": angelegt, "wartend": Sighting.objects.wartend().count()},
status=201,
)
def fotos_annehmen(dateien, camera=None, aufgenommen=None,
latitude=None, longitude=None) -> list[int]:
"""Legt je Bild eine wartende Sichtung an. Gibt die Kennungen zurück.
🔑 Eine Stelle für zwei Wege: die Schnittstelle für die App und das
Formular in der Web-App. Zwei Kopien dieser Logik würden auseinander
laufen, und zwar unbemerkt — der eine Weg legte dann Sichtungen an, die
der andere nicht mehr findet.
⚠️ Je Bild EINE Sichtung. Von einem Spaziergang kommen zehn Fotos mit
womöglich fünf Arten; eine Sichtung bleibt, was sie immer war — ein Vogel
zu einer Zeit an einem Ort. Alles andere brächte ``bestimmt()``, die
Abzeichen und den Erstfang durcheinander.
"""
aufgenommen = aufgenommen or timezone.now()
breite = _koordinate(latitude, 90.0)
laenge = _koordinate(longitude, 180.0)
if breite is None or laenge is None:
breite = laenge = None
# 🔑 **Erst alle Bilder säubern, dann speichern.** Von einem Spaziergang
# kommen zehn Fotos auf einmal; wäre das achte unbrauchbar, stünden sonst
# sieben Sichtungen in der Datenbank und der Hochladende bekäme einen
# Fehler — er wüsste nicht, was angekommen ist, und lüde alles noch
# einmal hoch. Entweder alle oder keins.
sauber = [saeubern(d) for d in dateien]
angelegt = []
for datei in sauber:
s = Sighting(
camera=camera,
started_at=aufgenommen,
ended_at=aufgenommen,
# Ein Foto ist ein Bild — die Zählwerte des Dauerbetriebs haben
# hier keine Entsprechung und bleiben bei null, bis der Detector
# sein Ergebnis nachträgt.
frames_total=1,
wartet_auf_bestimmung=True,
latitude=breite,
longitude=laenge,
)
s.image = datei
s.save()
angelegt.append(s.id)
if camera is not None:
camera.last_seen = timezone.now()
camera.letzter_kontakt = timezone.now()
camera.save(update_fields=["last_seen", "letzter_kontakt"])
return angelegt
@require_GET
def offen(request: HttpRequest) -> JsonResponse:
"""Was der Detector noch zu bestimmen hat. Älteste zuerst.
Geliefert werden Kennung und Bildadresse — das Bild selbst holt der
Detector über die normale Medienadresse, die ohnehin ausgeliefert wird.
"""
if not _lesen_erlaubt(request):
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
try:
grenze = min(int(request.GET.get("anzahl", 20)), 100)
except (TypeError, ValueError):
grenze = 20
offene = Sighting.objects.wartend().select_related("camera")[:grenze]
return JsonResponse({
"fotos": [
{
"id": s.id,
"bild": request.build_absolute_uri(s.image.url) if s.image else None,
"aufgenommen_am": s.started_at.isoformat(),
"kamera": s.camera.slug if s.camera else None,
}
for s in offene
],
"wartend_gesamt": Sighting.objects.wartend().count(),
})
@csrf_exempt
@require_POST
def bestimmt_melden(request: HttpRequest, sichtung_id: int) -> JsonResponse:
"""Das Ergebnis des Detectors zu einem hochgeladenen Foto.
⚠️ Der Wartezustand fällt IMMER weg, auch wenn nichts erkannt wurde. Sonst
bliebe ein Foto ohne Vogel ewig in der Warteschlange, der Detector würde es
bei jedem Durchgang erneut bearbeiten, und der Wächter meldete einen
Stillstand, den es nicht gibt.
"""
auth = _authenticate(request)
if auth is None:
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
sighting = Sighting.objects.filter(
id=sichtung_id, wartet_auf_bestimmung=True).first()
if sighting is None:
return JsonResponse(
{"fehler": "keine wartende Sichtung mit dieser Kennung"}, status=404)
raw = request.POST.get("data") or "{}"
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
return JsonResponse({"fehler": f"ungültiges JSON: {exc}"}, status=400)
species = None
slug = payload.get("species")
if slug:
species = Species.objects.filter(slug=slug).first()
if species is None:
return JsonResponse(
{"fehler": f"unbekannte Art '{slug}'"}, status=400)
sighting.species = species
sighting.confidence = float(payload.get("confidence") or 0.0)
sighting.mean_bird_score = float(payload.get("mean_bird_score") or 0.0)
sighting.votes = payload.get("votes") or {}
# 🔑 **Ein Vogel ohne Art ist trotzdem ein Vogel.** Hier stand
# ``1 if species else 0`` — damit galt ein sicher erkannter Vogel, dessen
# Art sich nicht bestimmen ließ (Gegenlicht, Silhouette), als „nichts auf
# dem Bild". Die Unterscheidung ist genau die, auf der das ganze Modul
# beruht: Artsicherheit 0 heißt „welche Art?", Vogelwert 0 heißt „gar kein
# Vogel". Siehe die Begründung bei ``ingest``.
sighting.frames_with_bird = 1 if (species or sighting.mean_bird_score > 0) else 0
_box_uebernehmen(sighting, payload)
sighting.wartet_auf_bestimmung = False
if "crop" in request.FILES:
sighting.crop = request.FILES["crop"]
sighting.save()
# Punkte und Abzeichen erst jetzt — vorher war ja nicht bekannt, ob
# überhaupt ein Vogel auf dem Bild ist.
reward = scoring.register(sighting) if species else None
if reward is not None:
benachrichtigung.melde_sichtung(sighting, reward.is_first)
return JsonResponse({
"id": sighting.id,
"art": species.german if species else None,
"xp": reward.xp if reward else 0,
"erstfang": reward.is_first if reward else False,
"abzeichen": [b.name for b in reward.badges] if reward else [],
})
+579
View File
@@ -0,0 +1,579 @@
"""Lese-Schnittstelle für die Handy-App.
🔑 **Warum ein eigenes Modul und nicht mehr Endpunkte in ``api.py``.** Dort
steht der Verkehr mit den *Kameras*: einliefern, Konfiguration abholen,
Warteschlange abarbeiten. Das hier ist der umgekehrte Fall — ein Mensch
blättert in seinem Album. Die beiden haben verschiedene Leser, verschiedene
Antwortformen und ändern sich aus verschiedenen Gründen; sie in eine Datei zu
legen hieße, bei jeder App-Änderung den Kameraverkehr mit zu lesen.
⚠️ **Es ist absichtlich keine neue Wahrheit.** Jede Zahl hier kommt aus
derselben Quelle wie die Website: ``scoring.progress()``, ``DexEntry``,
``Sighting``. Wer die Punktevergabe ändert, ändert beides zugleich — sonst
stünde auf dem Telefon Stufe 3 und im Browser Stufe 2, und niemand wüsste,
welche der beiden lügt.
🔑 **Die Sprache kommt aus ``Accept-Language``.** Djangos LocaleMiddleware
steht schon in der Kette, also liefern die ``*_lokal``-Eigenschaften der
Modelle von selbst Deutsch oder Englisch. Die App muss nichts übersetzen und
wir pflegen die Texte nicht zweimal.
"""
from __future__ import annotations
import hmac
from django.conf import settings
from django.db.models import Q
from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET
from . import scoring
from .models import Badge, Camera, DexEntry, Sighting, Species
from .species_meta import HABITATS
# Höchstens so viele Sichtungen je Seite. Ein Feed auf dem Telefon lädt
# nach; eine Antwort mit allen 300 Sichtungen und ihren Bildadressen wäre
# auf einer Mobilfunkverbindung die längste Wartezeit der ganzen App.
SEITE_MAX = 60
SEITE_VORGABE = 24
# --------------------------------------------------------------------------
# Zugang
# --------------------------------------------------------------------------
#
# 🔑 **Die App kommt mit EINEM Token aus.** Sie muss zwei Dinge können:
# Fotos einliefern (braucht einen Kamera-Token, siehe ``api.hochladen``) und
# das Album lesen. Hätte das Lesen einen eigenen Token, müsste man beim
# Einrichten zwei Geheimnisse abtippen und wir hätten einen Fehlerfall mehr
# ("hochladen geht, Album bleibt leer"). Deshalb gilt hier **beides**: der
# Lese-Token wie bei Home Assistant, oder der Token der eigenen Kamera.
#
# ⚠️ Dass ein Kamera-Token hier LESEN darf, ist kein Dammbruch: Das Album
# steht ohnehin öffentlich im Netz — ``/sammlung/``, ``/sichtungen/`` und
# ``/abzeichen/`` sind ohne Anmeldung erreichbar. Diese Schnittstelle gibt
# exakt dieselben Angaben in anderer Verpackung heraus und **nichts darüber
# hinaus**: insbesondere keine Koordinaten (die zeigt die Website nicht), kein
# Live-Bild und keine Kamera-Token.
def _zugang(request: HttpRequest) -> bool:
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return False
token = header[7:]
if Camera.by_token(token) is not None:
return True
# Konstantzeit-Vergleich, und leer heißt ZU — dieselbe Regel wie in
# ``api._lesen_erlaubt``. Ein vergessenes Geheimnis darf nicht die Tür
# aufmachen, sondern muss sie zuhalten.
if settings.LESE_TOKEN:
return hmac.compare_digest(token, settings.LESE_TOKEN)
return False
def _abgelehnt() -> JsonResponse:
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
# --------------------------------------------------------------------------
# Bausteine, die mehrere Endpunkte brauchen
# --------------------------------------------------------------------------
def _bild(request: HttpRequest, feld) -> str | None:
"""Absolute Adresse eines Bildfelds, oder ``None``.
⚠️ Absolut, nicht relativ. Eine App hat keine Basisadresse, von der aus
``/media/…`` etwas bedeuten würde — sie kennt nur die URL, die ihr der
Nutzer eingetragen hat, und die zeigt womöglich auf einen anderen Host als
die Medien.
"""
if not feld:
return None
return request.build_absolute_uri(feld.url)
def _art_kurz(request: HttpRequest, art: Species, eintrag: DexEntry | None) -> dict:
"""Eine Albumseite, wie die App sie im Raster braucht.
``gefunden=False`` heißt Schatten: Name und Nummer bleiben sichtbar, das
Bild nicht — genau wie im Klebebildchen-Album, wo die leere Stelle die
Umrisse zeigt. Die App soll diese Entscheidung nicht selbst treffen
müssen, deshalb steht sie hier als Feld.
"""
daten = {
"slug": art.slug,
"nummer": art.number,
"nummer_text": art.dex_nr,
"name": art.name,
"zweitname": art.zweitname,
"wissenschaftlich": art.scientific,
"seltenheit": art.rarity,
"seltenheit_label": art.rarity_label,
"seltenheit_farbe": art.rarity_color,
"typ": art.typ,
"typ_label": art.typ_label,
"typ_farbe": art.typ_color,
"typ_schrift": art.typ_ink,
"lebensraum": art.lebensraum_typ,
"punkte": art.xp,
"gefunden": eintrag is not None,
# Das Referenzbild gehört zum Katalog und ist auch bei ungefundenen
# Arten da. Die App zeigt es erst nach dem Fang — aber die
# Entscheidung trifft sie, nicht wir: fürs Nachschlagen ("wie sieht
# ein Gartenrotschwanz aus?") braucht sie es vorher.
"referenzbild": _bild(request, art.reference_image),
}
if eintrag is not None:
daten |= {
"anzahl": eintrag.count,
"erstmals": eintrag.first_seen.isoformat(),
"zuletzt": eintrag.last_seen.isoformat(),
"neu": eintrag.is_new,
"foto": (
request.build_absolute_uri(eintrag.best_sighting.kachel_url)
if eintrag.best_sighting and eintrag.best_sighting.kachel_url
else None
),
}
return daten
def _sichtung_kurz(request: HttpRequest, s: Sighting) -> dict:
"""Eine Zeile im Feed.
⚠️ ``effective_species`` statt ``species``: Eine Handbestimmung im Admin
gewinnt gegen das Modell. Wer hier ``species`` nähme, zeigte in der App
weiter die falsche Art, die längst korrigiert wurde — und zwar nur
in der App, was die Fehlersuche teuer macht.
"""
art = s.effective_species
return {
"id": s.id,
"zeitpunkt": s.started_at.isoformat(),
"dauer_s": round(s.duration_s, 1),
"wartet": s.wartet_auf_bestimmung,
"art": (
{
"slug": art.slug,
"name": art.name,
"wissenschaftlich": art.scientific,
"seltenheit": art.rarity,
"seltenheit_farbe": art.rarity_color,
"typ_farbe": art.typ_color,
# 🔑 Das Referenzbild gehört an die Sichtung, obwohl es zur Art
# gehört: Nicht jede Sichtung hat ein eigenes Foto (der
# Detector verwirft schlechte Bilder, und ältere Einträge haben
# gar keins). Ohne diesen Rückfall steht in der App ein
# Fragezeichen neben einer Art, die sie kennt und abbilden
# könnte — gefunden am gerenderten Feed, nicht im Test.
"referenzbild": _bild(request, art.reference_image),
}
if art
else None
),
"sicherheit": round(s.confidence, 3),
"vogelwert": round(s.mean_bird_score, 3),
"handbestimmt": s.confirmed_species_id is not None,
"neue_art": s.is_first_of_species,
"punkte": s.xp_awarded,
"kamera": (
{"kennung": s.camera.slug, "name": s.camera.name, "mobil": s.camera.mobil}
if s.camera
else None
),
"foto": _bild(request, s.image),
# 🔑 Der Ausschnitt wird aus dem Original gerechnet, nicht als
# zweites Bild gespeichert — siehe dex/zuschnitt.py. Damit schneidet
# eine geänderte Regel sofort den ganzen Bestand neu, und die App
# bekommt dieselbe Kachel wie die Website.
"ausschnitt": (request.build_absolute_uri(s.kachel_url)
if s.kachel_url else None),
}
def _fortschritt() -> dict:
"""Punkte, Stufe, Rang — die Kopfzeile jeder App-Ansicht."""
p = scoring.progress()
return {
"punkte": p.xp,
"stufe": p.level,
"rang": p.rank,
"punkte_stufe_start": p.xp_this_level,
"punkte_stufe_ziel": p.xp_next_level,
"punkte_bis_aufstieg": p.xp_to_next,
"anteil_stufe": round(p.level_progress, 3),
"arten_gesammelt": p.species_seen,
"arten_gesamt": p.species_total,
"sichtungen": p.sightings,
"abzeichen": p.badges,
}
# --------------------------------------------------------------------------
# Endpunkte
# --------------------------------------------------------------------------
@require_GET
def start(request: HttpRequest) -> JsonResponse:
"""Der Startbildschirm in einer Antwort.
🔑 Bewusst ein Sammelabruf statt vier einzelner. Beim Öffnen der App
wartet ein Mensch — vier Anfragen hintereinander über Mobilfunk sind
viermal die Latenz, und drei davon zeigen Dinge, die ohnehin zusammen auf
einen Bildschirm gehören.
"""
if not _zugang(request):
return _abgelehnt()
jetzt = timezone.now()
heute = jetzt.astimezone().date()
letzte = (
Sighting.objects.bestimmt()
.select_related("species", "confirmed_species", "camera")
.order_by("-started_at")
.first()
)
neueste = (
DexEntry.objects.select_related("species", "best_sighting")
.order_by("-first_seen")[:6]
)
return JsonResponse(
{
"fortschritt": _fortschritt(),
"sichtungen_heute": Sighting.objects.bestimmt()
.filter(started_at__date=heute)
.count(),
"letzte_sichtung": _sichtung_kurz(request, letzte) if letzte else None,
# Die letzten Erstfänge — das ist der Teil, den man jemandem
# zeigt. Eine Liste aller Sichtungen beantwortet "war was los",
# die Erstfänge beantworten "ist die Sammlung gewachsen".
"neueste_arten": [
_art_kurz(request, e.species, e) for e in neueste
],
"wartende_fotos": Sighting.objects.wartend().count(),
"kameras": [
{
"kennung": k.slug,
"name": k.name,
"standort": k.location,
"mobil": k.mobil,
"aktiv": k.active,
"still": k.is_stale,
"letzte_sichtung": (
k.last_seen.isoformat() if k.last_seen else None
),
}
for k in Camera.objects.order_by("name")
],
"stand": jetzt.isoformat(),
}
)
@require_GET
def album(request: HttpRequest) -> JsonResponse:
"""Das ganze Album eines Lebensraums, gefundene und fehlende.
⚠️ **Vorgabe ``garten``, wie auf der Website.** 148 Arten auf einmal sind
kein Ziel, sondern eine Wand; ``?lebensraum=alle`` holt sie trotzdem, wenn
die App einen Nachschlage-Modus anbietet.
"""
if not _zugang(request):
return _abgelehnt()
lebensraum = request.GET.get("lebensraum", "garten")
if lebensraum == "alle":
lebensraum = ""
arten = Species.objects.all()
if lebensraum:
arten = arten.filter(lebensraum_typ=lebensraum)
eintraege = {
e.species_id: e
for e in DexEntry.objects.select_related("best_sighting")
}
seiten = [_art_kurz(request, a, eintraege.get(a.id)) for a in arten]
# Nach Albumnummer, nicht nach Fundstatus. Die Nummer ist die Seitenzahl:
# Sie bleibt stehen, wo sie ist, auch wenn die Lücke daneben sich füllt.
seiten.sort(key=lambda s: s["nummer"] or 9999)
return JsonResponse(
{
"arten": seiten,
"gefunden": sum(1 for s in seiten if s["gefunden"]),
"gesamt": len(seiten),
"lebensraum": lebensraum,
"lebensraeume": _lebensraeume(),
}
)
def _lebensraeume() -> list[dict]:
"""Die Reiter über dem Album — nur die, in denen Arten stehen."""
from django.utils.translation import get_language
englisch = (get_language() or "de").startswith("en")
raus = []
for code, paar in HABITATS.items():
anzahl = Species.objects.filter(lebensraum_typ=code).count()
if not anzahl:
continue
gefunden = DexEntry.objects.filter(species__lebensraum_typ=code).count()
raus.append(
{
"code": code,
"label": paar[1] if englisch else paar[0],
"gesamt": anzahl,
"gefunden": gefunden,
}
)
return raus
@require_GET
def art(request: HttpRequest, slug: str) -> JsonResponse:
"""Eine Albumseite in voller Länge: Steckbrief, Besuche, Tagesverlauf."""
if not _zugang(request):
return _abgelehnt()
species = Species.objects.filter(slug=slug).first()
if species is None:
return JsonResponse({"fehler": f"unbekannte Art '{slug}'"}, status=404)
eintrag = (
DexEntry.objects.filter(species=species)
.select_related("best_sighting")
.first()
)
passend = Q(species=species) | Q(confirmed_species=species)
besuche = (
Sighting.objects.filter(passend)
.select_related("species", "confirmed_species", "camera")
.order_by("-started_at")[:24]
)
# Wann ist die Art aktiv? Über die Stunden der bisherigen Besuche,
# in Ortszeit — eine Sichtung um 6:31 gehört in die 6-Uhr-Säule, auch
# wenn sie in der Datenbank als 04:31 UTC steht.
stunden = [0] * 24
for dt in Sighting.objects.filter(passend).values_list("started_at", flat=True):
stunden[timezone.localtime(dt).hour] += 1
spitze = max(stunden) or 1
aehnliche = list(Species.objects.filter(slug__in=species.verwechslung or []))
reihenfolge = species.verwechslung or []
aehnliche.sort(key=lambda s: reihenfolge.index(s.slug))
vorher = (
Species.objects.filter(number__lt=species.number).order_by("-number").first()
)
nachher = (
Species.objects.filter(number__gt=species.number).order_by("number").first()
)
return JsonResponse(
{
"art": _art_kurz(request, species, eintrag)
| {
"kategorie": species.kategorie_lokal,
"beschreibung": species.absaetze,
"kurzfakt": species.fact,
"groesse_cm": species.size_cm,
"gewicht_g": species.gewicht_g,
"spannweite_cm": species.spannweite_cm,
"zug": species.zug_label,
"nahrung": species.nahrung_lokal,
"lebensraum_text": species.lebensraum_lokal,
"stimme": species.stimme_lokal,
"lebensraum_label": species.habitat_label,
"bildnachweis": {
"urheber": species.reference_credit,
"lizenz": species.reference_license,
"quelle": species.reference_source,
},
# Fertig gerechnete Balken, wie auf der Website. Die
# Bezugsgrößen stecken in ``Species.balken()``; sie in der App
# nachzubauen hieße, zwei Wahrheiten über denselben Katalog zu
# haben.
"balken": species.balken(),
},
"besuche": [_sichtung_kurz(request, s) for s in besuche],
"tagesverlauf": [
{"stunde": h, "anzahl": n, "anteil": round(n / spitze, 3)}
for h, n in enumerate(stunden)
],
"verwechslung": [
_art_kurz(request, s, None) for s in aehnliche
],
"blaettern": {
"vorher": vorher.slug if vorher else None,
"nachher": nachher.slug if nachher else None,
},
}
)
@require_GET
def sichtungen(request: HttpRequest) -> JsonResponse:
"""Der Feed. Jüngste zuerst, seitenweise.
``?nur_bestimmt=1`` blendet aus, was keine Art hat — das ist die Ansicht
für jemanden, der *sammelt*; die vollständige Liste ist die für jemanden,
der *prüft*.
"""
if not _zugang(request):
return _abgelehnt()
qs = Sighting.objects.select_related(
"species", "confirmed_species", "camera"
).order_by("-started_at")
if request.GET.get("nur_bestimmt"):
qs = qs.bestimmt()
kamera = request.GET.get("kamera", "")
if kamera:
qs = qs.filter(camera__slug=kamera)
art_slug = request.GET.get("art", "")
if art_slug:
qs = qs.filter(Q(species__slug=art_slug) | Q(confirmed_species__slug=art_slug))
seite = _ganzzahl(request.GET.get("seite"), 1, mindestens=1)
pro_seite = _ganzzahl(request.GET.get("anzahl"), SEITE_VORGABE, mindestens=1)
pro_seite = min(pro_seite, SEITE_MAX)
gesamt = qs.count()
ab = (seite - 1) * pro_seite
return JsonResponse(
{
"sichtungen": [
_sichtung_kurz(request, s) for s in qs[ab : ab + pro_seite]
],
"seite": seite,
"pro_seite": pro_seite,
"gesamt": gesamt,
"weiter": ab + pro_seite < gesamt,
}
)
def _ganzzahl(wert, vorgabe: int, mindestens: int = 0) -> int:
try:
return max(mindestens, int(wert))
except (TypeError, ValueError):
return vorgabe
@require_GET
def sichtung(request: HttpRequest, sichtung_id: int) -> JsonResponse:
"""Eine einzelne Sichtung.
🔑 **Das ist der Endpunkt, mit dem die App nach einem Upload nachfragt.**
``api.hochladen`` legt eine wartende Sichtung an und gibt ihre Kennung
zurück; bestimmt wird sie erst, wenn der Detector sie abgeholt hat. Die
App fragt also hier nach, bis ``wartet`` auf ``false`` springt, und zeigt
dann das Ergebnis.
⚠️ ``wartet: false`` **und** ``art: null`` ist kein Fehler, sondern die
Antwort "auf dem Foto war kein Vogel". Die App muss beide Fälle
unterscheiden können, sonst dreht sie für ein Foto vom eigenen Daumen
endlos die Warteschleife.
"""
if not _zugang(request):
return _abgelehnt()
s = (
Sighting.objects.select_related("species", "confirmed_species", "camera")
.filter(id=sichtung_id)
.first()
)
if s is None:
return JsonResponse({"fehler": "keine Sichtung mit dieser Kennung"}, status=404)
daten = _sichtung_kurz(request, s)
art = s.effective_species
if art is not None:
eintrag = DexEntry.objects.filter(species=art).first()
daten["album"] = _art_kurz(request, art, eintrag)
# Nach einem Erstfang will die App die neue Punktzahl zeigen, ohne noch
# einmal den Startbildschirm zu holen.
daten["fortschritt"] = _fortschritt()
return JsonResponse(daten)
@require_GET
def abzeichen(request: HttpRequest) -> JsonResponse:
"""Alle Abzeichen mit Fortschritt — verdiente und offene.
🔑 Nur die *zählbaren* Abzeichen haben einen Balken. "Ein Specht war da"
passiert oder passiert nicht; ein Balken auf 0 % wäre dort keine
Information, sondern nur entmutigend. Dieselbe Regel wie auf der Website,
und sie kommt aus derselben Funktion.
"""
if not _zugang(request):
return _abgelehnt()
from .templatetags.dex_extras import abzeichen_bild
verdient = {
b.code: b.earned
for b in Badge.objects.filter(earned__isnull=False).select_related("earned")
}
stand = scoring.fortschritt_je_abzeichen()
zeilen = []
for badge in Badge.objects.all():
erworben = verdient.get(badge.code)
# 🔑 **Der Pin, nicht das Emoji.** Die Website zeigt die gezeichneten
# Emaille-Pins aus `static/img/pins/`; eine App mit Emoji daneben wäre
# eine zweite Gestaltung derselben Sache. Dazu kommt der Grund, aus
# dem die Pins überhaupt entstanden sind: Auf einem System ohne
# Emoji-Schrift steht statt des Abzeichens ein leeres Rechteck — und
# zwar ausgerechnet auf der Seite, die Belohnungen zeigt.
#
# ⚠️ Das Emoji bleibt trotzdem im Feld `symbol`: Ein später
# hinzugefügtes Abzeichen hat noch keinen Pin, und dann ist das Emoji
# besser als eine Lücke. Die App entscheidet, was sie nimmt.
pin = abzeichen_bild(badge.code)
zeile = {
"code": badge.code,
"name": badge.name_lokal,
"beschreibung": badge.description_lokal,
"symbol": badge.emoji,
"bild": request.build_absolute_uri(pin) if pin else None,
"punkte": badge.xp,
"verdient": erworben is not None,
"am": erworben.earned_at.isoformat() if erworben else None,
}
messbar = stand.get(badge.code)
if messbar and erworben is None:
ist, ziel = messbar
zeile |= {
"ist": ist,
"ziel": ziel,
"anteil": round(ist / ziel, 3) if ziel else 0.0,
}
zeilen.append(zeile)
return JsonResponse(
{
"abzeichen": zeilen,
"verdient": sum(1 for z in zeilen if z["verdient"]),
"gesamt": len(zeilen),
}
)
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class DexConfig(AppConfig):
name = 'dex'
+129
View File
@@ -0,0 +1,129 @@
"""Meldungen über ntfy — Sichtungen und Störungen.
Was hier bewusst NICHT passiert: die Anwendung aufhalten. Eine Meldung ist
eine Nebensache; wenn der ntfy-Server nicht antwortet, darf das weder die
Antwort an den Detector verzögern noch einen Fehler auslösen. Eine verpasste
Meldung ist ärgerlich, eine verlorene Sichtung wäre schlimmer.
"""
from __future__ import annotations
import logging
import threading
import urllib.error
import urllib.request
from .models import Benachrichtigung
log = logging.getLogger(__name__)
ZEITGRENZE = 8 # Sekunden
def _senden(ziel: str, titel: str, text: str, prioritaet: str, symbole: str):
"""Schickt die Meldung. Läuft im eigenen Faden, siehe _im_hintergrund."""
try:
anfrage = urllib.request.Request(
ziel,
data=text.encode("utf-8"),
headers={
"Title": titel.encode("utf-8"),
"Priority": prioritaet,
"Tags": symbole,
},
method="POST",
)
with urllib.request.urlopen(anfrage, timeout=ZEITGRENZE):
pass
except (urllib.error.URLError, OSError, ValueError) as fehler:
# Nur protokollieren. Wer eine Meldung nicht zustellen kann, soll
# deshalb nicht den Betrieb anhalten.
log.warning("ntfy nicht erreicht: %s", fehler)
def _im_hintergrund(*args):
threading.Thread(target=_senden, args=args, daemon=True).start()
def melde_sichtung(sighting, ist_neue_art: bool) -> None:
"""Meldet eine Sichtung, je nach Einstellung im Backend."""
e = Benachrichtigung.laden()
if not e.aktiv or not e.ziel:
return
if e.sichtungen == Benachrichtigung.Sichtungen.AUS:
return
if e.sichtungen == Benachrichtigung.Sichtungen.NEUE and not ist_neue_art:
return
art = sighting.species.german if sighting.species else "Unbestimmt"
kamera = sighting.camera.name if sighting.camera else "unbekannte Kamera"
sicherheit = round(sighting.confidence * 100)
if ist_neue_art:
titel = f"Neue Art: {art}"
text = f"{kamera} · {sicherheit} % · zum ersten Mal gesehen"
symbole = "sparkles"
prioritaet = "default"
else:
titel = art
text = f"{kamera} · {sicherheit} %"
symbole = "bird"
# Leise: Wer alle Sichtungen meldet, will nicht bei jedem Spatz
# ein Summen am Handy.
prioritaet = "low"
_im_hintergrund(e.ziel, titel, text, prioritaet, symbole)
def melde_stoerung(titel: str, text: str) -> None:
"""Meldet eine Störung — laut, weil sie Handeln erfordert.
🔑 **Synchron, im Gegensatz zu den Sichtungen.** Und das ist keine
Unachtsamkeit, sondern der Grund, warum diese Meldung am 24.09.2026
nie ankam:
Störungen meldet ausschließlich der Befehl ``stoerungen_pruefen``, und
der ist ein Einmal-Lauf. Ein Hintergrundfaden ist dort ein
Daemon-Faden — der stirbt, sobald der Prozess endet. Der Befehl war
mit seiner Arbeit fertig, bevor die Meldung draußen war, und hat sie
damit mitgenommen.
⚠️ Bei ``melde_sichtung`` ist der Hintergrundfaden richtig: Die läuft
in einer Web-Anfrage, die weiterlebt, und darf den Detector nicht
warten lassen. Hier gibt es nichts, was wartet -- das Senden IST die
Arbeit.
"""
e = Benachrichtigung.laden()
if not e.aktiv or not e.stoerungen or not e.ziel:
return
_senden(e.ziel, titel, text, "high", "rotating_light")
def melde_probe() -> tuple[bool, str]:
"""Sofort und synchron — für den Knopf im Backend.
Hier wird ausnahmsweise gewartet: Wer auf 'Probe senden' drückt, will
wissen, ob es geklappt hat. Eine Meldung, die still im Hintergrund
scheitert, wäre genau das Gegenteil einer Probe.
"""
e = Benachrichtigung.laden()
if not e.ziel:
return False, "Kein Thema eingetragen."
try:
anfrage = urllib.request.Request(
e.ziel,
data="Wenn du das liest, funktioniert es.".encode("utf-8"),
headers={
"Title": "Vogelalbum Probe".encode("utf-8"),
"Priority": "default",
"Tags": "white_check_mark",
},
method="POST",
)
with urllib.request.urlopen(anfrage, timeout=ZEITGRENZE) as antwort:
return True, f"Gesendet (HTTP {antwort.status})."
except urllib.error.HTTPError as fehler:
return False, f"Server antwortet mit {fehler.code}."
except (urllib.error.URLError, OSError, ValueError) as fehler:
return False, f"Nicht erreicht: {fehler}"
+151
View File
@@ -0,0 +1,151 @@
"""Hochgeladene Bilder prüfen und sauber neu schreiben.
🔴 **Warum es diese Datei gibt.** Bis zum 14.10.2026 wanderte eine hochgeladene
Datei unverändert aus ``request.FILES`` in ein ``ImageField`` und von dort auf
das Volume, das ``/media/`` **ohne Anmeldung** ausliefert. Zwei Folgen, beide
unsichtbar, bis es zu spät ist:
1. ⚠️ **Metadaten.** Ein Handyfoto trägt EXIF, und darin steht der Ort. Bei
einer Kamera am Vogelhaus ist das folgenlos — die schreibt kein EXIF, an
einem echten Bild der Live-Instanz nachgesehen. Mit der Handy-App wird es
zum Garten, in dem ein Kind steht, öffentlich abrufbar.
2. ⚠️ **Was überhaupt gespeichert wird.** Django prüft bei einem ``ImageField``
nur, dass Pillow die Datei öffnen *kann*. Eine Datei kann gleichzeitig ein
gültiges Bild und etwas anderes sein; unter ``/media/`` liegt sie dann auf
derselben Herkunft wie das Backend.
🔑 **Deshalb nicht prüfen, sondern NEU SCHREIBEN.** Eine Prüfung sagt „sieht
gültig aus" und lässt alles stehen, was sie nicht kennt. Wer die Pixel liest
und neu kodiert, gibt nur weiter, was wirklich ein Bild war — Metadaten,
angehängte Daten und alles Unverstandene fallen dabei von selbst weg. Das ist
der Unterschied zwischen einem Filter und einem Flaschenhals.
"""
from __future__ import annotations
import io
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
from PIL import Image, ImageOps, UnidentifiedImageError
# 🔴 **Ohne das kann Pillow kein HEIC — und iPhones fotografieren HEIC.**
#
# Am 14.10.2026 wies eine Instanz jedes Foto vom iPhone mit 400 ab,
# Minuten nachdem die Bildprüfung ausgerollt war. Die Prüfung war richtig, nur
# kannte sie das Format nicht, das die Hälfte aller Telefone erzeugt. Eine
# App für iOS, die kein HEIC annimmt, nimmt nichts an.
#
# ⚠️ Der Import darf das Hochladen nicht zum Absturz bringen, wenn das Paket
# in einer schlanken Umgebung fehlt — dann bleibt es bei JPEG und PNG, und die
# Meldung an den Nutzer sagt, welches Format es war.
try: # pragma: no cover - haengt an der Umgebung, nicht am Code
import pillow_heif
pillow_heif.register_heif_opener()
HEIC_LESBAR = True
except ImportError: # pragma: no cover
HEIC_LESBAR = False
# ⚠️ Pillow öffnet sehr große Bilder bereitwillig und belegt dabei Speicher in
# der Größenordnung Breite × Höhe × 4 Bytes. Ein Bild mit absurden Maßen in
# einer winzigen Datei ist der billigste Weg, einen Dienst umzuwerfen
# ("decompression bomb"). Pillow warnt ab ~89 Megapixeln; wir lehnen vorher ab.
MAX_PIXEL = 50_000_000
# Längere Kante nach dem Verkleinern. 2400 px ist mehr, als die Sammlung je
# anzeigt, und klein genug, dass ein Album nicht die Platte füllt.
MAX_KANTE = 2400
# Was wir annehmen. Bewusst eine kurze Liste bekannter Rasterformate — kein
# SVG (das ist ein Dokument mit Skripten, kein Bild) und kein animiertes GIF.
ERLAUBT = {"JPEG", "PNG", "WEBP", "HEIF", "HEIC", "AVIF", "MPO"}
class KeinBild(Exception):
"""Die Datei ist kein Bild, das wir annehmen."""
def saeubern(datei: UploadedFile, name: str | None = None) -> InMemoryUploadedFile:
"""Liest die Pixel und gibt ein frisches JPEG ohne Metadaten zurück.
Wirft ``KeinBild``, wenn die Datei kein brauchbares Bild ist.
🔑 **Die Drehung wird angewandt, nicht mitgeschleppt.** Ein Handyfoto steht
oft nur deshalb richtig, weil im EXIF ein Drehwinkel steht. Wer die
Metadaten wegwirft, ohne das Bild zu drehen, legt jedes Hochkantfoto auf
die Seite — und zwar erst beim Anzeigen, also weit entfernt von der
Ursache.
"""
datei.seek(0)
try:
bild = Image.open(datei)
# ⚠️ ``verify()`` prüft die Struktur, macht das Objekt danach aber
# unbrauchbar. Deshalb danach neu öffnen statt weiterzuarbeiten.
bild.verify()
except (UnidentifiedImageError, OSError, ValueError) as fehler:
# ⚠️ Den Hinweis auf HEIC mitgeben: Ohne ihn sucht man den Fehler beim
# Telefon, und er liegt an einem fehlenden Paket auf dem Server.
hinweis = "" if HEIC_LESBAR else " (HEIC wird hier nicht unterstützt — pillow-heif fehlt)"
raise KeinBild(f"keine lesbare Bilddatei{hinweis}: {fehler}") from fehler
datei.seek(0)
try:
bild = Image.open(datei)
if bild.format not in ERLAUBT:
raise KeinBild(f"Format {bild.format!r} nehmen wir nicht an")
breite, hoehe = bild.size
if breite * hoehe > MAX_PIXEL:
raise KeinBild(f"Bild zu groß: {breite}x{hoehe} Pixel")
# Erst drehen (liest EXIF), dann alles Weitere — danach sind die
# Metadaten entbehrlich.
bild = ImageOps.exif_transpose(bild)
# Transparenz und Palettenbilder auf RGB bringen, sonst scheitert das
# Schreiben als JPEG an Bildern mit Alphakanal.
if bild.mode not in ("RGB", "L"):
if bild.mode in ("RGBA", "LA", "P"):
grund = Image.new("RGB", bild.size, (255, 255, 255))
bild = bild.convert("RGBA")
grund.paste(bild, mask=bild.split()[-1])
bild = grund
else:
bild = bild.convert("RGB")
bild.thumbnail((MAX_KANTE, MAX_KANTE), Image.LANCZOS)
ziel = io.BytesIO()
# Ohne ``exif=``/``icc_profile=`` schreibt Pillow keine Metadaten mit.
# Das ist die Stelle, an der der Ort verschwindet.
bild.save(ziel, format="JPEG", quality=88, optimize=True)
except KeinBild:
raise
except (OSError, ValueError) as fehler:
raise KeinBild(f"Bild nicht verarbeitbar: {fehler}") from fehler
finally:
bild.close() if hasattr(bild, "close") else None
groesse = ziel.tell()
ziel.seek(0)
return InMemoryUploadedFile(
ziel,
field_name=getattr(datei, "field_name", None),
name=_jpg_name(name or datei.name or "bild"),
content_type="image/jpeg",
size=groesse,
charset=None,
)
def _jpg_name(name: str) -> str:
"""Dateiname mit ``.jpg``, ohne Pfadanteile.
⚠️ Nur den letzten Namensteil nehmen: Ein hochgeladener Name darf keine
Verzeichnisse mitbringen. Django bereinigt das beim Speichern ebenfalls —
sich darauf zu verlassen heißt aber, dass diese Funktion außerhalb von
Django unsicher wäre, und sie ist einzeln testbar.
"""
letzter = name.replace("\\", "/").rsplit("/", 1)[-1]
stamm = letzter.rsplit(".", 1)[0] or "bild"
return f"{stamm[:60]}.jpg"
View File
Whitespace-only changes.
Whitespace-only changes.
+128
View File
@@ -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}."))
+136
View File
@@ -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
+193
View File
@@ -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"])
+118
View File
@@ -0,0 +1,118 @@
# Generated by Django 6.1 on 2026-08-28 09:08
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Badge',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(max_length=48, unique=True)),
('name', models.CharField(max_length=80)),
('description', models.CharField(max_length=200)),
('emoji', models.CharField(default='🏅', max_length=8)),
('xp', models.PositiveIntegerField(default=50)),
('sort', models.PositiveIntegerField(default=0)),
],
options={
'verbose_name': 'Abzeichen',
'verbose_name_plural': 'Abzeichen',
'ordering': ['sort', 'name'],
},
),
migrations.CreateModel(
name='Sighting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('confidence', models.FloatField(default=0.0)),
('started_at', models.DateTimeField(db_index=True)),
('ended_at', models.DateTimeField()),
('image', models.ImageField(blank=True, upload_to='sightings/%Y/%m/')),
('crop', models.ImageField(blank=True, upload_to='crops/%Y/%m/')),
('frames_total', models.PositiveIntegerField(default=0)),
('frames_with_bird', models.PositiveIntegerField(default=0)),
('votes', models.JSONField(blank=True, default=dict)),
('mean_bird_score', models.FloatField(default=0.0)),
('is_first_of_species', models.BooleanField(default=False)),
('xp_awarded', models.PositiveIntegerField(default=0)),
],
options={
'verbose_name': 'Sichtung',
'verbose_name_plural': 'Sichtungen',
'ordering': ['-started_at'],
},
),
migrations.CreateModel(
name='Species',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=64, unique=True)),
('german', models.CharField(max_length=80, verbose_name='Deutscher Name')),
('scientific', models.CharField(max_length=120, verbose_name='Wissenschaftlicher Name')),
('english', models.CharField(blank=True, max_length=120, verbose_name='Englischer Name')),
('rarity', models.CharField(choices=[('haeufig', 'Häufig'), ('gelegentlich', 'Gelegentlich'), ('selten', 'Selten'), ('episch', 'Episch'), ('legendaer', 'Legendär')], max_length=16)),
('xp', models.PositiveIntegerField(default=10)),
('fact', models.TextField(blank=True, verbose_name='Steckbrief')),
('size_cm', models.CharField(blank=True, max_length=16)),
],
options={
'verbose_name': 'Art',
'verbose_name_plural': 'Arten',
'ordering': ['german'],
},
),
migrations.CreateModel(
name='EarnedBadge',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('earned_at', models.DateTimeField(default=django.utils.timezone.now)),
('badge', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='earned', to='dex.badge')),
('sighting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dex.sighting')),
],
options={
'verbose_name': 'Verdientes Abzeichen',
'verbose_name_plural': 'Verdiente Abzeichen',
'ordering': ['-earned_at'],
},
),
migrations.AddField(
model_name='sighting',
name='confirmed_species',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmations', to='dex.species', verbose_name='Von Hand bestätigt als'),
),
migrations.AddField(
model_name='sighting',
name='species',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sightings', to='dex.species'),
),
migrations.CreateModel(
name='DexEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('first_seen', models.DateTimeField()),
('last_seen', models.DateTimeField()),
('count', models.PositiveIntegerField(default=0)),
('best_sighting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dex.sighting')),
('species', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='entry', to='dex.species')),
],
options={
'verbose_name': 'Dex-Eintrag',
'verbose_name_plural': 'Dex-Einträge',
'ordering': ['-count'],
},
),
migrations.AddIndex(
model_name='sighting',
index=models.Index(fields=['-started_at', 'species'], name='dex_sightin_started_7b6a20_idx'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 6.1 on 2026-08-28 09:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='species',
name='reference_credit',
field=models.CharField(blank=True, max_length=250, verbose_name='Urheber'),
),
migrations.AddField(
model_name='species',
name='reference_image',
field=models.ImageField(blank=True, upload_to='referenz/', verbose_name='Referenzfoto'),
),
migrations.AddField(
model_name='species',
name='reference_license',
field=models.CharField(blank=True, max_length=80, verbose_name='Lizenz'),
),
migrations.AddField(
model_name='species',
name='reference_source',
field=models.URLField(blank=True, max_length=400, verbose_name='Quelle'),
),
]
@@ -0,0 +1,38 @@
# Generated by Django 6.1 on 2026-08-28 11:11
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0002_species_reference_credit_species_reference_image_and_more'),
]
operations = [
migrations.CreateModel(
name='Camera',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=80)),
('slug', models.SlugField(max_length=48, unique=True)),
('location', models.CharField(blank=True, max_length=120, verbose_name='Standort')),
('token_hash', models.CharField(editable=False, max_length=64, unique=True)),
('active', models.BooleanField(default=True, verbose_name='Aktiv')),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('last_seen', models.DateTimeField(blank=True, null=True, verbose_name='Zuletzt gemeldet')),
],
options={
'verbose_name': 'Kamera',
'verbose_name_plural': 'Kameras',
'ordering': ['name'],
},
),
migrations.AddField(
model_name='sighting',
name='camera',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sightings', to='dex.camera', verbose_name='Kamera'),
),
]
@@ -0,0 +1,77 @@
# Generated by Django 6.1 on 2026-08-28 15:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0003_camera_sighting_camera'),
]
operations = [
migrations.AlterModelOptions(
name='species',
options={'ordering': ['number', 'german'], 'verbose_name': 'Art', 'verbose_name_plural': 'Arten'},
),
migrations.AddField(
model_name='species',
name='beschreibung',
field=models.TextField(blank=True, verbose_name='Beschreibung'),
),
migrations.AddField(
model_name='species',
name='gewicht_g',
field=models.FloatField(blank=True, null=True, verbose_name='Gewicht (g)'),
),
migrations.AddField(
model_name='species',
name='kategorie',
field=models.CharField(blank=True, max_length=80, verbose_name='Kategorie'),
),
migrations.AddField(
model_name='species',
name='lebensraum',
field=models.CharField(blank=True, max_length=200, verbose_name='Lebensraum'),
),
migrations.AddField(
model_name='species',
name='nahrung',
field=models.CharField(blank=True, max_length=200, verbose_name='Nahrung'),
),
migrations.AddField(
model_name='species',
name='number',
field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='Dex-Nummer'),
),
migrations.AddField(
model_name='species',
name='spannweite_cm',
field=models.FloatField(blank=True, null=True, verbose_name='Spannweite (cm)'),
),
migrations.AddField(
model_name='species',
name='stimme',
field=models.CharField(blank=True, max_length=200, verbose_name='Stimme'),
),
migrations.AddField(
model_name='species',
name='typ',
field=models.CharField(blank=True, max_length=24, verbose_name='Typ'),
),
migrations.AddField(
model_name='species',
name='verwechslung',
field=models.JSONField(blank=True, default=list, verbose_name='Verwechslungsgefahr'),
),
migrations.AddField(
model_name='species',
name='zug',
field=models.CharField(blank=True, max_length=24, verbose_name='Zugverhalten'),
),
migrations.AlterField(
model_name='species',
name='fact',
field=models.TextField(blank=True, verbose_name='Kurzfakt'),
),
]
@@ -0,0 +1,38 @@
# Generated by Django 6.1 on 2026-08-28 16:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0004_alter_species_options_species_beschreibung_and_more'),
]
operations = [
migrations.AddField(
model_name='species',
name='beschreibung_en',
field=models.TextField(blank=True, verbose_name='Beschreibung (en)'),
),
migrations.AddField(
model_name='species',
name='kategorie_en',
field=models.CharField(blank=True, max_length=80, verbose_name='Kategorie (en)'),
),
migrations.AddField(
model_name='species',
name='lebensraum_en',
field=models.CharField(blank=True, max_length=200, verbose_name='Lebensraum (en)'),
),
migrations.AddField(
model_name='species',
name='nahrung_en',
field=models.CharField(blank=True, max_length=200, verbose_name='Nahrung (en)'),
),
migrations.AddField(
model_name='species',
name='stimme_en',
field=models.CharField(blank=True, max_length=200, verbose_name='Stimme (en)'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.1 on 2026-08-28 16:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0005_species_beschreibung_en_species_kategorie_en_and_more'),
]
operations = [
migrations.AddField(
model_name='species',
name='lebensraum_typ',
field=models.CharField(db_index=True, default='garten', max_length=16, verbose_name='Lebensraum-Gruppe'),
),
migrations.AddField(
model_name='species',
name='regions',
field=models.JSONField(blank=True, default=list, verbose_name='Regionen'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.1 on 2026-09-12 12:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0006_species_lebensraum_typ_species_regions'),
]
operations = [
migrations.AddField(
model_name='camera',
name='live_enabled',
field=models.BooleanField(default=False, help_text='Zeigt das Livebild dieser Kamera auf der Seite /live/. Bewusst standardmäßig aus: ein Livebild aus dem eigenen Garten gehört nicht auf eine öffentliche Seite.', verbose_name='Live-Bild zeigen'),
),
migrations.AddField(
model_name='camera',
name='live_url',
field=models.URLField(blank=True, help_text='MJPEG-Adresse des Live-Verteilers, z.B. http://erkennung.local:8131/live — NICHT die Kamera selbst, sonst fehlt der Erkennung das Bild.', verbose_name='Live-Adresse'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 6.1 on 2026-09-12 13:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0007_camera_live_enabled_camera_live_url'),
]
operations = [
migrations.AddField(
model_name='camera',
name='detect_threshold',
field=models.FloatField(default=0.4, help_text='Ab welcher Sicherheit etwas als Vogel gilt (0–1).', verbose_name='Schwelle Vogelerkennung'),
),
migrations.AddField(
model_name='camera',
name='habitats',
field=models.CharField(default='garten', help_text='Kommagetrennt: garten, wald, offenland, wasser, gebirge. Schränkt ein, welche Arten der Klassifikator überhaupt erwägt — eine Kamera am Futterhaus soll gar nicht erst überlegen, ob da ein Kranich sitzt. Leer lässt alle zu.', max_length=120, verbose_name='Lebensräume'),
),
migrations.AddField(
model_name='camera',
name='min_bird_score',
field=models.FloatField(default=0.05, help_text='Darunter gilt die Sichtung als unbestimmt. Gemessen ist 0.05 richtig: bei 0.10 fliegen richtige Bestimmungen raus, weil bei manchen Arten global die amerikanische Schwesterart gewinnt.', verbose_name='Mindestanteil heimischer Arten'),
),
migrations.AddField(
model_name='camera',
name='stream_url',
field=models.URLField(blank=True, help_text='MJPEG- oder Snapshot-Adresse der Kamera, z.B. http://vogelkamera.local:8080/stream. Leer heißt: der Detector nimmt, was auf seiner Kommandozeile steht.', verbose_name='Stream-Adresse'),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 6.1 on 2026-09-23 20:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0008_camera_detect_threshold_camera_habitats_and_more'),
]
operations = [
migrations.CreateModel(
name='Benachrichtigung',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('aktiv', models.BooleanField(default=False, verbose_name='Meldungen einschalten')),
('server', models.URLField(default='https://ntfy.sh', help_text='Eigener Server oder ntfy.sh', verbose_name='ntfy-Server')),
('thema', models.CharField(blank=True, help_text='Der Name, den du in der ntfy-App abonnierst. ⚠ Wer das Thema kennt, kann mitlesen — nimm etwas, das niemand errät.', max_length=100, verbose_name='Thema')),
('sichtungen', models.CharField(choices=[('aus', 'keine Sichtungsmeldungen'), ('neue', 'nur neue Arten (Erstfang)'), ('alle', 'alle Sichtungen')], default='neue', help_text="Ein gut besuchtes Futterhaus erzeugt schnell Dutzende Meldungen am Tag. 'Nur neue Arten' bleibt dauerhaft interessant.", max_length=8, verbose_name='Sichtungen')),
('stoerungen', models.BooleanField(default=True, help_text='Kamera meldet sich nicht mehr, Erkennung steht.', verbose_name='Störungen melden')),
('stille_minuten', models.PositiveIntegerField(default=30, help_text='So lange darf eine Kamera schweigen, bevor es eine Meldung gibt. Zu knapp bedeutet Fehlalarm bei jedem Router-Neustart.', verbose_name='Störung nach … Minuten Stille')),
],
options={
'verbose_name': 'Benachrichtigung',
'verbose_name_plural': 'Benachrichtigungen',
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-09-23 22:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0009_benachrichtigung'),
]
operations = [
migrations.AddField(
model_name='camera',
name='letzter_kontakt',
field=models.DateTimeField(blank=True, null=True, verbose_name='Letztes Lebenszeichen'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-09-24 05:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0010_camera_letzter_kontakt'),
]
operations = [
migrations.AddField(
model_name='camera',
name='live_bildrate',
field=models.FloatField(default=2.0, help_text='Gilt NUR für Zuschauer — die Erkennung bekommt immer jedes Bild. Gemessen am 24.09.2026: ohne Begrenzung schickt eine Kamera in HD rund 6 bis 10 Mbit/s. Zwei Kameras nebeneinander sind über 16 Mbit/s, und auf dem Handy dauert es entsprechend, bis das erste Bild erscheint. 2 Bilder/s sind flüssig genug fürs Zuschauen und brauchen etwa ein Achtel davon. Höher setzen heißt: ruckelfreier, aber träger Start und mehr Funklast. 0 schaltet die Begrenzung ab.', verbose_name='Bilder je Sekunde in der Vorschau'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-10-01 06:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0011_camera_live_bildrate'),
]
operations = [
migrations.AddField(
model_name='camera',
name='stoerung_gemeldet_seit',
field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Störung gemeldet seit'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.1 on 2026-10-01 12:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0012_camera_stoerung_gemeldet_seit'),
]
operations = [
migrations.AddField(
model_name='sighting',
name='latitude',
field=models.FloatField(blank=True, null=True, verbose_name='Breitengrad'),
),
migrations.AddField(
model_name='sighting',
name='longitude',
field=models.FloatField(blank=True, null=True, verbose_name='Längengrad'),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.1 on 2026-10-01 12:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0013_sighting_latitude_sighting_longitude'),
]
operations = [
migrations.AddField(
model_name='benachrichtigung',
name='fotos_gemeldet_seit',
field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Wartende Fotos gemeldet seit'),
),
migrations.AddField(
model_name='camera',
name='mobil',
field=models.BooleanField(default=False, help_text='Lädt Fotos hoch statt zu streamen (Handy, Tablet). Solche Geräte werden nicht überwacht — sie melden sich nur, wenn jemand unterwegs fotografiert.', verbose_name='Mobiles Gerät'),
),
migrations.AddField(
model_name='sighting',
name='wartet_auf_bestimmung',
field=models.BooleanField(db_index=True, default=False, verbose_name='Wartet auf Bestimmung'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-10-01 14:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0014_benachrichtigung_fotos_gemeldet_seit_camera_mobil_and_more'),
]
operations = [
migrations.AddField(
model_name='camera',
name='min_mean_bird_score',
field=models.FloatField(default=0.0, help_text='Liegt der mittlere Vogelwert eines Besuchs darunter, entsteht gar keine Sichtung. 0 heißt: nur Meldungen ohne jeden Vogelwert werden abgewiesen. Erst hochdrehen, wenn genug eigene Sichtungen vorliegen — sonst wirft man richtige Bestimmungen weg.', verbose_name='Mindest-Vogelwert über den Besuch'),
),
]
+33
View File
@@ -0,0 +1,33 @@
# Generated by Django 6.1 on 2026-10-14 18:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0015_camera_min_mean_bird_score'),
]
operations = [
migrations.AddField(
model_name='sighting',
name='box_x0',
field=models.FloatField(blank=True, null=True, verbose_name='Vogel: links'),
),
migrations.AddField(
model_name='sighting',
name='box_x1',
field=models.FloatField(blank=True, null=True, verbose_name='Vogel: rechts'),
),
migrations.AddField(
model_name='sighting',
name='box_y0',
field=models.FloatField(blank=True, null=True, verbose_name='Vogel: oben'),
),
migrations.AddField(
model_name='sighting',
name='box_y1',
field=models.FloatField(blank=True, null=True, verbose_name='Vogel: unten'),
),
]
View File
Whitespace-only changes.
+957
View File
@@ -0,0 +1,957 @@
"""Datenmodell des Vogelalbum.
Drei Ebenen:
* ``Species`` — der Katalog. Was es theoretisch zu finden gibt.
* ``Sighting`` — ein einzelner Besuch vor der Kamera. Rohdaten, unveränderlich.
* ``DexEntry`` — der Sammel-Fortschritt pro Art. Abgeleitet, aber gespeichert,
damit die Dex-Seite nicht bei jedem Aufruf über alle Sichtungen aggregieren
muss.
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import timedelta
from django.conf import settings
from django.db import models
from django.db.models.functions import Coalesce
from django.utils import timezone
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
class Camera(models.Model):
"""Eine Kamera, die Sichtungen meldet.
Der Token identifiziert die Kamera — die Web-App muss sie deshalb nie
erreichen können. Der Detector meldet ausgehend, es gibt keinen Port, der
von außen offen sein müsste. Genau das erlaubt es, die Erkennung im
Heimnetz laufen zu lassen und die Sammlung öffentlich zu hosten.
Gespeichert wird nur der SHA-256 des Tokens. Ein hochentropischer Token
braucht kein Salt, und wer die Datenbank in die Hand bekommt, kann damit
trotzdem keine Sichtungen einliefern.
"""
name = models.CharField(max_length=80)
slug = models.SlugField(max_length=48, unique=True)
location = models.CharField("Standort", max_length=120, blank=True)
token_hash = models.CharField(max_length=64, unique=True, editable=False)
active = models.BooleanField("Aktiv", default=True)
created_at = models.DateTimeField(default=timezone.now)
last_seen = models.DateTimeField("Zuletzt gemeldet", null=True, blank=True)
# 🔑 Getrennt von last_seen, und das ist der Kern der Sache: last_seen
# heisst "zuletzt einen Vogel gesehen". Daran zu messen, ob die Kamera
# lebt, waere falsch -- an einem ruhigen Nachmittag kommt stundenlang
# nichts, und die Anlage ist trotzdem in Ordnung.
#
# Hier steht, wann sich der Detector zuletzt gemeldet hat. Er fragt
# ohnehin alle fuenf Minuten die Konfiguration ab; dieser Abruf ist das
# Lebenszeichen. Kein zusaetzlicher Verkehr, kein neuer Endpunkt.
letzter_kontakt = models.DateTimeField(
"Letztes Lebenszeichen", null=True, blank=True)
# ⚠ Hier und nicht im Prozessspeicher, und das ist der ganze Witz:
# `stoerungen_pruefen` ist ein Einmal-Lauf. Ein Modul-Set lebt genau
# einen Durchlauf lang und ist beim naechsten wieder leer -- die
# Entprellung griff deshalb NIE. Am 01.10.2026 kam der Beweis aufs
# Handy: nach dem Stromausfall meldete der Waechter beide Kameras alle
# zehn Minuten neu, viermal in zwei Laeufen, "schon gemeldet" kein
# einziges Mal. Genau das Summen, das der Befehl vermeiden wollte.
#
# Ein Zeitstempel statt eines Ja/Nein: so steht da auch, SEIT WANN
# gemeldet ist. Wer spaeter eine Erinnerung nach einigen Stunden will,
# braucht dafuer kein neues Feld.
stoerung_gemeldet_seit = models.DateTimeField(
"Störung gemeldet seit", null=True, blank=True, editable=False)
# Was der Detector von hier holt statt von seiner Kommandozeile. Bis diese
# Felder existierten, stand die Einstellung nur im Startskript auf dem
# Rechner, auf dem der Detector läuft — wer die Kamera umstellen wollte,
# brauchte eine Shell.
stream_url = models.URLField(
"Stream-Adresse",
blank=True,
help_text="MJPEG- oder Snapshot-Adresse der Kamera, z.B. "
"http://vogelkamera.local:8080/stream. Leer heißt: der Detector "
"nimmt, was auf seiner Kommandozeile steht.",
)
habitats = models.CharField(
"Lebensräume",
max_length=120,
default="garten",
help_text="Kommagetrennt: garten, wald, offenland, wasser, gebirge. "
"Schränkt ein, welche Arten der Klassifikator überhaupt "
"erwägt — eine Kamera am Futterhaus soll gar nicht erst "
"überlegen, ob da ein Kranich sitzt. Leer lässt alle zu.",
)
detect_threshold = models.FloatField(
"Schwelle Vogelerkennung",
default=0.4,
help_text="Ab welcher Sicherheit etwas als Vogel gilt (0–1).",
)
min_bird_score = models.FloatField(
"Mindestanteil heimischer Arten",
default=0.05,
help_text="Darunter gilt die Sichtung als unbestimmt. Gemessen ist "
"0.05 richtig: bei 0.10 fliegen richtige Bestimmungen raus, "
"weil bei manchen Arten global die amerikanische "
"Schwesterart gewinnt.",
)
# ⚠️ **Nicht zu verwechseln mit ``min_bird_score`` eine Zeile höher.** Die
# Namen ähneln sich, die Bedeutung ist verschieden:
#
# min_bird_score gilt je EINZELBILD
# min_mean_bird_score gilt für den DURCHSCHNITT des ganzen Besuchs
#
# 🔑 Genau dieser Durchschnitt war die Lücke. Mit ``--min-bird-frames 1``
# genügte ein einziger Zufallstreffer unter 73 Bildern, um einen Besuch
# anzulegen — so entstand am 24.09.2026 eine „Kohlmeise" aus einer
# Befestigungsschraube (1 von 73 Bildern, Wert 0,098), und am 27.09.
# vierundzwanzig Besuche aus Spiegelungen des Mondes (Wert 0,000).
#
# 📌 **Vorgabe 0, und das ist Absicht.** Alles bei exakt null wird ohnehin
# abgewiesen; darüber hinaus will der Wert gemessen und nicht geraten sein.
# Aus den Daten vom 01.10.2026: echte Sichtungen lagen bei 0,269 bis 0,600,
# die Schraube bei 0,098, die Spiegelungen bei 0,000. Eine Grenze um 0,15
# fienge beide Fehler ab — belegt ist das aber nur durch sieben echte
# Sichtungen, und das ist zu wenig. Wer mehr Daten hat, dreht hier hoch.
min_mean_bird_score = models.FloatField(
"Mindest-Vogelwert über den Besuch",
default=0.0,
help_text="Liegt der mittlere Vogelwert eines Besuchs darunter, "
"entsteht gar keine Sichtung. 0 heißt: nur Meldungen ohne "
"jeden Vogelwert werden abgewiesen. Erst hochdrehen, wenn "
"genug eigene Sichtungen vorliegen — sonst wirft man "
"richtige Bestimmungen weg.",
)
# 🔑 Ein Handy ist auch eine Kamera — es hängt nur nicht am Haus. Es lädt
# Fotos hoch statt zu streamen, bringt seinen Ort je Aufnahme mit und hat
# weder Live-Bild noch Bildeinstellungen.
#
# ⚠️ **Entscheidend für den Störungswächter.** `stoerungen_pruefen` meldet
# eine Kamera, die sich länger als `stille_minuten` nicht gemeldet hat. Ein
# Vogelhaus meldet sich alle fünf Minuten, ein Handy alle paar Tage — ohne
# dieses Kennzeichen käme nach dem ersten ruhigen Wochenende „Kamera still:
# Handy" aufs Telefon. Das ist dieselbe Fehlerklasse wie das Abzeichen für
# Mondspiegelungen am 01.10.2026: eine Regel, die für den einen Gerätetyp
# richtig ist und beim anderen Fehlalarm erzeugt.
mobil = models.BooleanField(
"Mobiles Gerät",
default=False,
help_text="Lädt Fotos hoch statt zu streamen (Handy, Tablet). Solche "
"Geräte werden nicht überwacht — sie melden sich nur, wenn "
"jemand unterwegs fotografiert.",
)
# Nicht die Kamera selbst, sondern der Live-Verteiler des Detectors
# (`python -m detector watch --live-port`). Die Kamera liefert nur einen
# Stream; wer direkt bei ihr zuschaut, nimmt ihn der Erkennung weg.
live_bildrate = models.FloatField(
"Bilder je Sekunde in der Vorschau", default=2.0,
help_text=(
"Gilt NUR für Zuschauer — die Erkennung bekommt immer jedes Bild. "
"Gemessen am 24.09.2026: ohne Begrenzung schickt eine Kamera in "
"HD rund 6 bis 10 Mbit/s. Zwei Kameras nebeneinander sind über 16 "
"Mbit/s, und auf dem Handy dauert es entsprechend, bis das erste "
"Bild erscheint. 2 Bilder/s sind flüssig genug fürs Zuschauen und "
"brauchen etwa ein Achtel davon. Höher setzen heißt: ruckelfreier, "
"aber träger Start und mehr Funklast. 0 schaltet die Begrenzung ab."
),
)
live_url = models.URLField(
"Live-Adresse",
blank=True,
help_text="MJPEG-Adresse des Live-Verteilers, z.B. "
"http://erkennung.local:8131/live — NICHT die Kamera selbst, "
"sonst fehlt der Erkennung das Bild.",
)
live_enabled = models.BooleanField(
"Live-Bild zeigen",
default=False,
help_text="Zeigt das Livebild dieser Kamera auf der Seite /live/. "
"Bewusst standardmäßig aus: ein Livebild aus dem eigenen "
"Garten gehört nicht auf eine öffentliche Seite.",
)
class Meta:
verbose_name = "Kamera"
verbose_name_plural = "Kameras"
ordering = ["name"]
def __str__(self) -> str:
return self.name
@staticmethod
def new_token() -> str:
return secrets.token_urlsafe(32)
def set_token(self, token: str) -> None:
self.token_hash = hash_token(token)
@classmethod
def by_token(cls, token: str) -> "Camera | None":
return cls.objects.filter(token_hash=hash_token(token), active=True).first()
@property
def is_stale(self) -> bool:
"""Kein Lebenszeichen seit über einer Stunde — da hängt vermutlich etwas.
⚠️ **Gemessen wird ``letzter_kontakt``, NICHT ``last_seen``.** Die
beiden Felder sehen ähnlich aus und bedeuten Verschiedenes:
``last_seen`` heißt „zuletzt einen Vogel gesehen", ``letzter_kontakt``
heißt „die Kamera hat sich gemeldet". An ``last_seen`` zu messen hieße
zu warnen, wenn nichts los ist, statt wenn etwas kaputt ist.
🔑 **Genau dieser Fehler stand hier bis zum 01.10.2026.** Die
Dev-Server zeigte das Warnzeichen vor beiden Kameras, während beide
tadellos liefen: Lebenszeichen vor einer Minute, letzter Vogel aber
1810 bzw. 5435 Minuten her. Ein Zeichen, das fast immer steht, sagt
nichts mehr.
⚠️ Dass es ein bekannter Fehler ist, macht es schlimmer: In
``stoerungen_pruefen`` steht seit jeher der Kommentar „an last_seen zu
messen waere der naheliegende Fehler -- an einem ruhigen Nachmittag
kommt stundenlang kein Vogel". Dort wurde er erkannt und behoben, hier
blieb er stehen. Eine Lehre gilt für alle Stellen, nicht nur für die,
an der sie aufgefallen ist.
⚠️ **Mobile Geräte sind nie „still".** Ein Handy meldet sich, wenn
jemand spazieren geht; „seit einer Stunde nichts" ist dort der
Normalfall. Dieselbe Unterscheidung trifft ``stoerungen_pruefen``.
"""
if self.mobil:
return False
# 📌 Noch nie gemeldet bleibt auffällig. Der Störungswächter wertet das
# anders — er meldet eine frisch eingerichtete Kamera nicht aufs
# Telefon, und das ist dort richtig: Es ist keine Störung. Auf der
# Seite ist es trotzdem die Wahrheit, nämlich „von hier kam noch
# nichts". Dieses Verhalten stand schon vorher so im Test
# ``test_camera_without_contact_is_stale`` und bleibt unverändert;
# korrigiert wurde am 01.10.2026 allein die Messgröße.
if self.letzter_kontakt is None:
return True
return timezone.now() - self.letzter_kontakt > timedelta(hours=1)
@property
def shows_live(self) -> bool:
"""Ob diese Kamera ein Livebild zeigen darf.
Drei Bedingungen, und die Umgebung hat das letzte Wort: auf einer
öffentlichen Instanz steht ``VOGELALBUM_LIVE_ERLAUBT=0``, und dann nützt
auch ein Häkchen im Admin nichts. Ein Livebild aus einem Privatgarten
soll nicht an einem Fehlklick hängen.
"""
return bool(
settings.LIVE_ALLOWED
and self.active
and self.live_enabled
and self.live_url
)
@property
def habitat_set(self) -> set[str]:
"""Die Lebensräume als Menge. Leer heißt: alle Arten zulassen."""
return {h.strip() for h in self.habitats.split(",") if h.strip()}
@property
def live_bild_url(self) -> str:
"""Einzelbild statt Dauerstrom.
🔑 Warum nicht der MJPEG-Strom in der Übersicht: Am 24.09.2026
gemessen — eine Kamera allein erscheint sofort, zwei nebeneinander
lassen den Browser hängen. Ein Dauerstrom belegt seine Verbindung
für immer, und mit zweien kommt mancher Browser (besonders auf dem
Handy) durcheinander.
Einzelbilder sind kurze, gewöhnliche Anfragen: Sie blockieren
nichts, holen sich nach einem Fehler von selbst wieder, und die
Bildrate bestimmt schlicht, wie oft gefragt wird.
Für EINE Kamera bleibt der Strom die bessere Wahl -- deshalb ist
live_url weiterhin da.
"""
if not self.live_url:
return ""
return self.live_url.rstrip("/").removesuffix("/live") + "/bild.jpg"
def konfiguration(self) -> dict:
"""Was der Detector über ``/api/config/`` bekommt.
Die Web-App ist damit die Wahrheit darüber, welche Arten zählen und
ab welcher Sicherheit — nicht mehr die Kommandozeile auf dem Rechner,
auf dem der Detector läuft. Die Artenliste kommt aus der Datenbank,
eingeschränkt auf Region und Lebensräume dieser Kamera.
"""
arten = Species.objects.all()
if self.habitat_set:
arten = arten.filter(lebensraum_typ__in=self.habitat_set)
# Die Region wird in Python geprüft: SQLite kann kein 'contains' auf
# einem JSON-Feld, und ein Textvergleich wäre falsch — bei
# zweibuchstabigen Kürzeln findet 'de' auch 'den'. Bei einigen hundert
# Arten kostet die Schleife nichts.
region = settings.REGION
arten = [a for a in arten.order_by("scientific") if region in (a.regions or [])]
return {
"kamera": self.slug,
"stream_url": self.stream_url,
"habitats": sorted(self.habitat_set),
"live_bildrate": self.live_bildrate,
"detect_threshold": self.detect_threshold,
"min_bird_score": self.min_bird_score,
# ⚠️ Zwei ähnlich klingende Werte, verschiedene Bedeutung:
# min_bird_score gilt je Einzelbild, min_mean_bird_score für den
# Durchschnitt des ganzen Besuchs. Der Detector braucht beide —
# den zweiten, um Besuche gar nicht erst zu senden, die die
# Web-App ohnehin abweisen würde. Das spart vor allem Funk: Ein
# Bild hochzuladen und dann verworfen zu bekommen, kostet eine
# Kamera am Gartenzaun echte Bandbreite.
"min_mean_bird_score": self.min_mean_bird_score,
"region": settings.REGION,
# Der wissenschaftliche Name ist gleichzeitig der Schlüssel ins
# Label-File des Klassifikators — deshalb braucht es hier kein
# zweites Mapping.
"arten": [
{
"scientific": a.scientific,
"german": a.german,
"english": a.english,
"rarity": a.rarity,
"lebensraum": a.lebensraum_typ,
}
for a in arten
],
}
@classmethod
def live_cameras(cls):
if not settings.LIVE_ALLOWED:
return cls.objects.none()
return cls.objects.filter(active=True, live_enabled=True).exclude(live_url="")
RARITY_CHOICES = [
("haeufig", "Häufig"),
("gelegentlich", "Gelegentlich"),
("selten", "Selten"),
("episch", "Episch"),
("legendaer", "Legendär"),
]
RARITY_ORDER = {k: i for i, (k, _) in enumerate(RARITY_CHOICES)}
class Species(models.Model):
slug = models.SlugField(max_length=64, unique=True)
german = models.CharField("Deutscher Name", max_length=80)
scientific = models.CharField("Wissenschaftlicher Name", max_length=120)
english = models.CharField("Englischer Name", max_length=120, blank=True)
rarity = models.CharField(max_length=16, choices=RARITY_CHOICES)
xp = models.PositiveIntegerField(default=10)
fact = models.TextField("Kurzfakt", blank=True)
size_cm = models.CharField(max_length=16, blank=True)
# Steckbrief im Stil einer Sammelkarte. Alles optional, damit neue
# Arten in den Katalog können, bevor der Text dazu geschrieben ist.
number = models.PositiveIntegerField("Dex-Nummer", default=0, db_index=True)
typ = models.CharField("Typ", max_length=24, blank=True)
kategorie = models.CharField("Kategorie", max_length=80, blank=True)
beschreibung = models.TextField("Beschreibung", blank=True)
gewicht_g = models.FloatField("Gewicht (g)", null=True, blank=True)
spannweite_cm = models.FloatField("Spannweite (cm)", null=True, blank=True)
zug = models.CharField("Zugverhalten", max_length=24, blank=True)
nahrung = models.CharField("Nahrung", max_length=200, blank=True)
lebensraum = models.CharField("Lebensraum", max_length=200, blank=True)
stimme = models.CharField("Stimme", max_length=200, blank=True)
verwechslung = models.JSONField("Verwechslungsgefahr", default=list, blank=True)
# Wo die Art vorkommt und wo sie lebt. Die Region entscheidet, welcher
# Katalog überhaupt gilt; der Lebensraum teilt ihn in Portionen, die man
# noch überblickt.
regions = models.JSONField("Regionen", default=list, blank=True)
lebensraum_typ = models.CharField(
"Lebensraum-Gruppe", max_length=16, default="garten", db_index=True
)
# Englische Fassung der Texte. Bewusst eigene Felder statt einer
# Übersetzungs-Bibliothek: es sind fünf Felder in zwei Sprachen, und die
# Texte sind geschrieben, nicht übersetzt — eine wörtliche Übertragung
# liest sich in beiden Richtungen schlecht.
kategorie_en = models.CharField("Kategorie (en)", max_length=80, blank=True)
beschreibung_en = models.TextField("Beschreibung (en)", blank=True)
nahrung_en = models.CharField("Nahrung (en)", max_length=200, blank=True)
lebensraum_en = models.CharField("Lebensraum (en)", max_length=200, blank=True)
stimme_en = models.CharField("Stimme (en)", max_length=200, blank=True)
# Referenzfoto aus Wikipedia: zeigt, wonach man Ausschau hält, bevor die
# Art zum ersten Mal selbst vor der Kamera saß. Lizenz und Urheber werden
# mitgespeichert — die Bilder stehen unter CC und wollen genannt werden.
reference_image = models.ImageField(
"Referenzfoto", upload_to="referenz/", blank=True
)
reference_credit = models.CharField("Urheber", max_length=250, blank=True)
reference_license = models.CharField("Lizenz", max_length=80, blank=True)
reference_source = models.URLField("Quelle", max_length=400, blank=True)
class Meta:
verbose_name = "Art"
verbose_name_plural = "Arten"
ordering = ["number", "german"]
def __str__(self) -> str:
return self.german
@property
def dex_nr(self) -> str:
return f"#{self.number:03d}" if self.number else "#—"
def _lang(self, feld: str) -> str:
"""Feld in der aktiven Sprache, mit Rückfall auf Deutsch.
Der Rückfall ist Absicht: der Katalog wächst, und eine Art ohne
englischen Text soll den deutschen zeigen statt gar nichts.
"""
from django.utils.translation import get_language
if (get_language() or "de").startswith("en"):
return getattr(self, f"{feld}_en", "") or getattr(self, feld, "")
return getattr(self, feld, "")
@property
def name(self) -> str:
"""Artname in der aktiven Sprache."""
from django.utils.translation import get_language
if (get_language() or "de").startswith("en") and self.english:
return self.english
return self.german
@property
def zweitname(self) -> str:
"""Der jeweils andere Name — steht klein unter dem Titel."""
from django.utils.translation import get_language
if (get_language() or "de").startswith("en") and self.english:
return self.german
return self.english
@property
def kategorie_lokal(self) -> str:
return self._lang("kategorie")
@property
def nahrung_lokal(self) -> str:
return self._lang("nahrung")
@property
def lebensraum_lokal(self) -> str:
return self._lang("lebensraum")
@property
def stimme_lokal(self) -> str:
return self._lang("stimme")
@property
def zug_label(self) -> str:
from django.utils.translation import get_language
from .species_meta import ZUG_LABEL, ZUG_LABEL_EN
table = (
ZUG_LABEL_EN if (get_language() or "de").startswith("en") else ZUG_LABEL
)
return table.get(self.zug, self.zug)
@property
def typ_label(self) -> str:
from django.utils.translation import get_language
from .species_meta import TYPE_LABEL_EN, TYPE_STYLE
if (get_language() or "de").startswith("en"):
return TYPE_LABEL_EN.get(self.typ, self.typ or "—")
return TYPE_STYLE.get(self.typ, ("—", "#7f9b6a", "#101a14"))[0]
@property
def rarity_label(self) -> str:
from django.utils.translation import get_language
from .species_meta import RARITY_LABEL_EN
if (get_language() or "de").startswith("en"):
return RARITY_LABEL_EN.get(self.rarity, self.rarity)
return self.get_rarity_display()
@property
def typ_color(self) -> str:
from .species_meta import TYPE_STYLE
return TYPE_STYLE.get(self.typ, ("—", "#7f9b6a", "#101a14"))[1]
@property
def typ_ink(self) -> str:
"""Schriftfarbe auf der Typfläche — die Farben sind zu hell für Weiß."""
from .species_meta import TYPE_STYLE
return TYPE_STYLE.get(self.typ, ("—", "#7f9b6a", "#101a14"))[2]
@property
def typ_vars(self) -> str:
"""Alle abgeleiteten Typfarben als fertige CSS-Variablen.
Vorberechnet statt per ``color-mix()`` im Stylesheet: das gibt es erst
ab Safari 16.2, und auf älteren Geräten fällt jede betroffene Regel
ersatzlos weg.
"""
from .species_meta import type_vars
return type_vars(self.typ)
@property
def absaetze(self) -> list[str]:
text = self._lang("beschreibung")
return [p.strip() for p in text.split("\n\n") if p.strip()]
def balken(self) -> list[dict]:
"""Statuswerte als Balken, wie die Werte auf einer Sammelkarte.
Die Bezugsgrößen sind die Extreme unseres eigenen Katalogs: der
Zaunkönig mit 9 g gegen den Kolkraben mit 1200 g. Ein Balken zeigt
also „wo steht diese Art unter unseren Arten", nicht einen absoluten
Anteil — genau das macht ihn auf einen Blick lesbar.
"""
from django.utils.translation import gettext as _
rows = []
def add(label: str, value, maximum: float, einheit: str, log: bool = False):
if value in (None, "", 0):
return
import math
if log:
pct = math.log10(value + 1) / math.log10(maximum + 1) * 100
else:
pct = value / maximum * 100
wert = f"{value:g} {einheit}".strip()
rows.append({"label": label, "wert": wert, "pct": max(4, min(100, round(pct)))})
# Gewicht logarithmisch: linear würde alles unter 100 g zu einem
# ununterscheidbaren Strich zusammenfallen.
add(_("Gewicht"), self.gewicht_g, 1200, "g", log=True)
add(_("Spannweite"), self.spannweite_cm, 125, "cm")
try:
groesse = float(str(self.size_cm).split()[0]) if self.size_cm else None
except (ValueError, IndexError):
groesse = None
add(_("Größe"), groesse, 64, "cm")
seltenheit = _("Seltenheit")
add(seltenheit, (self.rarity_rank + 1) * 20, 100, "")
if rows and rows[-1]["label"] == seltenheit:
rows[-1]["wert"] = self.rarity_label
return rows
@property
def habitat_label(self) -> str:
from django.utils.translation import get_language
from .species_meta import HABITATS
paar = HABITATS.get(self.lebensraum_typ)
if not paar:
return self.lebensraum_typ
return paar[1] if (get_language() or "de").startswith("en") else paar[0]
@property
def rarity_rank(self) -> int:
return RARITY_ORDER.get(self.rarity, 0)
@property
def rarity_color(self) -> str:
from .species_meta import RARITY_COLOR
return RARITY_COLOR.get(self.rarity, "#7f9b6a")
@property
def rarity_glow(self) -> str:
from .species_meta import RARITY_GLOW
return RARITY_GLOW.get(self.rarity, "none")
class SichtungsQuerySet(models.QuerySet):
"""Was als Besuch zählt — an EINER Stelle festgelegt.
🔑 Vorher stand diese Entscheidung siebenmal im Code, jedes Mal etwas
anders: mal ``Sighting.objects.count()`` (zählt alles), mal
``filter(species__isnull=False)`` (zählt die rohe Erkennung). Am
30.09.2026 war das Ergebnis zu sehen — die Übersicht meldete
„31 Besuche insgesamt" bei 7 echten Vögeln und „2 Arten heute" bei
einer. Beides waren keine Rechenfehler, sondern zwei verschiedene
Vorstellungen davon, was ein Besuch ist.
⚠️ ``effective_species`` ist eine Python-Property und in der Datenbank
nicht vorhanden. Wer danach filtern will, muss ``Coalesce`` nehmen —
sonst fällt es erst auf, wenn eine korrigierte Sichtung weiter bei der
falschen Art mitzählt.
"""
def mit_art(self):
"""Hängt ``art_id`` an: die bestätigte Art, sonst die erkannte."""
return self.annotate(
art_id=Coalesce("confirmed_species_id", "species_id")
)
def bestimmt(self):
"""Nur Sichtungen mit einer Art — das sind die echten Besuche.
📌 Unbestimmte Sichtungen bleiben in der Liste sichtbar (sie zeigen,
dass die Kamera arbeitet), aber sie sind kein Besuch: Am 30.09.2026
waren 24 von 31 Spiegelungen des Mondes auf der Vogeltränke.
📌 **Hochgeladene Fotos, die noch auf den Detector warten, fallen hier
von selbst heraus** — sie haben noch keine Art. Das ist kein Zufall,
auf den man sich verlässt, sondern dieselbe Regel: Was keine Art hat,
ist kein Besuch. Deshalb springt der Besuchszähler beim Hochladen
nicht hoch, um nach der Bestimmung wieder zurückzufallen.
"""
return self.filter(
models.Q(confirmed_species__isnull=False)
| models.Q(species__isnull=False)
)
def wartend(self):
"""Hochgeladene Fotos, die der Detector noch nicht bearbeitet hat.
Älteste zuerst — wer zuerst hochgeladen hat, wartet am längsten.
"""
return self.filter(wartet_auf_bestimmung=True).order_by("started_at")
def arten_anzahl(self) -> int:
"""Wie viele VERSCHIEDENE Arten — nach Bestätigung, nicht nach Modell."""
return (
self.bestimmt().mit_art()
.values("art_id").distinct().count()
)
class Sighting(models.Model):
"""Ein Besuch. Auch unbestimmte Besuche werden gespeichert — sie zeigen,
dass die Kamera arbeitet, und lassen sich später nachbestimmen.
⚠️ Zum Zählen immer ``objects.bestimmt()`` nehmen, nie ``objects.count()``
— siehe :class:`SichtungsQuerySet`.
"""
objects = SichtungsQuerySet.as_manager()
species = models.ForeignKey(
Species,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="sightings",
)
camera = models.ForeignKey(
Camera,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="sightings",
verbose_name="Kamera",
)
confidence = models.FloatField(default=0.0)
started_at = models.DateTimeField(db_index=True)
ended_at = models.DateTimeField()
image = models.ImageField(upload_to="sightings/%Y/%m/", blank=True)
crop = models.ImageField(upload_to="crops/%Y/%m/", blank=True)
# Wo der Vogel war. Leer bei allem, was aus einem Vogelhaus kommt: diese
# Kamera hängt fest, ihr Standort steht am Gerät (``Camera.location``) und
# gehört nicht an jede einzelne Sichtung.
#
# 🔑 Gefüllt wird das von unterwegs — ein Foto vom Spaziergang hat keinen
# festen Platz, und erst der Ort macht daraus mehr als ein Bild.
#
# ⚠️ **Koordinaten sind personenbezogene Daten.** Wer im eigenen Garten
# fotografiert, legt damit seine Wohnadresse in die Datenbank. Deshalb wird
# schon beim Einliefern auf rund einen Kilometer gerundet (``ORT_STELLEN``
# in ``api.py``) — die genaue Position entsteht gar nicht erst. Angezeigt
# wird sie zudem nicht von selbst. Dieselbe Überlegung wie beim Livebild
# (siehe ``Camera.live_enabled``): Was aus dem Privaten nach außen geht,
# darf nicht die Voreinstellung sein.
latitude = models.FloatField("Breitengrad", null=True, blank=True)
longitude = models.FloatField("Längengrad", null=True, blank=True)
# 🔑 **Noch nicht bestimmt ist etwas anderes als nicht bestimmbar.** Beides
# sieht an `species` gleich aus — leer. Ohne diese Unterscheidung könnte
# der Detector nicht wissen, welche Fotos auf ihn warten, und die
# Oberfläche nicht zwischen „gleich fertig" und „da war nichts" trennen.
#
# Gesetzt wird es beim Hochladen aus der App oder über das Formular, und
# vom Detector wieder entfernt, sobald er das Bild bearbeitet hat — egal ob
# mit Ergebnis oder ohne.
#
# ⚠️ Wartende Sichtungen sind KEIN Besuch (siehe ``SichtungsQuerySet``):
# sonst springt der Zähler beim Hochladen hoch und nach der Bestimmung
# wieder zurück, wenn gar kein Vogel drauf war.
wartet_auf_bestimmung = models.BooleanField(
"Wartet auf Bestimmung", default=False, db_index=True)
frames_total = models.PositiveIntegerField(default=0)
frames_with_bird = models.PositiveIntegerField(default=0)
votes = models.JSONField(default=dict, blank=True)
mean_bird_score = models.FloatField(default=0.0)
# 🔑 **Wo der Vogel im Bild sitzt — als vier Zahlen, nicht als zweites
# Bild.**
#
# Bis zum 14.10.2026 speicherte das Projekt neben dem Foto einen fertig
# geschnittenen Ausschnitt. Der Zuschnitt war damit für immer festgelegt:
# Eine bessere Regel half nur neuen Sichtungen, und alle vorhandenen
# mussten einzeln nachgezogen werden.
#
# Aufgefallen ist es an Handyfotos. Dort ist der Vogel klein im Bild, der
# enge Ausschnitt entsprechend schmal und hoch — und in einer quadratischen
# Kachel blieb davon die Bauchmitte übrig, formatfüllend und unscharf.
# Das Urteil darüber, und es war das richtige: *„Mir geht es darum, dass ein
# Nutzer das so nicht verwenden kann."*
#
# Mit den Koordinaten ist der Zuschnitt eine **Regel statt einer Datei**:
# Die Anzeige schneidet bei jedem Aufruf neu aus dem Original. Eine
# geänderte Regel wirkt sofort auf den gesamten Bestand, Website und App
# dürfen verschieden schneiden, und niemand braucht den Detector dafür.
#
# Werte sind relativ (0..1), damit sie eine Verkleinerung des Originals
# überleben. Leer heißt: von vor dieser Änderung — dann gilt weiter das
# gespeicherte ``crop``.
box_x0 = models.FloatField("Vogel: links", null=True, blank=True)
box_y0 = models.FloatField("Vogel: oben", null=True, blank=True)
box_x1 = models.FloatField("Vogel: rechts", null=True, blank=True)
box_y1 = models.FloatField("Vogel: unten", null=True, blank=True)
is_first_of_species = models.BooleanField(default=False)
xp_awarded = models.PositiveIntegerField(default=0)
# Wer die Sammlung betreut, kann eine Bestimmung überstimmen. Das ist später die
# Trainingsmenge für ein eigenes Modell auf genau dieser Kamera.
confirmed_species = models.ForeignKey(
Species,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="confirmations",
verbose_name="Von Hand bestätigt als",
)
class Meta:
verbose_name = "Sichtung"
verbose_name_plural = "Sichtungen"
ordering = ["-started_at"]
indexes = [models.Index(fields=["-started_at", "species"])]
def __str__(self) -> str:
who = self.effective_species.german if self.effective_species else "unbestimmt"
return f"{who} am {timezone.localtime(self.started_at):%d.%m. %H:%M}"
@property
def effective_species(self) -> Species | None:
"""Handkorrektur schlägt Modell."""
return self.confirmed_species or self.species
@property
def vogel_box(self) -> tuple[float, float, float, float] | None:
"""Wo der Vogel sitzt, relativ — oder ``None`` bei Altbestand."""
werte = (self.box_x0, self.box_y0, self.box_x1, self.box_y1)
if any(w is None for w in werte):
return None
x0, y0, x1, y1 = werte
if x1 <= x0 or y1 <= y0:
return None
return (x0, y0, x1, y1)
@property
def kachel_url(self) -> str:
"""Das Bild für eine Kachel: aus dem Original geschnitten.
🔑 Rückfall auf das gespeicherte ``crop`` für Sichtungen von vor der
Umstellung, danach auf das Original. So bleibt jede alte Sammlung
anzeigbar, ohne dass jemand etwas nachziehen muss.
"""
from .zuschnitt import adresse
neu = adresse(self)
if neu:
return neu
if self.crop:
return self.crop.url
return self.image.url if self.image else ""
@property
def duration_s(self) -> float:
return (self.ended_at - self.started_at).total_seconds()
class DexEntry(models.Model):
"""Sammel-Fortschritt für eine Art."""
species = models.OneToOneField(
Species, on_delete=models.CASCADE, related_name="entry"
)
first_seen = models.DateTimeField()
last_seen = models.DateTimeField()
count = models.PositiveIntegerField(default=0)
best_sighting = models.ForeignKey(
Sighting, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
)
class Meta:
verbose_name = "Dex-Eintrag"
verbose_name_plural = "Dex-Einträge"
ordering = ["-count"]
def __str__(self) -> str:
return f"{self.species.german} ×{self.count}"
@property
def is_new(self) -> bool:
return timezone.now() - self.first_seen < timedelta(days=3)
class Badge(models.Model):
"""Abzeichen. ``code`` wird von der Auswertung in ``scoring.py`` gesetzt."""
code = models.SlugField(max_length=48, unique=True)
name = models.CharField(max_length=80)
description = models.CharField(max_length=200)
emoji = models.CharField(max_length=8, default="🏅")
xp = models.PositiveIntegerField(default=50)
sort = models.PositiveIntegerField(default=0)
class Meta:
verbose_name = "Abzeichen"
verbose_name_plural = "Abzeichen"
ordering = ["sort", "name"]
def __str__(self) -> str:
return self.name
# Abzeichen sind ein fester, im Code definierter Satz. Deshalb liegt in der
# Datenbank der deutsche Text und die Übersetzung passiert bei der Ausgabe
# — anders als bei den Arten, wo der Katalog wächst und eigene Felder
# nötig sind. Ein Sprachwechsel braucht so keine Migration.
@property
def name_lokal(self) -> str:
from django.utils.translation import gettext as _
return _(self.name)
@property
def description_lokal(self) -> str:
from django.utils.translation import gettext as _
return _(self.description)
class EarnedBadge(models.Model):
badge = models.OneToOneField(Badge, on_delete=models.CASCADE, related_name="earned")
earned_at = models.DateTimeField(default=timezone.now)
sighting = models.ForeignKey(
Sighting, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
)
class Meta:
verbose_name = "Verdientes Abzeichen"
verbose_name_plural = "Verdiente Abzeichen"
ordering = ["-earned_at"]
def __str__(self) -> str:
return f"{self.badge.name} ({self.earned_at:%d.%m.%Y})"
class Benachrichtigung(models.Model):
"""Einstellungen für Meldungen über ntfy.
Es gibt genau einen Datensatz. Warum als Modell und nicht als Angabe in
den Einstellungsdateien: Man soll im Backend umstellen können,
ohne dass jemand die Anwendung neu startet -- und ohne dass er dafür
jemanden fragen muss.
"""
class Sichtungen(models.TextChoices):
AUS = "aus", "keine Sichtungsmeldungen"
NEUE = "neue", "nur neue Arten (Erstfang)"
ALLE = "alle", "alle Sichtungen"
aktiv = models.BooleanField("Meldungen einschalten", default=False)
server = models.URLField(
"ntfy-Server", default="https://ntfy.sh",
help_text="Eigener Server oder ntfy.sh",
)
thema = models.CharField(
"Thema", max_length=100, blank=True,
help_text="Der Name, den du in der ntfy-App abonnierst. "
"⚠ Wer das Thema kennt, kann mitlesen — nimm etwas, das "
"niemand errät.",
)
sichtungen = models.CharField(
"Sichtungen", max_length=8,
choices=Sichtungen.choices, default=Sichtungen.NEUE,
help_text="Ein gut besuchtes Futterhaus erzeugt schnell Dutzende "
"Meldungen am Tag. 'Nur neue Arten' bleibt dauerhaft "
"interessant.",
)
stoerungen = models.BooleanField(
"Störungen melden", default=True,
help_text="Kamera meldet sich nicht mehr, Erkennung steht.",
)
stille_minuten = models.PositiveIntegerField(
"Störung nach … Minuten Stille", default=30,
help_text="So lange darf eine Kamera schweigen, bevor es eine "
"Meldung gibt. Zu knapp bedeutet Fehlalarm bei jedem "
"Router-Neustart.",
)
# ⚠️ Merker für die Meldung „Fotos warten auf Bestimmung" — dasselbe
# Muster wie ``Camera.stoerung_gemeldet_seit`` und aus demselben Grund in
# der Datenbank statt im Prozess: ``stoerungen_pruefen`` ist ein Einmal-
# Lauf, ein Merker im Speicher lebt genau einen Durchlauf. Am 01.10.2026
# hat das eine Störung alle zehn Minuten neu aufs Telefon geschickt, bis
# der Merker hier gelandet ist. Ohne dieses Feld wäre derselbe Fehler mit
# der neuen Meldung sofort wieder da.
fotos_gemeldet_seit = models.DateTimeField(
"Wartende Fotos gemeldet seit", null=True, blank=True, editable=False)
class Meta:
verbose_name = "Benachrichtigung"
verbose_name_plural = "Benachrichtigungen"
def __str__(self):
return "Benachrichtigungen" + ("" if self.aktiv else " (aus)")
@classmethod
def laden(cls):
"""Den einen Datensatz holen, notfalls anlegen."""
eintrag, _ = cls.objects.get_or_create(pk=1)
return eintrag
@property
def ziel(self):
"""Vollständige Adresse, oder leer wenn nichts eingerichtet ist."""
if not self.thema:
return ""
return f"{self.server.rstrip('/')}/{self.thema}"
+423
View File
@@ -0,0 +1,423 @@
"""Spielmechanik: XP, Ränge, Abzeichen.
Absichtlich großzügig ausgelegt. Ein Achtjähriger, der drei Tage lang nur
Kohlmeisen bekommt, soll trotzdem vorankommen — die Seltenheitsstufen sorgen
für die Ausschläge, die Grundpunkte für den stetigen Fortschritt.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from django.db.models import Count, Sum
from django.db.models.functions import TruncDate
from django.utils import timezone
from django.utils.translation import gettext as _
from django.utils.translation import gettext_noop
from .models import Badge, DexEntry, EarnedBadge, Sighting, Species
# Ein Erstfang ist der eigentliche Moment — dafür der dicke Multiplikator.
FIRST_CATCH_MULTIPLIER = 3
RANKS = [
gettext_noop("Nestling"),
gettext_noop("Ästling"),
gettext_noop("Spatzenkenner"),
gettext_noop("Meisenmeister"),
gettext_noop("Federfuchser"),
gettext_noop("Amselflüsterer"),
gettext_noop("Spechtspäher"),
gettext_noop("Falkenauge"),
gettext_noop("Vogelmeister"),
gettext_noop("Legende des Vogelhauses"),
]
def xp_for_level(level: int) -> int:
"""Kumulierte XP, die für ``level`` nötig sind. Level 1 startet bei 0."""
if level <= 1:
return 0
return int(100 * (level - 1) ** 1.5)
def level_for_xp(xp: int) -> int:
level = 1
while xp >= xp_for_level(level + 1):
level += 1
if level > 200: # Sicherheitsnetz, nicht erreichbar
break
return level
def rank_name(level: int) -> str:
# Alle drei Level ein neuer Rang, oben gedeckelt.
return _(RANKS[min((level - 1) // 3, len(RANKS) - 1)])
@dataclass
class Progress:
xp: int
level: int
rank: str
xp_this_level: int
xp_next_level: int
species_seen: int
species_total: int
sightings: int
badges: int
@property
def level_progress(self) -> float:
span = self.xp_next_level - self.xp_this_level
if span <= 0:
return 1.0
return min(1.0, (self.xp - self.xp_this_level) / span)
@property
def xp_to_next(self) -> int:
return max(0, self.xp_next_level - self.xp)
def total_xp() -> int:
from_sightings = Sighting.objects.aggregate(s=Sum("xp_awarded"))["s"] or 0
from_badges = (
EarnedBadge.objects.aggregate(s=Sum("badge__xp"))["s"] or 0
)
return int(from_sightings + from_badges)
def progress() -> Progress:
xp = total_xp()
level = level_for_xp(xp)
return Progress(
xp=xp,
level=level,
rank=rank_name(level),
xp_this_level=xp_for_level(level),
xp_next_level=xp_for_level(level + 1),
species_seen=DexEntry.objects.count(),
species_total=Species.objects.count(),
sightings=Sighting.objects.bestimmt().count(),
badges=EarnedBadge.objects.count(),
)
# --------------------------------------------------------------------------
# Abzeichen
# --------------------------------------------------------------------------
BADGE_DEFS = [
("erster_gast", gettext_noop("Erster Gast"),
gettext_noop("Die allererste Sichtung im Vogelhaus."), "🐣", 50, 10),
("arten_5", gettext_noop("Fünf Freunde"),
gettext_noop("5 verschiedene Arten gesammelt."), "🖐️", 75, 20),
("arten_10", gettext_noop("Zweistellig"),
gettext_noop("10 verschiedene Arten gesammelt."), "🔟", 150, 21),
("arten_25", gettext_noop("Sammler"),
gettext_noop("25 verschiedene Arten gesammelt."), "📚", 400, 22),
("arten_40", gettext_noop("Vogelalbum-Meister"),
gettext_noop("40 verschiedene Arten gesammelt."), "👑", 1000, 23),
("fruehaufsteher", gettext_noop("Frühaufsteher"),
gettext_noop("Eine Sichtung vor 6 Uhr morgens."), "🌅", 100, 30),
("nachtschicht", gettext_noop("Nachtschicht"),
gettext_noop("Eine Sichtung nach 21 Uhr."), "🌙", 100, 31),
("volles_haus", gettext_noop("Volles Haus"),
gettext_noop("5 verschiedene Arten an einem Tag."), "🏠", 200, 40),
("stammgast", gettext_noop("Stammgast"),
gettext_noop("7 Tage in Folge mindestens eine Sichtung."), "🔥", 250, 41),
("dauergast", gettext_noop("Dauergast"),
gettext_noop("Ein Besuch, der länger als eine Minute dauerte."), "⏱️", 100, 42),
("besucher_100", gettext_noop("Hundert Besuche"),
gettext_noop("100 Sichtungen aufgezeichnet."), "💯", 200, 50),
("besucher_1000", gettext_noop("Tausend Besuche"),
gettext_noop("1000 Sichtungen aufgezeichnet."), "🏆", 750, 51),
("klopfgeist", gettext_noop("Klopfgeist"),
gettext_noop("Ein Specht war da."), "🪵", 250, 60),
("greifvogel_alarm", gettext_noop("Greifvogel-Alarm"),
gettext_noop("Ein Greifvogel oder eine Eule vor der Linse."), "🦅", 500, 61),
("seltener_gast", gettext_noop("Seltener Gast"),
gettext_noop("Eine Art der Stufe 'Selten' oder höher."), "💎", 200, 62),
]
def sync_badges() -> int:
"""Legt fehlende Abzeichen an. Idempotent — läuft bei jeder Migration mit."""
created = 0
for code, name, desc, emoji, xp, sort in BADGE_DEFS:
_, was_created = Badge.objects.update_or_create(
code=code,
defaults={
"name": name,
"description": desc,
"emoji": emoji,
"xp": xp,
"sort": sort,
},
)
created += was_created
# Codes, die es nicht mehr gibt, verschwinden — sonst bleibt nach einer
# Umbenennung die alte Fassung als Leiche in der Liste stehen.
bekannt = {code for code, *_ in BADGE_DEFS}
Badge.objects.exclude(code__in=bekannt).delete()
return created
def _award(code: str, sighting: Sighting | None = None) -> Badge | None:
badge = Badge.objects.filter(code=code).first()
if badge is None or EarnedBadge.objects.filter(badge=badge).exists():
return None
EarnedBadge.objects.create(badge=badge, sighting=sighting)
return badge
def _has_streak(days: int) -> bool:
today = timezone.localdate()
seen = {
timezone.localtime(dt).date()
for dt in Sighting.objects.bestimmt().filter(
started_at__gte=timezone.now() - timedelta(days=days + 1)
).values_list("started_at", flat=True)
}
return all((today - timedelta(days=i)) in seen for i in range(days))
# 🔑 Die Schwellen stehen hier EINMAL und werden an zwei Stellen benutzt:
# beim Vergeben (evaluate_badges) und beim Anzeigen des Fortschritts
# (fortschritt_je_abzeichen). Zwei getrennte Listen wuerden frueher oder
# spaeter auseinanderlaufen, und dann zeigt der Balken etwas anderes an,
# als das Abzeichen verlangt.
ARTEN_SCHWELLEN = ((5, "arten_5"), (10, "arten_10"),
(25, "arten_25"), (40, "arten_40"))
SICHTUNGS_SCHWELLEN = ((100, "besucher_100"), (1000, "besucher_1000"))
TAGESARTEN_SCHWELLE = 5 # volles_haus
STRAEHNE_SCHWELLE = 7 # stammgast
def bildguete(sichtung) -> float:
"""Wie brauchbar ist das Foto dieser Sichtung? Groesser ist besser.
🔑 **Nicht die Erkennungssicherheit.** Die sagt, wie sicher sich das
Modell bei der ART ist -- nicht, ob das Bild etwas taugt. Am
30.09.2026 wurde dadurch eine verwischte Aufnahme zum "besten Foto"
der Kohlmeise: Sicherheit 0,945, aber nur 5 von 31 Bildern mit Vogel
und der Kopf nicht zu erkennen. Die schoene scharfe Aufnahme (0,936,
23 von 67) landete dahinter.
Stattdessen zwei Groessen, die beide messen "der Vogel war wirklich
und deutlich da":
Anteil der Bilder mit Vogel er blieb, statt kurz durchzuhuschen
mittlerer Vogel-Wert er war deutlich zu sehen
⚠️ **Das ist ein Hilfsmass, keine Schaerfemessung.** Richtig waere,
die Schaerfe des gespeicherten Bildes zu messen (Laplace-Varianz im
Detector). Das hier kommt ohne Bildverarbeitung aus und trifft in der
Praxis dieselbe Wahl.
"""
gesamt = sichtung.frames_total or 0
anteil = (sichtung.frames_with_bird or 0) / gesamt if gesamt else 0.0
wert = sichtung.mean_bird_score or 0.0
guete = anteil * wert
# Voellig ohne Messwerte (alte Sichtungen) bleibt nur die Sicherheit --
# besser als gar keine Reihenfolge.
return guete if guete > 0 else (sichtung.confidence or 0.0) / 1000
def beste_sichtung(sichtungen):
"""Die Sichtung mit dem brauchbarsten Foto, oder None."""
sichtungen = list(sichtungen)
return max(sichtungen, key=bildguete) if sichtungen else None
def fortschritt_je_abzeichen() -> dict[str, tuple[int, int]]:
"""Wie weit ist man bei den ZAEHLBAREN Abzeichen? code -> (ist, ziel).
⚠️ Absichtlich nicht fuer alle: "Ein Specht war da" oder "Eine Sichtung
vor 6 Uhr" haben keinen Fortschritt -- sie passieren oder nicht. Ein
Balken auf 0 % waere dort keine Information, sondern nur entmutigend.
Aus der Rückmeldung am 26.09.2026: *"Bei den Abzeichen waer ein Fortschrittsbalken
cool."*
"""
stand: dict[str, tuple[int, int]] = {}
arten = DexEntry.objects.count()
for ziel, code in ARTEN_SCHWELLEN:
stand[code] = (arten, ziel)
sichtungen = Sighting.objects.bestimmt().count()
for ziel, code in SICHTUNGS_SCHWELLEN:
stand[code] = (sichtungen, ziel)
# Der beste Tag bisher -- nicht der heutige. Sonst faellt der Balken
# jede Nacht auf null zurueck, obwohl man schon vier Arten hatte.
bester_tag = (
Sighting.objects.bestimmt().mit_art()
.annotate(tag=TruncDate("started_at"))
.values("tag")
.annotate(n=Count("art_id", distinct=True))
.order_by("-n")
.first()
)
stand["volles_haus"] = (bester_tag["n"] if bester_tag else 0,
TAGESARTEN_SCHWELLE)
stand["stammgast"] = (_laengste_straehne(), STRAEHNE_SCHWELLE)
return stand
def _laengste_straehne() -> int:
"""Die laengste Folge von Tagen mit mindestens einer Sichtung."""
tage = sorted({
timezone.localtime(z).date()
for z in Sighting.objects.bestimmt().values_list("started_at", flat=True)
})
if not tage:
return 0
beste = laufend = 1
for vorher, jetzt in zip(tage, tage[1:]):
laufend = laufend + 1 if (jetzt - vorher).days == 1 else 1
beste = max(beste, laufend)
return beste
def evaluate_badges(sighting: Sighting) -> list[Badge]:
"""Prüft nach einer Sichtung alle Abzeichen. Gibt die neu verdienten zurück.
⚠️ **Eine unbestimmte Sichtung ist kein Besuch und verdient nichts.** Das
musste hier eigens hin, weil die Prüfungen weiter unten verschiedener
Herkunft sind: die auf Gesamtzahlen (``bestimmt()``, ``arten_anzahl()``)
ändern sich durch eine unbestimmte Sichtung ohnehin nicht, und ab
``effective_species`` wird sauber geprüft — aber die Abzeichen für Uhrzeit
und Dauer hingen allein am Zeitstempel und wurden deshalb auch für Bilder
ohne Vogel vergeben.
🔑 Am 27.09.2026 ist genau das passiert: Die Vogeltränke meldete abends
24 Spiegelungen des Mondes auf dem Wasser, und die erste davon brachte
„Nachtschicht" samt 100 XP ein. Jede weitere Spiegelung nach 21 Uhr hätte
es erneut getan.
"""
if sighting.effective_species is None:
return []
earned: list[Badge] = []
def add(code: str) -> None:
badge = _award(code, sighting)
if badge:
earned.append(badge)
if Sighting.objects.bestimmt().count() == 1:
add("erster_gast")
species_count = DexEntry.objects.count()
for threshold, code in ARTEN_SCHWELLEN:
if species_count >= threshold:
add(code)
local = timezone.localtime(sighting.started_at)
if local.hour < 6:
add("fruehaufsteher")
if local.hour >= 21:
add("nachtschicht")
if sighting.duration_s > 60:
add("dauergast")
total = Sighting.objects.bestimmt().count()
for schwelle, code in SICHTUNGS_SCHWELLEN:
if total >= schwelle:
add(code)
day_species = (
Sighting.objects.filter(started_at__date=local.date()).arten_anzahl()
)
if day_species >= TAGESARTEN_SCHWELLE:
add("volles_haus")
if _has_streak(STRAEHNE_SCHWELLE):
add("stammgast")
species = sighting.effective_species
if species:
if "specht" in species.slug:
add("klopfgeist")
if species.rarity in ("episch", "legendaer"):
add("greifvogel_alarm")
if species.rarity in ("selten", "episch", "legendaer"):
add("seltener_gast")
return earned
# --------------------------------------------------------------------------
# Aufnahme einer neuen Sichtung
# --------------------------------------------------------------------------
@dataclass
class Reward:
sighting: Sighting
xp: int
is_first: bool
badges: list[Badge]
level_before: int
level_after: int
@property
def leveled_up(self) -> bool:
return self.level_after > self.level_before
def register(sighting: Sighting) -> Reward:
"""Verbucht eine frisch gespeicherte Sichtung: XP, Dex-Eintrag, Abzeichen.
Erwartet, dass ``sighting`` bereits in der DB liegt (die Badge-Prüfungen
zählen über die Tabelle).
"""
level_before = level_for_xp(total_xp())
species = sighting.effective_species
xp = 0
is_first = False
if species is not None:
entry = DexEntry.objects.filter(species=species).first()
is_first = entry is None
xp = species.xp * (FIRST_CATCH_MULTIPLIER if is_first else 1)
# Eine sichere Bestimmung ist mehr wert als eine wackelige.
xp = int(round(xp * (0.5 + 0.5 * min(1.0, sighting.confidence))))
if entry is None:
DexEntry.objects.create(
species=species,
first_seen=sighting.started_at,
last_seen=sighting.started_at,
count=1,
best_sighting=sighting,
)
else:
entry.count += 1
entry.last_seen = max(entry.last_seen, sighting.started_at)
# 🔑 Nicht nach Sicherheit, sondern nach Bildguete -- siehe dort.
best = entry.best_sighting
if best is None or bildguete(sighting) > bildguete(best):
entry.best_sighting = sighting
entry.save(update_fields=["count", "last_seen", "best_sighting"])
sighting.is_first_of_species = is_first
sighting.xp_awarded = xp
sighting.save(update_fields=["is_first_of_species", "xp_awarded"])
badges = evaluate_badges(sighting)
return Reward(
sighting=sighting,
xp=xp,
is_first=is_first,
badges=badges,
level_before=level_before,
level_after=level_for_xp(total_xp()),
)
+170
View File
@@ -0,0 +1,170 @@
"""Darstellungs-Metadaten zu den Seltenheitsstufen.
Bewusst hier dupliziert statt aus ``detector.species_de`` importiert: die
Web-App soll ohne numpy und LiteRT laufen können, damit der Container klein
bleibt und sich unabhängig vom Detector deployen lässt.
"""
# Regionen. Nur "de" ist mit Daten gefüllt — die Struktur steht, damit eine
# zweite Region später kein Umbau ist, sondern ein Datensatz.
REGIONS = {
"de": ("Deutschland", "Germany"),
}
# Lebensraum: die Achse, nach der sich 148 Arten sinnvoll teilen lassen.
# Ohne sie ersäuft die Gartensammlung in Wasservögeln.
HABITATS = {
"garten": ("Garten & Siedlung", "Gardens & settlements"),
"wald": ("Wald", "Woodland"),
"offenland": ("Feld & Wiese", "Fields & meadows"),
"wasser": ("Wasser & Ufer", "Water & shoreline"),
"gebirge": ("Gebirge", "Mountains"),
}
RARITY_COLOR = {
"haeufig": "#7f9b6a",
"gelegentlich": "#4f8fbf",
"selten": "#9b7ef0",
"episch": "#e0851f",
"legendaer": "#d4af37",
}
# Typ -> (Anzeigename, Fläche, Schrift). Gespiegelt aus
# detector/species_info.py, damit die Web-App ohne den Detector auskommt.
TYPE_STYLE = {
"meise": ("Meise", "#f2c744", "#3b2f00"),
"fink": ("Fink", "#e0574f", "#fff1f0"),
"sperling": ("Sperling", "#b08347", "#2e1f0c"),
"drossel": ("Drossel", "#7a5c3e", "#f5ece3"),
"schnaepper": ("Schnäpper", "#ef8354", "#3b1a0a"),
"grasmuecke": ("Grasmücke", "#8fae5d", "#1f2a10"),
"specht": ("Specht", "#d64545", "#fff0f0"),
"rabenvogel": ("Rabenvogel", "#8b7fd4", "#161233"),
"taube": ("Taube", "#a8a5b8", "#26242e"),
"schwalbe": ("Schwalbe", "#5aa9e6", "#0c2338"),
"greifvogel": ("Greifvogel", "#c9963f", "#33240a"),
"eule": ("Eule", "#5c6bc0", "#f0f1ff"),
"klettervogel": ("Klettervogel", "#4db6ac", "#0c2b28"),
"buschvogel": ("Buschvogel", "#9c7b5c", "#f6efe8"),
"stelze": ("Stelze", "#cfd8dc", "#22282a"),
"ammer": ("Ammer", "#e5c04a", "#332a06"),
# Gruppen, die es im Garten nicht gibt — gespiegelt aus
# detector/species_extra.py. Fehlen sie hier, rendert die Karte einen
# Strich statt eines Typs.
"schreitvogel": ("Schreitvogel", "#6b8f8a", "#f0f6f5"),
"ente": ("Ente", "#3f7d9e", "#eef7fb"),
"gans": ("Gans", "#8d9aa6", "#151b20"),
"moewe": ("Möwe", "#7fa8c9", "#101c26"),
"watvogel": ("Watvogel", "#b3966b", "#241c0e"),
"taucher": ("Taucher", "#4a7a8c", "#eef6f9"),
"ralle": ("Ralle", "#5f6b5a", "#eef2ec"),
"lerche": ("Lerche", "#c2a878", "#241d0d"),
"wuerger": ("Würger", "#9a6f8f", "#f7eef5"),
"huhn": ("Hühnervogel", "#a4703f", "#f8efe6"),
"exot": ("Exot", "#22a3a3", "#06302f"),
}
ZUG_LABEL = {
"stand": "Standvogel",
"teil": "Teilzieher",
"zug": "Zugvogel",
}
ZUG_LABEL_EN = {
"stand": "Resident",
"teil": "Partial migrant",
"zug": "Migrant",
}
# Typnamen auf Englisch. Bewusst die gängigen Gruppenbegriffe, nicht die
# wörtliche Übertragung — „Schnäpper" heißt hier Chat, „Buschvogel" Skulker.
TYPE_LABEL_EN = {
"meise": "Tit", "fink": "Finch", "sperling": "Sparrow", "drossel": "Thrush",
"schnaepper": "Chat", "grasmuecke": "Warbler", "specht": "Woodpecker",
"rabenvogel": "Corvid", "taube": "Pigeon", "schwalbe": "Swallow",
"greifvogel": "Raptor", "eule": "Owl", "klettervogel": "Climber",
"buschvogel": "Skulker", "stelze": "Wagtail", "ammer": "Bunting",
"schreitvogel": "Long-legged", "ente": "Duck", "gans": "Goose",
"moewe": "Gull", "watvogel": "Shorebird", "taucher": "Grebe",
"ralle": "Rail", "lerche": "Lark", "wuerger": "Shrike",
"huhn": "Gamebird", "exot": "Exotic",
}
RARITY_LABEL_EN = {
"haeufig": "Common", "gelegentlich": "Occasional", "selten": "Rare",
"episch": "Epic", "legendaer": "Legendary",
}
def _hex(c: str) -> tuple[int, int, int]:
c = c.lstrip("#")
return int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
def mix(color: str, other: str, ratio: float) -> str:
"""Mischt zwei Hex-Farben. Ersatz für CSS ``color-mix()``.
``color-mix()`` gibt es erst ab Safari 16.2 — auf älteren Geräten fällt
jede Regel, die es benutzt, ersatzlos weg, und die Seite bricht auseinander.
Vorberechnen kostet nichts und funktioniert überall.
``ratio`` ist der Anteil von ``color``.
"""
a, b = _hex(color), _hex(other)
return "#" + "".join(
f"{round(a[i] * ratio + b[i] * (1 - ratio)):02x}" for i in range(3)
)
# Oberflächen, gegen die gemischt wird — müssen zu den Werten in vogelalbum.css
# passen. Je Modus ein Satz.
LIGHT_SURFACE_SOFT = "#f4f7f2"
LIGHT_LINE = "#dfe6dd"
LIGHT_INK = "#1a2a20"
DARK_SURFACE_SOFT = "#1b2129"
DARK_LINE = "#2f3945"
DARK_PAPER = "#141920"
def type_vars(typ: str) -> str:
"""Alle abgeleiteten Typfarben als CSS-Variablen. Landet als ``style=""``.
Es werden **beide** Modi ausgegeben, mit den Endungen ``-l`` und ``-d``.
Das Stylesheet entscheidet über eine Indirektion, welcher Satz gilt:
:root { --typ-wash: var(--typ-wash-l); }
[data-theme="dark"] { --typ-wash: var(--typ-wash-d); }
Das funktioniert, weil Custom Properties erst am Verwendungsort aufgelöst
werden — dort sind ``--typ-wash-l/-d`` von der Karte geerbt. Nötig ist der
Umweg, weil ein Inline-Style sich nicht per Stylesheet überschreiben lässt.
Die Rollen: ``--typ`` die reine Farbe für Flächen und Bänder, ``--typ-text``
für Text (auf Weiß abgedunkelt, auf Dunkel aufgehellt), ``--typ-wash`` als
blasser Hintergrund, ``--typ-line`` für Rahmen.
"""
label, farbe, ink = TYPE_STYLE.get(typ, ("—", "#7f9b6a", "#ffffff"))
return (
f"--typ:{farbe};"
f"--typ-ink:{ink};"
# hell
f"--typ-text-l:{mix(farbe, LIGHT_INK, 0.62)};"
f"--typ-wash-l:{mix(farbe, LIGHT_SURFACE_SOFT, 0.13)};"
f"--typ-card-l:{mix(farbe, LIGHT_SURFACE_SOFT, 0.22)};"
f"--typ-line-l:{mix(farbe, LIGHT_LINE, 0.45)};"
# dunkel — dort muss die Farbe aufgehellt werden, um lesbar zu bleiben,
# und die Flächen viel zurückhaltender sein, sonst blendet es.
f"--typ-text-d:{mix(farbe, '#ffffff', 0.80)};"
f"--typ-wash-d:{mix(farbe, DARK_SURFACE_SOFT, 0.14)};"
f"--typ-card-d:{mix(farbe, DARK_PAPER, 0.20)};"
f"--typ-line-d:{mix(farbe, DARK_LINE, 0.42)};"
)
RARITY_GLOW = {
"haeufig": "none",
"gelegentlich": "none",
"selten": "0 0 24px -6px #9b7ef0",
"episch": "0 0 28px -4px #e0851f",
"legendaer": "0 0 34px -2px #d4af37",
}
View File
Whitespace-only changes.
+134
View File
@@ -0,0 +1,134 @@
"""Template-Helfer."""
from __future__ import annotations
from pathlib import Path
from django import template
from dex.models import Species
register = template.Library()
@register.filter
def alternativen(votes: dict, limit: int = 3) -> list[dict]:
"""Die Zweit- und Drittplatzierten einer Sichtung.
Der Detector speichert das Abstimmungsergebnis über den ganzen Besuch. Wer
knapp verloren hat, ist die interessante Information: genau da lohnt der
Blick aufs Foto, und genau da hilft eine Handkorrektur dem späteren
eigenen Modell am meisten.
"""
if not isinstance(votes, dict) or len(votes) < 2:
return []
ranked = sorted(votes.items(), key=lambda kv: kv[1], reverse=True)[1 : limit + 1]
by_slug = {
s.slug: s for s in Species.objects.filter(slug__in=[slug for slug, _ in ranked])
}
return [
{"species": by_slug[slug], "anteil": round(score * 100)}
for slug, score in ranked
if slug in by_slug and score > 0
]
@register.filter
def zuglabel(code: str) -> str:
from dex.species_meta import ZUG_LABEL
return ZUG_LABEL.get(code, code)
@register.filter
def prozent(value: float | None) -> int:
"""0.973 -> 97. Spart im Template das widthratio-Geraffel."""
try:
return round(float(value) * 100)
except (TypeError, ValueError):
return 0
@register.filter
def zeitpunkt(wert) -> str:
"""Ein Besuchszeitpunkt, wie ihn ein Mensch nennen würde.
``heute, 10:05`` · ``gestern, 10:05`` · ``Sonntag, 10:05`` · und ab einer
Woche das volle Datum.
⚠️ Nicht durch ``|date:"d.m."`` ersetzen: Das Format ist deutsch
verdrahtet und steht auf der englischen Fassung falsch herum. ``date_format``
nimmt die Schreibweise aus der aktiven Sprache.
"""
from django.utils import timezone
from django.utils.formats import date_format
from django.utils.translation import gettext as _
if not wert:
return ""
ortszeit = timezone.localtime(wert)
tage = (timezone.localdate() - ortszeit.date()).days
uhr = date_format(ortszeit, "TIME_FORMAT")
if tage <= 0:
return _("heute, %(uhrzeit)s") % {"uhrzeit": uhr}
if tage == 1:
return _("gestern, %(uhrzeit)s") % {"uhrzeit": uhr}
if tage < 7:
return _("%(wochentag)s, %(uhrzeit)s") % {
"wochentag": date_format(ortszeit, "l"),
"uhrzeit": uhr,
}
return _("%(datum)s, %(uhrzeit)s") % {
"datum": date_format(ortszeit, "DATE_FORMAT"),
"uhrzeit": uhr,
}
@register.simple_tag
def static_v(pfad: str) -> str:
"""Wie ``static``, aber mit dem Änderungszeitpunkt der Datei als Anhang.
Ohne diesen Stempel behält ein Browser das alte Stylesheet, obwohl der
Server längst das neue ausliefert — im Entwicklungsbetrieb liefert Django
nur ein ``Last-Modified`` mit, und danach darf ein Browser heuristisch
cachen. Das hat schon eine Stunde Fehlersuche gekostet, an der nichts
kaputt war.
Im Betrieb steckt der Hash über ``ManifestStaticFilesStorage`` bereits im
Dateinamen; dann ändert dieser Anhang nichts und stört auch nicht.
"""
from django.contrib.staticfiles import finders
from django.templatetags.static import static
url = static(pfad)
treffer = finders.find(pfad)
if not treffer:
return url
if isinstance(treffer, (list, tuple)):
treffer = treffer[0]
try:
stempel = int(Path(treffer).stat().st_mtime)
except OSError:
return url
trenner = "&" if "?" in url else "?"
return f"{url}{trenner}v={stempel}"
@register.simple_tag
def abzeichen_bild(code: str) -> str:
"""Die Adresse des Pins zu einem Abzeichen — oder leer, wenn es keinen gibt.
🔑 Die Vorlage soll nicht raten müssen. Ein Abzeichen, das später
dazukommt, hat noch kein Bild; ohne diese Prüfung stünde dort ein
kaputtes Bildsymbol, und zwar genau auf der Seite, die Belohnungen zeigt.
Fehlt der Pin, faellt die Vorlage auf das Emoji zurueck.
"""
from django.contrib.staticfiles import finders
pfad = f"img/pins/{code}.png"
if not finders.find(pfad):
return ""
return static_v(pfad)
+644
View File
@@ -0,0 +1,644 @@
"""Tests für die Lese-Schnittstelle der Handy-App.
Konzentriert auf das, was leise falsch sein kann: der Zugang (ein zu weit
geöffneter Endpunkt fällt niemandem auf), die Handbestimmung (die App würde
sonst dauerhaft die korrigierte Art verschweigen), die Warteschleife nach
einem Upload (ein falsches ``wartet`` dreht endlos) und die Bildadressen
(relativ ausgeliefert ist in einer App schlicht kein Bild).
"""
from __future__ import annotations
import json
from datetime import timedelta
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.utils import timezone
from . import scoring
from .models import Badge, Camera, DexEntry, EarnedBadge, Sighting, Species
from .tests import make_camera, make_species, make_sighting
# Ein 1x1-PNG. Reicht, weil wir die Adresse prüfen, nicht das Bild.
PNG = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00"
b"\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
)
LESEN = "lese-token-fuer-tests"
def bild(name="v.png") -> SimpleUploadedFile:
return SimpleUploadedFile(name, PNG, content_type="image/png")
@override_settings(LESE_TOKEN=LESEN)
class ZugangTests(TestCase):
"""Wer darf lesen — und vor allem: wer nicht."""
def setUp(self):
make_camera(token="kamera-geheim")
def hole(self, token=None):
kopf = {}
if token is not None:
kopf["HTTP_AUTHORIZATION"] = f"Bearer {token}"
return self.client.get("/api/app/start/", **kopf)
def test_ohne_header_abgelehnt(self):
self.assertEqual(self.hole().status_code, 401)
def test_falscher_token_abgelehnt(self):
self.assertEqual(self.hole("nein").status_code, 401)
def test_lese_token_darf(self):
self.assertEqual(self.hole(LESEN).status_code, 200)
def test_kamera_token_darf_auch(self):
"""Die App soll mit EINEM Geheimnis auskommen — dem ihrer Kamera."""
self.assertEqual(self.hole("kamera-geheim").status_code, 200)
def test_header_ohne_bearer_abgelehnt(self):
resp = self.client.get("/api/app/start/", HTTP_AUTHORIZATION=LESEN)
self.assertEqual(resp.status_code, 401)
@override_settings(LESE_TOKEN="")
def test_leerer_lese_token_heisst_zu_nicht_offen(self):
"""⚠️ Die Regel aus api.py gilt hier genauso.
Ein leerer Token ist ein vergessenes Geheimnis, kein Freibrief. Ohne
diese Prüfung stünde die Sammlung im Netz, sobald jemand die
Umgebungsvariable nicht setzt — und ein leerer Bearer-Header käme
durch.
"""
self.assertEqual(self.hole("").status_code, 401)
self.assertEqual(self.hole("irgendwas").status_code, 401)
# Die Kamera bleibt der Weg hinein, auch ohne Lese-Token.
self.assertEqual(self.hole("kamera-geheim").status_code, 200)
def test_alle_endpunkte_sind_geschuetzt(self):
"""Ein neuer Endpunkt ohne Prüfung ist der Fehler, der nie auffällt."""
art = make_species()
s = make_sighting(art)
for pfad in (
"/api/app/start/",
"/api/app/album/",
f"/api/app/album/{art.slug}/",
"/api/app/sichtungen/",
f"/api/app/sichtungen/{s.id}/",
"/api/app/abzeichen/",
):
with self.subTest(pfad=pfad):
self.assertEqual(self.client.get(pfad).status_code, 401)
@override_settings(LESE_TOKEN=LESEN)
class AppTestBasis(TestCase):
def setUp(self):
scoring.sync_badges()
def json(self, pfad, **params):
resp = self.client.get(
pfad, params, HTTP_AUTHORIZATION=f"Bearer {LESEN}"
)
self.assertEqual(resp.status_code, 200, resp.content[:400])
return json.loads(resp.content)
class AlbumTests(AppTestBasis):
def setUp(self):
super().setUp()
self.kohlmeise = make_species(
slug="kohlmeise", german="Kohlmeise", number=3,
lebensraum_typ="garten", typ="meise",
)
self.blaumeise = make_species(
slug="blaumeise", german="Blaumeise", scientific="Cyanistes caeruleus",
number=1, lebensraum_typ="garten", typ="meise",
)
self.reiher = make_species(
slug="graureiher", german="Graureiher", scientific="Ardea cinerea",
number=2, lebensraum_typ="wasser", typ="schreitvogel",
)
def test_vorgabe_ist_garten(self):
"""Ohne Angabe nur der Garten — 148 Arten sind eine Wand, kein Ziel."""
daten = self.json("/api/app/album/")
self.assertEqual(daten["lebensraum"], "garten")
self.assertEqual(
{a["slug"] for a in daten["arten"]}, {"kohlmeise", "blaumeise"}
)
def test_alle_holt_jeden_lebensraum(self):
daten = self.json("/api/app/album/", lebensraum="alle")
self.assertEqual(len(daten["arten"]), 3)
self.assertEqual(daten["lebensraum"], "")
def test_sortierung_nach_albumnummer_nicht_nach_fund(self):
"""Die Nummer ist die Seitenzahl und bleibt, wo sie ist."""
scoring.register(make_sighting(self.kohlmeise)) # Nummer 3, gefunden
daten = self.json("/api/app/album/", lebensraum="alle")
self.assertEqual(
[a["nummer"] for a in daten["arten"]], [1, 2, 3]
)
def test_ungefundene_art_ist_schatten_ohne_foto(self):
daten = self.json("/api/app/album/")
seite = next(a for a in daten["arten"] if a["slug"] == "kohlmeise")
self.assertFalse(seite["gefunden"])
self.assertNotIn("foto", seite)
self.assertNotIn("anzahl", seite)
def test_gefundene_art_bringt_zaehler_und_foto(self):
s = make_sighting(self.kohlmeise)
s.image = bild()
s.save()
scoring.register(s)
daten = self.json("/api/app/album/")
seite = next(a for a in daten["arten"] if a["slug"] == "kohlmeise")
self.assertTrue(seite["gefunden"])
self.assertEqual(seite["anzahl"], 1)
self.assertTrue(seite["neu"])
self.assertTrue(seite["foto"].startswith("http://"))
def test_zaehler_je_lebensraum(self):
scoring.register(make_sighting(self.blaumeise))
daten = self.json("/api/app/album/")
self.assertEqual(daten["gefunden"], 1)
self.assertEqual(daten["gesamt"], 2)
garten = next(
h for h in daten["lebensraeume"] if h["code"] == "garten"
)
wasser = next(
h for h in daten["lebensraeume"] if h["code"] == "wasser"
)
self.assertEqual((garten["gefunden"], garten["gesamt"]), (1, 2))
self.assertEqual((wasser["gefunden"], wasser["gesamt"]), (0, 1))
def test_leere_lebensraeume_fehlen_in_den_reitern(self):
codes = {h["code"] for h in self.json("/api/app/album/")["lebensraeume"]}
self.assertEqual(codes, {"garten", "wasser"})
def test_farben_kommen_mit(self):
"""Die App soll die Palette nicht nachbauen müssen."""
seite = next(
a for a in self.json("/api/app/album/")["arten"]
if a["slug"] == "kohlmeise"
)
self.assertEqual(seite["typ_farbe"], "#f2c744")
self.assertEqual(seite["seltenheit_farbe"], "#7f9b6a")
class ArtTests(AppTestBasis):
def setUp(self):
super().setUp()
self.art = make_species(
number=5, gewicht_g=18.0, spannweite_cm=24.0, size_cm="14 cm",
verwechslung=["blaumeise"],
)
make_species(slug="blaumeise", german="Blaumeise", number=6)
def test_unbekannte_art_ist_404(self):
resp = self.client.get(
"/api/app/album/gibtsnicht/", HTTP_AUTHORIZATION=f"Bearer {LESEN}"
)
self.assertEqual(resp.status_code, 404)
def test_steckbrief_und_balken(self):
daten = self.json(f"/api/app/album/{self.art.slug}/")
self.assertEqual(daten["art"]["slug"], "kohlmeise")
self.assertTrue(daten["art"]["balken"])
labels = {b["label"] for b in daten["art"]["balken"]}
self.assertIn("Gewicht", labels)
def test_verwechslung_in_katalogreihenfolge(self):
daten = self.json(f"/api/app/album/{self.art.slug}/")
self.assertEqual(
[a["slug"] for a in daten["verwechslung"]], ["blaumeise"]
)
def test_blaettern_zeigt_die_nachbarn(self):
daten = self.json(f"/api/app/album/{self.art.slug}/")
self.assertIsNone(daten["blaettern"]["vorher"])
self.assertEqual(daten["blaettern"]["nachher"], "blaumeise")
def test_tagesverlauf_hat_24_stunden_in_ortszeit(self):
wann = timezone.localtime(timezone.now()).replace(hour=6, minute=31)
make_sighting(self.art, when=wann)
daten = self.json(f"/api/app/album/{self.art.slug}/")
self.assertEqual(len(daten["tagesverlauf"]), 24)
self.assertEqual(daten["tagesverlauf"][6]["anzahl"], 1)
self.assertEqual(daten["tagesverlauf"][6]["anteil"], 1.0)
def test_handbestimmung_zaehlt_als_besuch_dieser_art(self):
"""Eine Korrektur im Admin muss in der App ankommen."""
andere = make_species(slug="amsel", german="Amsel", number=9)
s = make_sighting(andere)
s.confirmed_species = self.art
s.save()
daten = self.json(f"/api/app/album/{self.art.slug}/")
self.assertEqual(len(daten["besuche"]), 1)
self.assertEqual(daten["besuche"][0]["art"]["slug"], "kohlmeise")
self.assertTrue(daten["besuche"][0]["handbestimmt"])
class SichtungenTests(AppTestBasis):
def setUp(self):
super().setUp()
self.art = make_species()
self.kamera = make_camera(token="kamera-geheim")
def test_juengste_zuerst(self):
jetzt = timezone.now()
alt = make_sighting(self.art, when=jetzt - timedelta(hours=2))
neu = make_sighting(self.art, when=jetzt)
daten = self.json("/api/app/sichtungen/")
self.assertEqual([s["id"] for s in daten["sichtungen"]], [neu.id, alt.id])
def test_nur_bestimmt_blendet_artlose_aus(self):
make_sighting(self.art)
make_sighting(None)
self.assertEqual(self.json("/api/app/sichtungen/")["gesamt"], 2)
self.assertEqual(
self.json("/api/app/sichtungen/", nur_bestimmt="1")["gesamt"], 1
)
def test_filter_nach_art(self):
andere = make_species(slug="amsel", german="Amsel")
make_sighting(self.art)
make_sighting(andere)
daten = self.json("/api/app/sichtungen/", art="amsel")
self.assertEqual(daten["gesamt"], 1)
self.assertEqual(daten["sichtungen"][0]["art"]["slug"], "amsel")
def test_filter_nach_kamera(self):
s = make_sighting(self.art)
s.camera = self.kamera
s.save()
make_sighting(self.art)
self.assertEqual(
self.json("/api/app/sichtungen/", kamera=self.kamera.slug)["gesamt"], 1
)
def test_seiten(self):
for _ in range(5):
make_sighting(self.art)
erste = self.json("/api/app/sichtungen/", anzahl=2)
self.assertEqual(len(erste["sichtungen"]), 2)
self.assertTrue(erste["weiter"])
letzte = self.json("/api/app/sichtungen/", anzahl=2, seite=3)
self.assertEqual(len(letzte["sichtungen"]), 1)
self.assertFalse(letzte["weiter"])
def test_anzahl_ist_gedeckelt(self):
"""Ein Feed über Mobilfunk darf nicht alles auf einmal ziehen."""
daten = self.json("/api/app/sichtungen/", anzahl=5000)
self.assertEqual(daten["pro_seite"], 60)
def test_unsinnige_seite_fuehrt_nicht_zum_fehler(self):
make_sighting(self.art)
daten = self.json("/api/app/sichtungen/", seite="haha", anzahl="auch")
self.assertEqual(daten["seite"], 1)
self.assertEqual(daten["pro_seite"], 24)
def test_art_bringt_ihr_referenzbild_mit(self):
"""Damit eine Sichtung ohne eigenes Foto nicht als Fragezeichen endet.
Gefunden am gerenderten Feed: Die Demo-Sichtungen hatten keine Bilder,
und die App zeigte neben "Haussperling" ein Fragezeichen — obwohl sie
den Vogel abbilden könnte.
"""
self.art.reference_image.name = "referenz/kohlmeise.jpg"
self.art.save(update_fields=["reference_image"])
make_sighting(self.art)
zeile = self.json("/api/app/sichtungen/")["sichtungen"][0]
self.assertTrue(zeile["art"]["referenzbild"].startswith("http://"))
def test_bildadressen_sind_absolut(self):
s = make_sighting(self.art)
s.image = bild("ganz.png")
s.crop = bild("aus.png")
s.save()
zeile = self.json("/api/app/sichtungen/")["sichtungen"][0]
self.assertTrue(zeile["foto"].startswith("http://"))
self.assertTrue(zeile["ausschnitt"].startswith("http://"))
def test_bildadressen_folgen_dem_proxy_auf_https(self):
"""🔴 Hinter Traefik spricht niemand HTTPS mit uns — Django baut sonst
jede Bildadresse mit ``http://``.
Im Browser fällt das nicht auf (Traefik leitet um). Auf dem Telefon ist
es ein Ausfall: iOS blockt einfaches HTTP, ohne der Umleitung zu
folgen, und das Album bleibt bilderlos — genau auf dem Gerät, für das
es gebaut ist. Gefunden an den echten Antworten der Live-Instanz, nicht
im Test.
"""
s = make_sighting(self.art)
s.image = bild("ganz.png")
s.save()
self.art.reference_image.name = "referenz/kohlmeise.jpg"
self.art.save(update_fields=["reference_image"])
resp = self.client.get(
"/api/app/sichtungen/",
HTTP_AUTHORIZATION=f"Bearer {LESEN}",
# Genau der Header, den Traefik setzt.
HTTP_X_FORWARDED_PROTO="https",
secure=False,
)
self.assertEqual(resp.status_code, 200)
zeile = json.loads(resp.content)["sichtungen"][0]
self.assertTrue(zeile["foto"].startswith("https://"), zeile["foto"])
self.assertTrue(
zeile["art"]["referenzbild"].startswith("https://"),
zeile["art"]["referenzbild"],
)
def test_bildadressen_sind_absolut(self):
s = make_sighting(self.art)
s.image = bild("ganz.png")
s.crop = bild("aus.png")
s.save()
zeile = self.json("/api/app/sichtungen/")["sichtungen"][0]
self.assertTrue(zeile["foto"].startswith("http://"))
self.assertTrue(zeile["ausschnitt"].startswith("http://"))
def test_bildadressen_folgen_dem_proxy_auf_https(self):
"""🔴 Hinter Traefik spricht niemand HTTPS mit uns — Django baut sonst
jede Bildadresse mit ``http://``.
Im Browser fällt das nicht auf (Traefik leitet um). Auf dem Telefon ist
es ein Ausfall: iOS blockt einfaches HTTP, ohne der Umleitung zu
folgen, und das Album bleibt bilderlos — genau auf dem Gerät, für das
es gebaut ist. Gefunden an den echten Antworten der Live-Instanz, nicht
im Test.
"""
s = make_sighting(self.art)
s.image = bild("ganz.png")
s.save()
self.art.reference_image.name = "referenz/kohlmeise.jpg"
self.art.save(update_fields=["reference_image"])
resp = self.client.get(
"/api/app/sichtungen/",
HTTP_AUTHORIZATION=f"Bearer {LESEN}",
# Genau der Header, den Traefik setzt.
HTTP_X_FORWARDED_PROTO="https",
secure=False,
)
self.assertEqual(resp.status_code, 200)
zeile = json.loads(resp.content)["sichtungen"][0]
self.assertTrue(zeile["foto"].startswith("https://"), zeile["foto"])
self.assertTrue(
zeile["art"]["referenzbild"].startswith("https://"),
zeile["art"]["referenzbild"],
)
def test_abzeichenbilder_folgen_dem_proxy_auf_https(self):
resp = self.client.get(
"/api/app/abzeichen/",
HTTP_AUTHORIZATION=f"Bearer {LESEN}",
HTTP_X_FORWARDED_PROTO="https",
)
pins = [z["bild"] for z in json.loads(resp.content)["abzeichen"] if z["bild"]]
self.assertTrue(pins)
self.assertTrue(all(p.startswith("https://") for p in pins), pins[:2])
class EinzelneSichtungTests(AppTestBasis):
"""Der Endpunkt, mit dem die App nach einem Upload nachfragt."""
def setUp(self):
super().setUp()
self.art = make_species()
def test_unbekannte_kennung_ist_404(self):
resp = self.client.get(
"/api/app/sichtungen/9999/", HTTP_AUTHORIZATION=f"Bearer {LESEN}"
)
self.assertEqual(resp.status_code, 404)
def test_wartendes_foto_meldet_wartet(self):
jetzt = timezone.now()
s = Sighting.objects.create(
started_at=jetzt, ended_at=jetzt, frames_total=1,
wartet_auf_bestimmung=True,
)
daten = self.json(f"/api/app/sichtungen/{s.id}/")
self.assertTrue(daten["wartet"])
self.assertIsNone(daten["art"])
def test_foto_ohne_vogel_endet_die_warteschleife(self):
"""⚠️ ``wartet: false`` mit ``art: null`` ist die Antwort, kein Fehler.
Ohne diesen Fall dreht die App für ein Foto vom eigenen Daumen endlos.
"""
jetzt = timezone.now()
s = Sighting.objects.create(
started_at=jetzt, ended_at=jetzt, frames_total=1,
wartet_auf_bestimmung=False,
)
daten = self.json(f"/api/app/sichtungen/{s.id}/")
self.assertFalse(daten["wartet"])
self.assertIsNone(daten["art"])
self.assertNotIn("album", daten)
def test_bestimmtes_foto_bringt_album_und_fortschritt(self):
s = make_sighting(self.art)
scoring.register(s)
daten = self.json(f"/api/app/sichtungen/{s.id}/")
self.assertFalse(daten["wartet"])
self.assertEqual(daten["art"]["slug"], "kohlmeise")
self.assertTrue(daten["neue_art"])
self.assertEqual(daten["punkte"], 30) # 10 XP x 3 beim Erstfang
self.assertTrue(daten["album"]["gefunden"])
self.assertEqual(daten["fortschritt"]["arten_gesammelt"], 1)
class StartTests(AppTestBasis):
def setUp(self):
super().setUp()
self.art = make_species()
self.kamera = make_camera(token="kamera-geheim", name="Futterhaus")
def test_leerer_stand_ist_kein_fehler(self):
"""Bens Album ist am ersten Tag leer — und die App muss starten."""
daten = self.json("/api/app/start/")
self.assertIsNone(daten["letzte_sichtung"])
self.assertEqual(daten["neueste_arten"], [])
self.assertEqual(daten["fortschritt"]["punkte"], 0)
self.assertEqual(daten["fortschritt"]["stufe"], 1)
def test_letzte_sichtung_ueberspringt_unbestimmtes(self):
"""Eine Mondspiegelung ist kein Besuch und gehört nicht auf die Startseite."""
jetzt = timezone.now()
echt = make_sighting(self.art, when=jetzt - timedelta(hours=1))
make_sighting(None, when=jetzt)
daten = self.json("/api/app/start/")
self.assertEqual(daten["letzte_sichtung"]["id"], echt.id)
def test_wartende_fotos_werden_gezaehlt(self):
jetzt = timezone.now()
Sighting.objects.create(
started_at=jetzt, ended_at=jetzt, frames_total=1,
wartet_auf_bestimmung=True,
)
self.assertEqual(self.json("/api/app/start/")["wartende_fotos"], 1)
def test_kameras_ohne_geheimnisse(self):
daten = self.json("/api/app/start/")
kamera = daten["kameras"][0]
self.assertEqual(kamera["name"], "Futterhaus")
# ⚠️ Kein Token, kein Live-Bild, keine Geräteadresse — die App ist
# kein Wartungswerkzeug.
for verboten in ("token", "token_hash", "bild_url", "geraet_url",
"stream_url", "live_url"):
self.assertNotIn(verboten, kamera)
def test_neueste_arten_sind_die_letzten_erstfaenge(self):
zweite = make_species(slug="amsel", german="Amsel", number=2)
scoring.register(make_sighting(self.art))
scoring.register(make_sighting(zweite))
daten = self.json("/api/app/start/")
self.assertEqual(
[a["slug"] for a in daten["neueste_arten"]], ["amsel", "kohlmeise"]
)
class AbzeichenTests(AppTestBasis):
def test_verdient_und_offen(self):
art = make_species()
scoring.register(make_sighting(art))
daten = self.json("/api/app/abzeichen/")
self.assertEqual(daten["gesamt"], Badge.objects.count())
self.assertGreaterEqual(daten["verdient"], 1)
verdiente = [z for z in daten["abzeichen"] if z["verdient"]]
self.assertTrue(all(z["am"] for z in verdiente))
# 🔑 Verdiente Abzeichen haben keinen Balken — der stuende dauerhaft
# auf 100 % und sagt nichts.
self.assertTrue(all("anteil" not in z for z in verdiente))
def test_abzeichen_liefern_den_pin_nicht_nur_das_emoji(self):
"""🔑 Die Website zeigt gezeichnete Emaille-Pins, die App muss dieselben
zeigen.
Der Anlass ist derselbe, aus dem die Pins überhaupt entstanden sind:
Auf einem System ohne Emoji-Schrift steht statt des Abzeichens ein
leeres Rechteck. Beim Ansehen der gerenderten App stand genau das da,
fünfzehnmal untereinander.
"""
daten = self.json("/api/app/abzeichen/")
mit_pin = [z for z in daten["abzeichen"] if z["bild"]]
self.assertTrue(mit_pin, "kein einziges Abzeichen mit Pin")
self.assertTrue(mit_pin[0]["bild"].startswith("http://"))
self.assertIn("/img/pins/", mit_pin[0]["bild"])
# ⚠️ Das Emoji bleibt als Rückfall erhalten — ein später
# hinzugefügtes Abzeichen hat noch keinen Pin.
self.assertTrue(all(z["symbol"] for z in daten["abzeichen"]))
def test_abzeichen_ohne_pin_liefert_bild_none_statt_kaputter_adresse(self):
from .models import Badge
Badge.objects.create(
code="ganz-neu", name="Ganz neu", description="Kommt später.", emoji="🆕"
)
daten = self.json("/api/app/abzeichen/")
neu = next(z for z in daten["abzeichen"] if z["code"] == "ganz-neu")
self.assertIsNone(neu["bild"])
self.assertEqual(neu["symbol"], "🆕")
def test_offene_zaehlbare_abzeichen_haben_einen_anteil(self):
art = make_species()
scoring.register(make_sighting(art))
daten = self.json("/api/app/abzeichen/")
mit_balken = [
z for z in daten["abzeichen"] if not z["verdient"] and "anteil" in z
]
self.assertTrue(mit_balken, "kein offenes Abzeichen mit Fortschritt")
for z in mit_balken:
self.assertLessEqual(z["anteil"], 1.0)
self.assertLessEqual(z["ist"], z["ziel"])
class VogelOhneArtTests(AppTestBasis):
"""🔴 „Vogel ja, Art nein" darf nicht als „kein Vogel" ankommen.
Am 14.10.2026 erkannte der Detektor auf einem Handyfoto mit 93 % einen
Vogel, konnte die Art aber nicht bestimmen (Gegenlicht, Silhouette). In
der App stand „Kein Vogel erkannt. Auf dem Foto war nichts zu finden" —
und daraufhin wurde zugeschnitten, gegen ein Problem, das nie der
Zuschnitt war. Eine falsche Auskunft ist teurer als gar keine.
"""
def setUp(self):
super().setUp()
self.kamera = make_camera(token="kamera-geheim", mobil=True)
def _wartendes_foto(self):
jetzt = timezone.now()
s = Sighting.objects.create(
camera=self.kamera, started_at=jetzt, ended_at=jetzt,
frames_total=1, wartet_auf_bestimmung=True,
)
return s
def melde(self, s, nutzlast):
return self.client.post(
f"/api/offen/{s.id}/",
{"data": json.dumps(nutzlast)},
HTTP_AUTHORIZATION="Bearer kamera-geheim",
)
def test_vogel_ohne_art_behaelt_seinen_vogelwert(self):
s = self._wartendes_foto()
resp = self.melde(s, {"species": None, "confidence": 0.0,
"mean_bird_score": 0.93})
self.assertEqual(resp.status_code, 200, resp.content)
daten = self.json(f"/api/app/sichtungen/{s.id}/")
self.assertFalse(daten["wartet"])
self.assertIsNone(daten["art"])
# Das ist der Unterschied, an dem die App ihren Text waehlt.
self.assertAlmostEqual(daten["vogelwert"], 0.93, places=3)
def test_wirklich_kein_vogel_bleibt_bei_null(self):
s = self._wartendes_foto()
self.melde(s, {"species": None, "confidence": 0.0, "mean_bird_score": 0.0})
daten = self.json(f"/api/app/sichtungen/{s.id}/")
self.assertIsNone(daten["art"])
self.assertEqual(daten["vogelwert"], 0.0)
def test_vogel_ohne_art_zaehlt_als_bild_mit_vogel(self):
"""``frames_with_bird`` hing an der ART statt am Vogel."""
s = self._wartendes_foto()
self.melde(s, {"species": None, "mean_bird_score": 0.93})
s.refresh_from_db()
self.assertEqual(s.frames_with_bird, 1)
def test_ohne_vogel_kein_bild_mit_vogel(self):
s = self._wartendes_foto()
self.melde(s, {"species": None, "mean_bird_score": 0.0})
s.refresh_from_db()
self.assertEqual(s.frames_with_bird, 0)
+231
View File
@@ -0,0 +1,231 @@
"""Tests für das Säubern hochgeladener Bilder.
🔑 Der Kern ist **nicht** „erkennt kaputte Dateien", sondern **„gibt nur
weiter, was wirklich ein Bild war"**. Eine Prüfung lässt alles stehen, was sie
nicht kennt; das Neuschreiben nimmt nur die Pixel mit. Die Tests hier zielen
deshalb auf das, was NICHT mehr in der gespeicherten Datei steht.
"""
from __future__ import annotations
import io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from PIL import Image
from .bilder import MAX_KANTE, KeinBild, saeubern
def bild_mit_exif(gps=True, drehung=None, groesse=(80, 50)) -> SimpleUploadedFile:
"""Ein JPEG mit EXIF — mit Ort, wie es ein Handy liefert."""
im = Image.new("RGB", groesse, (90, 140, 70))
exif = im.getexif()
exif[0x010E] = "Testaufnahme" # ImageDescription
if drehung is not None:
exif[0x0112] = drehung # Orientation
if gps:
# 52,52 N / 13,405 O. Pillow erwartet Grad, Minuten, Sekunden als
# einfache Zahlen — ein verschachteltes Zähler/Nenner-Paar lehnt es ab.
exif[0x8825] = {
1: "N",
2: (52.0, 31.0, 12.0),
3: "E",
4: (13.0, 24.0, 18.0),
}
puffer = io.BytesIO()
im.save(puffer, format="JPEG", exif=exif)
return SimpleUploadedFile("urlaub.jpg", puffer.getvalue(), "image/jpeg")
def einfaches_bild(format="PNG", groesse=(40, 40), modus="RGB") -> SimpleUploadedFile:
im = Image.new(modus, groesse, (10, 20, 30) if modus == "RGB" else 128)
puffer = io.BytesIO()
im.save(puffer, format=format)
endung = {"PNG": "png", "JPEG": "jpg", "WEBP": "webp"}[format]
return SimpleUploadedFile(f"v.{endung}", puffer.getvalue(), f"image/{endung}")
def gelesen(datei) -> Image.Image:
datei.seek(0)
return Image.open(io.BytesIO(datei.read()))
class SaeubernTests(TestCase):
def test_exif_verschwindet_vollstaendig(self):
"""🔴 Der eigentliche Zweck: Der Ort darf nicht öffentlich werden.
Ein Handyfoto trägt GPS — bei dieser App den Garten, in dem ein Kind
steht. ``/media/`` ist ohne Anmeldung erreichbar.
"""
roh = bild_mit_exif(gps=True)
# Gegenprobe: im Original IST der Ort drin.
self.assertTrue(gelesen(roh).getexif().get_ifd(0x8825))
sauber = saeubern(roh)
exif = gelesen(sauber).getexif()
self.assertFalse(exif.get_ifd(0x8825), "GPS steht noch im Bild")
self.assertFalse(dict(exif), f"EXIF steht noch im Bild: {dict(exif)}")
def test_drehung_wird_angewandt_nicht_weggeworfen(self):
"""⚠️ EXIF wegwerfen ohne zu drehen legt jedes Hochkantfoto um.
Der Fehler fiele erst beim Anzeigen auf, weit weg von der Ursache.
"""
# Orientation 6 heißt: um 90° gedreht anzeigen. Also wird aus einem
# 80x50-Bild ein 50x80-Bild.
roh = bild_mit_exif(gps=False, drehung=6, groesse=(80, 50))
sauber = saeubern(roh)
self.assertEqual(gelesen(sauber).size, (50, 80))
def test_ohne_drehungsangabe_bleibt_die_groesse(self):
roh = bild_mit_exif(gps=False, drehung=None, groesse=(80, 50))
self.assertEqual(gelesen(saeubern(roh)).size, (80, 50))
def test_ergebnis_ist_immer_jpeg(self):
for format in ("PNG", "JPEG", "WEBP"):
with self.subTest(format=format):
sauber = saeubern(einfaches_bild(format))
self.assertEqual(gelesen(sauber).format, "JPEG")
self.assertTrue(sauber.name.endswith(".jpg"))
def test_durchsichtigkeit_wird_auf_weiss_gelegt(self):
"""Ein PNG mit Alphakanal lässt sich nicht als JPEG schreiben."""
im = Image.new("RGBA", (20, 20), (255, 0, 0, 0))
puffer = io.BytesIO()
im.save(puffer, format="PNG")
datei = SimpleUploadedFile("t.png", puffer.getvalue(), "image/png")
sauber = saeubern(datei)
self.assertEqual(gelesen(sauber).mode, "RGB")
def test_grosse_bilder_werden_verkleinert(self):
sauber = saeubern(einfaches_bild(groesse=(4000, 3000)))
breite, hoehe = gelesen(sauber).size
self.assertEqual(max(breite, hoehe), MAX_KANTE)
# Seitenverhältnis bleibt.
self.assertAlmostEqual(breite / hoehe, 4 / 3, places=2)
def test_kleine_bilder_werden_nicht_aufgeblasen(self):
self.assertEqual(gelesen(saeubern(einfaches_bild(groesse=(40, 40)))).size, (40, 40))
def test_keine_bilddatei_wird_abgewiesen(self):
datei = SimpleUploadedFile("boese.jpg", b"GIF89a<?php echo 1; ?>", "image/jpeg")
with self.assertRaises(KeinBild):
saeubern(datei)
def test_leere_datei_wird_abgewiesen(self):
with self.assertRaises(KeinBild):
saeubern(SimpleUploadedFile("leer.jpg", b"", "image/jpeg"))
def test_pfadanteile_im_namen_verschwinden(self):
"""⚠️ Ein hochgeladener Name darf keine Verzeichnisse mitbringen."""
datei = einfaches_bild()
datei.name = "../../etc/passwd.png"
self.assertEqual(saeubern(datei).name, "passwd.jpg")
def test_riesige_abmessungen_werden_abgewiesen(self):
"""Ein winziges Bild kann beim Öffnen Gigabyte belegen."""
from . import bilder
with self.settings():
alt = bilder.MAX_PIXEL
bilder.MAX_PIXEL = 100 # 10x10
try:
with self.assertRaises(KeinBild):
saeubern(einfaches_bild(groesse=(40, 40)))
finally:
bilder.MAX_PIXEL = alt
@override_settings(LESE_TOKEN="lesen")
class UploadWegTests(TestCase):
"""Dass das Säubern auch wirklich in den Upload-Wegen steckt."""
def setUp(self):
from .tests import make_camera
self.kamera = make_camera(token="kamera-geheim", mobil=True)
def test_app_upload_speichert_ohne_exif(self):
resp = self.client.post(
"/api/hochladen/",
{"bilder": bild_mit_exif(gps=True)},
HTTP_AUTHORIZATION="Bearer kamera-geheim",
)
self.assertEqual(resp.status_code, 201, resp.content)
from .models import Sighting
s = Sighting.objects.get(id=resp.json()["angelegt"][0])
with s.image.open("rb") as f:
exif = Image.open(f).getexif()
self.assertFalse(exif.get_ifd(0x8825), "GPS kam durch den Upload")
def test_app_upload_weist_unbrauchbares_ab(self):
resp = self.client.post(
"/api/hochladen/",
{"bilder": SimpleUploadedFile("x.jpg", b"kein bild", "image/jpeg")},
HTTP_AUTHORIZATION="Bearer kamera-geheim",
)
self.assertEqual(resp.status_code, 400)
from .models import Sighting
# ⚠️ Und es bleibt KEINE halbe Sichtung stehen.
self.assertEqual(Sighting.objects.count(), 0)
def test_mehrere_bilder_entweder_alle_oder_keins(self):
"""Sonst weiß der Hochladende nicht, was angekommen ist."""
resp = self.client.post(
"/api/hochladen/",
{"bilder": [
einfaches_bild(),
SimpleUploadedFile("kaputt.jpg", b"nein", "image/jpeg"),
]},
HTTP_AUTHORIZATION="Bearer kamera-geheim",
)
self.assertEqual(resp.status_code, 400)
from .models import Sighting
self.assertEqual(Sighting.objects.count(), 0)
class HeicTests(TestCase):
"""🔴 iPhones fotografieren HEIC — eine App für iOS muss es annehmen.
Am 14.10.2026 wies die Instanz jedes Foto vom Telefon mit 400 ab, Minuten
nachdem die Bildprüfung ausgerollt war. Die Prüfung war richtig, nur kannte
sie das Format nicht, das die Hälfte aller Telefone erzeugt.
"""
def heic(self, groesse=(400, 300)) -> SimpleUploadedFile:
im = Image.new("RGB", groesse, (90, 140, 70))
puffer = io.BytesIO()
im.save(puffer, format="HEIF")
return SimpleUploadedFile("IMG_0042.HEIC", puffer.getvalue(), "image/heic")
def test_heic_wird_angenommen_und_zu_jpeg(self):
from .bilder import HEIC_LESBAR
self.assertTrue(HEIC_LESBAR, "pillow-heif fehlt — iPhone-Fotos gehen nicht")
sauber = saeubern(self.heic())
self.assertEqual(gelesen(sauber).format, "JPEG")
self.assertTrue(sauber.name.endswith(".jpg"))
def test_heic_verliert_seine_metadaten_genauso(self):
sauber = saeubern(self.heic())
self.assertFalse(dict(gelesen(sauber).getexif()))
def test_heic_ueber_den_app_weg(self):
from .tests import make_camera
make_camera(token="kamera-geheim", mobil=True)
resp = self.client.post(
"/api/hochladen/",
{"bilder": self.heic()},
HTTP_AUTHORIZATION="Bearer kamera-geheim",
)
self.assertEqual(resp.status_code, 201, resp.content)
+101
View File
@@ -0,0 +1,101 @@
"""Dass die Absicherung im Betrieb auch wirklich anspringt.
🔑 **Warum das einen eigenen Test braucht.** Die Einstellungen hängen an
``DEBUG=0``, und in der Entwicklung *und in den Tests* ist ``DJANGO_DEBUG``
standardmäßig ``1``. Die ganze Absicherung läuft also in keinem einzigen
normalen Testlauf — sie stünde richtig in der Datei und wäre nie geprüft.
Genau die Sorte Sicherheitsnetz, die stumm kaputt ist.
Deshalb hier die Einstellungsdatei einmal frisch mit ``DJANGO_DEBUG=0``
einlesen und nachsehen, was dabei herauskommt.
"""
from __future__ import annotations
import importlib
import os
from unittest import mock
from django.test import SimpleTestCase
def betriebs_einstellungen():
"""Lädt ``vogelalbum.settings`` neu, als liefe es im Betrieb."""
umgebung = {
"DJANGO_DEBUG": "0",
# Im Betrieb ist ein eigener Schlüssel Pflicht, sonst bricht der
# Import ab — hier einer, der nur in diesem Test lebt.
"DJANGO_SECRET_KEY": "nur-fuer-diesen-test-" + "x" * 40,
}
with mock.patch.dict(os.environ, umgebung):
modul = importlib.import_module("vogelalbum.settings")
return importlib.reload(modul)
class BetriebsEinstellungenTests(SimpleTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.betrieb = betriebs_einstellungen()
@classmethod
def tearDownClass(cls):
# ⚠️ Zurückladen, sonst sieht jeder nachfolgende Test die
# Betriebsfassung — und scheitert an Dingen, die mit ihm nichts zu
# tun haben.
import vogelalbum.settings
importlib.reload(vogelalbum.settings)
super().tearDownClass()
def test_debug_ist_aus(self):
self.assertFalse(self.betrieb.DEBUG)
def test_cookies_nur_ueber_https(self):
"""🔴 Der Kern: Die Umleitung auf https kommt zu spät.
Traefik antwortet mit 307 — da hat der Browser das Cookie aber schon
mitgeschickt. Erst ``Secure`` hält ihn davon ab.
"""
self.assertTrue(self.betrieb.SESSION_COOKIE_SECURE)
self.assertTrue(self.betrieb.CSRF_COOKIE_SECURE)
def test_hsts_ist_gesetzt(self):
self.assertGreaterEqual(self.betrieb.SECURE_HSTS_SECONDS, 31536000)
def test_hsts_nicht_fuer_fremde_namen(self):
"""Keine Zusagen für Namen, die es nicht gibt, und kein preload.
``includeSubDomains`` gilt auch für Namen, die noch niemand angelegt
hat, und ``preload`` nimmt einem die Entscheidung aus der Hand: Aus
fremden Listen kommt man nur langsam wieder heraus.
"""
self.assertFalse(self.betrieb.SECURE_HSTS_INCLUDE_SUBDOMAINS)
self.assertFalse(self.betrieb.SECURE_HSTS_PRELOAD)
def test_proxy_kopf_wird_ausgewertet(self):
"""Ohne das baut build_absolute_uri() http://-Adressen."""
self.assertEqual(
self.betrieb.SECURE_PROXY_SSL_HEADER,
("HTTP_X_FORWARDED_PROTO", "https"),
)
def test_referrer_bleibt_im_haus(self):
self.assertEqual(self.betrieb.SECURE_REFERRER_POLICY, "same-origin")
def test_in_der_entwicklung_gilt_nichts_davon(self):
"""⚠️ Sonst kann sich auf http://localhost niemand mehr anmelden.
Ein ``Secure``-Cookie wird über einfaches HTTP gar nicht gesetzt — die
Anmeldung schlüge fehl, und die Ursache wäre ausgerechnet die
Einstellung, die Sicherheit bringen soll.
"""
from django.conf import settings
# ⚠️ ``settings.DEBUG`` taugt hier NICHT als Probe: Der Testläufer
# setzt es grundsätzlich auf False, egal was die Datei gerechnet hat.
# Entscheidend ist, was der ``if not DEBUG``-Block beim IMPORT gesehen
# hat — und das war ``DJANGO_DEBUG=1``. Also die Flaggen prüfen.
self.assertFalse(getattr(settings, "SESSION_COOKIE_SECURE", False))
self.assertFalse(getattr(settings, "CSRF_COOKIE_SECURE", False))
self.assertEqual(getattr(settings, "SECURE_HSTS_SECONDS", 0), 0)
+149
View File
@@ -0,0 +1,149 @@
"""Tests für die Zuschnitt-Regel.
🔑 Der Kern der Umstellung: Der Ausschnitt ist eine **Regel**, keine Datei.
Deshalb prüfen diese Tests nicht „ein Bild entsteht", sondern die Eigenschaft,
wegen der wir umgebaut haben — dass sich der Zuschnitt für den ganzen Bestand
ändern lässt, auch rückwirkend.
"""
from __future__ import annotations
import io
from datetime import timedelta
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.utils import timezone
from PIL import Image
from .models import Sighting
from .zuschnitt import quadrat
def foto(groesse=(1800, 2400)) -> SimpleUploadedFile:
im = Image.new("RGB", groesse, (120, 160, 200))
puffer = io.BytesIO()
im.save(puffer, format="JPEG")
return SimpleUploadedFile("v.jpg", puffer.getvalue(), "image/jpeg")
class QuadratTests(TestCase):
"""Die Rechnung allein — ohne Dateien, ohne Datenbank."""
def test_ergebnis_ist_quadratisch(self):
"""Weil die Kachel quadratisch ist. Sonst schneidet sie den Vogel ab."""
x0, y0, x1, y1 = quadrat((0.40, 0.30, 0.52, 0.62), 1800, 2400)
self.assertEqual(x1 - x0, y1 - y0)
def test_der_vogel_passt_hinein(self):
box = (0.40, 0.30, 0.52, 0.62)
x0, y0, x1, y1 = quadrat(box, 1800, 2400)
self.assertLessEqual(x0, box[0] * 1800)
self.assertLessEqual(y0, box[1] * 2400)
self.assertGreaterEqual(x1, box[2] * 1800)
self.assertGreaterEqual(y1, box[3] * 2400)
def test_am_rand_bleibt_das_fenster_im_bild(self):
"""⚠️ Sonst sitzt ein Vogel am Bildrand ausserhalb seiner Kachel."""
for box in ((0.0, 0.0, 0.08, 0.10), (0.92, 0.90, 1.0, 1.0)):
with self.subTest(box=box):
x0, y0, x1, y1 = quadrat(box, 1800, 2400)
self.assertGreaterEqual(x0, 0)
self.assertGreaterEqual(y0, 0)
self.assertLessEqual(x1, 1800)
self.assertLessEqual(y1, 2400)
self.assertEqual(x1 - x0, y1 - y0)
def test_mehr_rand_heisst_groesseres_fenster(self):
"""Das ist die Stellschraube, um die es bei der Umstellung ging."""
eng = quadrat((0.4, 0.3, 0.5, 0.6), 1800, 2400, rand=0.05)
weit = quadrat((0.4, 0.3, 0.5, 0.6), 1800, 2400, rand=0.60)
self.assertGreater(weit[2] - weit[0], eng[2] - eng[0])
def test_fenster_wird_nie_groesser_als_das_bild(self):
x0, y0, x1, y1 = quadrat((0.0, 0.0, 1.0, 1.0), 1800, 2400, rand=2.0)
self.assertLessEqual(x1 - x0, 1800)
self.assertLessEqual(y1 - y0, 2400)
class KachelTests(TestCase):
def sichtung(self, **kw) -> Sighting:
jetzt = timezone.now()
s = Sighting(started_at=jetzt, ended_at=jetzt + timedelta(seconds=5), **kw)
s.image = foto()
s.save()
return s
def test_mit_koordinaten_entsteht_eine_kachel(self):
s = self.sichtung(box_x0=0.40, box_y0=0.30, box_x1=0.52, box_y1=0.62)
self.assertIn("zuschnitte/", s.kachel_url)
def test_die_kachel_ist_quadratisch(self):
s = self.sichtung(box_x0=0.40, box_y0=0.30, box_x1=0.52, box_y1=0.62)
from pathlib import Path
from django.conf import settings
pfad = Path(settings.MEDIA_ROOT) / s.kachel_url.split("/media/")[-1]
with Image.open(pfad) as im:
self.assertEqual(im.width, im.height)
def test_ohne_koordinaten_gilt_der_alte_ausschnitt(self):
"""Altbestand muss anzeigbar bleiben, ohne dass jemand nachzieht."""
s = self.sichtung()
s.crop = foto((300, 300))
s.save()
self.assertEqual(s.kachel_url, s.crop.url)
def test_ohne_alles_bleibt_das_original(self):
s = self.sichtung()
self.assertEqual(s.kachel_url, s.image.url)
def test_halbe_koordinaten_zaehlen_nicht(self):
"""Ein halbes Viereck ist keins — sonst entstünde eine graue Kachel."""
s = self.sichtung(box_x0=0.4, box_y0=0.3, box_x1=None, box_y1=0.62)
self.assertIsNone(s.vogel_box)
def test_verdrehte_kanten_zaehlen_nicht(self):
s = self.sichtung(box_x0=0.6, box_y0=0.3, box_x1=0.4, box_y1=0.62)
self.assertIsNone(s.vogel_box)
class RegelAendernTests(TestCase):
"""🔑 Die Eigenschaft, wegen der umgebaut wurde.
Eine geänderte Regel muss den ganzen Bestand erfassen — nicht nur das, was
danach entsteht. Genau das konnte die alte Lösung nicht.
"""
def test_mehr_rand_erzeugt_eine_neue_kachel_fuer_alte_sichtungen(self):
from . import zuschnitt
jetzt = timezone.now()
s = Sighting(started_at=jetzt, ended_at=jetzt,
box_x0=0.40, box_y0=0.30, box_x1=0.52, box_y1=0.62)
s.image = foto()
s.save()
vorher = s.kachel_url
alt_rand, alt_regel = zuschnitt.RAND, zuschnitt.REGEL
try:
zuschnitt.RAND = 0.8
zuschnitt.REGEL = alt_regel + 1
nachher = s.kachel_url
finally:
zuschnitt.RAND, zuschnitt.REGEL = alt_rand, alt_regel
# Andere Adresse, anderes Bild — ohne dass jemand die Sichtung angefasst hat.
self.assertNotEqual(vorher, nachher)
from pathlib import Path
from django.conf import settings
def kante(url):
with Image.open(Path(settings.MEDIA_ROOT) / url.split("/media/")[-1]) as im:
return im.width
self.assertGreater(kante(nachher), 0)
+1869
View File
File diff suppressed because it is too large. Load diff
+445
View File
@@ -0,0 +1,445 @@
"""Ansichten des Vogelalbum."""
from __future__ import annotations
from collections import Counter
from datetime import timedelta
from django.contrib.admin.views.decorators import staff_member_required
from django.db.models import Count, Max, Q
from django.http import Http404, JsonResponse
from django.conf import settings
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from django.views.decorators.clickjacking import xframe_options_exempt
from . import scoring
from .api import fotos_annehmen
from .bilder import KeinBild
from .models import Badge, Camera, DexEntry, Sighting, Species
from .species_meta import HABITATS, RARITY_COLOR, TYPE_STYLE
def _base_context() -> dict:
return {
"progress": scoring.progress(),
# Der Live-Link in der Navigation erscheint nur, wenn es etwas zu sehen
# gibt — auf der öffentlichen Instanz also nie.
"live_available": Camera.live_cameras().exists(),
# Pflicht aus der AGPL, nicht Dekoration — siehe settings.QUELLTEXT_URL.
"quelltext_url": settings.QUELLTEXT_URL,
}
def blickfang_titel(letzte, heute) -> str:
"""Die Überschrift der Startseite, passend zum letzten Besuch.
🔑 Vorher stand dort fest „Wer war heute im Futterhaus?" — auch wenn der
letzte Gast fünf Tage her war. Eine Überschrift, die etwas behauptet, was
daneben widerlegt wird, kostet mehr Vertrauen als sie Schwung bringt.
Wochentag und Datum kommen über ``date_format``, damit sie der aktiven
Sprache folgen; ein selbst gebautes ``d.m.`` wäre auf Englisch falsch herum.
"""
from django.utils.formats import date_format
if letzte is None:
return _("Noch wartet die Kamera")
tag = timezone.localtime(letzte.started_at).date()
tage = (heute - tag).days
if tage <= 0:
return _("Wer war heute zu Gast?")
if tage == 1:
return _("Zuletzt gestern zu Gast")
if tage < 7:
return _("Zuletzt am %(wochentag)s zu Gast") % {
"wochentag": date_format(tag, "l")
}
return _("Zuletzt am %(datum)s zu Gast") % {
"datum": date_format(tag, "DATE_FORMAT")
}
def type_label(code: str) -> str:
from django.utils.translation import get_language
from .species_meta import TYPE_LABEL_EN
if (get_language() or "de").startswith("en"):
return TYPE_LABEL_EN.get(code, code)
return TYPE_STYLE.get(code, (code,))[0]
def habitat_label(code: str) -> str:
from django.utils.translation import get_language
from .species_meta import HABITATS
paar = HABITATS.get(code)
if not paar:
return code
return paar[1] if (get_language() or "de").startswith("en") else paar[0]
def rarity_choices() -> list[tuple[str, str]]:
"""Seltenheitsstufen mit Beschriftung in der aktiven Sprache."""
from django.utils.translation import get_language
from .models import RARITY_CHOICES
from .species_meta import RARITY_LABEL_EN
if (get_language() or "de").startswith("en"):
return [(k, RARITY_LABEL_EN.get(k, k)) for k, _ in RARITY_CHOICES]
return list(RARITY_CHOICES)
def home(request):
now = timezone.now()
today = timezone.localdate()
recent = (
Sighting.objects.select_related("species", "confirmed_species")
.exclude(species__isnull=True, confirmed_species__isnull=True)
.order_by("-started_at")[:8]
)
# ⚠️ ``bestimmt()``, nicht alles: Spiegelungen auf der Vogeltränke sind
# kein Besuch. Und ``arten_anzahl()`` statt values("species") — sonst
# zaehlt eine von Hand korrigierte Sichtung weiter bei der alten Art.
today_sightings = Sighting.objects.bestimmt().filter(started_at__date=today)
today_species = today_sightings.arten_anzahl()
# Erstfänge der letzten Woche — das ist die Neuigkeit, die man sehen will.
new_entries = (
DexEntry.objects.select_related("species", "best_sighting")
.filter(first_seen__gte=now - timedelta(days=7))
.order_by("-first_seen")[:4]
)
top = (
DexEntry.objects.select_related("species", "best_sighting")
.order_by("-count")[:5]
)
ctx = _base_context() | {
"recent": recent,
"blickfang_titel": blickfang_titel(recent[0] if recent else None, today),
"today_count": today_sightings.count(),
"today_species": today_species,
"new_entries": new_entries,
"top": top,
"badges": Badge.objects.filter(earned__isnull=False).order_by("-earned__earned_at")[:6],
"rarity_color": RARITY_COLOR,
}
return render(request, "dex/home.html", ctx)
def dex(request):
"""Der Katalog. Ungefangene Arten bleiben Silhouette — genau das treibt
die Sammelmotivation."""
entries = {e.species_id: e for e in DexEntry.objects.select_related("best_sighting")}
rarity_filter = request.GET.get("seltenheit", "")
typ_filter = request.GET.get("typ", "")
# Ohne Vorgabe: Garten. 148 Arten auf einmal sind kein Ziel, sondern eine
# Wand — man soll eine Sammlung sehen, die sich füllen lässt.
habitat = request.GET.get("lebensraum", "garten")
if habitat == "alle":
habitat = ""
species = Species.objects.all()
if habitat:
species = species.filter(lebensraum_typ=habitat)
if rarity_filter:
species = species.filter(rarity=rarity_filter)
if typ_filter:
species = species.filter(typ=typ_filter)
cards = [
{"species": s, "entry": entries.get(s.id), "found": s.id in entries}
for s in species
]
# Nach Dex-Nummer, wie die Seitenzahl im Album. Die Nummer ist bereits nach
# Seltenheit vergeben, die Sortierung liest sich also ohnehin sinnvoll —
# und sie bleibt stabil, egal was gefunden wurde.
cards.sort(key=lambda c: c["species"].number or 9999)
# Nur Typen anbieten, die im aktiven Lebensraum auch vorkommen — sonst
# steht im Auswahlfeld "Möwe" neben einer leeren Gartensammlung.
im_bereich = Species.objects.all()
if habitat:
im_bereich = im_bereich.filter(lebensraum_typ=habitat)
zaehlung = Counter(im_bereich.values_list("typ", flat=True))
typen = [
{
"code": code,
"label": type_label(code),
"n": zaehlung[code],
"farbe": TYPE_STYLE[code][1],
"ink": TYPE_STYLE[code][2],
}
for code in TYPE_STYLE
if zaehlung.get(code)
]
habitate = [
{
"code": code,
"label": habitat_label(code),
"n": Species.objects.filter(lebensraum_typ=code).count(),
}
for code in HABITATS
]
habitate = [h for h in habitate if h["n"]]
ctx = _base_context() | {
"cards": cards,
"found_count": sum(1 for c in cards if c["found"]),
"total_count": len(cards),
"rarity_filter": rarity_filter,
"typ_filter": typ_filter,
"habitat": habitat,
"habitate": habitate,
"habitat_label": habitat_label(habitat) if habitat else "",
"gesamt_alle": Species.objects.count(),
"typen": typen,
"rarity_color": RARITY_COLOR,
"rarities": rarity_choices(),
}
return render(request, "dex/dex.html", ctx)
def species_detail(request, slug: str):
species = get_object_or_404(Species, slug=slug)
entry = DexEntry.objects.filter(species=species).select_related("best_sighting").first()
sightings = (
Sighting.objects.filter(Q(species=species) | Q(confirmed_species=species))
.select_related("camera")
.order_by("-started_at")[:24]
)
# Blättern wie im Album: die Nachbarn nach Dex-Nummer.
vorher = Species.objects.filter(number__lt=species.number).order_by("-number").first()
nachher = Species.objects.filter(number__gt=species.number).order_by("number").first()
# Verwechslungsgefahr in Katalogreihenfolge, damit die Reihe stabil bleibt.
aehnliche = list(Species.objects.filter(slug__in=species.verwechslung or []))
aehnliche.sort(key=lambda s: (species.verwechslung or []).index(s.slug))
# Wann ist die Art aktiv? Über die Stunden der bisherigen Besuche.
hours = [0] * 24
for dt in Sighting.objects.filter(
Q(species=species) | Q(confirmed_species=species)
).values_list("started_at", flat=True):
hours[timezone.localtime(dt).hour] += 1
peak = max(hours) or 1
ctx = _base_context() | {
"species": species,
"entry": entry,
"sightings": sightings,
"vorher": vorher,
"nachher": nachher,
"aehnliche": aehnliche,
"hours": [{"h": h, "n": n, "pct": round(100 * n / peak)} for h, n in enumerate(hours)],
"rarity_color": RARITY_COLOR,
}
return render(request, "dex/species.html", ctx)
def sightings(request):
qs = (
Sighting.objects.select_related("species", "confirmed_species", "camera")
.order_by("-started_at")
)
if request.GET.get("nur_bestimmt"):
qs = qs.exclude(species__isnull=True, confirmed_species__isnull=True)
kamera = request.GET.get("kamera", "")
if kamera:
qs = qs.filter(camera__slug=kamera)
try:
page = max(1, int(request.GET.get("seite", 1)))
except ValueError:
page = 1
per_page = 40
total = qs.count()
cameras = list(Camera.objects.filter(active=True))
ctx = _base_context() | {
"sightings": qs[(page - 1) * per_page : page * per_page],
"page": page,
"has_next": page * per_page < total,
"total": total,
"nur_bestimmt": bool(request.GET.get("nur_bestimmt")),
"kamera": kamera,
# Der Filter lohnt sich erst, wenn es mehr als eine Kamera gibt.
"cameras": cameras if len(cameras) > 1 else [],
"rarity_color": RARITY_COLOR,
}
return render(request, "dex/sightings.html", ctx)
def badges(request):
earned_codes = set(
Badge.objects.filter(earned__isnull=False).values_list("code", flat=True)
)
# 🔑 Nur die ZAEHLBAREN Abzeichen haben einen Fortschritt. "Ein Specht
# war da" passiert oder passiert nicht -- ein Balken auf 0 % waere dort
# keine Information, sondern nur entmutigend. Siehe scoring.py.
fortschritt = scoring.fortschritt_je_abzeichen()
rows = []
for badge in Badge.objects.all():
verdient = badge.code in earned_codes
zeile = {
"badge": badge,
"earned": verdient,
"at": badge.earned.earned_at if verdient else None,
}
stand = fortschritt.get(badge.code)
# Bei verdienten Abzeichen keinen Balken mehr -- der stuende
# dauerhaft auf 100 % und sagt nichts.
if stand and not verdient:
ist, ziel = stand
zeile["ist"] = ist
zeile["ziel"] = ziel
zeile["anteil"] = min(100, round(ist * 100 / ziel)) if ziel else 0
rows.append(zeile)
return render(request, "dex/badges.html", _base_context() | {"rows": rows})
@xframe_options_exempt
def widget(request):
"""Kompakte Einbettung für eine fremde Seite — ohne Navigation.
🔴 **Ohne diesen Ausnahme-Dekorator ist das Widget nicht einbettbar**, und
zwar genau entgegen seinem Zweck: Djangos ``XFrameOptionsMiddleware``
schickt über jede Antwort ``X-Frame-Options: DENY``, also auch über diese.
Der Docstring sagte „iframe-tauglich", die Antwort sagte das Gegenteil —
gefunden am 14.10.2026 an den echten Kopfzeilen der Live-Instanz, nicht im
Code.
⚠️ Der Schutz bleibt für alles andere bestehen; ausgenommen ist nur die
eine Seite, die eingebettet werden soll. Sie zeigt nichts als die letzte
Sichtung und hat kein Formular — es gibt hier nichts zu erschleichen.
"""
latest = (
Sighting.objects.select_related("species", "confirmed_species")
.exclude(species__isnull=True, confirmed_species__isnull=True)
.order_by("-started_at")
.first()
)
ctx = _base_context() | {"latest": latest, "rarity_color": RARITY_COLOR}
return render(request, "dex/widget.html", ctx)
def live(request):
"""Livebilder der Kameras, die im Admin dafür freigegeben sind.
Gibt es keine, ist die Seite nicht da — 404 statt einer leeren Seite mit
einem Versprechen, das niemand eingelöst hat. Der Link in der Navigation
erscheint aus demselben Grund nur, wenn es etwas zu sehen gibt.
"""
kameras = list(Camera.live_cameras())
if not kameras:
raise Http404("Kein Livebild freigegeben")
return render(request, "dex/live.html", _base_context() | {"cameras": kameras})
def api_stats(request):
"""Kleiner JSON-Endpunkt, damit die Homepage live tickern kann."""
p = scoring.progress()
latest = (
Sighting.objects.select_related("species", "confirmed_species")
.exclude(species__isnull=True, confirmed_species__isnull=True)
.order_by("-started_at")
.first()
)
return JsonResponse(
{
"level": p.level,
"rang": p.rank,
"xp": p.xp,
"arten_gesehen": p.species_seen,
"arten_gesamt": p.species_total,
"sichtungen": p.sightings,
"letzte": (
{
"art": latest.effective_species.german,
"zeitpunkt": latest.started_at.isoformat(),
"confidence": round(latest.confidence, 3),
"bild": (
request.build_absolute_uri(latest.crop.url)
if latest.crop
else None
),
}
if latest and latest.effective_species
else None
),
}
)
# ⚠️ **Hinter dem Admin-Login, und das ist keine Bequemlichkeit.** Die Instanz
# darf öffentlich stehen — ein offenes Upload-Formular wäre dann eine
# Einladung, fremde Bilder in eine fremde Sammlung zu legen, und der
# Detector würde sie auch noch brav verarbeiten. Das vorhandene
# Django-Login reicht dafür; das Projekt braucht keine eigene
# Nutzerverwaltung, und niemand soll sich eins anlegen müssen.
@staff_member_required
def hochladen(request):
"""Fotos von Hand einliefern — der Weg ohne App.
🔑 Benutzt dieselbe Warteschlange wie die spätere App (``fotos_annehmen``).
Das ist der eigentliche Zweck dieser Seite: Funktioniert sie, ist die
ganze Kette bewiesen — annehmen, warten, abholen, bestimmen, sammeln —
ohne dass eine Zeile App existiert. Dasselbe Argument wie bei der
Home-Assistant-Brücke, die über die Schnittstelle geht statt an die
Datenbank.
"""
ctx = _base_context()
ctx["kameras"] = Camera.objects.filter(mobil=True).order_by("name")
if request.method == "POST":
dateien = request.FILES.getlist("bilder")
if not dateien:
ctx["fehler"] = _("Bitte mindestens ein Bild auswählen.")
else:
kamera = None
if request.POST.get("kamera"):
kamera = Camera.objects.filter(
slug=request.POST["kamera"], mobil=True).first()
try:
angelegt = fotos_annehmen(
dateien,
camera=kamera,
latitude=request.POST.get("latitude"),
longitude=request.POST.get("longitude"),
)
except KeinBild as problem:
# Dem Menschen am Formular sagen, was nicht ging — nicht eine
# Fehlerseite zeigen und ihn raten lassen.
ctx["fehler"] = _("Das war kein brauchbares Bild: %(grund)s") % {
"grund": problem,
}
else:
# Nach dem Hochladen umleiten, damit ein Neuladen der Seite die
# Bilder nicht ein zweites Mal einliefert.
return redirect(f"{reverse('hochladen')}?angelegt={len(angelegt)}")
try:
ctx["angelegt"] = int(request.GET.get("angelegt", 0))
except ValueError:
ctx["angelegt"] = 0
ctx["wartend"] = Sighting.objects.wartend().count()
ctx["wartende_fotos"] = Sighting.objects.wartend().select_related("camera")[:12]
return render(request, "dex/hochladen.html", ctx)
+120
View File
@@ -0,0 +1,120 @@
"""Wie ein Vogelfoto in einer Kachel aussieht.
🔑 **Der Zuschnitt ist eine Regel, keine Datei.** Das Original liegt ohnehin
auf der Platte, und wo der Vogel darin sitzt, steht als vier Zahlen an der
Sichtung. Daraus lässt sich der Ausschnitt jederzeit neu rechnen — bei jedem
Aufruf, für jede Sichtung, auch für die von vorletztem Jahr.
Vorher war es umgekehrt: Der Detector schnitt ein zweites Bild zu und legte es
daneben. Damit war der Zuschnitt für immer festgelegt. Eine bessere Regel half
nur neuen Sichtungen, alle vorhandenen hätte man einzeln nachziehen müssen —
und das auf jeder Anlage, die es irgendwo gibt.
⚠️ **Der Unterschied ist nicht Bequemlichkeit, sondern Benutzbarkeit.** An
Handyfotos fiel auf, woran die alte Lösung scheitert: Dort ist der Vogel klein
im Bild, der enge Ausschnitt entsprechend schmal und hoch. In einer
quadratischen Kachel blieb davon die Bauchmitte übrig — formatfüllend und
unscharf, ohne Kopf und ohne Schwanz.
Das Ergebnis wird als Datei zwischengespeichert, weil Zuschneiden Rechenzeit
kostet und ein Album viele Kacheln hat. **Der Zwischenspeicher ist
wegwerfbar**: Löscht man ihn, entsteht beim nächsten Aufruf alles neu — und
genau das ist der Weg, eine geänderte Regel auszurollen.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from django.conf import settings
from PIL import Image
# Wie viel Luft um den Vogel. 0.35 heißt: Die Kante des Ausschnitts ist 35 %
# der Vogelgröße von ihm entfernt.
#
# 🔑 Diese Zahl ist der ganze Sinn der Umstellung. Sie zu ändern und den
# Zwischenspeicher zu leeren ändert jede Kachel im Bestand.
RAND = 0.35
# Kantenlänge des erzeugten Bildes. Die Kacheln sind kleiner; größer zu
# speichern lohnt nur für Bildschirme mit hoher Punktdichte.
KANTE = 600
# Unterordner im Medienverzeichnis. Nicht ins Repo, nicht in die Sicherung —
# alles hier lässt sich aus Original und Koordinaten neu erzeugen.
ORDNER = "zuschnitte"
# Steigt diese Zahl, bekommen alle Dateien neue Namen und werden neu
# gerechnet. So rollt man eine geänderte Regel aus, ohne etwas zu löschen.
REGEL = 1
def _ziel(sichtung) -> Path:
kennung = hashlib.sha1(
f"{sichtung.pk}-{sichtung.image.name}-{RAND}-{KANTE}-{REGEL}".encode()
).hexdigest()[:16]
return Path(settings.MEDIA_ROOT) / ORDNER / f"{sichtung.pk}-{kennung}.jpg"
def quadrat(box: tuple[float, float, float, float], breite: int, hoehe: int,
rand: float = RAND) -> tuple[int, int, int, int]:
"""Aus relativen Vogel-Koordinaten ein quadratisches Fenster in Pixeln.
⚠️ **Die Mitte wird nachgeführt, nicht der Ausschnitt gestaucht.** Sitzt
der Vogel am Bildrand, verschiebt sich das Fenster nach innen, statt
schmaler zu werden — sonst säße er außermittig in seiner eigenen Kachel.
"""
x0, y0, x1, y1 = box
bx0, by0, bx1, by1 = x0 * breite, y0 * hoehe, x1 * breite, y1 * hoehe
bw, bh = max(1.0, bx1 - bx0), max(1.0, by1 - by0)
seite = min(max(bw, bh) * (1 + 2 * rand), float(breite), float(hoehe))
mx = min(max((bx0 + bx1) / 2, seite / 2), breite - seite / 2)
my = min(max((by0 + by1) / 2, seite / 2), hoehe - seite / 2)
return (
int(mx - seite / 2), int(my - seite / 2),
int(mx + seite / 2), int(my + seite / 2),
)
def adresse(sichtung) -> str | None:
"""Adresse der Kachel für diese Sichtung — oder ``None``.
Erzeugt das Bild beim ersten Abruf. Gibt es keine Koordinaten (Sichtungen
von vor der Umstellung), fällt die Vorlage auf das gespeicherte ``crop``
zurück; dafür liefert diese Funktion ``None``.
"""
box = sichtung.vogel_box
if box is None or not sichtung.image:
return None
ziel = _ziel(sichtung)
if not ziel.exists():
try:
_erzeugen(sichtung, box, ziel)
except (OSError, ValueError):
# ⚠️ Ein fehlendes Original darf keine Seite zerreißen. Dann gilt
# der Rückfall, genau wie bei einer Sichtung ohne Koordinaten.
return None
return f"{settings.MEDIA_URL}{ORDNER}/{ziel.name}"
def _erzeugen(sichtung, box, ziel: Path) -> None:
ziel.parent.mkdir(parents=True, exist_ok=True)
with sichtung.image.open("rb") as f:
bild = Image.open(f)
bild.load()
if bild.mode != "RGB":
bild = bild.convert("RGB")
ausschnitt = bild.crop(quadrat(box, bild.width, bild.height))
if ausschnitt.width > KANTE:
ausschnitt = ausschnitt.resize((KANTE, KANTE), Image.LANCZOS)
# Erst daneben schreiben, dann umbenennen: Zwei gleichzeitige Abrufe
# dürfen sich nicht gegenseitig eine halbe Datei hinterlassen.
vorlaeufig = ziel.with_suffix(".teil")
ausschnitt.save(vorlaeufig, format="JPEG", quality=86, optimize=True)
vorlaeufig.replace(ziel)
+689
View File
@@ -0,0 +1,689 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: Vogelalbum\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-10 12:07+0200\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: dex/admin.py:28
msgid ""
"Der Detector holt sich diese Werte selbst — er fragt <code>/api/config/</"
"code> mit seinem Token und übernimmt Änderungen im laufenden Betrieb, ohne "
"Neustart. Eine Stream-Adresse, die auf seiner Kommandozeile steht, behält "
"aber Vorrang."
msgstr "The detector fetches these values itself — it asks <code>/api/config/</code> with its token and applies changes while running, without a restart. A stream address given on its command line still takes precedence."
#: dex/admin.py:38
msgid ""
"Das Livebild kommt vom Live-Verteiler des Detectors, nicht von der Kamera "
"selbst — die liefert nur einen Stream, und den braucht die Erkennung. "
"Starten mit <code>python -m detector watch … --live-port 8131</code>."
msgstr "The live image comes from the detector's relay, not from the camera itself — the camera serves only one stream, and the recognition needs it. Start it with <code>python -m detector watch … --live-port 8131</code>."
#: dex/admin.py:61
msgid "gesperrt (VOGELALBUM_LIVE_ERLAUBT=0)"
msgstr "locked (VOGELALBUM_LIVE_ERLAUBT=0)"
#: dex/admin.py:63
msgid "aus"
msgstr "off"
#: dex/admin.py:65
msgid "an, aber keine Adresse"
msgstr "on, but no address"
#: dex/admin.py:67
msgid "an, aber Kamera stillgelegt"
msgstr "on, but camera disabled"
#: dex/admin.py:68
msgid "an"
msgstr "on"
#: dex/models.py:551
msgid "Gewicht"
msgstr "Weight"
#: dex/models.py:552
msgid "Spannweite"
msgstr "Wingspan"
#: dex/models.py:557
msgid "Größe"
msgstr "Length"
#: dex/models.py:558
msgid "Seltenheit"
msgstr "Rarity"
#: dex/scoring.py:25
msgid "Nestling"
msgstr "Nestling"
#: dex/scoring.py:26
msgid "Ästling"
msgstr "Fledgling"
#: dex/scoring.py:27
msgid "Spatzenkenner"
msgstr "Sparrow Spotter"
#: dex/scoring.py:28
msgid "Meisenmeister"
msgstr "Tit Master"
#: dex/scoring.py:29
msgid "Federfuchser"
msgstr "Feather Nerd"
#: dex/scoring.py:30
msgid "Amselflüsterer"
msgstr "Blackbird Whisperer"
#: dex/scoring.py:31
msgid "Spechtspäher"
msgstr "Woodpecker Watcher"
#: dex/scoring.py:32
msgid "Falkenauge"
msgstr "Falcon Eye"
#: dex/scoring.py:33
msgid "Vogelmeister"
msgstr "Bird Master"
#: dex/scoring.py:34
msgid "Legende des Vogelhauses"
msgstr "Legend of the Feeder"
#: dex/scoring.py:112
msgid "Erster Gast"
msgstr "First Guest"
#: dex/scoring.py:113
msgid "Die allererste Sichtung im Vogelhaus."
msgstr "The very first sighting at the feeder."
#: dex/scoring.py:114
msgid "Fünf Freunde"
msgstr "High Five"
#: dex/scoring.py:115
msgid "5 verschiedene Arten gesammelt."
msgstr "5 different species collected."
#: dex/scoring.py:116
msgid "Zweistellig"
msgstr "Double Digits"
#: dex/scoring.py:117
msgid "10 verschiedene Arten gesammelt."
msgstr "10 different species collected."
#: dex/scoring.py:118
msgid "Sammler"
msgstr "Collector"
#: dex/scoring.py:119
msgid "25 verschiedene Arten gesammelt."
msgstr "25 different species collected."
#: dex/scoring.py:120
msgid "Vogelalbum-Meister"
msgstr "Vogelalbum Master"
#: dex/scoring.py:121
msgid "40 verschiedene Arten gesammelt."
msgstr "40 different species collected."
#: dex/scoring.py:122
msgid "Frühaufsteher"
msgstr "Early Bird"
#: dex/scoring.py:123
msgid "Eine Sichtung vor 6 Uhr morgens."
msgstr "A sighting before 6 in the morning."
#: dex/scoring.py:124
msgid "Nachtschicht"
msgstr "Night Shift"
#: dex/scoring.py:125
msgid "Eine Sichtung nach 21 Uhr."
msgstr "A sighting after 9 in the evening."
#: dex/scoring.py:126
msgid "Volles Haus"
msgstr "Full House"
#: dex/scoring.py:127
msgid "5 verschiedene Arten an einem Tag."
msgstr "5 different species in one day."
#: dex/scoring.py:128
msgid "Stammgast"
msgstr "Regular"
#: dex/scoring.py:129
msgid "7 Tage in Folge mindestens eine Sichtung."
msgstr "At least one sighting on 7 consecutive days."
#: dex/scoring.py:130
msgid "Dauergast"
msgstr "Long Stay"
#: dex/scoring.py:131
msgid "Ein Besuch, der länger als eine Minute dauerte."
msgstr "A visit lasting longer than a minute."
#: dex/scoring.py:132
msgid "Hundert Besuche"
msgstr "One Hundred Visits"
#: dex/scoring.py:133
msgid "100 Sichtungen aufgezeichnet."
msgstr "100 sightings recorded."
#: dex/scoring.py:134
msgid "Tausend Besuche"
msgstr "One Thousand Visits"
#: dex/scoring.py:135
msgid "1000 Sichtungen aufgezeichnet."
msgstr "1000 sightings recorded."
#: dex/scoring.py:136
msgid "Klopfgeist"
msgstr "The Knocker"
#: dex/scoring.py:137
msgid "Ein Specht war da."
msgstr "A woodpecker came by."
#: dex/scoring.py:138
msgid "Greifvogel-Alarm"
msgstr "Raptor Alert"
#: dex/scoring.py:139
msgid "Ein Greifvogel oder eine Eule vor der Linse."
msgstr "A bird of prey or an owl in front of the lens."
#: dex/scoring.py:140
msgid "Seltener Gast"
msgstr "Rare Guest"
#: dex/scoring.py:141
msgid "Eine Art der Stufe 'Selten' oder höher."
msgstr "A species rated 'Rare' or above."
#: dex/templatetags/dex_extras.py:76
#, python-format
msgid "heute, %(uhrzeit)s"
msgstr "today, %(uhrzeit)s"
#: dex/templatetags/dex_extras.py:78
#, python-format
msgid "gestern, %(uhrzeit)s"
msgstr "yesterday, %(uhrzeit)s"
#: dex/templatetags/dex_extras.py:80
#, python-format
msgid "%(wochentag)s, %(uhrzeit)s"
msgstr "%(wochentag)s, %(uhrzeit)s"
#: dex/templatetags/dex_extras.py:84
#, python-format
msgid "%(datum)s, %(uhrzeit)s"
msgstr "%(datum)s, %(uhrzeit)s"
#: dex/views.py:47
msgid "Noch wartet die Kamera"
msgstr "The camera is still waiting"
#: dex/views.py:52
msgid "Wer war heute zu Gast?"
msgstr "Who stopped by today?"
#: dex/views.py:54
msgid "Zuletzt gestern zu Gast"
msgstr "Last visitor: yesterday"
#: dex/views.py:56
#, python-format
msgid "Zuletzt am %(wochentag)s zu Gast"
msgstr "Last visitor: %(wochentag)s"
#: dex/views.py:59
#, python-format
msgid "Zuletzt am %(datum)s zu Gast"
msgstr "Last visitor: %(datum)s"
#: dex/views.py:400
msgid "Bitte mindestens ein Bild auswählen."
msgstr "Please select at least one image."
#: templates/dex/badges.html:4 templates/dex/badges.html:39
#: templates/dex/base.html:81
msgid "Abzeichen"
msgstr "Badges"
#: templates/dex/badges.html:39
#, python-format
msgid "%(n)s verdient"
msgstr "%(n)s earned"
#: templates/dex/base.html:7
msgid ""
"Vögel erkennen, sammeln und katalogisieren — auf dem eigenen Server, ohne "
"Cloud."
msgstr "Identify, collect and catalogue birds — on your own server, without a cloud."
#: templates/dex/base.html:74 templates/dex/live.html:4
#: templates/dex/live.html:8
msgid "Live"
msgstr "Live"
#: templates/dex/base.html:78
msgid "Übersicht"
msgstr "Overview"
#: templates/dex/base.html:79 templates/dex/dex.html:4
msgid "Sammlung"
msgstr "Collection"
#: templates/dex/base.html:80 templates/dex/sightings.html:4
#: templates/dex/sightings.html:28
msgid "Sichtungen"
msgstr "Sightings"
#: templates/dex/base.html:97
msgid "Zwischen hellem und dunklem Modus wechseln"
msgstr "Switch between light and dark mode"
#: templates/dex/base.html:122 templates/dex/base.html:123
msgid "Einstellungen"
msgstr "Settings"
#: templates/dex/base.html:139 templates/dex/home.html:42
#, python-format
msgid "%(xp)s XP · noch %(rest)s XP bis Level %(lvl)s"
msgstr "%(xp)s XP · %(rest)s XP to level %(lvl)s"
#: templates/dex/base.html:143
msgid "Arten gesammelt"
msgstr "species collected"
#: templates/dex/base.html:168
msgid "Dieses Vogelalbum füllt sich von allein."
msgstr "This bird album fills itself."
#: templates/dex/base.html:169
msgid ""
"Eine Kamera am Futterhaus, der Rest passiert auf deinem eigenen Server — "
"ohne Cloud, ohne Konto, ohne Abo. Freie Software zum Nachbauen."
msgstr "A camera at the feeder, everything else happens on your own server — no cloud, no account, no subscription. Free software, built to be rebuilt."
#: templates/dex/base.html:172
msgid "Projekt ansehen"
msgstr "View the project"
#: templates/dex/dex.html:8
msgid "Die Sammlung"
msgstr "The Collection"
#: templates/dex/dex.html:10
#, python-format
msgid "%(a)s von %(b)s Arten"
msgstr "%(a)s of %(b)s species"
#: templates/dex/dex.html:22 templates/dex/species.html:80
msgid "Lebensraum"
msgstr "Habitat"
#: templates/dex/dex.html:27
msgid "Alle"
msgstr "All"
#: templates/dex/dex.html:36
msgid "Stufe"
msgstr "Tier"
#: templates/dex/dex.html:38
msgid "Alle Stufen"
msgstr "All tiers"
#: templates/dex/dex.html:46
msgid "Gruppe"
msgstr "Group"
#: templates/dex/dex.html:48
msgid "Alle Gruppen"
msgstr "All groups"
#: templates/dex/dex.html:55
msgid "Anwenden"
msgstr "Apply"
#: templates/dex/dex.html:58
msgid "Zurücksetzen"
msgstr "Reset"
#: templates/dex/dex.html:70
msgid "Noch nicht gesichtet"
msgstr "Not yet sighted"
#: templates/dex/dex.html:95
msgid "Keine Art passt zu dieser Auswahl."
msgstr "No species matches this selection."
#: templates/dex/hochladen.html:4 templates/dex/hochladen.html:76
msgid "Fotos hochladen"
msgstr "Upload photos"
#: templates/dex/hochladen.html:85
msgid ""
"Ein Vogel unterwegs? Foto hier einliefern — der Detector bestimmt es beim "
"nächsten Durchgang und trägt es in die Sammlung ein."
msgstr "Spotted a bird? Hand in a photo here — the detector identifies it on its next pass and adds it to the collection."
#: templates/dex/hochladen.html:90
#, python-format
msgid "%(n)s Foto angenommen. Es wartet auf den Detector."
msgid_plural "%(n)s Fotos angenommen. Sie warten auf den Detector."
msgstr[0] "%(n)s photo accepted. It is waiting for the detector."
msgstr[1] "%(n)s photos accepted. They are waiting for the detector."
#: templates/dex/hochladen.html:101
msgid "Bilder"
msgstr "Images"
#: templates/dex/hochladen.html:109
msgid "Mehrere Bilder auf einmal sind möglich. Jedes wird einzeln bestimmt."
msgstr "You can pick several images at once. Each one is identified separately."
#: templates/dex/hochladen.html:114
msgid "Gerät"
msgstr "Device"
#: templates/dex/hochladen.html:116
msgid "— ohne Zuordnung —"
msgstr "— unassigned —"
#: templates/dex/hochladen.html:125
msgid "Ort (freiwillig)"
msgstr "Location (optional)"
#: templates/dex/hochladen.html:127
msgid "Breite, z. B. 49.49"
msgstr "Latitude, e.g. 49.49"
#: templates/dex/hochladen.html:128
msgid "Länge, z. B. 8.47"
msgstr "Longitude, e.g. 8.47"
#: templates/dex/hochladen.html:131
msgid ""
"Wird auf etwa einen Kilometer gerundet gespeichert — die Gegend, nicht die "
"Adresse."
msgstr "Stored rounded to about a kilometre — the area, not the address."
#: templates/dex/hochladen.html:136
msgid "Hochladen"
msgstr "Upload"
#: templates/dex/hochladen.html:142
msgid "Warten auf Bestimmung"
msgstr "Awaiting identification"
#: templates/dex/hochladen.html:154
msgid "Noch nicht bestimmt"
msgstr "Not identified yet"
#: templates/dex/hochladen.html:160
msgid "Bleiben die Bilder länger stehen, läuft der Detector nicht."
msgstr "If images sit here for a while, the detector is not running."
#: templates/dex/home.html:4
msgid "Vogelalbum — Übersicht"
msgstr "Vogelalbum — Overview"
#: templates/dex/home.html:33
msgid ""
"Jeder Vogel wird bestimmt, gezählt und gesammelt — aus dem Kamerabild oder "
"vom Handyfoto. Alles auf dem eigenen Server."
msgstr "Every bird is identified, counted and collected — from a camera frame or a photo off your phone. All on your own server."
#: templates/dex/home.html:46 templates/dex/widget.html:37
msgid "Arten"
msgstr "species"
#: templates/dex/home.html:59
msgid "ZULETZT"
msgstr "LATEST"
#: templates/dex/home.html:67
msgid "Noch kein Gast aufgezeichnet."
msgstr "No visitor recorded yet."
#: templates/dex/home.html:68
msgid "Der erste bestimmte Vogel steht hier."
msgstr "The first identified bird shows up here."
#: templates/dex/home.html:75
msgid "Neu in der Sammlung"
msgstr "New in the collection"
#: templates/dex/home.html:75
msgid "letzte 7 Tage"
msgstr "last 7 days"
#: templates/dex/home.html:92
msgid "Neu"
msgstr "New"
#: templates/dex/home.html:100
msgid "Zuletzt zu Besuch"
msgstr "Latest visitors"
#: templates/dex/home.html:101
#, python-format
msgid "%(n)s Besuche insgesamt"
msgstr "%(n)s visits in total"
#: templates/dex/home.html:120 templates/dex/sightings.html:75
#: templates/dex/species.html:127
msgid "Erstfang"
msgstr "First catch"
#: templates/dex/home.html:129
msgid "Noch keine Sichtung aufgezeichnet."
msgstr "No sightings recorded yet."
#: templates/dex/home.html:130
msgid ""
"Sobald der Detector einen Vogel erkennt oder das erste Foto eingeliefert "
"ist, erscheint hier der erste Gast."
msgstr "As soon as the detector spots a bird or the first photo is handed in, the first visitor appears here."
#: templates/dex/home.html:135
msgid "Stammgäste"
msgstr "Regulars"
#: templates/dex/live.html:10
#, python-format
msgid "%(n)s Kamera"
msgid_plural "%(n)s Kameras"
msgstr[0] "%(n)s camera"
msgstr[1] "%(n)s cameras"
#: templates/dex/live.html:51
msgid ""
"Bleibt das Bild schwarz, läuft der Detector nicht — die Erkennung verteilt "
"das Livebild aus ihrem eigenen Bildstrom."
msgstr "If the image stays black the detector is not running — the live view is relayed from the recognition's own frame stream."
#: templates/dex/sightings.html:28 templates/dex/species.html:114
#, python-format
msgid "%(n)s insgesamt"
msgstr "%(n)s in total"
#: templates/dex/sightings.html:35
msgid "Foto hochladen"
msgstr "Upload photo"
#: templates/dex/sightings.html:40 templates/dex/species.html:114
msgid "Alle Besuche"
msgstr "All visits"
#: templates/dex/sightings.html:41
msgid "Nur bestimmte Arten"
msgstr "Identified only"
#: templates/dex/sightings.html:46
msgid "Alle Kameras"
msgstr "All cameras"
#: templates/dex/sightings.html:71
msgid "von Hand"
msgstr "by hand"
#: templates/dex/sightings.html:73
msgid "Unbestimmt"
msgstr "Unidentified"
#: templates/dex/sightings.html:84
msgid "Alternativen"
msgstr "Alternatives"
#: templates/dex/sightings.html:99
msgid "Neuer"
msgstr "Newer"
#: templates/dex/sightings.html:100
msgid "Älter"
msgstr "Older"
#: templates/dex/sightings.html:103
msgid "Noch nichts aufgezeichnet."
msgstr "Nothing recorded yet."
#: templates/dex/species.html:28
msgid "Bestes eigenes Foto"
msgstr "Best own photo"
#: templates/dex/species.html:30
msgid "Referenzfoto — diese Art war noch nicht vor unserer Kamera."
msgstr "Reference photo — this species has not been in front of our camera yet."
#: templates/dex/species.html:32
msgid "Foto"
msgstr "Photo"
#: templates/dex/species.html:50
#, python-format
msgid "%(n)s× seen"
msgid_plural "%(n)s× seen"
msgstr[0] "%(n)s× seen"
msgstr[1] "%(n)s× seen"
#: templates/dex/species.html:69 templates/dex/species.html:74
msgid "Steckbrief"
msgstr "Profile"
#: templates/dex/species.html:83
msgid "Nahrung"
msgstr "Diet"
#: templates/dex/species.html:86
msgid "Stimme"
msgstr "Voice"
#: templates/dex/species.html:89
msgid "Zugverhalten"
msgstr "Migration"
#: templates/dex/species.html:94
msgid "Verwechslungsgefahr"
msgstr "Easily confused with"
#: templates/dex/species.html:94
msgid "Arten, die man nicht auf Anhieb unterscheidet"
msgstr "species you will not tell apart at a glance"
#: templates/dex/species.html:106
#, python-format
msgid "Wann ist %(n)s da?"
msgstr "When does the %(n)s show up?"
#: templates/dex/species.html:106
msgid "Besuche über den Tag"
msgstr "visits across the day"
#: templates/dex/species.html:110
msgid "0 Uhr"
msgstr "midnight"
#: templates/dex/species.html:130
#, python-format
msgid "%(a)s von %(b)s Bildern mit Vogel"
msgstr "%(a)s of %(b)s frames with a bird"
#: templates/dex/species.html:143
msgid "Noch nicht gesammelt."
msgstr "Not collected yet."
#: templates/dex/species.html:144
#, python-format
msgid ""
"Sobald %(n)s vor der Kamera landet, wird dieser Eintrag freigeschaltet — mit "
"eurem eigenen Foto."
msgstr "Once a %(n)s lands in front of the camera this entry unlocks — with your own photo."
#: templates/dex/widget.html:25
msgid "Zuletzt gesehen"
msgstr "Last seen"
#: templates/dex/widget.html:27
msgid "sicher"
msgstr "confident"
#: templates/dex/widget.html:32
msgid "Noch kein Gast"
msgstr "No visitor yet"
#~ msgid ""
#~ "Vogelalbum · Arterkennung auf dem eigenen Server. Keine Cloud, keine "
#~ "fremden Dienste."
#~ msgstr ""
#~ "Vogelalbum · Species recognition on your own server. No cloud, no third-"
#~ "party services."
#~ msgid ""
#~ "Eine Kamera am Vogelhaus, die Erkennung auf dem eigenen Server. Jeder "
#~ "Gast wird bestimmt, gezählt und gesammelt."
#~ msgstr ""
#~ "A camera at the bird house, recognition on your own server. Every visitor "
#~ "is identified, counted and collected."
#~ msgid "Sobald sich etwas im Vogelhaus rührt, steht er hier."
#~ msgstr "As soon as something stirs at the bird house, it will show up here."
#~ msgid "Zuletzt im Vogelhaus"
#~ msgstr "Latest at the feeder"
#~ msgid "Besuche heute"
#~ msgstr "Visits today"
#~ msgid "Arten heute"
#~ msgstr "Species today"
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vogelalbum.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+781
View File
@@ -0,0 +1,781 @@
/* Vogelalbum — eigenständiges Stylesheet, kein Build-Schritt.
*
* Zwei Modi über Farbtokens: jede Farbe existiert einmal hell und einmal
* dunkel, umgeschaltet wird nur die Zuordnung. Deshalb steht unten kein
* einziger Farbwert mehr direkt in einer Regel.
*
* Die Akzentfarbe kommt vom Typ der Art, nicht global — dadurch fühlt sich
* jede Artseite anders an. Die typabhängigen Töne werden in
* dex/species_meta.py vorberechnet und als Inline-Variablen mitgegeben, in
* beiden Modi (Endung -l und -d). Die Indirektion weiter unten wählt aus.
*
* Keine modernen Funktionen ohne Not: kein color-mix (Safari ab 16.2), kein
* aspect-ratio (Safari ab 15). Beides fällt auf älteren Geräten ersatzlos weg
* und reißt das Layout auseinander — Bildverhältnisse laufen über padding-top.
*/
/* Die Palette kommt aus der Marke: tiefes Grün und Creme. Vorher war das
* Grün der Oberfläche ein anderes als das des Zeichens — nebeneinander sah
* man den Unterschied sofort, und die Seite wirkte kühl und beliebig.
* Papierweiß statt Blauweiß, damit Fotos von Vögeln warm wirken. */
:root {
--bg: #fbf8ef;
--bg-grad: #f4eddd;
--surface: #ffffff;
--surface-2: #f3ece0;
--line: #e7dfcc;
--text: #14281c;
--text-2: #5c6b60;
--muted: #7a8a7f;
--accent: #0f5f3c;
--accent-2: #0a3f28;
--accent-wash: #e6f0e9;
--accent-on: #fcf2d3;
/* Der Kopf ist dunkel, damit die Seite nicht als weißes Blatt beginnt. */
--kopf-bg: #0a3f28;
--kopf-text: #fcf2d3;
--kopf-text-2: #a8ceb8;
--kopf-an: rgba(255,255,255,.15);
--kopf-linie: rgba(255,255,255,.22);
--gold: #cf9a1c;
--gold-wash: #fbf3dc;
--gold-line: #ecd9a0;
--shadow: 0 1px 2px rgba(22,33,26,.05), 0 8px 20px -12px rgba(22,33,26,.18);
--shadow-lift: 0 2px 4px rgba(22,33,26,.07), 0 16px 32px -16px rgba(22,33,26,.26);
--nr-bg: rgba(255,255,255,.92);
--head-bg: rgba(255,255,255,.86);
--locked-opacity: .30;
--r-sm: 10px;
--r: 16px;
--r-lg: 24px;
--font: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--typ: #7f9b6a;
--typ-ink: #ffffff;
}
/* Typabhängige Töne werden je Modus direkt am Verwendungsort gewählt.
Der naheliegende Umweg über eine Variable auf :root funktioniert nicht:
var() in einer Custom-Property-Deklaration wird auf dem deklarierenden
Element ersetzt, und dort gibt es die -l/-d-Werte der Karte nicht. */
[data-theme="dark"] {
/* Kühles Blaugrau statt Dunkelgrün: neutral genug, dass 16 Typfarben
darauf nebeneinander funktionieren, ohne sich mit dem Grund zu beißen. */
--bg: #10151b;
--bg-grad: #141b23;
--surface: #1a2129;
--surface-2: #222b35;
--line: #2e3945;
--text: #e8eef4;
--text-2: #b3c1cd;
--muted: #7e8f9e;
--accent: #45c98a;
--accent-2: #2f9d68;
--accent-wash: #163024;
--accent-on: #08150d;
--gold: #e8be5a;
--gold-wash: #2a2415;
--gold-line: #4a4026;
/* Auf dunklem Grund trägt nicht der Schatten, sondern die Kante. */
--shadow: 0 1px 2px rgba(0,0,0,.4);
--shadow-lift: 0 10px 30px -14px rgba(0,0,0,.75);
--nr-bg: rgba(10,14,19,.82);
--head-bg: rgba(16,21,27,.86);
--locked-opacity: .26;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--font);
color: var(--text);
background: linear-gradient(180deg, var(--bg-grad) 0%, var(--bg) 420px);
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
img { max-width: 100%; }
/* ⚠ padding-inline, nicht die Kurzform ``padding``. Der Kopf traegt
``class="wrap top-inner"``, und ``.top-inner`` setzt den senkrechten
Abstand (12px). Eine Kurzform hier setzt den auf 0 zurueck — genau das
ist auf Telefonen passiert: Marke und Navigation klebten oben und unten
an der Kante der gruenen Leiste. */
.wrap { max-width: 1120px; margin: 0 auto; padding-inline: 20px; }
/* ---------- Kopf ---------- */
/* 🔑 Der Kopf ist dunkel, nicht durchscheinend hell. Die Seite begann vorher
als weisses Blatt und wirkte dadurch unfertig; eine dunkle Leiste gibt ihr
oben einen Halt und laesst die Fotos darunter waermer wirken. */
header.top {
position: sticky; top: 0; z-index: 30;
background: var(--kopf-bg);
color: var(--kopf-text);
}
.top-inner { display: flex; align-items: center; gap: 14px; padding: 12px 0; flex-wrap: wrap; }
.brand { font-weight: 800; font-size: 21px; letter-spacing: -0.03em; display: flex; align-items: center; gap: 10px; color: var(--kopf-text); }
/* 🔑 Die Wortmarke ist EIN Flex-Kind, nicht zwei.
Vorher standen "Vogel" und "album" getrennt im Flex-Container und bekamen
dessen gap von 10 px zwischen sich — bei "Bird dex" war das gewollt, bei
einem deutschen Kompositum liest es sich wie ein Tippfehler. Jetzt trennt
der Abstand nur noch Zeichen und Wort; die Farbe wechselt mitten im Wort. */
.brand .wort { color: var(--kopf-text); }
.brand .wort span { color: #7fd3a4; }
/* ⚠ Das Zeichen traegt dunkelgruene Flaechen. Direkt auf der dunklen Leiste
verschwinden die, und uebrig bleibt ein cremefarbener Klecks — die Marke
war an der wichtigsten Stelle nicht mehr zu erkennen. Deshalb eine helle
Scheibe darunter: das Zeichen selbst bleibt unveraendert, es bekommt nur
einen Traeger. Nebenbei nimmt die runde Form die Abzeichen-Pins auf. */
.brand .scheibe {
width: 40px; height: 40px; flex: 0 0 40px;
border-radius: 50%; background: var(--kopf-text);
display: grid; place-items: center;
}
.brand img { display: block; width: 31px; height: 31px; }
nav.main { display: flex; gap: 3px; margin-left: auto; flex-wrap: wrap; }
nav.main a {
padding: 8px 15px; border-radius: 999px;
font-size: 14px; font-weight: 600; color: var(--text-2);
transition: background .15s, color .15s;
}
nav.main a:hover { background: var(--surface-2); color: var(--text); }
nav.main a.active { background: var(--accent); color: var(--accent-on); }
/* ---------- Bedienelemente auf der dunklen Leiste ----------
Alles im Kopf steht jetzt auf Dunkelgruen. Die Regeln davor rechneten mit
hellem Grund; ohne diesen Block waere die Navigation dunkelgrau auf
dunkelgruen, also praktisch unsichtbar. Gesammelt an einer Stelle, damit
beim naechsten Farbwechsel klar ist, was zusammengehoert. */
header.top nav.main a { color: var(--kopf-text-2); }
header.top nav.main a:hover { background: rgba(255,255,255,.10); color: var(--kopf-text); }
header.top nav.main a.active { background: var(--kopf-an); color: var(--kopf-text); }
header.top .live-knopf {
background: transparent; border-color: var(--kopf-linie); color: var(--kopf-text-2);
}
header.top .live-knopf:hover { color: var(--kopf-text); border-color: var(--kopf-text); }
header.top .lang-switch { background: transparent; border-color: var(--kopf-linie); }
header.top .lang-switch button { color: var(--kopf-text-2); }
header.top .lang-switch button:hover { color: var(--kopf-text); }
header.top .lang-switch button.on { background: var(--kopf-text); color: var(--kopf-bg); }
header.top .theme-toggle, header.top .gear {
background: transparent; border-color: var(--kopf-linie); color: var(--kopf-text-2);
}
header.top .theme-toggle:hover, header.top .gear:hover {
border-color: var(--kopf-text); color: var(--kopf-text);
}
/* Der Live-Knopf. Kein Navigationspunkt, sondern ein Hinweis, dass gerade
etwas zu sehen ist — der Punkt pulst, solange die Seite offen ist. Rot ist
hier die richtige Farbe: sie heisst "laeuft jetzt", nicht "Fehler". */
.live-knopf {
flex: none; display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 700; text-decoration: none;
color: var(--text-2); background: var(--surface);
border: 1px solid var(--line); border-radius: 999px; padding: 6px 12px 6px 10px;
transition: background .15s, color .15s, border-color .15s;
}
.live-knopf:hover { color: var(--text); border-color: var(--text-2); }
.live-knopf.on { background: #d6303a; border-color: #d6303a; color: #fff; }
.live-knopf .punkt {
width: 8px; height: 8px; border-radius: 50%; background: #d6303a;
animation: live-puls 2s ease-in-out infinite;
}
.live-knopf.on .punkt { background: #fff; }
@keyframes live-puls {
0%, 100% { opacity: 1; }
50% { opacity: .35; }
}
.theme-toggle {
width: 38px; height: 38px; flex-shrink: 0;
border: 1px solid var(--line); border-radius: 50%;
background: var(--surface); color: var(--text-2);
font-size: 17px; line-height: 1; cursor: pointer;
display: grid; place-items: center;
transition: border-color .15s, color .15s;
}
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
.theme-toggle .icon-dark { display: none; }
[data-theme="dark"] .theme-toggle .icon-dark { display: block; }
[data-theme="dark"] .theme-toggle .icon-light { display: none; }
/* ---------- Fortschritt ---------- */
.progress-bar {
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
padding: 18px 22px; margin: 22px 0;
border-radius: var(--r-lg);
background: var(--surface); border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.level-badge {
width: 60px; height: 60px; border-radius: 50%;
display: grid; place-items: center; flex-shrink: 0;
font-weight: 800; font-size: 24px; color: var(--accent-on);
background: linear-gradient(145deg, var(--accent), var(--accent-2));
box-shadow: 0 0 0 5px var(--accent-wash);
}
.xp-track { height: 11px; border-radius: 999px; overflow: hidden; background: var(--surface-2); }
.xp-fill {
height: 100%; border-radius: 999px;
background: linear-gradient(90deg, var(--accent-2), var(--accent));
transition: width .6s cubic-bezier(.2,.8,.3,1);
}
.rank { font-weight: 750; font-size: 18px; letter-spacing: -0.015em; }
.sub { color: var(--muted); font-size: 13px; }
/* ---------- Kennzahlen ---------- */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px,1fr)); gap: 12px; }
.stat {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r); padding: 17px 19px; box-shadow: var(--shadow);
}
.stat .n { font-size: 30px; font-weight: 800; letter-spacing: -0.03em; line-height: 1; color: var(--accent-2); }
.stat .l { color: var(--muted); font-size: 13px; margin-top: 6px; }
h2 {
font-size: 21px; margin: 36px 0 15px; letter-spacing: -0.025em;
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
}
h2 .count { color: var(--muted); font-weight: 500; font-size: 14px; letter-spacing: 0; }
/* ---------- Sammelkarten ---------- */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px,1fr)); gap: 16px; }
.card {
position: relative;
border-radius: var(--r); overflow: hidden;
display: flex; flex-direction: column;
background: var(--surface); border: 1px solid var(--line);
box-shadow: var(--shadow);
transition: transform .18s cubic-bezier(.2,.8,.3,1), box-shadow .18s, border-color .18s;
}
.card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lift); border-color: var(--typ-line-l, var(--line)); }
[data-theme="dark"] .card:hover { border-color: var(--typ-line-d, var(--line)); }
/* Farbband oben — die Karte bekennt ihren Typ, bevor man den Namen liest. */
.card::before {
content: ""; position: absolute; top: 0; left: 0; right: 0; height: 4px;
background: var(--typ); z-index: 3;
}
.card .nr {
position: absolute; top: 12px; left: 11px; z-index: 2;
font-size: 11px; font-weight: 800; letter-spacing: .05em;
padding: 3px 8px; border-radius: 999px;
background: var(--nr-bg); color: var(--text-2);
}
.card .thumb {
position: relative; width: 100%;
padding-top: 75%; /* 4:3, funktioniert ohne aspect-ratio */
background: var(--typ-wash-l, var(--surface-2));
overflow: hidden;
}
[data-theme="dark"] .card .thumb { background: var(--typ-wash-d, var(--surface-2)); }
.card .thumb img {
position: absolute; inset: 0;
width: 100%; height: 100%; object-fit: cover; display: block;
}
/* Nicht gesammelt: entfärbt und blass. Die Form ist zu ahnen, die
Federzeichnung nicht — der Reiz des Freischaltens bleibt, ohne dass die
Karte tot wirkt. */
/* ⚠ Hier stand der Typton der Art als Grund. Gemeint war ein Hauch Farbe,
wirksam war ein Flickenteppich: nebeneinander lagen graue, sepiafarbene,
rosa und violette Kacheln, und das sah nach Zufall aus statt nach System.
Verschlossen ist ein Zustand, kein Thema — also fuer alle derselbe
neutrale Grund. Die Typfarbe bleibt sichtbar, wo sie etwas bedeutet:
im Band oben und in der Pille unter dem Namen. */
.card.locked .thumb { background: var(--surface-2); }
[data-theme="dark"] .card.locked .thumb { background: var(--surface-2); }
.card.locked .thumb img { filter: grayscale(1) contrast(.85); opacity: var(--locked-opacity); }
.card.locked .thumb::after {
content: "?"; position: absolute;
right: 12px; bottom: 6px;
font-size: 34px; font-weight: 800; line-height: 1;
color: var(--typ); opacity: .6;
}
.card.locked .name { color: var(--text-2); }
.card .body { padding: 12px 14px 14px; }
.card .name { font-weight: 700; font-size: 15.5px; line-height: 1.25; letter-spacing: -0.015em; }
.card .meta { display: flex; align-items: center; gap: 6px; margin-top: 9px; flex-wrap: wrap; }
.card .times { margin-left: auto; color: var(--muted); font-size: 12px; font-weight: 700; }
/* ---------- Badges ---------- */
.pill {
font-size: 11px; font-weight: 800; letter-spacing: .03em;
padding: 4px 10px; border-radius: 999px;
color: var(--typ-ink); background: var(--typ);
white-space: nowrap;
}
.pill.ghost { background: var(--surface-2); color: var(--text-2); border: 1px solid var(--line); }
.pill.rar { color: #fff; }
.tag-new {
background: var(--gold); color: #fff;
font-size: 10px; font-weight: 800; letter-spacing: .06em;
padding: 3px 8px; border-radius: 999px; text-transform: uppercase;
}
/* ---------- Artseite ---------- */
.species-hero {
position: relative; overflow: hidden;
border-radius: var(--r-lg); margin: 18px 0 8px;
background: var(--surface); border: 1px solid var(--line);
box-shadow: var(--shadow);
}
/* Kräftiges Typband am oberen Rand statt einer flächigen Einfärbung — das
gibt der Seite ihre Identität, ohne den Text zu bedrängen. */
.species-hero::before {
content: ""; position: absolute; top: 0; left: 0; right: 0; height: 6px;
background: var(--typ);
}
.hero-grid {
display: grid; grid-template-columns: minmax(0,1.05fr) minmax(0,1fr);
gap: 26px; padding: 28px 26px 26px;
}
@media (max-width: 950px) { .hero-grid { grid-template-columns: 1fr; padding: 22px 18px 18px; } }
.hero-photo {
position: relative; width: 100%; padding-top: 75%;
border-radius: var(--r); overflow: hidden;
background: var(--typ-wash-l, var(--surface-2)); border: 1px solid var(--typ-line-l, var(--line));
}
[data-theme="dark"] .hero-photo { background: var(--typ-wash-d, var(--surface-2)); border-color: var(--typ-line-d, var(--line)); }
.hero-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.dexnr { font-size: 13px; font-weight: 800; letter-spacing: .12em; color: var(--typ-text-l, var(--muted)); }
[data-theme="dark"] .dexnr { color: var(--typ-text-d, var(--muted)); }
.species-hero h1 { margin: 4px 0 2px; font-size: clamp(30px, 5vw, 44px); letter-spacing: -0.04em; line-height: 1.02; }
.kategorie { font-size: 16px; font-weight: 700; color: var(--typ-text-l, var(--text-2)); margin-bottom: 5px; }
[data-theme="dark"] .kategorie { color: var(--typ-text-d, var(--text-2)); }
.sci { color: var(--muted); font-style: italic; font-size: 14px; }
.badge-row { display: flex; gap: 7px; flex-wrap: wrap; margin: 16px 0; }
/* Statuswerte als Balken — die Geste jeder Sammelkarte. */
.werte { display: grid; gap: 9px; margin-top: 18px; }
.wert { display: grid; grid-template-columns: 84px 1fr auto; align-items: center; gap: 11px; }
.wert .k { font-size: 12px; font-weight: 700; color: var(--muted); }
.wert .track { height: 9px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
.wert .fill { height: 100%; border-radius: 999px; background: var(--typ); }
.wert .v { font-size: 12.5px; font-weight: 750; font-variant-numeric: tabular-nums; min-width: 62px; text-align: right; }
/* ---------- Fließtext und Steckbrief ---------- */
.prose { max-width: 68ch; }
.prose p { line-height: 1.7; margin: 0 0 16px; font-size: 16px; color: var(--text-2); }
.prose p:first-of-type::first-letter {
font-size: 3.1em; float: left; line-height: .82;
padding: 5px 10px 0 0; font-weight: 800; color: var(--typ-text-l, var(--text-2));
}
[data-theme="dark"] .prose p:first-of-type::first-letter { color: var(--typ-text-d, var(--text-2)); }
.fakten { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap: 12px; margin: 18px 0; }
.fakt {
background: var(--surface); border: 1px solid var(--line);
border-left: 3px solid var(--typ);
border-radius: var(--r); padding: 14px 16px; box-shadow: var(--shadow);
}
.fakt .k { font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.fakt .v { margin-top: 5px; line-height: 1.5; font-size: 14.5px; color: var(--text-2); }
.verwechslung { display: flex; gap: 12px; flex-wrap: wrap; }
.vw {
display: flex; align-items: center; gap: 11px;
background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 6px 18px 6px 6px;
box-shadow: var(--shadow);
transition: transform .15s, box-shadow .15s;
}
.vw:hover { transform: translateY(-2px); box-shadow: var(--shadow-lift); }
.vw img { width: 42px; height: 42px; border-radius: 50%; object-fit: cover; background: var(--surface-2); }
.vw .n { font-weight: 650; font-size: 14.5px; }
/* ---------- Sichtungen ---------- */
.rows { display: flex; flex-direction: column; gap: 9px; }
.row {
display: flex; align-items: center; gap: 14px;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r); padding: 10px 16px 10px 10px;
box-shadow: var(--shadow); transition: box-shadow .15s;
}
.row:hover { box-shadow: var(--shadow-lift); }
.row img.shot, .row .shot {
width: 74px; height: 56px; object-fit: cover;
border-radius: var(--r-sm); background: var(--surface-2); flex-shrink: 0;
}
.row .who { font-weight: 680; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.row .when { color: var(--muted); font-size: 13px; margin-top: 2px; }
.row .right { margin-left: auto; text-align: right; flex-shrink: 0; }
.row .conf { font-variant-numeric: tabular-nums; font-weight: 800; font-size: 15px; }
.row .xp { color: var(--accent-2); font-size: 13px; font-weight: 800; }
/* ---------- Abzeichen ---------- */
.badges { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px,1fr)); gap: 13px; }
.badge {
display: flex; gap: 14px; align-items: flex-start;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r); padding: 16px 18px; box-shadow: var(--shadow);
}
.badge.locked { opacity: .55; box-shadow: none; background: var(--surface-2); }
/* Der Pin ist ein Bild, das Emoji ein Zeichen — beide landen in .em, deshalb
gilt die Graustufe fuer beides. Fehlt der Pin, faellt die Vorlage aufs
Emoji zurueck und die Karte sieht trotzdem richtig aus. */
.badge .em { font-size: 30px; line-height: 1; filter: grayscale(1); }
.badge img.em {
width: 64px; height: 64px; flex: 0 0 64px; object-fit: contain;
/* blass, solange es nicht verdient ist: man soll sehen, was es zu holen
gibt, ohne dass es wie eingeloest aussieht. */
opacity: .45;
}
.badge:not(.locked) .em { filter: none; }
.badge:not(.locked) img.em {
opacity: 1;
filter: drop-shadow(0 3px 6px rgba(20,40,28,.28));
}
.badge:not(.locked) { border-color: var(--gold-line); background: var(--gold-wash); }
.badge .n { font-weight: 750; font-size: 15.5px; }
.badge .d { color: var(--muted); font-size: 13.5px; margin-top: 4px; line-height: 1.45; }
/* ---------- Aktivität über den Tag ---------- */
.hours { display: flex; align-items: flex-end; gap: 3px; height: 74px; margin-top: 12px; }
.hours div { flex: 1; min-height: 3px; border-radius: 3px 3px 0 0; background: var(--typ); }
.hours-axis { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; margin-top: 6px; }
/* ---------- Kleinteile ---------- */
.empty {
border: 1px dashed var(--line); border-radius: var(--r-lg);
padding: 42px 24px; text-align: center; color: var(--muted);
line-height: 1.6; background: var(--surface);
}
.filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.filters a {
padding: 7px 14px; border-radius: 999px; font-size: 13px; font-weight: 650;
border: 1px solid var(--line); background: var(--surface); color: var(--text-2);
transition: background .15s, color .15s, border-color .15s;
}
.filters a:hover { border-color: var(--muted); }
.filters a.on { background: var(--accent); color: var(--accent-on); border-color: var(--accent); }
.pager { display: flex; gap: 10px; justify-content: center; margin: 28px 0; }
.pager a {
padding: 9px 20px; border-radius: 999px; border: 1px solid var(--line);
background: var(--surface); font-size: 14px; font-weight: 650; color: var(--text-2);
}
.pager a:hover { border-color: var(--accent); color: var(--accent); }
.blaettern { display: flex; justify-content: space-between; gap: 12px; margin: 20px 0 4px; }
.blaettern a { color: var(--muted); font-size: 14px; font-weight: 650; }
.blaettern a:hover { color: var(--text); }
footer.bottom {
border-top: 1px solid var(--line); margin-top: 60px; padding: 24px 0 32px;
color: var(--muted); font-size: 13px; line-height: 1.6;
}
/* Der Werbeblock im Fuss.
Hier unten ist der richtige Ort: Wer so weit gescrollt hat, hat fremde
Vogelfotos gesehen und verstanden, was das Ding tut. Erst dann traegt der
Satz "kannst du auch haben". Deshalb ein Angebot mit Knopf statt einer
Fussnote — die leise Fassung stand hier einen Commit lang und war genau
das Gegenteil dessen, was die Seite leisten soll. */
footer.bottom .werbung {
display: flex; align-items: center; justify-content: space-between;
gap: 28px; flex-wrap: wrap;
background: var(--accent-wash); border: 1px solid var(--line);
border-radius: 16px; padding: 22px 26px;
/* Kein margin-bottom mehr: Unter dem Block kommt nichts, seit die zweite
Fusszeile weg ist. Der Abstand nach unten gehoert dem footer selbst. */
}
footer.bottom .werbung-text { flex: 1 1 340px; }
footer.bottom .werbung-haken {
margin: 0 0 4px; font-size: 19px; font-weight: 800;
letter-spacing: -0.02em; color: var(--text);
}
footer.bottom .werbung-satz { margin: 0; font-size: 14px; color: var(--text-2); max-width: 54ch; }
footer.bottom .werbung-knopf {
flex: none; display: inline-flex; align-items: center; gap: 8px;
background: var(--accent); color: var(--accent-on);
font-size: 15px; font-weight: 700; text-decoration: none;
border-radius: 999px; padding: 12px 20px;
transition: background .15s, transform .15s;
}
footer.bottom .werbung-knopf:hover { background: var(--accent-2); transform: translateY(-1px); }
footer.bottom .werbung-knopf svg { transition: transform .15s; }
footer.bottom .werbung-knopf:hover svg { transform: translateX(3px); }
/* Auf dem Telefon untereinander, der Knopf ueber die volle Breite — ein
Knopf, den man mit dem Daumen trifft, ist mehr wert als einer, der
huebsch rechts sitzt. */
@media (max-width: 560px) {
footer.bottom .werbung { padding: 20px; gap: 18px; }
footer.bottom .werbung-knopf { width: 100%; justify-content: center; }
}
/* Das Zahnrad zum Admin — gleiche Form wie der Modus-Umschalter, weil es
dieselbe Sorte Knopf ist: eine Einstellung, kein Navigationspunkt. Oben bei
den Bedienelementen versteht man es ohne Beschriftung; im Footer neben
einem Fliesstext nicht, das war der erste Versuch. */
.gear {
width: 38px; height: 38px; flex-shrink: 0;
border: 1px solid var(--line); border-radius: 50%;
background: var(--surface); color: var(--text-2);
font-size: 17px; line-height: 1;
display: grid; place-items: center;
transition: border-color .15s, color .15s;
}
.gear:hover { border-color: var(--accent); color: var(--accent); }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
/* ---------- Schmale Geräte ---------- */
/* Der Kopf bricht um, sobald Marke, Live-Knopf, Navigation, Sprache und
Umschalter nicht mehr in eine Zeile passen. Gemessen, nicht geschätzt: bei
810 px (iPad hochkant) rutschte sonst der Umschalter allein in Zeile zwei —
einer der Gründe, warum Layoutarbeit in vier Breiten angesehen wird.
Marke und die kleinen Knöpfe bleiben oben, die Navigation nimmt die Zeile
darunter. */
@media (max-width: 1000px) {
.brand { order: 0; flex: 1 1 auto; }
.live-knopf, .lang-switch, .theme-toggle, .gear { order: 1; }
nav.main { order: 2; flex: 1 0 100%; margin-left: 0; }
}
@media (max-width: 560px) {
/* Fünf Punkte passen auf einem Telefon nicht mehr in eine Zeile, und einer
allein in der zweiten sieht nach Versehen aus. Also seitlich wischen
statt umbrechen — die Zeile bleibt eine Zeile, egal wie viele Punkte
dazukommen oder wie lang die Sprache sie macht ("Sightings" gegen
"Sichtungen"). Die Leiste wird bündig am Rand gescrollt, damit man ihr
ansieht, dass da noch etwas kommt. */
nav.main {
order: 2; flex: 1 0 100%; margin-left: 0;
flex-wrap: nowrap; justify-content: flex-start;
overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
/* Der Wisch-Rand gehört in die Leiste, nicht neben sie. */
margin-inline: -14px; padding-inline: 14px;
}
nav.main::-webkit-scrollbar { display: none; }
nav.main { gap: 1px; }
nav.main a { padding: 7px 8px; font-size: 12.5px; white-space: nowrap; flex: none; }
.wrap { padding-inline: 14px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(150px,1fr)); gap: 12px; }
/* Auf 360-px-Geräten ergeben Marke, Live, Sprache und Umschalter zusammen
380 px und der Umschalter faellt in eine dritte Zeile. Gemessen, nicht
geraten: 154 + 68 + 90 + 38 plus Abstaende gegen 332 px Platz, und das
Zahnrad kommt noch dazu. Also alles eine Stufe kompakter — und vom
Sprachumschalter bleibt nur die Sprache, in die man wechseln kann: welche
gerade aktiv ist, sieht man am Text der Seite. */
.top-inner { gap: 8px; }
.brand { font-size: 19px; }
.live-knopf { font-size: 12px; padding: 5px 10px 5px 8px; gap: 5px; }
.theme-toggle, .gear { width: 34px; height: 34px; font-size: 15px; }
.lang-switch button.on { display: none; }
.progress-bar { padding: 16px; gap: 14px; }
.species-hero h1 { font-size: 30px; }
.wert { grid-template-columns: 74px 1fr auto; gap: 8px; }
.fakten { grid-template-columns: 1fr; }
}
/* ---------- Sprachumschalter ---------- */
.lang-switch {
display: flex; gap: 2px; flex-shrink: 0;
border: 1px solid var(--line); border-radius: 999px;
background: var(--surface); padding: 2px;
}
.lang-switch button {
border: 0; background: transparent; cursor: pointer;
padding: 5px 11px; border-radius: 999px;
font: inherit; font-size: 12px; font-weight: 800; letter-spacing: .04em;
color: var(--muted);
transition: background .15s, color .15s;
}
.lang-switch button:hover { color: var(--text); }
.lang-switch button.on { background: var(--accent); color: var(--accent-on); }
/* ---------- Filterleiste ---------- */
.filterleiste {
display: flex; flex-direction: column; gap: 12px;
padding: 14px 16px; margin-bottom: 18px;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r); box-shadow: var(--shadow);
}
.filtergruppe { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.filterlabel {
font-size: 11px; font-weight: 800; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted);
flex-shrink: 0;
}
.filterleiste .pills { display: flex; gap: 7px; flex-wrap: wrap; }
.filterleiste .pills a {
padding: 6px 13px; border-radius: 999px;
font-size: 13px; font-weight: 650;
border: 1px solid var(--line); background: var(--surface); color: var(--text-2);
transition: border-color .15s, background .15s, color .15s;
}
.filterleiste .pills a b { font-weight: 800; opacity: .55; margin-left: 2px; }
.filterleiste .pills a:hover { border-color: var(--muted); }
.filterleiste .pills a.on {
background: var(--accent); color: var(--accent-on); border-color: var(--accent);
}
.filterleiste .pills a.on b { opacity: .8; }
/* Die Verfeinerung sitzt optisch unter der Hauptachse, nicht daneben. */
.verfeinern { border-top: 1px solid var(--line-soft, var(--line)); padding-top: 12px; }
.verfeinern label { display: flex; align-items: center; gap: 8px; }
.verfeinern select {
font: inherit; font-size: 13.5px; font-weight: 600;
padding: 7px 30px 7px 12px; border-radius: 999px;
border: 1px solid var(--line); background: var(--surface); color: var(--text);
cursor: pointer;
/* Eigener Pfeil: das native Dreieck sieht in jedem Browser anders aus. */
-webkit-appearance: none; -moz-appearance: none; appearance: none;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: calc(100% - 16px) 52%, calc(100% - 11px) 52%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.verfeinern select:hover { border-color: var(--muted); }
.verfeinern select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.verfeinern button {
font: inherit; font-size: 13.5px; font-weight: 650;
padding: 7px 16px; border-radius: 999px; cursor: pointer;
border: 1px solid var(--accent); background: var(--accent); color: var(--accent-on);
}
.zuruecksetzen {
font-size: 13px; font-weight: 650; color: var(--muted);
text-decoration: underline; text-underline-offset: 3px;
}
.zuruecksetzen:hover { color: var(--text); }
@media (max-width: 560px) {
.filterleiste { padding: 12px; gap: 10px; }
.filtergruppe { gap: 8px; }
/* Label über die Zeile, damit die Pillen die volle Breite bekommen. */
.filtergruppe > .filterlabel { flex: 1 0 100%; }
.filterleiste .pills a { padding: 6px 11px; font-size: 12.5px; }
.verfeinern { flex-wrap: wrap; }
.verfeinern label { flex: 1 1 46%; }
.verfeinern label .filterlabel { display: none; }
.verfeinern select { width: 100%; }
}
/* ---------- Blickfang der Startseite ----------
Die Seite begann mit vier Kacheln, von denen an einem ruhigen Tag zwei auf
null standen. Jetzt steht der letzte Gast gross oben; die Zahlen sind
dorthin gewandert, wo sie etwas erklaeren. */
.held {
display: grid; grid-template-columns: 1fr 1fr; gap: 24px;
align-items: stretch; margin: 22px 0 8px;
}
.held-text h1 {
font-size: 38px; line-height: 1.08; letter-spacing: -0.03em;
font-weight: 800; margin: 0 0 10px;
}
.held-text p { margin: 0 0 20px; color: var(--text-2); font-size: 16px; max-width: 44ch; }
.held .progress-bar { margin: 0; }
.held-bild {
position: relative; border-radius: var(--r-lg); overflow: hidden;
box-shadow: var(--shadow); min-height: 280px; background: var(--surface-2);
display: block;
}
.held-bild img { width: 100%; height: 100%; object-fit: cover; display: block; }
.held-bild .schild {
position: absolute; left: 0; right: 0; bottom: 0; padding: 40px 20px 16px;
color: #fff;
/* Fester Farbwert statt Token: der Verlauf liegt ueber einem Foto, nicht
ueber dem Seitenhintergrund — er muss in beiden Modi gleich dunkeln. */
background: linear-gradient(transparent, rgba(8,24,16,.88));
}
.held-bild .schild b { display: block; font-size: 22px; font-weight: 800; }
.held-bild .schild small { opacity: .9; font-size: 13px; }
.frisch {
position: absolute; top: 14px; left: 14px;
background: var(--kopf-text); color: var(--kopf-bg);
padding: 5px 11px; border-radius: 999px;
font-size: 11px; font-weight: 800; letter-spacing: .07em;
}
.held-leer {
display: grid; place-items: center; text-align: center; padding: 30px;
border: 1px dashed var(--line); border-radius: var(--r-lg);
color: var(--muted); background: var(--surface);
}
/* ---------- Besuche als Bildkarten ---------- */
.besuche { display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; }
.besuch {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r); overflow: hidden; box-shadow: var(--shadow);
transition: transform .15s, box-shadow .15s;
}
.besuch:hover { transform: translateY(-2px); box-shadow: var(--shadow-lift); }
/* ⚠ Feste Hoehe statt aspect-ratio: das gibt es erst ab Safari 15 und faellt
davor ersatzlos weg, womit die Karten unterschiedlich hoch waeren. */
.besuch img, .besuch .leer { width: 100%; height: 118px; object-fit: cover; display: block; }
.besuch .leer { background: var(--surface-2); }
.besuch .f { padding: 10px 12px; }
.besuch b { font-weight: 750; font-size: 14.5px; }
.besuch small { display: block; color: var(--muted); font-size: 12.5px; margin-top: 2px; }
.besuch .xp { float: right; color: var(--accent); font-weight: 800; font-size: 12.5px; }
@media (max-width: 900px) {
.held { grid-template-columns: 1fr; }
.held-bild { min-height: 230px; order: -1; }
.held-text h1 { font-size: 30px; }
.besuche { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 420px) {
.held-text h1 { font-size: 26px; }
.besuche { grid-template-columns: 1fr 1fr; gap: 10px; }
}
+9
View File
@@ -0,0 +1,9 @@
<!-- Vogelalbum-Marke. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M27.75 12.33 C28.34 12.31 30.07 12.24 31.17 12.33 C32.28 12.42 33.38 12.64 34.38 12.88 C35.37 13.12 36.22 13.40 37.14 13.77 C38.06 14.14 38.94 14.56 39.90 15.09 C40.86 15.63 41.93 16.29 42.88 16.97 C43.84 17.65 44.71 18.32 45.65 19.18 C46.59 20.05 47.89 21.39 48.52 22.17 C49.15 22.94 49.26 23.36 49.40 23.82 C49.55 24.28 49.48 24.60 49.40 24.93 C49.33 25.26 49.42 25.41 48.96 25.81 C48.50 26.22 47.54 26.97 46.64 27.36 C45.74 27.75 45.13 27.87 43.55 28.13 C41.96 28.39 38.56 28.63 37.14 28.91 C35.72 29.18 35.70 29.38 35.04 29.79 C34.38 30.20 33.69 30.69 33.16 31.34 C32.63 31.98 32.09 32.81 31.83 33.66 C31.58 34.50 31.56 35.61 31.61 36.42 C31.67 37.23 31.78 37.69 32.17 38.52 C32.55 39.35 33.38 40.66 33.93 41.39 C34.49 42.13 34.74 42.37 35.48 42.94 C36.22 43.51 36.99 44.21 38.35 44.82 C39.72 45.43 42.31 46.24 43.66 46.59 C45.00 46.94 45.35 46.88 46.42 46.92 C47.49 46.95 48.92 46.94 50.07 46.81 C51.21 46.68 52.41 46.38 53.27 46.14 C54.14 45.90 54.85 45.48 55.26 45.37 C55.66 45.26 56.35 44.91 55.70 45.48 C55.06 46.05 53.07 47.76 51.39 48.80 C49.72 49.83 47.34 50.97 45.65 51.67 C43.95 52.37 42.90 52.66 41.23 52.99 C39.55 53.33 37.06 53.57 35.59 53.66 C34.12 53.75 33.51 53.80 32.39 53.55 C31.26 53.29 30.20 52.85 28.85 52.11 C27.51 51.37 25.50 50.05 24.32 49.13 C23.14 48.21 22.76 47.84 21.78 46.59 C20.80 45.33 19.81 44.36 18.46 41.61 C17.12 38.87 14.78 32.52 13.71 30.12 C12.64 27.73 12.29 28.11 12.06 27.25 C11.82 26.38 12.04 25.85 12.28 24.93 C12.52 24.01 13.09 22.59 13.49 21.72 C13.90 20.86 14.23 20.40 14.71 19.73 C15.19 19.07 15.76 18.37 16.36 17.75 C16.97 17.12 17.30 16.68 18.35 15.98 C19.40 15.28 21.54 14.08 22.66 13.55 C23.79 13.01 24.27 12.96 25.09 12.77 C25.92 12.59 27.19 12.52 27.64 12.44 C28.08 12.37 27.16 12.35 27.75 12.33 Z" fill="#fcf2d3"/>
<path d="M26.97 10.34 C27.65 10.32 29.92 10.29 30.95 10.34 C31.98 10.40 31.93 10.34 33.16 10.67 C34.39 11.01 36.99 11.80 38.35 12.33 C39.72 12.87 40.31 13.25 41.34 13.88 C42.37 14.50 43.03 14.80 44.54 16.09 C46.05 17.38 48.43 19.44 50.40 21.61 C52.37 23.79 54.43 26.46 56.36 29.13 C58.30 31.80 61.13 36.03 62.00 37.64 C62.87 39.24 62.33 37.69 61.56 38.74 C60.78 39.79 58.32 42.87 57.36 43.93 C56.40 45.00 56.71 44.74 55.81 45.15 C54.91 45.55 53.10 46.09 51.94 46.36 C50.78 46.64 49.99 46.75 48.85 46.81 C47.71 46.86 46.27 46.83 45.09 46.70 C43.92 46.57 42.87 46.35 41.78 46.03 C40.69 45.72 39.62 45.35 38.57 44.82 C37.52 44.28 36.24 43.40 35.48 42.83 C34.73 42.26 34.58 42.11 34.04 41.39 C33.51 40.67 32.66 39.35 32.28 38.52 C31.89 37.69 31.78 37.23 31.72 36.42 C31.67 35.61 31.69 34.50 31.94 33.66 C32.20 32.81 32.76 31.96 33.27 31.34 C33.79 30.71 34.39 30.29 35.04 29.90 C35.68 29.51 35.72 29.29 37.14 29.02 C38.56 28.74 41.96 28.50 43.55 28.24 C45.13 27.99 45.72 27.87 46.64 27.47 C47.56 27.06 48.59 26.24 49.07 25.81 C49.55 25.39 49.44 25.26 49.51 24.93 C49.59 24.60 49.66 24.28 49.51 23.82 C49.37 23.36 49.27 22.96 48.63 22.17 C47.99 21.37 46.60 19.96 45.65 19.07 C44.69 18.19 43.84 17.54 42.88 16.86 C41.93 16.18 40.86 15.52 39.90 14.98 C38.94 14.45 38.06 14.03 37.14 13.66 C36.22 13.29 35.37 13.01 34.38 12.77 C33.38 12.53 32.28 12.31 31.17 12.22 C30.07 12.13 28.83 12.13 27.75 12.22 C26.66 12.31 25.59 12.53 24.65 12.77 C23.71 13.01 23.03 13.23 22.11 13.66 C21.19 14.08 19.99 14.74 19.13 15.31 C18.26 15.89 17.62 16.42 16.92 17.08 C16.22 17.75 15.52 18.52 14.93 19.29 C14.34 20.07 13.82 20.86 13.38 21.72 C12.94 22.59 12.52 23.57 12.28 24.49 C12.04 25.41 11.72 26.31 11.94 27.25 C12.17 28.19 12.66 27.99 13.60 30.12 C14.54 32.26 16.44 37.58 17.58 40.07 C18.72 42.55 19.77 43.95 20.45 45.04 C21.13 46.13 20.86 45.76 21.67 46.59 C22.48 47.41 24.12 49.07 25.31 50.01 C26.51 50.95 27.82 51.67 28.85 52.22 C29.88 52.77 31.71 53.25 31.50 53.33 C31.30 53.40 28.83 53.01 27.64 52.66 C26.44 52.31 25.37 51.82 24.32 51.23 C23.27 50.64 22.18 49.83 21.34 49.13 C20.49 48.43 19.92 47.82 19.24 47.03 C18.56 46.24 17.87 45.39 17.25 44.38 C16.62 43.36 16.25 42.99 15.48 40.95 C14.71 38.91 13.27 33.95 12.61 32.11 C11.94 30.27 12.04 30.64 11.50 29.90 C10.97 29.16 9.97 28.22 9.40 27.69 C8.83 27.16 9.31 27.40 8.08 26.70 C6.84 26.00 2.44 24.23 2.00 23.49 C1.56 22.76 3.80 22.70 5.43 22.28 C7.05 21.85 10.31 21.76 11.72 20.95 C13.14 20.14 12.96 18.59 13.93 17.41 C14.91 16.24 16.70 14.65 17.58 13.88 C18.46 13.10 18.39 13.22 19.24 12.77 C20.08 12.33 21.39 11.61 22.66 11.23 C23.93 10.84 26.14 10.60 26.86 10.45 C27.58 10.31 26.29 10.36 26.97 10.34 Z" fill="#106943"/>
<path d="M39.13 29.02 C39.81 29.02 41.17 28.74 43.10 29.13 C45.04 29.51 48.72 30.86 50.73 31.34 C52.74 31.82 54.43 31.82 55.15 32.00 C55.87 32.18 55.39 32.06 55.04 32.44 C54.69 32.83 53.73 33.80 53.05 34.32 C52.37 34.84 51.80 35.15 50.95 35.54 C50.10 35.92 48.30 36.35 47.97 36.64 C47.64 36.94 48.19 36.99 48.96 37.30 C49.73 37.62 51.47 38.21 52.61 38.52 C53.75 38.83 54.84 39.03 55.81 39.18 C56.79 39.33 58.02 39.33 58.46 39.40 C58.91 39.48 58.78 39.35 58.46 39.62 C58.15 39.90 57.32 40.60 56.59 41.06 C55.85 41.52 54.89 42.02 54.04 42.39 C53.20 42.76 52.39 43.03 51.50 43.27 C50.62 43.51 49.79 43.71 48.74 43.82 C47.69 43.93 46.13 43.95 45.20 43.93 C44.28 43.92 43.99 43.86 43.22 43.71 C42.44 43.57 41.43 43.34 40.56 43.05 C39.70 42.76 38.91 42.46 38.02 41.94 C37.14 41.43 35.94 40.55 35.26 39.96 C34.58 39.37 34.34 39.03 33.93 38.41 C33.53 37.78 33.05 36.94 32.83 36.20 C32.61 35.46 32.53 34.67 32.61 33.99 C32.68 33.31 32.92 32.68 33.27 32.11 C33.62 31.54 34.15 30.99 34.71 30.56 C35.26 30.14 35.87 29.81 36.59 29.57 C37.30 29.33 38.59 29.22 39.02 29.13 C39.44 29.03 38.45 29.02 39.13 29.02 Z" fill="#fcf2d3"/>
<path d="M26.86 17.97 C27.25 17.95 28.54 17.91 29.07 17.97 C29.61 18.02 29.66 18.10 30.07 18.30 C30.47 18.50 31.02 18.74 31.50 19.18 C31.98 19.62 32.61 20.38 32.94 20.95 C33.27 21.52 33.40 22.02 33.49 22.61 C33.58 23.20 33.55 23.97 33.49 24.49 C33.44 25.00 33.36 25.24 33.16 25.70 C32.96 26.16 32.79 26.71 32.28 27.25 C31.76 27.78 30.62 28.57 30.07 28.91 C29.51 29.24 29.46 29.18 28.96 29.24 C28.46 29.29 27.75 29.35 27.08 29.24 C26.42 29.13 25.70 29.05 24.98 28.57 C24.27 28.10 23.25 27.06 22.77 26.36 C22.29 25.66 22.20 24.76 22.11 24.38 C22.02 23.99 22.17 23.90 22.22 24.04 C22.28 24.19 22.17 24.67 22.44 25.26 C22.72 25.85 23.27 26.97 23.88 27.58 C24.49 28.19 25.59 28.70 26.09 28.91 C26.59 29.11 26.99 29.00 26.86 28.80 C26.73 28.59 25.74 28.08 25.31 27.69 C24.89 27.30 24.60 26.97 24.32 26.48 C24.04 25.98 23.77 25.33 23.66 24.71 C23.55 24.08 23.47 23.47 23.66 22.72 C23.84 21.96 24.41 20.77 24.76 20.18 C25.11 19.59 25.37 19.48 25.76 19.18 C26.14 18.89 26.90 18.57 27.08 18.41 C27.27 18.24 27.03 18.21 26.86 18.19 C26.70 18.17 26.48 18.15 26.09 18.30 C25.70 18.45 25.02 18.72 24.54 19.07 C24.06 19.42 23.53 20.01 23.22 20.40 C22.90 20.78 22.70 21.37 22.66 21.39 C22.63 21.41 22.79 20.84 22.99 20.51 C23.20 20.18 23.42 19.77 23.88 19.40 C24.34 19.03 25.28 18.52 25.76 18.30 C26.24 18.08 26.57 18.13 26.75 18.08 C26.94 18.02 26.48 17.99 26.86 17.97 Z" fill="#106943"/>
<path d="M26.09 18.52 C26.18 18.50 26.75 18.28 26.53 18.52 C26.31 18.76 25.19 19.51 24.76 19.96 C24.34 20.40 24.21 20.67 23.99 21.17 C23.77 21.67 23.53 22.39 23.44 22.94 C23.34 23.49 23.25 23.80 23.44 24.49 C23.62 25.17 24.08 26.35 24.54 27.03 C25.00 27.71 26.16 28.41 26.20 28.57 C26.24 28.74 25.20 28.30 24.76 28.02 C24.32 27.75 23.93 27.51 23.55 26.92 C23.16 26.33 22.63 25.17 22.44 24.49 C22.26 23.80 22.35 23.40 22.44 22.83 C22.53 22.26 22.64 21.65 22.99 21.06 C23.34 20.47 24.04 19.70 24.54 19.29 C25.04 18.89 25.72 18.76 25.98 18.63 C26.24 18.50 26.00 18.54 26.09 18.52 Z" fill="#fefefe"/>
<path d="M26.86 19.85 C26.99 19.85 27.27 19.83 27.52 19.96 C27.78 20.08 28.22 20.43 28.41 20.62 C28.59 20.80 28.63 20.77 28.63 21.06 C28.63 21.36 28.63 22.04 28.41 22.39 C28.19 22.74 27.65 23.05 27.30 23.16 C26.95 23.27 26.60 23.18 26.31 23.05 C26.01 22.92 25.68 22.74 25.54 22.39 C25.39 22.04 25.33 21.34 25.43 20.95 C25.52 20.56 25.87 20.23 26.09 20.07 C26.31 19.90 26.62 19.99 26.75 19.96 C26.88 19.92 26.73 19.85 26.86 19.85 Z" fill="#fefefe"/>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"name": "Vogelalbum",
"short_name": "Vogelalbum",
"description": "Welche Vögel besuchen das Vogelhaus? Erkannt, gesammelt, katalogisiert.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#106943",
"icons": [
{ "src": "/static/img/symbol-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/img/symbol-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/static/img/symbol-512.png", "sizes": "512x512", "type": "image/png",
"purpose": "maskable" }
]
}
+74
View File
@@ -0,0 +1,74 @@
{% extends "dex/base.html" %}
{% load i18n dex_extras %}
{% block title %}{% trans "Abzeichen" %} — Vogelalbum{% endblock %}
{% block kopf %}
<style>
/* ⚠ Eigene Klassen statt der xp-track vom Kopfbereich: Die ist dort
breit und hoch, hier soll der Balken in eine Abzeichenkarte passen
ohne sie auseinanderzuziehen. */
.fortschritt {
margin-top: 8px;
height: 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--text) 12%, transparent);
overflow: hidden;
}
.fortschritt-fuellung {
height: 100%;
border-radius: 999px;
background: var(--accent);
/* Auch bei 0 % ein Hauch sichtbar -- sonst sieht die Karte aus,
als fehlte der Balken. */
min-width: 3px;
}
.fortschritt-text {
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
/* Die Karte ist abgeblendet, solange das Abzeichen fehlt. Der Balken
soll trotzdem lesbar bleiben -- er ist ja der Grund hinzuschauen. */
.badge.locked .fortschritt-fuellung { opacity: .85; }
</style>
{% endblock %}
{% block content %}
<h2>{% trans "Abzeichen" %} <span class="count">{% blocktrans with n=progress.badges %}{{ n }} verdient{% endblocktrans %}</span></h2>
<div class="badges">
{% for row in rows %}
<div class="badge {% if not row.earned %}locked{% endif %}">
{% abzeichen_bild row.badge.code as pin %}
{% if pin %}
<img class="em" src="{{ pin }}" alt="" width="72" height="72" loading="lazy">
{% else %}
<div class="em">{{ row.badge.emoji }}</div>
{% endif %}
<div>
<div class="n">{{ row.badge.name_lokal }}</div>
<div class="d">{{ row.badge.description_lokal }}</div>
<div class="d" style="margin-top:6px; color:var(--accent-dim); font-weight:700">
{% if row.earned %}{{ row.at|date:"SHORT_DATE_FORMAT" }} · +{{ row.badge.xp }} XP{% else %}+{{ row.badge.xp }} XP{% endif %}
</div>
{% comment %}
🔑 Balken nur, wo es etwas zu zaehlen gibt. Bei "Ein Specht war da"
stuende er dauerhaft auf null und waere keine Information.
⚠️ {# … #} ist in Django EINZEILIG — mehrzeilig braucht es comment,
sonst steht der Text sichtbar auf der Seite.
{% endcomment %}
{% if row.ziel %}
<div class="fortschritt" title="{{ row.ist }} von {{ row.ziel }}">
<div class="fortschritt-fuellung" style="width: {{ row.anteil }}%"></div>
</div>
<div class="d fortschritt-text">{{ row.ist }} / {{ row.ziel }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
+273
View File
@@ -0,0 +1,273 @@
{% load static i18n dex_extras %}{% get_current_language as LANG %}<!doctype html>
<html lang="{{ LANG }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Vogelalbum{% endblock %}</title>
<meta name="description" content="{% trans "Vögel erkennen, sammeln und katalogisieren — auf dem eigenen Server, ohne Cloud." %}">
{% comment %}
Vor dem Stylesheet und ohne defer: sonst rendert die Seite kurz hell und
springt dann auf dunkel. Das Flackern sieht man, und es nervt.
{% endcomment %}
<script>
(function () {
try {
var g = localStorage.getItem("vogelalbum-theme");
var d = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (g === "dark" || (g === null && d)) {
document.documentElement.setAttribute("data-theme", "dark");
}
} catch (e) { /* privater Modus: dann eben Standard */ }
})();
</script>
<link rel="stylesheet" href="{% static_v 'css/vogelalbum.css' %}">
{% comment %}
⚠ Hier stand ein Emoji als Symbol im Reiter. Das sieht auf einem Mac gut
aus und ist anderswo ein leeres Kaestchen: Emoji kommen aus einer Schrift,
die nicht jedes System mitbringt — ein Linux-Browser ohne Emoji-Schrift
zeigte an der Stelle ein Tofu-Rechteck, und zwar genau dort, wo die Marke
steht. Eine Marke darf nicht davon abhaengen, was auf dem fremden Rechner
installiert ist.
{% endcomment %}
<link rel="icon" type="image/svg+xml" href="{% static_v 'img/marke.svg' %}">
{% comment %}
⚠ Fuer den Startbildschirm reicht das SVG NICHT: iOS nimmt dort weder SVG
noch Durchsichtigkeit. Ohne diese Zeile legt Safari einen verkleinerten
Bildschirmabzug der Seite als Symbol ab — das sieht aus wie ein Fehler.
Deshalb ein PNG mit Flaeche darunter und Luft am Rand, weil das System
die Ecken selbst zuschneidet.
{% endcomment %}
<link rel="apple-touch-icon" href="{% static 'img/symbol-180.png' %}">
<link rel="manifest" href="{% static 'manifest.webmanifest' %}">
<meta name="theme-color" content="#106943">
{% comment %}
Hier gehoert seitenbezogenes CSS hin, NICHT in den title-Block.
⚠ Der Titel steht in <title>…</title>, und dort behandelt der Browser
alles als Text. Ein <style> darin wird nie angewandt — es landet
stattdessen im Seitentitel. Die Abzeichen-Seite hatte ihre Regeln auf
diesem Weg eingebunden, und der Fortschrittsbalken war dadurch 0 Pixel
hoch: unsichtbar, ohne dass irgendwo ein Fehler erschien.
{% endcomment %}
{% block kopf %}{% endblock %}
</head>
<body>
<header class="top">
<div class="wrap top-inner">
<a href="{% url 'home' %}" class="brand">
<span class="scheibe"><img src="{% static_v 'img/marke.svg' %}" alt="" width="31" height="31"></span>
<span class="wort">Vogel<span>album</span></span></a>
{% if live_available %}
{% comment %}
Live steht bewusst nicht in der Rubriken-Navigation: es ist kein Bereich
der Sammlung, sondern ein Zustand — und es gibt es nur, solange der
Detector läuft. Als fünfter Punkt sprengte es auf dem Telefon die Zeile;
hier ist es sichtbarer und kostet die Navigation nichts.
{% endcomment %}
<a href="{% url 'live' %}" class="live-knopf {% if request.resolver_match.url_name == 'live' %}on{% endif %}">
<span class="punkt" aria-hidden="true"></span>{% trans "Live" %}
</a>
{% endif %}
<nav class="main">
<a href="{% url 'home' %}" {% if request.resolver_match.url_name == 'home' %}class="active"{% endif %}>{% trans "Übersicht" %}</a>
<a href="{% url 'dex' %}" {% if request.resolver_match.url_name == 'dex' %}class="active"{% endif %}>{% trans "Sammlung" %}</a>
<a href="{% url 'sightings' %}" {% if request.resolver_match.url_name == 'sightings' %}class="active"{% endif %}>{% trans "Sichtungen" %}</a>
<a href="{% url 'badges' %}" {% if request.resolver_match.url_name == 'badges' %}class="active"{% endif %}>{% trans "Abzeichen" %}</a>
</nav>
{% comment %}
Djangos set_language erwartet POST. Ein Formular statt eines Links ist
hier auch inhaltlich richtig: die Sprachwahl ändert Serverzustand.
{% endcomment %}
<form action="{% url 'set_language' %}" method="post" class="lang-switch">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" name="language" value="de"
{% if LANG == 'de' %}class="on" aria-current="true"{% endif %}>DE</button>
<button type="submit" name="language" value="en"
{% if LANG == 'en' %}class="on" aria-current="true"{% endif %}>EN</button>
</form>
<button class="theme-toggle" type="button" id="theme-toggle"
aria-label="{% trans "Zwischen hellem und dunklem Modus wechseln" %}">
{% comment %}
Gezeichnet statt Emoji, aus demselben Grund wie beim Reitersymbol:
☀️ und 🌙 sehen auf jedem System anders aus und fehlen auf manchen ganz.
currentColor laesst sie die Textfarbe erben, also auch im dunklen Modus.
{% endcomment %}
<svg class="icon-light" viewBox="0 0 24 24" aria-hidden="true" width="17" height="17">
<path d="M20.3 14.2A8.5 8.5 0 0 1 9.8 3.7a8.5 8.5 0 1 0 10.5 10.5z"
fill="currentColor"/>
</svg>
<svg class="icon-dark" viewBox="0 0 24 24" aria-hidden="true" width="17" height="17">
<circle cx="12" cy="12" r="4.4" fill="currentColor"/>
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 2.2v2.4M12 19.4v2.4M2.2 12h2.4M19.4 12h2.4"/>
<path d="M5.1 5.1 6.8 6.8M17.2 17.2l1.7 1.7M18.9 5.1l-1.7 1.7M6.8 17.2l-1.7 1.7"/>
</g>
</svg>
</button>
{% comment %}
Kameras, Live-Bild und Arten stellt man im Django-Admin ein. Oben bei den
Bedienelementen versteht man ein Zahnrad ohne Beschriftung — im Footer
neben einem Fließtext nicht.
{% endcomment %}
<a href="{% url 'admin:index' %}" class="gear" title="{% trans 'Einstellungen' %}"
aria-label="{% trans 'Einstellungen' %}">
<svg viewBox="0 0 24 24" aria-hidden="true" width="17" height="17">
<path fill="currentColor" d="M12 15.4a3.4 3.4 0 1 1 0-6.8 3.4 3.4 0 0 1 0 6.8zm8.3-2.2.1-1.2-.1-1.2 2-1.5-1.9-3.4-2.4.9a7.9 7.9 0 0 0-2.1-1.2L15.5 3h-3.9l-.4 2.6c-.8.3-1.5.7-2.1 1.2l-2.4-.9L4.8 9.3l2 1.5-.1 1.2.1 1.2-2 1.5 1.9 3.4 2.4-.9c.6.5 1.3.9 2.1 1.2l.4 2.6h3.9l.4-2.6c.8-.3 1.5-.7 2.1-1.2l2.4.9 1.9-3.4z"/>
</svg></a>
</div>
</header>
<main class="wrap">
{% block progress %}
<div class="progress-bar">
<div class="level-badge">{{ progress.level }}</div>
<div style="flex:1 1 240px; min-width:200px">
<div class="rank">{{ progress.rank }}</div>
<div class="xp-track" style="margin:8px 0 6px">
<div class="xp-fill" style="width: {% widthratio progress.level_progress 1 100 %}%"></div>
</div>
<div class="sub">{% blocktrans with xp=progress.xp rest=progress.xp_to_next lvl=progress.level|add:1 %}{{ xp }} XP · noch {{ rest }} XP bis Level {{ lvl }}{% endblocktrans %}</div>
</div>
<div style="text-align:right">
<div class="rank">{{ progress.species_seen }} / {{ progress.species_total }}</div>
<div class="sub">{% trans "Arten gesammelt" %}</div>
</div>
</div>
{% endblock %}
{% block content %}{% endblock %}
</main>
{% comment %}
Der Fuß ist die einzige Stelle, an der diese Seite für sich selbst wirbt.
Wer hier unten ankommt, hat sich durch fremde Vogelfotos geklickt und
gerade verstanden, was das Ding tut — das ist der Moment für den Satz
"kannst du auch haben", nicht die Startseite. Deshalb steht hier ein
Angebot und kein Kleingedrucktes.
Nebenbei erfüllt der Link die AGPL: Wer eine Fassung über ein Netz
anbietet, muss den Quelltext zugänglich machen. Wohin er zeigt, steht in
settings.QUELLTEXT_URL und ist überschreibbar — wer etwas verändert,
verlinkt SEIN Repository.
{% endcomment %}
<footer class="bottom">
{% if quelltext_url %}
<div class="wrap werbung">
<div class="werbung-text">
<p class="werbung-haken">{% trans "Dieses Vogelalbum füllt sich von allein." %}</p>
<p class="werbung-satz">{% trans "Eine Kamera am Futterhaus, der Rest passiert auf deinem eigenen Server — ohne Cloud, ohne Konto, ohne Abo. Freie Software zum Nachbauen." %}</p>
</div>
<a href="{{ quelltext_url }}" class="werbung-knopf" rel="noopener">
{% trans "Projekt ansehen" %}
<svg viewBox="0 0 24 24" aria-hidden="true" width="16" height="16">
<path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"
stroke-linejoin="round" d="M5 12h13M13 6l6 6-6 6"/>
</svg>
</a>
</div>
{% endif %}
{% comment %}
Hier stand bis zum 10.10.2026 noch ein Satz: "Vogelalbum · Arterkennung
auf dem eigenen Server. Keine Cloud, keine fremden Dienste." Der sagte
dasselbe wie der Block darueber, nur leiser und ohne Angebot. Zwei
Fassungen derselben Aussage untereinander schwaechen beide.
{% endcomment %}
</footer>
<!-- Grossansicht fuer Sichtungsfotos.
🔑 Warum das hier im Grundgeruest steht und nicht auf jeder Seite:
Vorschaubilder gibt es auf der Startseite, in der Sichtungsliste und
auf der Artenseite. Dreimal dasselbe zu bauen heisst, es dreimal zu
pflegen. -->
<div id="grossansicht" hidden>
<img alt="">
<div class="bildunterschrift"></div>
</div>
<style>
#grossansicht {
position: fixed; inset: 0; z-index: 90;
background: rgba(0,0,0,.88);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 14px; padding: 20px; cursor: zoom-out;
}
#grossansicht[hidden] { display: none; }
#grossansicht img {
max-width: 100%; max-height: 82vh;
border-radius: 10px; object-fit: contain;
}
/* ⚠ Eigene Farbe noetig: Die Seitenschrift ist fast schwarz und waere
auf dem dunklen Grund unsichtbar. Derselbe Fehler wie bei den
Live-Karten am 24.09. */
#grossansicht .bildunterschrift {
color: #fff; font-size: 14px; text-align: center;
text-shadow: 0 1px 3px rgba(0,0,0,.8);
}
</style>
<script>
/*
* Klick auf ein Vorschaubild zeigt das grosse Foto.
*
* 🔑 Das grosse Bild liegt bei JEDER Sichtung auf der Platte -- es wurde
* bisher nur nie verlinkt. Aus der Rueckmeldung: "man kann sich aber
* nirgendwo die Fotos der einzelnen Besuche in voller groesse anschauen,
* das ist schade weil nur das kleine Vorschaubild verschwendet Potenzial".
*
* ⚠ Ueber Delegation am Dokument, nicht je Bild: Die Sichtungsliste kann
* nachgeladen werden, und dann haetten neue Zeilen keinen Zuhoerer.
*/
(function () {
var schirm = document.getElementById("grossansicht");
if (!schirm) return;
var bild = schirm.querySelector("img");
var text = schirm.querySelector(".bildunterschrift");
function zeigen(quelle, beschriftung) {
bild.src = quelle;
text.textContent = beschriftung || "";
schirm.hidden = false;
// Die Seite darf hinter der Ueberlagerung nicht mitscrollen.
document.body.style.overflow = "hidden";
}
function schliessen() {
schirm.hidden = true;
bild.src = "";
document.body.style.overflow = "";
}
document.addEventListener("click", function (e) {
var ziel = e.target.closest("[data-gross]");
if (ziel) {
e.preventDefault();
zeigen(ziel.dataset.gross, ziel.dataset.beschriftung);
return;
}
if (!schirm.hidden && schirm.contains(e.target)) schliessen();
});
// Escape schliesst -- und der Zurueck-Knopf am Handy soll nicht die
// ganze Seite verlassen, nur weil ein Bild offen ist.
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !schirm.hidden) schliessen();
});
})();
</script>
<script>
document.getElementById("theme-toggle").addEventListener("click", function () {
var root = document.documentElement;
var dark = root.getAttribute("data-theme") === "dark";
if (dark) { root.removeAttribute("data-theme"); }
else { root.setAttribute("data-theme", "dark"); }
try { localStorage.setItem("vogelalbum-theme", dark ? "light" : "dark"); } catch (e) {}
});
</script>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
{% extends "dex/base.html" %}
{% load i18n %}
{% block title %}{% trans "Sammlung" %} — Vogelalbum{% endblock %}
{% block content %}
<h2>{% trans "Die Sammlung" %}
<span class="count">
{% blocktrans with a=found_count b=total_count %}{{ a }} von {{ b }} Arten{% endblocktrans %}{% if habitat_label %} · {{ habitat_label }}{% endif %}
</span>
</h2>
{% comment %}
Lebensraum ist die Hauptachse und bleibt sichtbar. Seltenheit und Gruppe
sind Verfeinerungen und gehören in Auswahlfelder — 27 Typen als Pillen
waren auf dem Handy zwölf Zeilen, bevor der erste Vogel kam.
{% endcomment %}
<div class="filterleiste">
{% if habitate %}
<div class="filtergruppe">
<span class="filterlabel">{% trans "Lebensraum" %}</span>
<div class="pills">
{% for h in habitate %}
<a href="?lebensraum={{ h.code }}" {% if habitat == h.code %}class="on"{% endif %}>{{ h.label }} <b>{{ h.n }}</b></a>
{% endfor %}
<a href="?lebensraum=alle" {% if not habitat %}class="on"{% endif %}>{% trans "Alle" %} <b>{{ gesamt_alle }}</b></a>
</div>
</div>
{% endif %}
<form class="filtergruppe verfeinern" method="get" action="{% url 'dex' %}">
<input type="hidden" name="lebensraum" value="{{ habitat|default:'alle' }}">
<label>
<span class="filterlabel">{% trans "Stufe" %}</span>
<select name="seltenheit" onchange="this.form.submit()">
<option value="">{% trans "Alle Stufen" %}</option>
{% for value, label in rarities %}
<option value="{{ value }}" {% if rarity_filter == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
<span class="filterlabel">{% trans "Gruppe" %}</span>
<select name="typ" onchange="this.form.submit()">
<option value="">{% trans "Alle Gruppen" %}</option>
{% for t in typen %}
<option value="{{ t.code }}" {% if typ_filter == t.code %}selected{% endif %}>{{ t.label }} ({{ t.n }})</option>
{% endfor %}
</select>
</label>
<noscript><button type="submit">{% trans "Anwenden" %}</button></noscript>
{% if rarity_filter or typ_filter %}
<a class="zuruecksetzen" href="?lebensraum={{ habitat|default:'alle' }}">{% trans "Zurücksetzen" %}</a>
{% endif %}
</form>
</div>
{% if cards %}
<div class="grid">
{% for card in cards %}
{% with s=card.species %}
<a class="card {% if not card.found %}locked{% endif %}"
href="{% url 'species' s.slug %}"
style="{{ s.typ_vars }}"
{% if not card.found %}title="{% trans "Noch nicht gesichtet" %}"{% endif %}>
<span class="nr">{{ s.dex_nr }}</span>
<div class="thumb">
{% if card.found and card.entry.best_sighting and card.entry.best_sighting.kachel_url %}
<img src="{{ card.entry.best_sighting.kachel_url }}" alt="{{ s.name }}" loading="lazy">
{% elif s.reference_image %}
<img src="{{ s.reference_image.url }}" alt="{{ s.name }}" loading="lazy">
{% endif %}
</div>
<div class="body">
<div class="name">{{ s.name }}</div>
<div class="meta">
{% if s.typ %}<span class="pill">{{ s.typ_label }}</span>{% endif %}
{% if card.found %}
<span class="times">×{{ card.entry.count }}</span>
{% else %}
<span class="times">+{{ s.xp }} XP</span>
{% endif %}
</div>
</div>
</a>
{% endwith %}
{% endfor %}
</div>
{% else %}
<div class="empty">{% trans "Keine Art passt zu dieser Auswahl." %}</div>
{% endif %}
{% endblock %}
+161
View File
@@ -0,0 +1,161 @@
{% extends "dex/base.html" %}
{% load i18n %}
{% block title %}{% trans "Fotos hochladen" %} — Vogelalbum{% endblock %}
{% block kopf %}
<style>
.hochladen-form {
display: grid;
gap: 16px;
max-width: 560px;
margin-bottom: 32px;
}
.hochladen-form label { display: block; font-weight: 600; margin-bottom: 6px; }
.hochladen-form input[type="file"],
.hochladen-form select,
.hochladen-form input[type="text"] {
width: 100%;
padding: 10px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
background: var(--card);
color: var(--text);
}
.hochladen-form .hinweis {
font-size: .9em;
opacity: .75;
margin-top: 4px;
}
.ort-zeile { display: flex; gap: 12px; }
.ort-zeile > div { flex: 1; }
.meldung {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
.meldung.fehler {
background: color-mix(in srgb, crimson 22%, transparent);
}
/* Die wartenden Fotos als schlichte Reihe -- sie sollen zeigen, dass
etwas passiert, und nicht mit der Sammlung konkurrieren. */
.wartend-liste { display: flex; flex-wrap: wrap; gap: 10px; }
.wartend-liste figure { margin: 0; width: 110px; }
.wartend-liste img {
width: 110px; height: 110px; object-fit: cover;
border-radius: 8px; display: block;
/* Solange nicht bestimmt ist, bleibt das Bild bewusst zurückhaltend. */
opacity: .72;
}
.wartend-liste figcaption { font-size: .78em; opacity: .7; margin-top: 4px; }
/* ⚠ Es gibt im Projekt-CSS keine allgemeine Knopfklasse — die Knöpfe im
Kopfbereich sind eigene Fälle. Also hier einer, der sich an die
Farbvariablen hält statt an den Browserstandard. */
.hochladen-form button {
padding: 10px 22px;
border: 0;
border-radius: 999px;
background: var(--accent);
color: var(--accent-on);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.hochladen-form button:hover { filter: brightness(1.08); }
/* Auf dem Telefon untereinander: zwei Koordinatenfelder nebeneinander
sind bei 390 px so schmal, dass der Platzhalter abgeschnitten wird. */
@media (max-width: 460px) {
.ort-zeile { flex-direction: column; gap: 10px; }
}
</style>
{% endblock %}
{% block content %}
<h2>{% trans "Fotos hochladen" %}</h2>
<p>
{% comment %}
⚠ In einer Zeile lassen. Ein Umbruch im Quelltext landet als ``\n`` mitten
im Schlüssel der .po-Datei — der Text ist dann nur noch mit genau diesem
Umbruch übersetzbar, und jede Einrückungsänderung macht ihn wieder deutsch.
{% endcomment %}
{% blocktrans %}Ein Vogel unterwegs? Foto hier einliefern — der Detector bestimmt es beim nächsten Durchgang und trägt es in die Sammlung ein.{% endblocktrans %}
</p>
{% if angelegt %}
<div class="meldung">
{% blocktrans count n=angelegt %}{{ n }} Foto angenommen. Es wartet auf den Detector.{% plural %}{{ n }} Fotos angenommen. Sie warten auf den Detector.{% endblocktrans %}
</div>
{% endif %}
{% if fehler %}
<div class="meldung fehler">{{ fehler }}</div>
{% endif %}
<form class="hochladen-form" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div>
<label for="bilder">{% trans "Bilder" %}</label>
{% comment %}
multiple ist Absicht: Von einem Spaziergang kommen selten einzelne
Fotos. Je Bild entsteht eine eigene Sichtung — zehn Fotos können
fünf verschiedene Arten sein.
{% endcomment %}
<input type="file" name="bilder" id="bilder" accept="image/*" multiple required>
<div class="hinweis">{% trans "Mehrere Bilder auf einmal sind möglich. Jedes wird einzeln bestimmt." %}</div>
</div>
{% if kameras %}
<div>
<label for="kamera">{% trans "Gerät" %}</label>
<select name="kamera" id="kamera">
<option value="">{% trans "— ohne Zuordnung —" %}</option>
{% for k in kameras %}
<option value="{{ k.slug }}">{{ k.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div>
<label>{% trans "Ort (freiwillig)" %}</label>
<div class="ort-zeile">
<div><input type="text" name="latitude" inputmode="decimal" placeholder="{% trans "Breite, z. B. 49.49" %}"></div>
<div><input type="text" name="longitude" inputmode="decimal" placeholder="{% trans "Länge, z. B. 8.47" %}"></div>
</div>
<div class="hinweis">
{% blocktrans %}Wird auf etwa einen Kilometer gerundet gespeichert — die Gegend, nicht die Adresse.{% endblocktrans %}
</div>
</div>
<div>
<button type="submit" class="knopf">{% trans "Hochladen" %}</button>
</div>
</form>
{% if wartende_fotos %}
<h3>
{% trans "Warten auf Bestimmung" %}
<span class="count">{{ wartend }}</span>
</h3>
{% comment %}
Sichtbar, obwohl noch nichts bestimmt ist. Wäre hier nichts zu sehen,
hielte man den Upload für fehlgeschlagen und lüde dasselbe Foto erneut
hoch — und die Doppel sortiert hinterher niemand auseinander.
{% endcomment %}
<div class="wartend-liste">
{% for s in wartende_fotos %}
<figure>
{% if s.image %}<img src="{{ s.image.url }}" alt="{% trans "Noch nicht bestimmt" %}" loading="lazy">{% endif %}
<figcaption>{{ s.started_at|date:"SHORT_DATETIME_FORMAT" }}</figcaption>
</figure>
{% endfor %}
</div>
<p class="hinweis">
{% blocktrans %}Bleiben die Bilder länger stehen, läuft der Detector nicht.{% endblocktrans %}
</p>
{% endif %}
{% endblock %}
+159
View File
@@ -0,0 +1,159 @@
{% extends "dex/base.html" %}
{% load i18n dex_extras %}
{% block title %}{% trans "Vogelalbum — Übersicht" %}{% endblock %}
{% comment %}
Der Fortschrittsbalken steht auf allen Seiten oben. Hier nicht: er ist Teil
des Blickfangs geworden. Zweimal dieselbe Leiste untereinander sähe nach
Versehen aus.
{% endcomment %}
{% block progress %}{% endblock %}
{% block content %}
{% comment %}
🔑 Die Startseite begann vorher mit vier Kacheln, von denen an einem ruhigen
Tag zwei auf null standen — "0 Besuche heute, 0 Arten heute". Das ist die
erste Seite, die ein Fremder sieht, und sie sagte ihm: hier passiert nichts.
Jetzt steht oben der letzte Gast in groß. Die Zahlen sind nicht weg, sie
stehen dort, wo sie etwas erklären: neben der Überschrift des Abschnitts,
zu dem sie gehören.
{% endcomment %}
<section class="held">
<div class="held-text">
{% comment %}
🔑 Die Überschrift kommt aus ``views.blickfang_titel`` und richtet sich
nach dem letzten Besuch. Vorher stand hier fest „Wer war heute im
Futterhaus?" — fünf Tage nach dem letzten Gast behauptete die Seite damit
etwas, das das Bild daneben widerlegte.
{% endcomment %}
<h1>{{ blickfang_titel }}</h1>
<p>{% trans "Jeder Vogel wird bestimmt, gezählt und gesammelt — aus dem Kamerabild oder vom Handyfoto. Alles auf dem eigenen Server." %}</p>
<div class="progress-bar">
<div class="level-badge">{{ progress.level }}</div>
<div style="flex:1 1 200px; min-width:170px">
<div class="rank">{{ progress.rank }}</div>
<div class="xp-track" style="margin:8px 0 6px">
<div class="xp-fill" style="width: {% widthratio progress.level_progress 1 100 %}%"></div>
</div>
<div class="sub">{% blocktrans with xp=progress.xp rest=progress.xp_to_next lvl=progress.level|add:1 %}{{ xp }} XP · noch {{ rest }} XP bis Level {{ lvl }}{% endblocktrans %}</div>
</div>
<div style="text-align:right">
<div class="rank">{{ progress.species_seen }} / {{ progress.species_total }}</div>
<div class="sub">{% trans "Arten" %}</div>
</div>
</div>
</div>
{% with letzte=recent.0 %}
{% if letzte %}
<a class="held-bild" href="{% url 'species' letzte.effective_species.slug %}">
{% if letzte.image %}
<img src="{{ letzte.image.url }}" alt="{{ letzte.effective_species.name }}">
{% elif letzte.kachel_url %}
<img src="{{ letzte.kachel_url }}" alt="{{ letzte.effective_species.name }}">
{% endif %}
<span class="frisch">{% trans "ZULETZT" %}</span>
<div class="schild">
<b>{{ letzte.effective_species.name }}</b>
<small>{{ letzte.started_at|zeitpunkt }} · {{ letzte.duration_s|floatformat:0 }}s · {% widthratio letzte.confidence 1 100 %}%</small>
</div>
</a>
{% else %}
<div class="held-leer">
{% trans "Noch kein Gast aufgezeichnet." %}<br>
{% trans "Der erste bestimmte Vogel steht hier." %}
</div>
{% endif %}
{% endwith %}
</section>
{% if new_entries %}
<h2>{% trans "Neu in der Sammlung" %} <span class="count">{% trans "letzte 7 Tage" %}</span></h2>
<div class="grid">
{% for entry in new_entries %}
<a class="card" href="{% url 'species' entry.species.slug %}"
style="{{ entry.species.typ_vars }}">
<span class="nr">{{ entry.species.dex_nr }}</span>
<div class="thumb">
{% if entry.best_sighting and entry.best_sighting.kachel_url %}
<img src="{{ entry.best_sighting.kachel_url }}" alt="{{ entry.species.name }}" loading="lazy">
{% elif entry.species.reference_image %}
<img src="{{ entry.species.reference_image.url }}" alt="{{ entry.species.name }}" loading="lazy">
{% endif %}
</div>
<div class="body">
<div class="name">{{ entry.species.name }}</div>
<div class="meta">
{% if entry.species.typ %}<span class="pill">{{ entry.species.typ_label }}</span>{% endif %}
<span class="tag-new">{% trans "Neu" %}</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% endif %}
<h2>{% trans "Zuletzt zu Besuch" %}
<span class="count">{% blocktrans with n=progress.sightings %}{{ n }} Besuche insgesamt{% endblocktrans %}</span></h2>
{% if recent %}
{% comment %}
⚠ Bildkarten statt Zeilen. Die Zeilen liefen über die ganze Breite, mit dem
Namen links und dem Prozentwert rechts — dazwischen auf einem großen
Bildschirm tausend Pixel Nichts. Eine Karte ist so breit wie ihr Inhalt.
Der erste Eintrag fehlt hier mit Absicht: der steht oben im Blickfang.
{% endcomment %}
<div class="besuche">
{% for s in recent|slice:"1:5" %}
<a class="besuch" href="{% url 'species' s.effective_species.slug %}">
{% if s.kachel_url %}
<img src="{{ s.kachel_url }}" alt="" loading="lazy">
{% else %}<div class="leer"></div>{% endif %}
<div class="f">
{% if s.xp_awarded %}<span class="xp">+{{ s.xp_awarded }} XP</span>{% endif %}
<b>{{ s.effective_species.name }}</b>
<small>
{% if s.is_first_of_species %}<span class="tag-new">{% trans "Erstfang" %}</span>
{% else %}{{ s.started_at|zeitpunkt }}{% endif %}
</small>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty">
{% trans "Noch keine Sichtung aufgezeichnet." %}<br>
{% trans "Sobald der Detector einen Vogel erkennt oder das erste Foto eingeliefert ist, erscheint hier der erste Gast." %}
</div>
{% endif %}
{% if top %}
<h2>{% trans "Stammgäste" %}</h2>
<div class="grid">
{% for entry in top %}
<a class="card" href="{% url 'species' entry.species.slug %}"
style="{{ entry.species.typ_vars }}">
<span class="nr">{{ entry.species.dex_nr }}</span>
<div class="thumb">
{% if entry.best_sighting and entry.best_sighting.kachel_url %}
<img src="{{ entry.best_sighting.kachel_url }}" alt="{{ entry.species.name }}" loading="lazy">
{% elif entry.species.reference_image %}
<img src="{{ entry.species.reference_image.url }}" alt="{{ entry.species.name }}" loading="lazy">
{% endif %}
</div>
<div class="body">
<div class="name">{{ entry.species.name }}</div>
<div class="meta">
{% if entry.species.typ %}<span class="pill">{{ entry.species.typ_label }}</span>{% endif %}
<span class="times">×{{ entry.count }}</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% endif %}
{% endblock %}
+135
View File
@@ -0,0 +1,135 @@
{% extends "dex/base.html" %}
{% load i18n l10n %}
{% block title %}{% trans "Live" %} — Vogelalbum{% endblock %}
{% block content %}
<h2>{% trans "Live" %}
<span class="count">
{% blocktrans count n=cameras|length %}{{ n }} Kamera{% plural %}{{ n }} Kameras{% endblocktrans %}
</span>
</h2>
<div class="live-grid">
{% for kamera in cameras %}
<figure class="live-karte">
{% comment %}
Das Bild kommt vom Live-Verteiler des Detectors, nicht von der Kamera —
die liefert nur einen Stream, und den braucht die Erkennung. Der Browser
lädt es direkt, der Server reicht nichts durch: sonst hinge an jedem
Zuschauer eine offene Verbindung in der Web-App.
{% endcomment %}
<div class="live-bild">
<img alt="{{ kamera.name }}"
data-quelle="{{ kamera.live_bild_url }}"
data-rate="{{ kamera.live_bildrate|unlocalize }}">
{% comment %}
Der Name liegt AUF dem Bild: In einer Reihe gleich aussehender
Gartenbilder erkennt man sonst nicht, welche Kamera welche ist —
und beim Scrollen ist die Bildunterschrift oft aus dem Blick.
⚠ Diese lange Kommentarform benutzen, nicht die kurze mit der
Raute: Die kurze gilt nur für EINE Zeile. Über mehrere Zeilen wird
sie nicht entfernt, sondern steht wörtlich auf der Seite. Genau das
ist am 23.09.2026 passiert, und man sieht es erst im Browser.
{% endcomment %}
<span class="warte">Bild wird geholt &hellip;</span>
<span class="live-schild">{{ kamera.name }}</span>
</div>
<figcaption>
<strong>{{ kamera.name }}</strong>
{% if kamera.location %}<span class="live-ort">{{ kamera.location }}</span>{% endif %}
</figcaption>
</figure>
{% endfor %}
</div>
<p class="d" style="margin-top:14px">
{% trans "Bleibt das Bild schwarz, läuft der Detector nicht — die Erkennung verteilt das Livebild aus ihrem eigenen Bildstrom." %}
</p>
<script>
/*
* Einzelbilder statt Dauerstrom.
*
* 🔑 Am 24.09.2026 gemessen und an der Anlage bestaetigt: EINE Kamera
* erscheint sofort, ZWEI nebeneinander lassen die Seite haengen. Ein
* MJPEG-Strom belegt seine Verbindung fuer immer; mit zwei solchen kommt
* mancher Browser durcheinander -- auf dem Handy besonders.
*
* Ein Einzelbild ist eine kurze, gewoehnliche Anfrage. Sie blockiert
* nichts, und nach einem Fehler wird beim naechsten Mal einfach wieder
* gefragt. Wie oft, sagt die Bildrate der Kamera aus dem Backend.
*
* ⚠ Der Zeitstempel an der Adresse ist Pflicht: Ohne ihn liefert der
* Browser sein zwischengespeichertes Bild aus und man sieht ein
* Standbild, das aussieht wie ein Livebild.
*/
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".live-bild img").forEach(function (bild) {
var warte = bild.parentElement.querySelector(".warte");
var quelle = bild.dataset.quelle;
var rate = parseFloat(bild.dataset.rate) || 2;
var pause = Math.max(200, 1000 / rate);
var laeuft = false;
if (!quelle) return;
bild.addEventListener("load", function () {
if (warte) warte.style.display = "none";
laeuft = false;
});
bild.addEventListener("error", function () {
if (warte) {
warte.style.display = "";
warte.textContent = "Kein Bild — neuer Versuch …";
}
laeuft = false;
});
function holen() {
// Nicht nachlegen, solange das vorige Bild noch unterwegs ist --
// sonst stapeln sich Anfragen, wenn die Strecke langsam ist.
if (!laeuft) {
laeuft = true;
bild.src = quelle + "?t=" + Date.now();
}
setTimeout(holen, pause);
}
holen();
});
});
</script>
<style>
.live-grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
.live-karte{margin:0;background:#000;border-radius:10px;overflow:hidden}
.live-karte img{display:block;width:100%;height:auto}
/* ⚠ Die Karte ist schwarz, die Schriftfarbe der Seite aber fast auch
(--text: #16211a). Ohne eigene Farbe stand die Beschriftung schwarz
auf schwarz — sie war da und trotzdem unsichtbar. */
.live-karte figcaption{padding:8px 12px;display:flex;gap:10px;
align-items:baseline;color:#fff}
.live-karte figcaption .live-ort{color:#9fb3a6;font-size:13px}
/* ⚠ Feste Form, auch wenn kein Bild da ist. Ohne das faellt die Karte
in sich zusammen, sobald der Bildstrom abreisst -- dann steht da nur
noch die Beschriftung auf einem schwarzen Strich, und es sieht nach
einem kaputten Programm aus statt nach einer Kamera ohne Bild. */
.live-bild{position:relative;aspect-ratio:16/9;background:#000;
display:flex;align-items:center;justify-content:center}
.live-bild img{position:absolute;inset:0;width:100%;height:100%;
object-fit:cover}
.live-bild .warte{color:#8a8a94;font-size:14px;text-align:center;
padding:0 20px;line-height:1.5}
.live-schild{position:absolute;top:10px;left:10px;
padding:4px 10px;border-radius:999px;
background:rgba(0,0,0,.55);color:#fff;
font-size:13px;font-weight:600;letter-spacing:.01em;
/* Damit der Name auch auf hellem Himmel lesbar bleibt. */
backdrop-filter:blur(4px);text-shadow:0 1px 2px rgba(0,0,0,.6)}
</style>
{% endblock %}
+105
View File
@@ -0,0 +1,105 @@
{% extends "dex/base.html" %}
{% load dex_extras i18n %}
{% block title %}{% trans "Sichtungen" %} — Vogelalbum{% endblock %}
{% block kopf %}
<style>
/* Der Einlieferungs-Knopf sitzt in der Zeile der Ueberschrift, rechts.
Auf dem Telefon rutscht er darunter, statt die Zeile zu sprengen. */
.kopf-zeile {
display: flex; align-items: baseline; justify-content: space-between;
flex-wrap: wrap; gap: 10px;
}
.kopf-zeile h2 { margin: 0; }
.hochladen-link {
padding: 7px 16px; border-radius: 999px;
background: var(--accent); color: var(--accent-on);
font-weight: 600; font-size: .92em; text-decoration: none;
white-space: nowrap;
}
.hochladen-link:hover { filter: brightness(1.08); }
</style>
{% endblock %}
{% block content %}
<div class="kopf-zeile">
<h2>{% trans "Sichtungen" %} <span class="count">{% blocktrans with n=total %}{{ n }} insgesamt{% endblocktrans %}</span></h2>
{% comment %}
Nur fuer Angemeldete. Ein oeffentlicher Besucher soll keinen Knopf sehen,
der ihn bloss ins Login schickt -- und einliefern darf er ohnehin nicht.
{% endcomment %}
{% if user.is_staff %}
<a href="{% url 'hochladen' %}" class="hochladen-link">{% trans "Foto hochladen" %}</a>
{% endif %}
</div>
<div class="filters">
<a href="{% url 'sightings' %}" {% if not nur_bestimmt %}class="on"{% endif %}>{% trans "Alle Besuche" %}</a>
<a href="?nur_bestimmt=1" {% if nur_bestimmt %}class="on"{% endif %}>{% trans "Nur bestimmte Arten" %}</a>
</div>
{% if cameras %}
<div class="filters">
<a href="?{% if nur_bestimmt %}nur_bestimmt=1{% endif %}" {% if not kamera %}class="on"{% endif %}>{% trans "Alle Kameras" %}</a>
{% for c in cameras %}
<a href="?kamera={{ c.slug }}{% if nur_bestimmt %}&nur_bestimmt=1{% endif %}"
{% if kamera == c.slug %}class="on"{% endif %}>
{% if c.is_stale %}⚠ {% endif %}{{ c.name }}
</a>
{% endfor %}
</div>
{% endif %}
{% if sightings %}
<div class="rows">
{% for s in sightings %}
<div class="row">
{% if s.kachel_url %}
{# Klick zeigt das grosse Foto -- siehe base.html #}
<img class="shot" src="{{ s.kachel_url }}" alt="" loading="lazy"
{% if s.image %}data-gross="{{ s.image.url }}"
data-beschriftung="{% if s.effective_species %}{{ s.effective_species.name }} · {% endif %}{{ s.started_at|date:"SHORT_DATETIME_FORMAT" }}{% if s.camera %} · {{ s.camera.name }}{% endif %}"
style="cursor:zoom-in"{% endif %}>
{% else %}<div class="shot"></div>{% endif %}
<div>
<div class="who">
{% if s.effective_species %}
<a href="{% url 'species' s.effective_species.slug %}">{{ s.effective_species.name }}</a>
{% if s.confirmed_species %}<span class="pill ghost">{% trans "von Hand" %}</span>{% endif %}
{% else %}
<span style="color:var(--muted)">{% trans "Unbestimmt" %}</span>
{% endif %}
{% if s.is_first_of_species %}<span class="tag-new">{% trans "Erstfang" %}</span>{% endif %}
</div>
<div class="when">
{{ s.started_at|date:"DATETIME_FORMAT" }} · {{ s.duration_s|floatformat:0 }}s
{% if s.camera %} · {{ s.camera.name }}{% endif %}
</div>
{% with alt=s.votes|alternativen %}
{% if alt %}
<div class="when" style="margin-top:2px">
{% trans "Alternativen" %}:
{% for a in alt %}{{ a.species.name }} {{ a.anteil }}%{% if not forloop.last %}, {% endif %}{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
<div class="right">
<div class="conf">{{ s.confidence|prozent }}%</div>
{% if s.xp_awarded %}<div class="xp">+{{ s.xp_awarded }} XP</div>{% endif %}
</div>
</div>
{% endfor %}
</div>
<div class="pager">
{% if page > 1 %}<a href="?seite={{ page|add:'-1' }}{% if nur_bestimmt %}&nur_bestimmt=1{% endif %}">← {% trans "Neuer" %}</a>{% endif %}
{% if has_next %}<a href="?seite={{ page|add:'1' }}{% if nur_bestimmt %}&nur_bestimmt=1{% endif %}">{% trans "Älter" %} →</a>{% endif %}
</div>
{% else %}
<div class="empty">{% trans "Noch nichts aufgezeichnet." %}</div>
{% endif %}
{% endblock %}
+149
View File
@@ -0,0 +1,149 @@
{% extends "dex/base.html" %}
{% load dex_extras i18n %}
{% block title %}{{ species.name }} — Vogelalbum{% endblock %}
{% block content %}
{# Die Typfarbe faerbt die ganze Seite: Hero, Balken, Initiale, Diagramm. #}
<div style="{{ species.typ_vars }}">
<div class="blaettern">
{% if vorher %}<a href="{% url 'species' vorher.slug %}">← {{ vorher.dex_nr }} {{ vorher.name }}</a>{% else %}<span></span>{% endif %}
{% if nachher %}<a href="{% url 'species' nachher.slug %}">{{ nachher.dex_nr }} {{ nachher.name }} →</a>{% endif %}
</div>
<div class="species-hero">
<div class="hero-grid">
<div>
<div class="hero-photo">
{% if entry and entry.best_sighting and entry.best_sighting.image %}
<img src="{{ entry.best_sighting.image.url }}" alt="{{ species.name }}">
{% elif species.reference_image %}
<img src="{{ species.reference_image.url }}" alt="{{ species.german }}"
style="filter: saturate(.7) brightness(.72)">
{% endif %}
</div>
<div class="sub" style="margin-top:9px">
{% if entry and entry.best_sighting and entry.best_sighting.image %}
{% trans "Bestes eigenes Foto" %} · {{ entry.best_sighting.started_at|date:"SHORT_DATETIME_FORMAT" }}
{% elif species.reference_image %}
{% trans "Referenzfoto — diese Art war noch nicht vor unserer Kamera." %}
{% if species.reference_credit %}
{% trans "Foto" %}: <a href="{{ species.reference_source }}" rel="nofollow noopener" target="_blank"
style="text-decoration:underline">{{ species.reference_credit }}</a>{% if species.reference_license %}, {{ species.reference_license }}{% endif %}
{% endif %}
{% endif %}
</div>
</div>
<div>
<div class="dexnr">{{ species.dex_nr }}</div>
<h1>{{ species.name }}</h1>
{% if species.kategorie_lokal %}<div class="kategorie">{{ species.kategorie_lokal }}</div>{% endif %}
<div class="sci">{{ species.scientific }}{% if species.zweitname %} · {{ species.zweitname }}{% endif %}</div>
<div class="badge-row">
{% if species.typ %}<span class="pill">{{ species.typ_label }}</span>{% endif %}
<span class="pill rar" style="background:{{ species.rarity_color }}">{{ species.rarity_label }}</span>
{% if species.zug %}<span class="pill ghost">{{ species.zug_label }}</span>{% endif %}
<span class="pill ghost">{{ species.xp }} XP</span>
{% if entry %}<span class="pill ghost">{% blocktrans count n=entry.count %}{{ n }}× seen{% plural %}{{ n }}× seen{% endblocktrans %}</span>{% endif %}
</div>
{% if species.balken %}
<div class="werte">
{% for b in species.balken %}
<div class="wert">
<div class="k">{{ b.label }}</div>
<div class="track"><div class="fill" style="width:{{ b.pct }}%"></div></div>
<div class="v">{{ b.wert }}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
{% if species.absaetze %}
<h2>{% trans "Steckbrief" %}</h2>
<div class="prose">
{% for p in species.absaetze %}<p>{{ p }}</p>{% endfor %}
</div>
{% elif species.fact %}
<h2>{% trans "Steckbrief" %}</h2>
<div class="prose"><p>{{ species.fact }}</p></div>
{% endif %}
<div class="fakten">
{% if species.lebensraum_lokal %}
<div class="fakt"><div class="k">{% trans "Lebensraum" %}</div><div class="v">{{ species.lebensraum_lokal }}</div></div>
{% endif %}
{% if species.nahrung_lokal %}
<div class="fakt"><div class="k">{% trans "Nahrung" %}</div><div class="v">{{ species.nahrung_lokal }}</div></div>
{% endif %}
{% if species.stimme_lokal %}
<div class="fakt"><div class="k">{% trans "Stimme" %}</div><div class="v">{{ species.stimme_lokal }}</div></div>
{% endif %}
{% if species.zug %}
<div class="fakt"><div class="k">{% trans "Zugverhalten" %}</div><div class="v">{{ species.zug_label }}</div></div>
{% endif %}
</div>
{% if aehnliche %}
<h2>{% trans "Verwechslungsgefahr" %} <span class="count">{% trans "Arten, die man nicht auf Anhieb unterscheidet" %}</span></h2>
<div class="verwechslung">
{% for a in aehnliche %}
<a class="vw" href="{% url 'species' a.slug %}">
{% if a.reference_image %}<img src="{{ a.reference_image.url }}" alt="" loading="lazy">{% endif %}
<span class="n">{{ a.name }}</span>
</a>
{% endfor %}
</div>
{% endif %}
{% if entry %}
<h2>{% blocktrans with n=species.name %}Wann ist {{ n }} da?{% endblocktrans %} <span class="count">{% trans "Besuche über den Tag" %}</span></h2>
<div class="hours">
{% for h in hours %}<div style="height:{{ h.pct }}%" title="{{ h.h }} Uhr: {{ h.n }}"></div>{% endfor %}
</div>
<div class="hours-axis"><span>{% trans "0 Uhr" %}</span><span>6</span><span>12</span><span>18</span><span>23</span></div>
{% endif %}
{% if sightings %}
<h2>{% trans "Alle Besuche" %} <span class="count">{% blocktrans with n=entry.count %}{{ n }} insgesamt{% endblocktrans %}</span></h2>
<div class="rows">
{% for s in sightings %}
<div class="row">
{% if s.kachel_url %}
<img class="shot" src="{{ s.kachel_url }}" alt="" loading="lazy"
{% if s.image %}data-gross="{{ s.image.url }}"
data-beschriftung="{{ species.name }} · {{ s.started_at|date:"SHORT_DATETIME_FORMAT" }}{% if s.camera %} · {{ s.camera.name }}{% endif %}"
style="cursor:zoom-in"{% endif %}>
{% else %}<div class="shot"></div>{% endif %}
<div>
<div class="who">
{{ s.started_at|date:"DATETIME_FORMAT" }}
{% if s.is_first_of_species %}<span class="tag-new">{% trans "Erstfang" %}</span>{% endif %}
</div>
<div class="when">
{{ s.duration_s|floatformat:0 }}s · {% blocktrans with a=s.frames_with_bird b=s.frames_total %}{{ a }} von {{ b }} Bildern mit Vogel{% endblocktrans %}
{% if s.camera %} · {{ s.camera.name }}{% endif %}
</div>
</div>
<div class="right">
<div class="conf">{{ s.confidence|prozent }}%</div>
{% if s.xp_awarded %}<div class="xp">+{{ s.xp_awarded }} XP</div>{% endif %}
</div>
</div>
{% endfor %}
</div>
{% elif not entry %}
<div class="empty" style="margin-top:24px">
<strong style="color:var(--text); font-size:17px">{% trans "Noch nicht gesammelt." %}</strong><br>
{% blocktrans with n=species.name %}Sobald {{ n }} vor der Kamera landet, wird dieser Eintrag freigeschaltet — mit eurem eigenen Foto.{% endblocktrans %}
</div>
{% endif %}
</div>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
{% load static i18n dex_extras %}{% get_current_language as LANG %}<!doctype html>
<html lang="{{ LANG }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vogelalbum</title>
<link rel="stylesheet" href="{% static_v 'css/vogelalbum.css' %}">
<style>
/* Im iframe gibt es keine Navigation und keinen Seitenrand. */
body { background: transparent; }
.wrap { padding: 0; max-width: none; }
.widget { display: flex; gap: 14px; align-items: center;
background: var(--card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px; }
.widget img { width: 96px; height: 74px; object-fit: cover;
border-radius: 10px; background: #0b120e; flex-shrink: 0; }
</style>
</head>
<body>
<div class="wrap">
<div class="widget">
{% if latest %}
{% if latest.kachel_url %}<img src="{{ latest.kachel_url }}" alt="{{ latest.effective_species.name }}">{% endif %}
<div style="min-width:0">
<div class="sub">{% trans "Zuletzt gesehen" %}</div>
<div style="font-weight:700; font-size:18px; margin:2px 0 4px">{{ latest.effective_species.name }}</div>
<div class="sub">{{ latest.started_at|zeitpunkt }} · {% widthratio latest.confidence 1 100 %}% {% trans "sicher" %}</div>
</div>
{% else %}
<div>
<div class="sub">Vogelalbum</div>
<div style="font-weight:700; font-size:18px">{% trans "Noch kein Gast" %}</div>
</div>
{% endif %}
<div style="margin-left:auto; text-align:right; flex-shrink:0">
<div style="font-weight:800; font-size:22px">{{ progress.species_seen }}<span class="sub" style="font-size:15px">/{{ progress.species_total }}</span></div>
<div class="sub">{% trans "Arten" %}</div>
</div>
</div>
</div>
</body>
</html>
View File
Whitespace-only changes.
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for vogelalbum project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vogelalbum.settings')
application = get_asgi_application()
+288
View File
@@ -0,0 +1,288 @@
"""Django-Einstellungen fürs Vogelalbum."""
from pathlib import Path
import os
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1"
# Im Betrieb wird nichts geraten. Wer das Repo klont und DEBUG abschaltet,
# soll nicht versehentlich mit einem Schlüssel laufen, der öffentlich im
# Quelltext steht.
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "")
# 🔑 Zweite Quelle: die Schlüsseldatei auf dem Datenvolume. Der Entrypoint legt
# sie an, wenn keine Umgebungsvariable gesetzt ist, und exportiert den Wert —
# aber nur in SEINE eigene Prozesskette. Gunicorn hat ihn damit, ein spaeter
# dazukommendes `docker exec` nicht.
#
# ⚠️ Das ist kein Randfall, sondern der Normalweg fuer Wartungsbefehle:
# `createsuperuser`, `stoerungen_pruefen` als geplante Aufgabe, jede
# Fehlersuche im laufenden Container. Ohne diese Zeilen brechen sie alle mit
# "DJANGO_SECRET_KEY fehlt" ab — und eine geplante Aufgabe scheitert dabei
# still: sie steht sichtbar in der Oberflaeche und hat nie etwas getan.
# Gefunden am 03.10.2026, als der Stoerungswaechter nach Coolify zog.
if not SECRET_KEY:
schluesseldatei = Path(os.environ.get("SQLITE_DIR", BASE_DIR)) / "secret_key"
try:
SECRET_KEY = schluesseldatei.read_text().strip()
except OSError:
pass
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = "nur-fuer-entwicklung-nicht-im-betrieb-verwenden"
else:
raise ImproperlyConfigured(
"DJANGO_SECRET_KEY fehlt. Im Betrieb (DJANGO_DEBUG=0) ist ein "
"eigener Schlüssel Pflicht:\n"
" python -c \"import secrets; print(secrets.token_urlsafe(50))\""
)
ALLOWED_HOSTS = [
h.strip()
for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",")
if h.strip()
]
CSRF_TRUSTED_ORIGINS = [
o.strip()
for o in os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",")
if o.strip()
]
# 🔑 **Hinter Traefik spricht niemand HTTPS mit uns.** Coolify terminiert TLS
# davor und reicht einfaches HTTP an den Container weiter. Django glaubt
# deshalb, es werde über http:// angesprochen — und `build_absolute_uri()`
# baut jede Bild- und Medienadresse mit `http://`.
#
# ⚠️ **Das fällt im Browser nicht auf und bricht die App.** Traefik leitet
# http auf https um, also sieht eine Seite im Browser richtig aus; es kostet
# nur eine zusätzliche Runde. Auf dem Telefon ist es ein Ausfall: iOS blockt
# nach App Transport Security einfaches HTTP, ohne der Umleitung zu folgen.
# Dann ist das Album bilderlos, und zwar genau auf dem Gerät, für das es
# gebaut ist.
#
# Gefunden am 14.10.2026 beim Prüfen der Live-Antworten — `referenzbild` und
# `bild` kamen als `http://…` zurück und antworteten mit 302. Betroffen war
# nicht nur die App-API: `api.zustand` liefert dem Home-Assistant-Bridge
# dieselben Adressen, der Fehler stand seit dem Coolify-Umzug drin.
#
# Der Header kommt von Traefik; dass wir ihm trauen, ist nur richtig, solange
# der Container nicht direkt aus dem Netz erreichbar ist — hinter Coolify ist
# er das nicht.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
# --- Einstellungen aus der Umgebung ---------------------------------------
#
# ⚠️ Dieselbe kleine Funktion steht noch zweimal im Projekt
# (``detector/umgebung.py`` und ``homeassistant/bruecke.py``). Das ist Absicht
# und kein Versehen: Die drei Teile werden getrennt ausgeliefert — der
# Detector als eigenes Image, die Brücke als einzelne Datei auf einem dritten
# Rechner — und teilen sich keinen Paketpfad. Ein paar Zeilen dreimal sind
# billiger als eine Abhängigkeit, die bei einem der drei fehlt.
def umgebung(name: str, default: str = "") -> str:
"""Liest ``VOGELALBUM_<name>``.
🔑 **Ein ausdrücklich leerer Wert schlägt den Vorgabewert.** Das trägt
hier Bedeutung: Ein leerer Ingest-Token heißt „Endpunkt zu", nicht „nimm
den Standard". Wer ``or default`` schriebe, machte aus einer bewussten
Abschaltung stillschweigend wieder eine Öffnung.
"""
wert = os.environ.get(f"VOGELALBUM_{name}")
if wert is None:
return default
return wert
# Gemeinsames Geheimnis zwischen Detector und Web-App. Der Detector schickt es
# als Bearer-Token; ohne gültigen Token nimmt /api/sightings/ nichts an.
#
# Leer ist erlaubt und heißt: keine Kamera ohne eigenen Token. Registrierte
# Kameras (manage.py camera add) funktionieren dann trotzdem — der Weg ist
# ohnehin der bessere, weil jede Kamera einzeln stillgelegt werden kann.
INGEST_TOKEN = umgebung("INGEST_TOKEN", "dev-token" if DEBUG else "")
# Wohin der Quelltext-Link im Fuß zeigt.
#
# 🔑 Das ist keine Höflichkeit, sondern die AGPL: Wer eine veränderte Fassung
# über ein Netz anbietet, muss ihren Nutzern den Quelltext zugänglich machen
# (§13). Ein Link im Fuß ist der einfachste Weg, das zu erfüllen.
#
# Deshalb überschreibbar: Wer Vogelalbum verändert und öffentlich betreibt,
# trägt hier SEIN Repository ein — nicht dieses. Die Vorgabe passt für alle,
# die nichts verändert haben.
QUELLTEXT_URL = umgebung("QUELLTEXT_URL", "https://gitea.gaertner.onl/christian/vogelalbum")
# Welche Region gilt. Entscheidet, welche Arten der Klassifikator überhaupt
# erwägt: das iNat-Modell kennt 965 Arten weltweit und ist auf Nordamerika
# trainiert — ohne Einschränkung wird aus einer Kohlmeise eine Carolina
# Chickadee. Die Arten tragen das Kürzel in ihrem Feld 'regions'.
#
# Aktuell gibt es nur 'de'. Die Variable existiert, damit die Leitung steht,
# wenn weitere Kataloge dazukommen.
REGION = umgebung("REGION", "de")
# Ob ein Livebild überhaupt gezeigt werden darf. Ein- und ausgeschaltet wird
# pro Kamera im Admin; diese Variable ist die Sperre darüber.
#
# Auf einer öffentlichen Instanz (Showcase) gehört sie auf 0. Dann ist Live
# nicht einschaltbar — auch nicht mit einem Fehlklick im Admin. Ein Livebild
# aus einem Privatgarten soll nicht an einem Häkchen hängen, und wer nur die
# Sammlung herzeigen will, braucht es nicht.
LIVE_ALLOWED = umgebung("LIVE_ERLAUBT", "1") not in ("0", "false", "")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"dex",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
# Muss nach Session und vor Common stehen: sie liest die Sprache aus
# Cookie bzw. Accept-Language und setzt sie für den Request.
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "vogelalbum.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.i18n",
],
},
},
]
WSGI_APPLICATION = "vogelalbum.wsgi.application"
# SQLITE_DIR erlaubt es, die Datenbank auf ein Volume zu legen — dieselbe
# Konvention wie bei remote-admins, damit ein Coolify-Deploy nicht überrascht.
SQLITE_DIR = Path(os.environ.get("SQLITE_DIR", BASE_DIR))
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": SQLITE_DIR / "db.sqlite3",
"OPTIONS": {"init_command": "PRAGMA journal_mode=WAL;"},
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# --- Absicherung im Betrieb -----------------------------------------------
#
# 🔑 **Alles hier hängt an DEBUG=0 und gilt NICHT in der Entwicklung.** Ein
# `Secure`-Cookie wird über einfaches HTTP gar nicht erst gesetzt — stünde das
# hart auf True, könnte sich niemand mehr auf `http://localhost:8000` am Admin
# anmelden, und die Ursache wäre genau die Einstellung, die Sicherheit bringen
# sollte.
if not DEBUG:
# ⚠️ **Der eigentliche Punkt: Die Umleitung von http auf https kommt zu
# spät.** Traefik antwortet zwar mit 307 — aber da hat der Browser das
# Cookie schon mitgeschickt. Erst `Secure` sorgt dafür, dass er es über
# eine unverschlüsselte Verbindung gar nicht erst anfasst. Betrifft die
# Admin-Sitzung, also den Zugang zu allem.
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Dasselbe eine Ebene höher: Nach dem ersten Besuch geht der Browser von
# sich aus nur noch über https, die unverschlüsselte Anfrage entfällt.
#
# ⚠️ Ein Jahr ist lang und lässt sich nicht zurücknehmen — wer die Domain
# später ohne TLS betreiben will, ist für die Dauer ausgesperrt. Für eine
# Seite, die ohnehin nur über https erreichbar ist, ist das richtig;
# deshalb steht die Zahl in einer Variablen und nicht im Code.
SECURE_HSTS_SECONDS = int(os.environ.get("DJANGO_HSTS_SEKUNDEN", 31536000))
# Bewusst OHNE includeSubDomains und ohne preload: Beides erklärt man auch
# für Namen mit, die es noch gar nicht gibt, und preload nimmt einem
# fremde Listen aus der Hand. Wer das will, soll es ausdrücklich tun.
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False
# Eine Sitzung läuft mit dem Browser aus. Das Backend ist ein Werkzeug für
# Minuten, kein Ort, an dem man wohnt.
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
# Kein Weiterreichen der Adresse an fremde Seiten. Die Sichtungsseiten
# tragen Artnamen im Pfad; welche Art jemand angesehen hat, geht einen
# verlinkten Bildnachweis auf Wikimedia nichts an.
SECURE_REFERRER_POLICY = "same-origin"
LANGUAGE_CODE = "de"
LANGUAGES = [("de", "Deutsch"), ("en", "English")]
LOCALE_PATHS = [BASE_DIR / "locale"]
# Ein Jahr, damit die Sprachwahl nicht nach der Sitzung vergessen wird.
LANGUAGE_COOKIE_AGE = 60 * 60 * 24 * 365
LANGUAGE_COOKIE_SAMESITE = "Lax"
TIME_ZONE = "Europe/Berlin"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "media/"
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", BASE_DIR / "media"))
# Das Manifest entsteht erst bei 'collectstatic'. In Entwicklung und Tests gibt
# es keins, deshalb dort die einfache Variante — sonst scheitert jeder Test,
# der eine Seite rendert.
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": (
"django.contrib.staticfiles.storage.StaticFilesStorage"
if DEBUG
else "whitenoise.storage.CompressedManifestStaticFilesStorage"
)
},
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Ein Vogelhaus-Foto in Originalauflösung soll durchpassen, ein Upload von
# 50 MB nicht.
DATA_UPLOAD_MAX_MEMORY_SIZE = 16 * 1024 * 1024
# Token zum LESEN nach außen — für Home Assistant und später eine
# HACS-Integration.
#
# 🔑 Bewusst getrennt vom INGEST_TOKEN: Wer lesen darf, soll deshalb nicht
# Sichtungen einliefern können. Und wer eine Kamera betreibt, soll nicht
# automatisch die ganze Sammlung abfragen dürfen.
#
# Leer heißt: der Endpunkt ist zu. Das ist die richtige Vorgabe für eine
# Anlage, die im Netz steht.
LESE_TOKEN = umgebung("LESE_TOKEN", "dev-lesen" if DEBUG else "")
+53
View File
@@ -0,0 +1,53 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from dex import api, app_api, views
urlpatterns = [
# Djangos eingebauter Umschalter: POST mit 'language' und 'next', setzt
# das Sprach-Cookie und leitet zurück. Kein eigener Code nötig.
path("i18n/", include("django.conf.urls.i18n")),
path("", views.home, name="home"),
# 🔑 Die sichtbare Adresse heisst "sammlung", der interne App-Name "dex".
#
# Dass die beiden auseinanderfallen, ist Absicht: Den App-Namen zu
# aendern hiesse, saemtliche Migrationen umzuschreiben — viel Risiko fuer
# ein Wort, das kein Besucher je sieht. Sichtbares und Strukturelles
# duerfen verschiedene Namen tragen.
path("sammlung/", views.dex, name="dex"),
path("sammlung/<slug:slug>/", views.species_detail, name="species"),
path("sichtungen/", views.sightings, name="sightings"),
path("abzeichen/", views.badges, name="badges"),
# Fotos von Hand einliefern — hinter dem Admin-Login, siehe views.hochladen.
path("hochladen/", views.hochladen, name="hochladen"),
path("live/", views.live, name="live"),
path("widget/", views.widget, name="widget"),
path("api/stats/", views.api_stats, name="api_stats"),
path("api/sightings/", api.ingest, name="api_ingest"),
path("api/config/", api.config, name="api_config"),
# Fotos von unterwegs. Hochladen mit Kamera-Token, abholen mit Lese-Token,
# Ergebnis zurück mit Kamera-Token — siehe api.py.
path("api/hochladen/", api.hochladen, name="api_hochladen"),
path("api/offen/", api.offen, name="api_offen"),
path("api/offen/<int:sichtung_id>/", api.bestimmt_melden,
name="api_bestimmt_melden"),
# Lesen nach außen — Home Assistant, später eine HACS-Integration.
path("api/zustand/", api.zustand, name="api_zustand"),
# 🔑 Die Handy-App liest unter /api/app/. Eigener Zweig, weil sie etwas
# anderes braucht als Home Assistant: HA will einen Zustand in einer
# Antwort, die App will blättern. Siehe dex/app_api.py.
path("api/app/start/", app_api.start, name="app_start"),
path("api/app/album/", app_api.album, name="app_album"),
path("api/app/album/<slug:slug>/", app_api.art, name="app_art"),
path("api/app/sichtungen/", app_api.sichtungen, name="app_sichtungen"),
path("api/app/sichtungen/<int:sichtung_id>/", app_api.sichtung,
name="app_sichtung"),
path("api/app/abzeichen/", app_api.abzeichen, name="app_abzeichen"),
path("admin/", admin.site.urls),
]
# Im Betrieb liefert nginx/Coolify die Medien aus; im Dev-Server macht es Django.
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for vogelalbum project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vogelalbum.settings')
application = get_wsgi_application()