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