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.
No files matched your search
@@ -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)
|
||||
@@ -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 [],
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DexConfig(AppConfig):
|
||||
name = 'dex'
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Kameras anlegen, auflisten, Token erneuern.
|
||||
|
||||
manage.py camera add vogelhaus --name "Futterhaus Garten"
|
||||
manage.py camera list
|
||||
manage.py camera rotate vogelhaus
|
||||
manage.py camera disable vogelhaus
|
||||
|
||||
Der Token wird genau einmal ausgegeben — danach liegt nur noch sein Hash in
|
||||
der Datenbank. Wer ihn verliert, erzeugt mit ``rotate`` einen neuen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from dex.models import Camera
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Kameras verwalten."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
sub = parser.add_subparsers(dest="action", required=True)
|
||||
|
||||
add = sub.add_parser("add", help="Neue Kamera anlegen")
|
||||
add.add_argument("slug")
|
||||
add.add_argument("--name", help="Anzeigename (Standard: aus dem Slug)")
|
||||
add.add_argument("--location", default="", help="Standort, z.B. 'Garten Nord'")
|
||||
# 🔑 Ein Handy ist eine Kamera, die DARF schweigen. Ohne diese Angabe
|
||||
# trägt es im Kamerafilter und im Störungswächter dauerhaft ein ⚠,
|
||||
# weil beide "seit Stunden nichts geliefert" als Defekt lesen — richtig
|
||||
# für ein Vogelhaus, falsch für ein Telefon in der Tasche. Bisher gab
|
||||
# es das Feld nur im Admin; wer eine Handy-Kamera auf der
|
||||
# Kommandozeile anlegt, hat es zwangsläufig falsch.
|
||||
add.add_argument(
|
||||
"--mobil",
|
||||
action="store_true",
|
||||
help="Handy o.ä.: liefert nur gelegentlich, gilt nie als gestört.",
|
||||
)
|
||||
|
||||
sub.add_parser("list", help="Alle Kameras anzeigen")
|
||||
|
||||
rot = sub.add_parser("rotate", help="Neuen Token erzeugen")
|
||||
rot.add_argument("slug")
|
||||
|
||||
dis = sub.add_parser("disable", help="Kamera stilllegen")
|
||||
dis.add_argument("slug")
|
||||
|
||||
ena = sub.add_parser("enable", help="Kamera wieder aktivieren")
|
||||
ena.add_argument("slug")
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
action = opts["action"]
|
||||
if action == "add":
|
||||
return self._add(opts)
|
||||
if action == "list":
|
||||
return self._list()
|
||||
return self._toggle(action, opts["slug"])
|
||||
|
||||
def _add(self, opts):
|
||||
slug = slugify(opts["slug"])
|
||||
if Camera.objects.filter(slug=slug).exists():
|
||||
raise CommandError(f"Kamera '{slug}' gibt es schon.")
|
||||
|
||||
token = Camera.new_token()
|
||||
camera = Camera(
|
||||
slug=slug,
|
||||
name=opts.get("name") or slug.replace("-", " ").title(),
|
||||
location=opts.get("location") or "",
|
||||
mobil=bool(opts.get("mobil")),
|
||||
)
|
||||
camera.set_token(token)
|
||||
camera.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"\nKamera '{camera.name}' angelegt.\n"))
|
||||
self.stdout.write("Token (wird nur dieses eine Mal angezeigt):\n")
|
||||
self.stdout.write(self.style.WARNING(f" {token}\n"))
|
||||
if camera.mobil:
|
||||
# Eine mobile Kamera hat keinen Erkennungs-Host — der Token geht
|
||||
# in die App. Der Hinweis unten wäre hier eine falsche Fährte.
|
||||
self.stdout.write("In der App unter 'Server verbinden' eintragen.\n")
|
||||
else:
|
||||
self.stdout.write(
|
||||
"Auf dem Erkennungs-Host in /etc/vogelalbum.env eintragen:\n"
|
||||
)
|
||||
self.stdout.write(f" VOGELALBUM_TOKEN={token}\n")
|
||||
|
||||
def _list(self):
|
||||
cameras = Camera.objects.all()
|
||||
if not cameras:
|
||||
self.stdout.write("Keine Kameras angelegt.")
|
||||
return
|
||||
|
||||
self.stdout.write(f"\n{'Slug':<18} {'Name':<24} {'Aktiv':<6} {'Zuletzt':<18} Sichtungen")
|
||||
self.stdout.write("-" * 82)
|
||||
for c in cameras:
|
||||
last = (
|
||||
timezone.localtime(c.last_seen).strftime("%d.%m. %H:%M:%S")
|
||||
if c.last_seen
|
||||
else "nie"
|
||||
)
|
||||
flag = "ja" if c.active else "nein"
|
||||
warn = " ⚠ hängt?" if c.active and c.is_stale else ""
|
||||
self.stdout.write(
|
||||
f"{c.slug:<18} {c.name:<24} {flag:<6} {last:<18} "
|
||||
f"{c.sightings.count()}{warn}"
|
||||
)
|
||||
|
||||
def _toggle(self, action: str, slug: str):
|
||||
camera = Camera.objects.filter(slug=slug).first()
|
||||
if camera is None:
|
||||
raise CommandError(f"Keine Kamera '{slug}'.")
|
||||
|
||||
if action == "rotate":
|
||||
token = Camera.new_token()
|
||||
camera.set_token(token)
|
||||
camera.save(update_fields=["token_hash"])
|
||||
self.stdout.write(self.style.SUCCESS(f"Neuer Token für '{camera.name}':"))
|
||||
self.stdout.write(self.style.WARNING(f" {token}"))
|
||||
self.stdout.write("Der alte Token ist ab sofort ungültig.")
|
||||
return
|
||||
|
||||
camera.active = action == "enable"
|
||||
camera.save(update_fields=["active"])
|
||||
state = "aktiviert" if camera.active else "stillgelegt"
|
||||
self.stdout.write(self.style.SUCCESS(f"'{camera.name}' {state}."))
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Erzeugt plausible Beispiel-Sichtungen, damit sich die Oberfläche vor dem
|
||||
ersten echten Vogel beurteilen lässt.
|
||||
|
||||
Bewusst kein Teil des Normalbetriebs: ``--clear`` räumt alles wieder weg.
|
||||
Die Bilder sind die Referenzfotos aus Wikipedia, keine echten Kameraaufnahmen
|
||||
— die Sichtungen sind also erkennbar Attrappe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from dex import scoring
|
||||
from dex.models import DexEntry, EarnedBadge, Sighting, Species
|
||||
|
||||
# Wie wahrscheinlich taucht eine Seltenheitsstufe auf? Grob an einem echten
|
||||
# Garten orientiert: Kohlmeisen im Dauerbetrieb, der Sperber einmal im Monat.
|
||||
RARITY_WEIGHT = {
|
||||
"haeufig": 60,
|
||||
"gelegentlich": 22,
|
||||
"selten": 6,
|
||||
"episch": 1.2,
|
||||
"legendaer": 0.3,
|
||||
}
|
||||
|
||||
# Vögel am Futterhaus haben zwei Stoßzeiten: kurz nach Sonnenaufgang und am
|
||||
# späten Nachmittag.
|
||||
HOUR_WEIGHT = [
|
||||
0.1, 0.1, 0.1, 0.2, 0.6, 2.5, 6.0, 9.0, 8.0, 6.0, 4.5, 3.5,
|
||||
3.0, 3.2, 4.0, 5.5, 7.0, 6.5, 4.0, 1.5, 0.5, 0.2, 0.1, 0.1,
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Legt Beispiel-Sichtungen an (nur zum Ansehen der Oberfläche)."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--tage", type=int, default=21)
|
||||
parser.add_argument("--pro-tag", type=int, default=9)
|
||||
parser.add_argument(
|
||||
"--clear",
|
||||
action="store_true",
|
||||
help="Alle Sichtungen, Dex-Einträge und Abzeichen löschen.",
|
||||
)
|
||||
parser.add_argument("--seed", type=int, default=1312)
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
if opts["clear"]:
|
||||
n = Sighting.objects.count()
|
||||
Sighting.objects.all().delete()
|
||||
DexEntry.objects.all().delete()
|
||||
EarnedBadge.objects.all().delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"{n} Sichtungen gelöscht."))
|
||||
return
|
||||
|
||||
if Sighting.objects.exists():
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"Es gibt bereits Sichtungen. Erst 'demo_data --clear' ausführen, "
|
||||
"sonst mischen sich Demo- und Echtdaten."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
species = list(Species.objects.all())
|
||||
if not species:
|
||||
self.stdout.write(self.style.ERROR("Katalog leer — erst 'seed_dex'."))
|
||||
return
|
||||
|
||||
rng = random.Random(opts["seed"])
|
||||
weights = [RARITY_WEIGHT.get(s.rarity, 1) for s in species]
|
||||
|
||||
# Chronologisch anlegen, damit Erstfänge, XP und Abzeichen in der
|
||||
# richtigen Reihenfolge verbucht werden.
|
||||
now = timezone.localtime()
|
||||
events = []
|
||||
for day_offset in range(opts["tage"], -1, -1):
|
||||
day = (now - timedelta(days=day_offset)).date()
|
||||
for _ in range(rng.randint(max(1, opts["pro_tag"] - 4), opts["pro_tag"] + 4)):
|
||||
hour = rng.choices(range(24), weights=HOUR_WEIGHT)[0]
|
||||
start = timezone.make_aware(
|
||||
timezone.datetime.combine(day, timezone.datetime.min.time())
|
||||
) + timedelta(hours=hour, minutes=rng.randint(0, 59), seconds=rng.randint(0, 59))
|
||||
if start > timezone.now():
|
||||
continue
|
||||
events.append(start)
|
||||
|
||||
events.sort()
|
||||
created = 0
|
||||
for start in events:
|
||||
sp = rng.choices(species, weights=weights)[0]
|
||||
|
||||
# Ein Teil der Besuche bleibt unbestimmt — so sieht es im Betrieb
|
||||
# auch aus, und die Oberfläche muss den Fall aushalten.
|
||||
unbestimmt = rng.random() < 0.12
|
||||
confidence = 0.0 if unbestimmt else rng.uniform(0.55, 0.99)
|
||||
duration = rng.uniform(3, 75)
|
||||
|
||||
sighting = Sighting(
|
||||
species=None if unbestimmt else sp,
|
||||
confidence=confidence,
|
||||
started_at=start,
|
||||
ended_at=start + timedelta(seconds=duration),
|
||||
frames_total=int(duration * 4),
|
||||
frames_with_bird=int(duration * 4 * rng.uniform(0.5, 0.95)),
|
||||
votes={sp.slug: round(confidence, 3)} if not unbestimmt else {},
|
||||
mean_bird_score=rng.uniform(0.4, 0.95),
|
||||
)
|
||||
|
||||
if not unbestimmt and sp.reference_image:
|
||||
try:
|
||||
sp.reference_image.open("rb")
|
||||
data = sp.reference_image.read()
|
||||
sp.reference_image.close()
|
||||
sighting.image.save(f"demo-{sp.slug}.jpg", ContentFile(data), save=False)
|
||||
sighting.crop.save(f"demo-{sp.slug}.jpg", ContentFile(data), save=False)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
sighting.save()
|
||||
scoring.register(sighting)
|
||||
created += 1
|
||||
|
||||
p = scoring.progress()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"{created} Beispiel-Sichtungen angelegt. "
|
||||
f"Level {p.level} ({p.rank}), {p.xp} XP, "
|
||||
f"{p.species_seen}/{p.species_total} Arten, {p.badges} Abzeichen."
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Holt für jede Art ein Referenzfoto aus der deutschen Wikipedia.
|
||||
|
||||
Damit weiß man, wonach man Ausschau hält, bevor eine Art zum ersten Mal selbst
|
||||
vor der Kamera saß. Urheber und Lizenz werden mitgespeichert und in der
|
||||
Oberfläche angezeigt — die Bilder stehen fast alle unter CC-BY-SA und die
|
||||
Namensnennung ist Bedingung, nicht Höflichkeit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management.base import BaseCommand
|
||||
from PIL import Image
|
||||
|
||||
from dex.models import Species
|
||||
|
||||
UA = "VogelalbumBot/1.0 (https://gaertner.onl; webmaster@gaertner.onl)"
|
||||
WP_API = "https://de.wikipedia.org/w/api.php"
|
||||
COMMONS_API = "https://commons.wikimedia.org/w/api.php"
|
||||
|
||||
MAX_WIDTH = 900
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _get(url: str, params: dict) -> dict:
|
||||
full = f"{url}?{urllib.parse.urlencode(params)}"
|
||||
req = urllib.request.Request(full, headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def _strip_html(value: str) -> str:
|
||||
"""Der Artist kommt als HTML-Schnipsel mit Link. Wir wollen nur den Namen."""
|
||||
text = TAG_RE.sub("", value or "")
|
||||
return " ".join(text.split())[:250]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Lädt Referenzfotos samt Lizenzangaben aus Wikipedia/Commons."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Auch Arten neu laden, die schon ein Referenzfoto haben.",
|
||||
)
|
||||
parser.add_argument("--only", help="Nur diese Art (Slug).")
|
||||
parser.add_argument(
|
||||
"--delay",
|
||||
type=float,
|
||||
default=0.6,
|
||||
help="Pause zwischen Anfragen; Wikipedia bittet um Zurückhaltung.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
qs = Species.objects.all()
|
||||
if opts["only"]:
|
||||
qs = qs.filter(slug=opts["only"])
|
||||
if not opts["force"]:
|
||||
qs = qs.filter(reference_image="")
|
||||
|
||||
ok = skipped = failed = 0
|
||||
for species in qs:
|
||||
try:
|
||||
if self._fetch(species, opts["delay"]):
|
||||
ok += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" ✓ {species.german}"))
|
||||
else:
|
||||
skipped += 1
|
||||
self.stdout.write(f" – {species.german}: kein Bild gefunden")
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
self.stdout.write(self.style.WARNING(f" ✗ {species.german}: {exc}"))
|
||||
time.sleep(opts["delay"])
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\n{ok} geladen, {skipped} ohne Bild, {failed} Fehler.")
|
||||
)
|
||||
|
||||
def _lead_image(self, title: str) -> dict | None:
|
||||
page = _get(
|
||||
WP_API,
|
||||
{
|
||||
"action": "query",
|
||||
"titles": title,
|
||||
"prop": "pageimages",
|
||||
"piprop": "original|name",
|
||||
"format": "json",
|
||||
"formatversion": "2",
|
||||
"redirects": "1",
|
||||
},
|
||||
)
|
||||
pages = page.get("query", {}).get("pages", [])
|
||||
if not pages or "original" not in pages[0]:
|
||||
return None
|
||||
return pages[0]
|
||||
|
||||
def _fetch(self, species: Species, delay: float) -> bool:
|
||||
# 1. Artikelbild der deutschen Wikipedia finden. Der deutsche Name ist
|
||||
# meist das Artikellemma — außer bei Gimpel, Kleiber, Sperber, Star:
|
||||
# das sind Begriffsklärungsseiten ohne Bild. Der wissenschaftliche
|
||||
# Name leitet dort zuverlässig auf den Artikel weiter.
|
||||
page = self._lead_image(species.german)
|
||||
if page is None:
|
||||
time.sleep(delay)
|
||||
page = self._lead_image(species.scientific)
|
||||
if page is None:
|
||||
return False
|
||||
|
||||
img_url = page["original"]["source"].split("?", 1)[0]
|
||||
file_name = page.get("pageimage")
|
||||
|
||||
# 2. Lizenz und Urheber von Commons nachschlagen.
|
||||
credit = license_name = source = ""
|
||||
if file_name:
|
||||
time.sleep(delay)
|
||||
meta = _get(
|
||||
COMMONS_API,
|
||||
{
|
||||
"action": "query",
|
||||
"titles": f"File:{file_name}",
|
||||
"prop": "imageinfo",
|
||||
"iiprop": "extmetadata|url",
|
||||
"format": "json",
|
||||
"formatversion": "2",
|
||||
},
|
||||
)
|
||||
info = meta.get("query", {}).get("pages", [{}])[0].get("imageinfo")
|
||||
if info:
|
||||
ext = info[0].get("extmetadata", {})
|
||||
credit = _strip_html(ext.get("Artist", {}).get("value", ""))
|
||||
license_name = _strip_html(
|
||||
ext.get("LicenseShortName", {}).get("value", "")
|
||||
)
|
||||
source = info[0].get("descriptionurl", "")
|
||||
|
||||
# 3. Bild holen und auf vernünftige Größe bringen.
|
||||
req = urllib.request.Request(img_url, headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(req, timeout=45) as resp:
|
||||
raw = resp.read()
|
||||
|
||||
img = Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
if img.width > MAX_WIDTH:
|
||||
img = img.resize(
|
||||
(MAX_WIDTH, round(img.height * MAX_WIDTH / img.width)), Image.LANCZOS
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85, optimize=True)
|
||||
|
||||
species.reference_image.save(
|
||||
f"{species.slug}.jpg", ContentFile(buf.getvalue()), save=False
|
||||
)
|
||||
species.reference_credit = credit
|
||||
species.reference_license = license_name
|
||||
species.reference_source = source
|
||||
species.save(
|
||||
update_fields=[
|
||||
"reference_image",
|
||||
"reference_credit",
|
||||
"reference_license",
|
||||
"reference_source",
|
||||
]
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Füllt Artenkatalog und Abzeichen. Idempotent — beliebig oft ausführbar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from dex import scoring
|
||||
from dex.models import Species
|
||||
|
||||
def _steckbrief(z) -> dict:
|
||||
"""Steckbrief-Felder aus einem Zusatz-Eintrag, oder leer."""
|
||||
if z is None:
|
||||
return {}
|
||||
return {
|
||||
"kategorie": z.kategorie, "beschreibung": z.text,
|
||||
"nahrung": z.nahrung, "lebensraum": z.lebensraum, "stimme": z.stimme,
|
||||
"kategorie_en": z.kategorie_en, "beschreibung_en": z.text_en,
|
||||
"nahrung_en": z.nahrung_en, "lebensraum_en": z.lebensraum_en,
|
||||
"stimme_en": z.stimme_en,
|
||||
}
|
||||
|
||||
|
||||
# Muss zu RARITY_XP in detector/species_de.py passen.
|
||||
RARITY_XP = {
|
||||
"haeufig": 10, "gelegentlich": 25, "selten": 60, "episch": 150, "legendaer": 400,
|
||||
}
|
||||
|
||||
def find_catalog_root() -> Path | None:
|
||||
"""Sucht das Verzeichnis, das ``detector/species_de.py`` enthält.
|
||||
|
||||
Lokal liegt es eine Ebene über ``web/``, im Container direkt neben
|
||||
``manage.py`` — statt beide Fälle zu raten, einfach aufwärts suchen.
|
||||
"""
|
||||
for base in Path(__file__).resolve().parents:
|
||||
if (base / "detector" / "species_de.py").exists():
|
||||
return base
|
||||
return None
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Legt Arten und Abzeichen an bzw. aktualisiert sie."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# detector.species_de ist die einzige Quelle der Wahrheit für den
|
||||
# Katalog. Das Modul hängt nur an der Stdlib, der Import kostet die
|
||||
# Web-App also keine schweren Abhängigkeiten.
|
||||
root = find_catalog_root()
|
||||
if root is None:
|
||||
self.stderr.write(
|
||||
self.style.ERROR(
|
||||
"detector/species_de.py nicht gefunden — "
|
||||
"weder neben manage.py noch in einem übergeordneten Verzeichnis."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
sys.path.insert(0, str(root))
|
||||
try:
|
||||
from detector.species_de import SPECIES
|
||||
except ImportError as exc:
|
||||
self.stderr.write(self.style.ERROR(f"Artenkatalog nicht ladbar: {exc}"))
|
||||
return
|
||||
|
||||
try:
|
||||
from detector.species_extra import EXTRA
|
||||
except ImportError:
|
||||
EXTRA = []
|
||||
|
||||
try:
|
||||
from detector.species_info import INFO
|
||||
except ImportError:
|
||||
INFO = {}
|
||||
self.stdout.write(
|
||||
self.style.WARNING("Keine Steckbriefe gefunden — Arten bleiben knapp.")
|
||||
)
|
||||
|
||||
try:
|
||||
from detector.species_info_en import INFO_EN
|
||||
except ImportError:
|
||||
INFO_EN = {}
|
||||
|
||||
# Steckbriefe fuer die erweiterten Arten — beide Sprachen in einem
|
||||
# Datensatz, weil sie zusammen geschrieben werden.
|
||||
try:
|
||||
from detector.profiles import INFO_EXTRA
|
||||
except ImportError:
|
||||
INFO_EXTRA = {}
|
||||
|
||||
# Die Dex-Nummer soll stabil bleiben, damit #012 immer dieselbe Art
|
||||
# ist. Sortiert nach Seltenheit, dann alphabetisch — neue Arten
|
||||
# verschieben also Nummern, aber nur innerhalb ihrer Stufe.
|
||||
order = {"haeufig": 0, "gelegentlich": 1, "selten": 2, "episch": 3, "legendaer": 4}
|
||||
# Kernarten zuerst, dann die Erweiterung — so behalten die Gartenvögel
|
||||
# ihre niedrigen Dex-Nummern, auch wenn der Katalog wächst.
|
||||
geordnet = sorted(SPECIES, key=lambda s: (order.get(s.rarity, 9), s.german))
|
||||
geordnet_extra = sorted(EXTRA, key=lambda e: (order.get(e.rarity, 9), e.german))
|
||||
|
||||
created = updated = mit_text = 0
|
||||
for nummer, s in enumerate(geordnet, start=1):
|
||||
info = INFO.get(s.slug)
|
||||
defaults = {
|
||||
"german": s.german,
|
||||
"scientific": s.scientific,
|
||||
"english": s.english,
|
||||
"rarity": s.rarity,
|
||||
"xp": s.xp,
|
||||
"fact": s.fact,
|
||||
"size_cm": s.size_cm,
|
||||
"number": nummer,
|
||||
"lebensraum_typ": s.lebensraum,
|
||||
"regions": ["de"],
|
||||
}
|
||||
if info is not None:
|
||||
mit_text += 1
|
||||
defaults |= {
|
||||
"typ": info.typ,
|
||||
"kategorie": info.kategorie,
|
||||
"beschreibung": info.text,
|
||||
"gewicht_g": info.gewicht_g,
|
||||
"spannweite_cm": info.spannweite_cm,
|
||||
"zug": info.zug,
|
||||
"nahrung": info.nahrung,
|
||||
"lebensraum": info.lebensraum,
|
||||
"stimme": info.stimme,
|
||||
# Verweise auf Arten, die es (noch) nicht gibt, fliegen raus —
|
||||
# sonst zeigt die Seite Links ins Leere.
|
||||
"verwechslung": [
|
||||
v for v in info.verwechslung
|
||||
if any(x.slug == v for x in SPECIES)
|
||||
],
|
||||
}
|
||||
|
||||
info_en = INFO_EN.get(s.slug)
|
||||
if info_en is not None:
|
||||
defaults |= {
|
||||
"kategorie_en": info_en.kategorie,
|
||||
"beschreibung_en": info_en.text,
|
||||
"nahrung_en": info_en.nahrung,
|
||||
"lebensraum_en": info_en.lebensraum,
|
||||
"stimme_en": info_en.stimme,
|
||||
}
|
||||
|
||||
_, was_created = Species.objects.update_or_create(slug=s.slug, defaults=defaults)
|
||||
created += was_created
|
||||
updated += not was_created
|
||||
|
||||
# Erweiterte Arten: nur Strukturdaten, kein Erzähltext. Vorhandene
|
||||
# Texte werden nicht überschrieben — wer einen Steckbrief nachträgt,
|
||||
# verliert ihn beim nächsten Seed nicht.
|
||||
naechste = len(geordnet)
|
||||
for offset, e in enumerate(geordnet_extra, start=1):
|
||||
obj, was_created = Species.objects.update_or_create(
|
||||
slug=e.slug,
|
||||
defaults={
|
||||
"german": e.german,
|
||||
"scientific": e.scientific,
|
||||
"english": e.english,
|
||||
"rarity": e.rarity,
|
||||
"xp": RARITY_XP.get(e.rarity, 10),
|
||||
"size_cm": e.size_cm,
|
||||
"number": naechste + offset,
|
||||
"typ": e.typ,
|
||||
"gewicht_g": e.gewicht_g,
|
||||
"spannweite_cm": e.spannweite_cm,
|
||||
"zug": e.zug,
|
||||
"lebensraum_typ": e.lebensraum,
|
||||
"regions": ["de"],
|
||||
}
|
||||
| _steckbrief(INFO_EXTRA.get(e.slug)),
|
||||
)
|
||||
created += was_created
|
||||
updated += not was_created
|
||||
|
||||
badges = scoring.sync_badges()
|
||||
gesamt = len(SPECIES) + len(EXTRA)
|
||||
mit_text += sum(1 for e in EXTRA if e.slug in INFO_EXTRA)
|
||||
mit_en = sum(1 for s in SPECIES if s.slug in INFO_EN)
|
||||
mit_en += sum(1 for e in EXTRA if e.slug in INFO_EXTRA)
|
||||
ohne_text = gesamt - mit_text
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Arten: {created} neu, {updated} aktualisiert, "
|
||||
f"{mit_text} von {gesamt} mit Steckbrief ({mit_en} auch auf Englisch). "
|
||||
f"Abzeichen: {badges} neu."
|
||||
)
|
||||
)
|
||||
if ohne_text:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"{ohne_text} Arten noch ohne Steckbrief")
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Setzt die Art einer Sichtung richtig — samt allem, was daran hängt.
|
||||
|
||||
🔑 Warum ein eigener Befehl und nicht nur das Feld im Admin: An einer
|
||||
Bestimmung hängt mehr als der Name. Der Dex-Eintrag der falschen Art
|
||||
bleibt sonst stehen, der Erstfang wird nicht zurückgenommen, und die
|
||||
dreifachen Punkte bleiben vergeben. Wer nur das Feld ändert, hat danach
|
||||
eine Art in der Sammlung, die nie da war.
|
||||
|
||||
Am 30.09.2026 hat das Modell eine Kohlmeise mit **94,5 % Sicherheit** als
|
||||
Blaumeise bestimmt. Am Foto war es zu sehen:
|
||||
|
||||
gelbe Brust mit dunklem Streifen → Kohlmeise
|
||||
Kopf komplett verwischt → das entscheidende
|
||||
Merkmal fehlte
|
||||
|
||||
⚠️ **Hohe Sicherheit heißt nicht richtig.** Das Modell musste ohne die
|
||||
blaue Kappe entscheiden und war sich trotzdem sicher.
|
||||
|
||||
manage.py sichtung_bestimmen 228 kohlmeise --trocken
|
||||
manage.py sichtung_bestimmen 228 kohlmeise
|
||||
|
||||
📌 Die Punkte werden neu gerechnet, nicht geschätzt — mit derselben
|
||||
Formel, die bei der Aufnahme gilt (``scoring``). Sonst laufen Vergabe und
|
||||
Korrektur auseinander.
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
|
||||
from dex import scoring
|
||||
from dex.models import Camera, DexEntry, Sighting, Species
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Setzt die Art einer Sichtung richtig und repariert die Sammlung"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("id", type=int, help="ID der Sichtung")
|
||||
parser.add_argument("art", help="Kürzel der richtigen Art, z.B. kohlmeise")
|
||||
parser.add_argument(
|
||||
"--trocken", action="store_true",
|
||||
help="Nur anzeigen, was passieren würde",
|
||||
)
|
||||
|
||||
def handle(self, *args, **optionen):
|
||||
trocken = optionen["trocken"]
|
||||
|
||||
try:
|
||||
sichtung = Sighting.objects.select_related(
|
||||
"species", "confirmed_species", "camera").get(pk=optionen["id"])
|
||||
except Sighting.DoesNotExist:
|
||||
raise CommandError(f"Sichtung {optionen['id']} gibt es nicht.")
|
||||
|
||||
try:
|
||||
richtig = Species.objects.get(slug=optionen["art"])
|
||||
except Species.DoesNotExist:
|
||||
raise CommandError(
|
||||
f"Art '{optionen['art']}' gibt es nicht. "
|
||||
f"Kürzel siehe Admin oder: manage.py shell")
|
||||
|
||||
bisher = sichtung.effective_species
|
||||
if bisher is not None and bisher.pk == richtig.pk:
|
||||
self.stdout.write(f"Sichtung {sichtung.pk} ist bereits "
|
||||
f"{richtig.german} — nichts zu tun.")
|
||||
return
|
||||
|
||||
self.stdout.write(
|
||||
f"Sichtung {sichtung.pk} · "
|
||||
f"{sichtung.started_at:%d.%m.%Y %H:%M:%S} · "
|
||||
f"{sichtung.camera.name if sichtung.camera else 'ohne Kamera'}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f" erkannt als {bisher.german if bisher else 'unbestimmt'} "
|
||||
f"({sichtung.confidence:.3f} Sicherheit)"
|
||||
)
|
||||
self.stdout.write(f" richtig ist {richtig.german}")
|
||||
|
||||
if sichtung.is_first_of_species:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
"⚠ Das galt als ERSTFANG — dreifache Punkte. Die werden "
|
||||
"neu gerechnet."
|
||||
))
|
||||
|
||||
punkte_neu = self._punkte(sichtung, richtig)
|
||||
self.stdout.write(f" Punkte {sichtung.xp_awarded} → {punkte_neu}")
|
||||
|
||||
if trocken:
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
"[trocken] Es wurde nichts geändert."))
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
alt = bisher
|
||||
sichtung.confirmed_species = richtig
|
||||
# 🔑 is_first_of_species neu bestimmen: Gibt es zu der
|
||||
# richtigen Art schon einen Eintrag, ist es kein Erstfang.
|
||||
war_erst = sichtung.is_first_of_species
|
||||
sichtung.is_first_of_species = not DexEntry.objects.filter(
|
||||
species=richtig).exclude(
|
||||
first_seen__gt=sichtung.started_at).exists()
|
||||
sichtung.xp_awarded = self._punkte(sichtung, richtig)
|
||||
sichtung.save(update_fields=["confirmed_species",
|
||||
"is_first_of_species", "xp_awarded"])
|
||||
|
||||
self._dex_reparieren(alt)
|
||||
self._dex_reparieren(richtig)
|
||||
if sichtung.camera:
|
||||
self._kamera_reparieren(sichtung.camera)
|
||||
|
||||
if war_erst and not sichtung.is_first_of_species:
|
||||
self.stdout.write(" Erstfang zurückgenommen.")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"Sichtung {sichtung.pk} ist jetzt {richtig.german}."))
|
||||
|
||||
# -- Rechnen ------------------------------------------------------
|
||||
|
||||
def _punkte(self, sichtung, art) -> int:
|
||||
"""Dieselbe Formel wie bei der Aufnahme — siehe scoring.register.
|
||||
|
||||
⚠️ Bewusst nicht nachgebaut, sondern abgeleitet: Weicht die
|
||||
Korrektur von der Vergabe ab, stimmt der Punktestand nie wieder.
|
||||
"""
|
||||
erstfang = not DexEntry.objects.filter(species=art).exclude(
|
||||
first_seen__gt=sichtung.started_at).exists()
|
||||
punkte = art.xp * (scoring.FIRST_CATCH_MULTIPLIER if erstfang else 1)
|
||||
return int(round(punkte * (0.5 + 0.5 * min(1.0, sichtung.confidence))))
|
||||
|
||||
# -- Aufräumen ----------------------------------------------------
|
||||
|
||||
def _dex_reparieren(self, art):
|
||||
"""Zählt den Eintrag einer Art neu — aus den Sichtungen.
|
||||
|
||||
🔑 Neu zählen statt herunterzählen: Ein Eintrag, der schon vorher
|
||||
falsch stand, wird dabei nebenbei richtig.
|
||||
"""
|
||||
if art is None:
|
||||
return
|
||||
# ⚠️ Über effective_species zählen, nicht über species -- sonst
|
||||
# zählt eine korrigierte Sichtung weiter bei der falschen Art.
|
||||
passend = [
|
||||
s for s in Sighting.objects.select_related(
|
||||
"species", "confirmed_species")
|
||||
if s.effective_species and s.effective_species.pk == art.pk
|
||||
]
|
||||
eintrag = DexEntry.objects.filter(species=art).first()
|
||||
|
||||
if not passend:
|
||||
if eintrag:
|
||||
eintrag.delete()
|
||||
self.stdout.write(f" Dex-Eintrag {art.german} entfernt "
|
||||
f"(keine Sichtung mehr).")
|
||||
return
|
||||
|
||||
passend.sort(key=lambda s: s.started_at)
|
||||
# 🔑 Bestes Foto nach Bildguete, nicht nach Sicherheit.
|
||||
beste = scoring.beste_sichtung(passend)
|
||||
if eintrag is None:
|
||||
DexEntry.objects.create(
|
||||
species=art, first_seen=passend[0].started_at,
|
||||
last_seen=passend[-1].started_at, count=len(passend),
|
||||
best_sighting=beste)
|
||||
self.stdout.write(f" Dex-Eintrag {art.german} angelegt "
|
||||
f"({len(passend)}×).")
|
||||
return
|
||||
|
||||
eintrag.count = len(passend)
|
||||
eintrag.first_seen = passend[0].started_at
|
||||
eintrag.last_seen = passend[-1].started_at
|
||||
eintrag.best_sighting = beste
|
||||
eintrag.save()
|
||||
self.stdout.write(f" Dex-Eintrag {art.german}: jetzt "
|
||||
f"{eintrag.count}×, beste Sichtung {beste.pk}.")
|
||||
|
||||
def _kamera_reparieren(self, kamera):
|
||||
"""`last_seen` ist die letzte SICHTUNG, nicht der letzte Kontakt.
|
||||
|
||||
⚠️ Nicht mit `letzter_kontakt` verwechseln — das ist der Herzschlag
|
||||
und bleibt unberührt.
|
||||
"""
|
||||
zuletzt = Sighting.objects.filter(camera=kamera).aggregate(
|
||||
z=Max("ended_at"))["z"]
|
||||
Camera.objects.filter(pk=kamera.pk).update(last_seen=zuletzt)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Entfernt eine Sichtung — samt allem, was aus ihr abgeleitet wurde.
|
||||
|
||||
🔑 Warum das ein eigener Befehl ist und kein Löschen im Admin: An einer
|
||||
Sichtung hängt mehr als ihre eigene Zeile. Der Dex-Eintrag zählt sie mit,
|
||||
die Kamera merkt sich den Zeitpunkt, die Punkte stecken im Datensatz, und
|
||||
zwei Bilddateien liegen auf der Platte. Wer nur die Zeile löscht, lässt
|
||||
einen Dex-Eintrag zurück, der eine Sichtung zu viel zählt.
|
||||
|
||||
Gedacht für Fehlerkennungen. Die erste war am 24.09.2026: Die Kamera für
|
||||
die Vogeltränke lag noch auf dem Schreibtisch und hat Teile der
|
||||
Befestigung für eine Kohlmeise gehalten — ein einziges von 73 Bildern
|
||||
hatte einen Vogel-Wert über der Schwelle, und der lag bei 0,098.
|
||||
|
||||
⚠ Die Punkte der Sichtung verrechnet niemand gesondert: Der Gesamtstand
|
||||
summiert ``xp_awarded``, also verschwinden sie mit der Sichtung von selbst.
|
||||
|
||||
🔴 **Für die Punkte eines Abzeichens gilt das NICHT** — und bis zum
|
||||
11.10.2026 stand hier das Gegenteil. ``scoring.total_xp()`` zählt
|
||||
``Sighting.xp_awarded`` **und** ``EarnedBadge.badge__xp`` zusammen. Ein
|
||||
Abzeichen, das aus der entfernten Sichtung stammt, lässt seine Punkte also
|
||||
stehen. An jenem Tag waren das **200 Punkte für „Seltener Gast"** aus einer
|
||||
Fehlerkennung — mehr als die 144 der Sichtung selbst, und sie haben eine
|
||||
Stufe getragen, die es nicht gab.
|
||||
|
||||
🔑 Abzeichen bleiben trotzdem die Vorgabe: Ein einmal verdientes Abzeichen
|
||||
nimmt man einem Kind nicht wieder weg. Aber **stillschweigend darf das nicht
|
||||
passieren** — der Befehl nennt sie jetzt samt Punkten, und ``--abzeichen``
|
||||
nimmt sie mit, wenn sie nie verdient waren.
|
||||
|
||||
manage.py sichtung_entfernen 199 --trocken # erst schauen
|
||||
manage.py sichtung_entfernen 199 # dann löschen
|
||||
manage.py sichtung_entfernen 199 --abzeichen # samt Abzeichen daraus
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
|
||||
from dex import scoring
|
||||
from dex.models import Camera, DexEntry, EarnedBadge, Sighting
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Entfernt eine Sichtung und repariert, was aus ihr abgeleitet wurde"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("id", type=int, help="ID der Sichtung")
|
||||
parser.add_argument(
|
||||
"--trocken", action="store_true",
|
||||
help="Nur anzeigen, was passieren würde",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--abzeichen", action="store_true",
|
||||
help="Abzeichen aus dieser Sichtung mitnehmen (samt ihrer Punkte)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **optionen):
|
||||
trocken = optionen["trocken"]
|
||||
abzeichen_mit = optionen["abzeichen"]
|
||||
|
||||
try:
|
||||
sichtung = Sighting.objects.select_related(
|
||||
"species", "camera").get(pk=optionen["id"])
|
||||
except Sighting.DoesNotExist:
|
||||
raise CommandError(f"Sichtung {optionen['id']} gibt es nicht.")
|
||||
|
||||
art = sichtung.species
|
||||
kamera = sichtung.camera
|
||||
|
||||
self.stdout.write(
|
||||
f"Sichtung {sichtung.pk}: "
|
||||
f"{art.german if art else 'unbestimmt'} · "
|
||||
f"{kamera.name if kamera else 'ohne Kamera'} · "
|
||||
f"{sichtung.started_at:%d.%m.%Y %H:%M:%S} · "
|
||||
f"{sichtung.confidence:.3f} Sicherheit · "
|
||||
f"{sichtung.frames_with_bird} von {sichtung.frames_total} Bildern "
|
||||
f"mit Vogel · {sichtung.xp_awarded} Punkte"
|
||||
)
|
||||
|
||||
# ⚠️ Eine Erstsichtung wiegt schwerer: Mit ihr fällt der Tag, an dem
|
||||
# die Art zum ersten Mal da war. Deshalb ausdrücklich benennen statt
|
||||
# stillschweigend mitlöschen.
|
||||
if sichtung.is_first_of_species:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
"⚠ Das ist die ERSTSICHTUNG dieser Art. Der Dex-Eintrag "
|
||||
"rückt auf die nächstältere Sichtung nach."
|
||||
))
|
||||
|
||||
dateien = [f for f in (sichtung.image, sichtung.crop) if f]
|
||||
for datei in dateien:
|
||||
self.stdout.write(f" Datei: {datei.name}")
|
||||
|
||||
# ⚠️ VOR dem Löschen einsammeln: ``EarnedBadge.sighting`` ist
|
||||
# SET_NULL. Nach dem Löschen ist die Spur zur Sichtung weg, und dann
|
||||
# weiß niemand mehr, welches Abzeichen aus ihr stammte.
|
||||
verdiente = list(EarnedBadge.objects.filter(
|
||||
sighting=sichtung).select_related("badge"))
|
||||
self._abzeichen_nennen(verdiente, abzeichen_mit)
|
||||
|
||||
if trocken:
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
"[trocken] Es wurde nichts geändert."))
|
||||
self._vorschau(sichtung, art, kamera)
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
sichtung_id = sichtung.pk
|
||||
for datei in dateien:
|
||||
datei.delete(save=False) # Platte, nicht nur Datensatz
|
||||
sichtung.delete()
|
||||
|
||||
if abzeichen_mit:
|
||||
for verdient in verdiente:
|
||||
# Nur das Verdienst, nie der Katalogeintrag: Das Abzeichen
|
||||
# als solches bleibt natürlich erreichbar.
|
||||
verdient.delete()
|
||||
|
||||
self._dex_reparieren(art)
|
||||
self._kamera_reparieren(kamera)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"Sichtung {sichtung_id} entfernt."))
|
||||
|
||||
# -- Aufräumen ----------------------------------------------------
|
||||
|
||||
def _dex_reparieren(self, art):
|
||||
"""Zählt den Dex-Eintrag neu — aus den Sichtungen, nicht per -1.
|
||||
|
||||
🔑 Neu zählen statt herunterzählen: Ein Eintrag, der schon vorher
|
||||
falsch stand, wird dadurch nebenbei richtig. Ein '-1' würde den
|
||||
Fehler mitschleppen.
|
||||
"""
|
||||
if art is None:
|
||||
return
|
||||
eintrag = DexEntry.objects.filter(species=art).first()
|
||||
if eintrag is None:
|
||||
return
|
||||
|
||||
uebrig = Sighting.objects.filter(species=art)
|
||||
if not uebrig.exists():
|
||||
eintrag.delete()
|
||||
self.stdout.write(" Dex-Eintrag entfernt (keine Sichtung mehr).")
|
||||
return
|
||||
|
||||
eintrag.count = uebrig.count()
|
||||
eintrag.first_seen = uebrig.earliest("started_at").started_at
|
||||
eintrag.last_seen = uebrig.latest("started_at").started_at
|
||||
# 🔑 Bestes Foto nach Bildguete, nicht nach Sicherheit -- siehe
|
||||
# scoring.bildguete.
|
||||
eintrag.best_sighting = scoring.beste_sichtung(uebrig)
|
||||
eintrag.save()
|
||||
self.stdout.write(
|
||||
f" Dex-Eintrag: jetzt {eintrag.count}×, zuletzt "
|
||||
f"{eintrag.last_seen:%d.%m.%Y %H:%M}, beste Sichtung "
|
||||
f"{eintrag.best_sighting_id}."
|
||||
)
|
||||
|
||||
def _kamera_reparieren(self, kamera):
|
||||
"""`last_seen` ist die letzte SICHTUNG, nicht der letzte Kontakt.
|
||||
|
||||
⚠️ Nicht mit `letzter_kontakt` verwechseln — das ist der Herzschlag
|
||||
und bleibt unberührt. Genau diese Verwechslung hatte am 23.09.2026
|
||||
einen Fehlalarm ausgelöst.
|
||||
"""
|
||||
if kamera is None:
|
||||
return
|
||||
zuletzt = Sighting.objects.filter(camera=kamera).aggregate(
|
||||
z=Max("ended_at"))["z"]
|
||||
Camera.objects.filter(pk=kamera.pk).update(last_seen=zuletzt)
|
||||
self.stdout.write(
|
||||
f" Kamera {kamera.name}: letzte Sichtung "
|
||||
+ (f"{zuletzt:%d.%m.%Y %H:%M}." if zuletzt else "keine mehr.")
|
||||
)
|
||||
|
||||
def _abzeichen_nennen(self, verdiente, mitnehmen):
|
||||
"""Sagt, welche Abzeichen an der Sichtung hängen — und was sie wiegen.
|
||||
|
||||
🔑 Dass hier überhaupt etwas steht, ist der eigentliche Fix vom
|
||||
11.10.2026: Vorher blieben Abzeichen **und ihre Punkte** stumm
|
||||
zurück, während die Beschreibung des Befehls das Gegenteil
|
||||
behauptete. Wer eine Fehlerkennung entfernt und danach dieselbe
|
||||
Stufe sieht, sucht den Fehler an der falschen Stelle.
|
||||
"""
|
||||
if not verdiente:
|
||||
return
|
||||
|
||||
punkte = sum(v.badge.xp for v in verdiente)
|
||||
for verdient in verdiente:
|
||||
self.stdout.write(
|
||||
f" Abzeichen: {verdient.badge.emoji} "
|
||||
f"{verdient.badge.name} ({verdient.badge.xp} Punkte)"
|
||||
)
|
||||
if mitnehmen:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"⚠ Diese Abzeichen werden mit entfernt — {punkte} Punkte "
|
||||
f"weniger."
|
||||
))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"⚠ Abzeichen BLEIBEN, samt ihren {punkte} Punkten. War die "
|
||||
f"Sichtung eine Fehlerkennung, waren sie nie verdient: "
|
||||
f"--abzeichen nimmt sie mit."
|
||||
))
|
||||
|
||||
def _vorschau(self, sichtung, art, kamera):
|
||||
if art is not None:
|
||||
uebrig = Sighting.objects.filter(species=art).exclude(
|
||||
pk=sichtung.pk)
|
||||
self.stdout.write(
|
||||
f" Dex-Eintrag {art.german}: würde auf {uebrig.count()}× "
|
||||
+ ("sinken." if uebrig.exists() else "0 fallen und wegfallen.")
|
||||
)
|
||||
if kamera is not None:
|
||||
zuletzt = Sighting.objects.filter(camera=kamera).exclude(
|
||||
pk=sichtung.pk).aggregate(z=Max("ended_at"))["z"]
|
||||
self.stdout.write(
|
||||
f" Kamera {kamera.name}: letzte Sichtung wäre "
|
||||
+ (f"{zuletzt:%d.%m.%Y %H:%M}." if zuletzt else "keine mehr.")
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Prüft, ob die Kameras noch melden — und schlägt Alarm, wenn nicht.
|
||||
|
||||
🔑 Warum das hier liegt und nicht im Detector: Ein abgestürzter Prozess kann
|
||||
seinen eigenen Tod nicht melden. Wer das Ausbleiben von Meldungen bemerken
|
||||
soll, muss von aussen schauen. Diese Seite hier läuft ohnehin.
|
||||
|
||||
Gedacht als regelmässiger Aufruf (systemd-Zeitgeber oder cron):
|
||||
|
||||
*/10 * * * * .../manage.py stoerungen_pruefen
|
||||
|
||||
⚠ Es wird pro Kamera nur EINMAL gemeldet, nicht bei jedem Durchlauf. Eine
|
||||
Störung, die alle zehn Minuten aufs Handy summt, schaltet man nach einer
|
||||
Stunde ab — und dann merkt man die nächste nicht mehr.
|
||||
|
||||
🔑 **Der Merker steht in der Datenbank, nicht im Prozess.** Hier stand
|
||||
einmal ein Modul-Set `_gemeldet`. In einem Einmal-Lauf ist das wirkungslos:
|
||||
jeder Aufruf ist ein frischer Prozess, das Set beim Start wieder leer. Die
|
||||
Zusage eine Zeile weiter oben war damit acht Monate lang unwahr, und am
|
||||
01.10.2026 hat es sich gezeigt -- nach dem Stromausfall summten beide
|
||||
Kameras im Zehnminutentakt.
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from dex import benachrichtigung as bn
|
||||
from dex.models import Benachrichtigung, Camera, Sighting
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Meldet Kameras, die sich länger nicht gemeldet haben"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--trocken", action="store_true",
|
||||
help="Nur anzeigen, nichts senden",
|
||||
)
|
||||
|
||||
def handle(self, *args, **optionen):
|
||||
einstellung = Benachrichtigung.laden()
|
||||
grenze = timezone.now() - timezone.timedelta(
|
||||
minutes=einstellung.stille_minuten)
|
||||
|
||||
# ⚠️ Mobile Geräte bleiben außen vor. Die Prüfung misst, ob sich ein
|
||||
# Gerät länger als erlaubt nicht gemeldet hat — das ergibt nur bei
|
||||
# Dauerbetrieb einen Sinn. Ein Handy meldet sich, wenn jemand
|
||||
# spazieren geht, und nach dem ersten ruhigen Wochenende stünde
|
||||
# sonst „Kamera still: Handy" auf dem Telefon. Genau so entstehen
|
||||
# Fehlalarme, die man abschaltet — und dann bemerkt man den echten
|
||||
# Ausfall nicht mehr.
|
||||
for kamera in Camera.objects.filter(active=True, mobil=False):
|
||||
# ⚠ Gemessen wird das LEBENSZEICHEN, nicht die letzte Sichtung.
|
||||
# An last_seen zu messen waere der naheliegende Fehler: An
|
||||
# einem ruhigen Nachmittag kommt stundenlang kein Vogel, und
|
||||
# es gaebe jeden Tag Fehlalarm. (Genau so hatte ich es zuerst
|
||||
# gebaut -- der Trockenlauf meldete prompt "still seit 824
|
||||
# min" fuer eine tadellos laufende Kamera.)
|
||||
if kamera.letzter_kontakt is None:
|
||||
# Noch nie gemeldet. Das ist keine Stoerung, sondern eine
|
||||
# Kamera, die gerade erst eingerichtet wurde.
|
||||
self.stdout.write(f"{kamera.name}: noch kein Lebenszeichen")
|
||||
continue
|
||||
|
||||
still_seit = timezone.now() - kamera.letzter_kontakt
|
||||
minuten = int(still_seit.total_seconds() // 60)
|
||||
|
||||
if kamera.letzter_kontakt < grenze:
|
||||
if kamera.stoerung_gemeldet_seit is not None:
|
||||
self.stdout.write(f"{kamera.name}: still ({minuten} min), "
|
||||
"schon gemeldet")
|
||||
continue
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f"{kamera.name}: STILL seit {minuten} min"))
|
||||
if not optionen["trocken"]:
|
||||
bn.melde_stoerung(
|
||||
f"Kamera still: {kamera.name}",
|
||||
f"Seit {minuten} Minuten kein Lebenszeichen. "
|
||||
f"Läuft die Erkennung noch?")
|
||||
# ⚠ Erst nach dem Senden vermerken. melde_stoerung
|
||||
# sendet synchron und schluckt Fehler nur im Log --
|
||||
# wer vorher vermerkt, verliert bei einem Aussetzer
|
||||
# des Meldewegs die Stoerung still und fuer immer.
|
||||
kamera.stoerung_gemeldet_seit = timezone.now()
|
||||
kamera.save(update_fields=["stoerung_gemeldet_seit"])
|
||||
else:
|
||||
if kamera.stoerung_gemeldet_seit is not None:
|
||||
# Wieder da — das gehört auch gemeldet, sonst weiss
|
||||
# niemand, ob das Problem noch besteht.
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"{kamera.name}: wieder da"))
|
||||
if not optionen["trocken"]:
|
||||
bn.melde_stoerung(
|
||||
f"Kamera wieder da: {kamera.name}",
|
||||
"Sie meldet sich wieder.")
|
||||
kamera.stoerung_gemeldet_seit = None
|
||||
kamera.save(update_fields=["stoerung_gemeldet_seit"])
|
||||
else:
|
||||
self.stdout.write(f"{kamera.name}: ok ({minuten} min her)")
|
||||
|
||||
self._wartende_fotos(einstellung, optionen["trocken"])
|
||||
|
||||
def _wartende_fotos(self, einstellung, trocken: bool) -> None:
|
||||
"""Meldet, wenn hochgeladene Fotos zu lange auf den Detector warten.
|
||||
|
||||
🔑 **Das ist die Stillstandsprüfung für den Detector selbst.** Die
|
||||
Kameraprüfung oben merkt, wenn ein Gerät nichts mehr liefert — sie
|
||||
merkt aber nicht, wenn der Detector steht, denn der meldet sich bei
|
||||
niemandem an. Liegengebliebene Fotos sind das einzige sichtbare
|
||||
Zeichen dafür, und sie sagen es unmissverständlich: Jemand hat
|
||||
hochgeladen, und seitdem ist nichts passiert.
|
||||
|
||||
📌 Verworfen wird nichts. Das Foto vom Spaziergang gibt es kein
|
||||
zweites Mal, und stilles Löschen wäre der schlechteste Umgang damit.
|
||||
"""
|
||||
wartend = Sighting.objects.wartend()
|
||||
aeltestes = wartend.first()
|
||||
|
||||
if aeltestes is None:
|
||||
if einstellung.fotos_gemeldet_seit is not None:
|
||||
self.stdout.write(self.style.SUCCESS("Fotos: alle bearbeitet"))
|
||||
if not trocken:
|
||||
bn.melde_stoerung(
|
||||
"Fotos wieder bearbeitet",
|
||||
"Die liegengebliebenen Fotos sind bestimmt.")
|
||||
einstellung.fotos_gemeldet_seit = None
|
||||
einstellung.save(update_fields=["fotos_gemeldet_seit"])
|
||||
return
|
||||
|
||||
minuten = int(
|
||||
(timezone.now() - aeltestes.started_at).total_seconds() // 60)
|
||||
anzahl = wartend.count()
|
||||
|
||||
if minuten < einstellung.stille_minuten:
|
||||
self.stdout.write(
|
||||
f"{anzahl} Foto(s) warten, ältestes {minuten} min — ok")
|
||||
return
|
||||
|
||||
if einstellung.fotos_gemeldet_seit is not None:
|
||||
self.stdout.write(
|
||||
f"{anzahl} Foto(s) warten ({minuten} min), schon gemeldet")
|
||||
return
|
||||
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f"{anzahl} Foto(s) warten auf Bestimmung, "
|
||||
f"ältestes seit {minuten} min"))
|
||||
if not trocken:
|
||||
bn.melde_stoerung(
|
||||
"Fotos warten auf Bestimmung",
|
||||
f"{anzahl} hochgeladene Foto(s) liegen seit {minuten} Minuten "
|
||||
f"unbearbeitet. Läuft der Detector noch?")
|
||||
# Erst nach dem Senden vermerken — siehe oben bei den Kameras.
|
||||
einstellung.fotos_gemeldet_seit = timezone.now()
|
||||
einstellung.save(update_fields=["fotos_gemeldet_seit"])
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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()),
|
||||
)
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 …</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
@@ -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 "")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||