diff --git a/backend/main.py b/backend/main.py index 0ae2338..96d0236 100644 --- a/backend/main.py +++ b/backend/main.py @@ -84,6 +84,11 @@ app.mount("/css", StaticFiles(directory=f"{STATIC_DIR}/css"), name="css") app.mount("/js", StaticFiles(directory=f"{STATIC_DIR}/js"), name="js") app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons") +# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.) +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +os.makedirs(MEDIA_DIR, exist_ok=True) +app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") + @app.get("/manifest.json") async def manifest(): return FileResponse(f"{STATIC_DIR}/manifest.json") diff --git a/backend/routes/poison.py b/backend/routes/poison.py index 7426d4f..4916a6f 100644 --- a/backend/routes/poison.py +++ b/backend/routes/poison.py @@ -1,3 +1,161 @@ -"""BAN YARO — poison Routes (Stub, wird ausgebaut)""" -from fastapi import APIRouter -router = APIRouter() +"""BAN YARO — Giftköder-Alarm Routes""" + +import os, uuid, math +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + + +# ------------------------------------------------------------------ +# Haversine-Distanz in Metern +# ------------------------------------------------------------------ +def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + R = 6_371_000 + p1 = math.radians(lat1) + p2 = math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class PoisonCreate(BaseModel): + lat: float + lon: float + beschreibung: Optional[str] = None + typ: str = "unbekannt" + + +# ------------------------------------------------------------------ +# GET /api/poison — aktive Meldungen im Umkreis (kein Login nötig) +# ------------------------------------------------------------------ +@router.get("") +async def list_poison(lat: float, lon: float, radius: int = 5000): + now = datetime.utcnow().isoformat() + with db() as conn: + rows = conn.execute( + """SELECT p.*, u.name AS melder_name + FROM poison p + LEFT JOIN users u ON u.id = p.user_id + WHERE p.geloest = 0 + AND p.expires_at > ? + ORDER BY p.created_at DESC""", + (now,) + ).fetchall() + + results = [] + for r in rows: + entry = dict(r) + dist = _haversine(lat, lon, entry["lat"], entry["lon"]) + if dist <= radius: + entry["distanz_m"] = round(dist) + results.append(entry) + + results.sort(key=lambda x: x["distanz_m"]) + return results + + +# ------------------------------------------------------------------ +# POST /api/poison — neue Meldung (Login erforderlich) +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def report_poison(data: PoisonCreate, user=Depends(get_current_user)): + expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat() + with db() as conn: + conn.execute( + """INSERT INTO poison (user_id, lat, lon, beschreibung, typ, expires_at) + VALUES (?, ?, ?, ?, ?, ?)""", + (user["id"], data.lat, data.lon, data.beschreibung, data.typ, expires_at) + ) + row = conn.execute( + "SELECT * FROM poison WHERE user_id=? ORDER BY id DESC LIMIT 1", + (user["id"],) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# POST /api/poison/{id}/confirm — Community-Bestätigung +# Verlängert die Laufzeit um 7 weitere Tage ab jetzt +# ------------------------------------------------------------------ +@router.post("/{poison_id}/confirm") +async def confirm_poison(poison_id: int, user=Depends(get_current_user)): + with db() as conn: + entry = conn.execute( + "SELECT * FROM poison WHERE id=?", (poison_id,) + ).fetchone() + if not entry: + raise HTTPException(404, "Meldung nicht gefunden.") + if dict(entry)["user_id"] == user["id"]: + raise HTTPException(400, "Eigene Meldung kann nicht bestätigt werden.") + + new_expires = (datetime.utcnow() + timedelta(days=7)).isoformat() + conn.execute( + """UPDATE poison + SET bestaetigt=1, bestaetigt_von=?, expires_at=? + WHERE id=?""", + (user["id"], new_expires, poison_id) + ) + row = conn.execute("SELECT * FROM poison WHERE id=?", (poison_id,)).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# POST /api/poison/{id}/resolve — als erledigt markieren +# Nur der Melder selbst oder ein Admin +# ------------------------------------------------------------------ +@router.post("/{poison_id}/resolve") +async def resolve_poison(poison_id: int, user=Depends(get_current_user)): + with db() as conn: + entry = conn.execute( + "SELECT * FROM poison WHERE id=?", (poison_id,) + ).fetchone() + if not entry: + raise HTTPException(404, "Meldung nicht gefunden.") + e = dict(entry) + if e["user_id"] != user["id"] and user.get("rolle") != "admin": + raise HTTPException(403, "Keine Berechtigung.") + conn.execute("UPDATE poison SET geloest=1 WHERE id=?", (poison_id,)) + return {"ok": True} + + +# ------------------------------------------------------------------ +# POST /api/poison/{id}/photo — Foto nachträglich anhängen +# Nur vom Melder selbst +# ------------------------------------------------------------------ +@router.post("/{poison_id}/photo") +async def upload_photo( + poison_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user), +): + with db() as conn: + entry = conn.execute( + "SELECT id FROM poison WHERE id=? AND user_id=?", + (poison_id, user["id"]) + ).fetchone() + if not entry: + raise HTTPException(404, "Meldung nicht gefunden oder keine Berechtigung.") + + ext = os.path.splitext(file.filename or "")[1] or ".jpg" + filename = f"poison_{poison_id}_{uuid.uuid4().hex[:8]}{ext}" + path = os.path.join(MEDIA_DIR, "poison", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + + with open(path, "wb") as f: + f.write(await file.read()) + + foto_url = f"/media/poison/{filename}" + with db() as conn: + conn.execute("UPDATE poison SET foto_url=? WHERE id=?", (foto_url, poison_id)) + + return {"foto_url": foto_url} diff --git a/backend/static/js/pages/poison.js b/backend/static/js/pages/poison.js new file mode 100644 index 0000000..5f2559a --- /dev/null +++ b/backend/static/js/pages/poison.js @@ -0,0 +1,614 @@ +/* ============================================================ + BAN YARO — Giftköder-Alarm (Sprint 2) + Seiten-Modul: Leaflet-Karte + Meldungsliste + Melden-Formular. + ============================================================ */ + +window.Page_poison = (() => { + + // ---------------------------------------------------------- + // MODUL-STATE + // ---------------------------------------------------------- + let _container = null; + let _appState = null; + let _map = null; + let _markers = []; + let _userMarker = null; + let _reports = []; + let _userPos = null; + let _leafletLoaded = false; + + const TYPEN = { + unbekannt: { label: 'Unbekannt', icon: '❓', color: '#e67e22' }, + koeoder: { label: 'Köder', icon: '🎣', color: '#e74c3c' }, + vergiftet: { label: 'Vergiftetes Tier', icon: '☠️', color: '#8e44ad' }, + chemikalie: { label: 'Chemikalie', icon: '⚗️', color: '#c0392b' }, + andere: { label: 'Andere Gefahr', icon: '⚠️', color: '#d35400' }, + }; + + // ---------------------------------------------------------- + // INIT + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _appState = appState; + await _render(); + } + + // ---------------------------------------------------------- + // REFRESH — Navigation zur bereits geladenen Seite + // ---------------------------------------------------------- + async function refresh() { + if (_userPos) await _loadReports(); + } + + // ---------------------------------------------------------- + // OPEN NEW — vom + Button + // ---------------------------------------------------------- + function openNew() { + _showReportForm(); + } + + // ---------------------------------------------------------- + // RENDER — Grundstruktur aufbauen + // ---------------------------------------------------------- + async function _render() { + _container.innerHTML = ` +
+ + +
+ +
+
+ +

+ Standort wird ermittelt… +

+ +
+ `; + + document.getElementById('poison-btn-locate') + ?.addEventListener('click', _locateUser); + document.getElementById('poison-btn-report') + ?.addEventListener('click', _showReportForm); + + await _loadLeaflet(); + _initMap(); + await _locateAndLoad(); + } + + // ---------------------------------------------------------- + // LEAFLET DYNAMISCH LADEN + // ---------------------------------------------------------- + async function _loadLeaflet() { + if (_leafletLoaded || window.L) { _leafletLoaded = true; return; } + + if (!document.querySelector('link[href*="leaflet"]')) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; + document.head.appendChild(link); + } + + await new Promise((resolve, reject) => { + if (document.querySelector('script[src*="leaflet"]')) { resolve(); return; } + const s = document.createElement('script'); + s.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'; + s.onload = resolve; + s.onerror = reject; + document.head.appendChild(s); + }); + + _leafletLoaded = true; + } + + // ---------------------------------------------------------- + // KARTE INITIALISIEREN + // ---------------------------------------------------------- + function _initMap() { + const mapEl = document.getElementById('poison-map'); + if (!mapEl || !window.L || _map) return; + + _map = L.map('poison-map', { zoomControl: true }) + .setView([51.1657, 10.4515], 6); // Deutschland-Mitte + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution : '© OpenStreetMap', + maxZoom : 19, + }).addTo(_map); + } + + // ---------------------------------------------------------- + // STANDORT ERMITTELN + LADEN + // ---------------------------------------------------------- + async function _locateAndLoad() { + try { + _userPos = await API.getLocation({ timeout: 8000 }); + _showUserOnMap(); + } catch { + _userPos = null; + } + await _loadReports(); + } + + async function _locateUser() { + const btn = document.getElementById('poison-btn-locate'); + UI.setLoading(btn, true); + try { + _userPos = await API.getLocation({ timeout: 8000 }); + _showUserOnMap(); + if (_map) _map.setView([_userPos.lat, _userPos.lon], 13); + await _loadReports(); + } catch { + UI.toast.warning('Standort konnte nicht ermittelt werden.'); + } + UI.setLoading(btn, false); + } + + function _showUserOnMap() { + if (!_map || !window.L || !_userPos) return; + if (_userMarker) _map.removeLayer(_userMarker); + _userMarker = L.circleMarker([_userPos.lat, _userPos.lon], { + radius : 9, + fillColor : '#3498db', + color : '#fff', + weight : 2, + fillOpacity : 0.9, + }).addTo(_map).bindPopup('Du bist hier'); + _map.setView([_userPos.lat, _userPos.lon], 13); + } + + // ---------------------------------------------------------- + // MELDUNGEN LADEN + // ---------------------------------------------------------- + async function _loadReports() { + const infoEl = document.getElementById('poison-info'); + + if (!_userPos) { + _reports = []; + _renderList(); + if (infoEl) infoEl.textContent = + 'Standort unbekannt — bitte Standort freigeben (📍 Mein Standort).'; + return; + } + + try { + _reports = await API.poison.listNearby(_userPos.lat, _userPos.lon, 10000); + _renderMarkers(); + _renderList(); + _updateBadge(_reports.length); + if (infoEl) { + infoEl.textContent = _reports.length > 0 + ? `${_reports.length} aktive Meldung${_reports.length !== 1 ? 'en' : ''} im Umkreis von 10 km` + : 'Keine aktiven Giftköder-Meldungen in deiner Nähe (10 km Radius).'; + } + } catch { + UI.toast.error('Meldungen konnten nicht geladen werden.'); + } + } + + // ---------------------------------------------------------- + // KARTEN-MARKER + // ---------------------------------------------------------- + function _renderMarkers() { + if (!_map || !window.L) return; + _markers.forEach(m => _map.removeLayer(m)); + _markers = []; + + _reports.forEach(r => { + const typ = TYPEN[r.typ] || TYPEN.unbekannt; + const icon = L.divIcon({ + className : '', + html : `
${typ.icon}
`, + iconSize : [34, 34], + iconAnchor : [17, 17], + }); + + const distStr = r.distanz_m < 1000 + ? `${r.distanz_m} m` + : `${(r.distanz_m / 1000).toFixed(1)} km`; + + const marker = L.marker([r.lat, r.lon], { icon }) + .addTo(_map) + .bindPopup(` + ${typ.icon} ${typ.label}
+ ${r.beschreibung ? _escape(r.beschreibung.slice(0, 80)) + '
' : ''} + 📍 ${distStr} entfernt
+ 📅 ${_fmtDate(r.created_at)} + ${r.bestaetigt ? '
✅ Bestätigt' : ''} + `); + + marker.on('click', () => _openDetail(r)); + _markers.push(marker); + }); + } + + // ---------------------------------------------------------- + // LISTE + // ---------------------------------------------------------- + function _renderList() { + const listEl = document.getElementById('poison-list'); + if (!listEl) return; + + if (_reports.length === 0) { + listEl.innerHTML = UI.emptyState({ + icon : '✅', + title : 'Alles sicher', + text : 'In deiner Nähe (10 km) gibt es aktuell keine Giftköder-Meldungen.', + action: ``, + }); + listEl.querySelector('#poison-empty-report') + ?.addEventListener('click', _showReportForm); + return; + } + + listEl.innerHTML = _reports.map(r => _reportCard(r)).join(''); + listEl.querySelectorAll('[data-poison-id]').forEach(card => { + card.addEventListener('click', () => { + const r = _reports.find(x => x.id === parseInt(card.dataset.poisonId)); + if (r) _openDetail(r); + }); + }); + } + + function _reportCard(r) { + const typ = TYPEN[r.typ] || TYPEN.unbekannt; + const distStr = r.distanz_m < 1000 + ? `${r.distanz_m} m` + : `${(r.distanz_m / 1000).toFixed(1)} km`; + + return ` +
+
+
${typ.icon}
+
+
+ ${typ.label} + ${r.bestaetigt + ? '✅ Bestätigt' + : ''} + + ${distStr} + +
+ ${r.beschreibung + ? `

+ ${_escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''} +

` + : ''} +
+ Gemeldet ${_fmtDate(r.created_at)} · + läuft ab ${_fmtDate(r.expires_at)} +
+
+
+ ${r.foto_url + ? `Foto` + : ''} +
+ `; + } + + // ---------------------------------------------------------- + // DETAIL-MODAL + // ---------------------------------------------------------- + function _openDetail(r) { + const typ = TYPEN[r.typ] || TYPEN.unbekannt; + const isOwnEntry = _appState.user && _appState.user.id === r.user_id; + const isAdmin = _appState.user?.rolle === 'admin'; + + const body = ` + ${r.foto_url + ? `Foto` + : ''} + +
+ + ${typ.icon} ${typ.label} + + ${r.bestaetigt ? '✅ Bestätigt' : ''} +
+ + ${r.beschreibung + ? `

${_escape(r.beschreibung)}

` + : ''} + +
+
📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}
+
📅 Gemeldet: ${_fmtDate(r.created_at)}
+
⏰ Läuft ab: ${_fmtDate(r.expires_at)}
+ ${r.melder_name ? `
👤 Gemeldet von: ${_escape(r.melder_name)}
` : ''} +
+ +
+ ${!r.bestaetigt && _appState.user && !isOwnEntry + ? `` + : ''} + + ${isOwnEntry || isAdmin + ? `` + : ''} +
+ `; + + UI.modal.open({ title: `${typ.icon} Giftköder-Meldung`, body }); + + document.getElementById('detail-confirm')?.addEventListener('click', async () => { + try { + const updated = await API.poison.confirm(r.id); + const idx = _reports.findIndex(x => x.id === r.id); + if (idx !== -1) _reports[idx] = { ..._reports[idx], ...updated }; + UI.toast.success('Meldung bestätigt. Danke!'); + UI.modal.close(); + _renderMarkers(); + _renderList(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Bestätigen.'); + } + }); + + document.getElementById('detail-show-map')?.addEventListener('click', () => { + UI.modal.close(); + if (_map) { + _map.setView([r.lat, r.lon], 16); + document.getElementById('poison-map') + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // Popup des Markers öffnen + const marker = _markers[_reports.findIndex(x => x.id === r.id)]; + marker?.openPopup(); + } + }); + + document.getElementById('detail-resolve')?.addEventListener('click', async () => { + const ok = await UI.modal.confirm({ + title : 'Meldung als erledigt markieren?', + message: 'Das Problem wurde beseitigt oder die Meldung war fehlerhaft.', + confirmText: 'Erledigt markieren', + }); + if (!ok) return; + try { + await API.poison.resolve(r.id); + _reports = _reports.filter(x => x.id !== r.id); + _markers.splice(_reports.length, 1); // cleanup (wird bei _renderMarkers neu gesetzt) + _renderMarkers(); + _renderList(); + _updateBadge(_reports.length); + UI.toast.success('Meldung als erledigt markiert.'); + } catch (err) { + UI.toast.error(err.message || 'Fehler.'); + } + }); + } + + // ---------------------------------------------------------- + // MELDE-FORMULAR + // ---------------------------------------------------------- + function _showReportForm() { + if (!_appState.user) { + UI.toast.warning('Bitte zuerst anmelden, um eine Meldung abzuschicken.'); + App.navigate('settings'); + return; + } + + const typOpts = Object.entries(TYPEN) + .map(([val, { icon, label }]) => + ``) + .join(''); + + const body = ` +
+ +
+ + +
+ +
+ +
+ + + +
+ + + + ${_userPos + ? '✅ Aktueller Standort vorausgefüllt' + : 'GPS-Button drücken um Standort zu ermitteln'} + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+
+ `; + + UI.modal.open({ title: '⚠️ Giftköder melden', body }); + + // Standort vorausfüllen wenn bekannt + if (_userPos) { + document.getElementById('pf-lat').value = _userPos.lat; + document.getElementById('pf-lon').value = _userPos.lon; + document.getElementById('pf-lat-disp').value = _userPos.lat.toFixed(6); + document.getElementById('pf-lon-disp').value = _userPos.lon.toFixed(6); + } + + // GPS-Button + document.getElementById('pf-gps-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('pf-gps-btn'); + UI.setLoading(btn, true); + try { + const pos = await API.getLocation({ timeout: 10000, enableHighAccuracy: true }); + document.getElementById('pf-lat').value = pos.lat; + document.getElementById('pf-lon').value = pos.lon; + document.getElementById('pf-lat-disp').value = pos.lat.toFixed(6); + document.getElementById('pf-lon-disp').value = pos.lon.toFixed(6); + document.getElementById('pf-gps-hint').textContent = '✅ Standort aktualisiert'; + _userPos = pos; + } catch { + UI.toast.error('GPS-Standort konnte nicht ermittelt werden.'); + } + UI.setLoading(btn, false); + }); + + // Foto-Vorschau + const photoInput = document.querySelector('#poison-form [name="photo"]'); + const photoPreview = document.getElementById('pf-photo-preview'); + if (photoInput && photoPreview) { + UI.setupPhotoPreview(photoInput, photoPreview); + photoInput.addEventListener('change', () => { + photoPreview.style.display = photoInput.files[0] ? 'block' : 'none'; + }); + } + + document.getElementById('pf-cancel') + ?.addEventListener('click', UI.modal.close); + + // Formular absenden + document.getElementById('poison-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const submitBtn = e.target.querySelector('[type="submit"]'); + const fd = UI.formData(e.target); + + if (!fd.lat || !fd.lon) { + UI.toast.warning('Bitte zuerst den GPS-Standort ermitteln (📍).'); + return; + } + + await UI.asyncButton(submitBtn, async () => { + const payload = { + lat : parseFloat(fd.lat), + lon : parseFloat(fd.lon), + typ : fd.typ, + beschreibung : fd.beschreibung || null, + }; + + const created = await API.poison.report(payload); + + // Foto hochladen + if (photoInput?.files[0]) { + try { + const formData = new FormData(); + formData.append('file', photoInput.files[0]); + const media = await API.poison.uploadPhoto(created.id, formData); + created.foto_url = media.foto_url; + } catch { + UI.toast.warning('Meldung erstellt — Foto konnte nicht hochgeladen werden.'); + } + } + + // Distanz client-seitig berechnen (für sofortige Anzeige) + created.distanz_m = _userPos + ? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon)) + : 0; + + _reports.unshift(created); + _renderMarkers(); + _renderList(); + _updateBadge(_reports.length); + UI.toast.success('Giftköder gemeldet! Danke für die Warnung.'); + UI.modal.close(); + }); + }); + } + + // ---------------------------------------------------------- + // BADGE (Sidebar + Bottom-Nav) + // ---------------------------------------------------------- + function _updateBadge(count) { + const b1 = document.getElementById('poison-badge'); + const b2 = document.getElementById('poison-nav-badge'); + if (b1) { b1.textContent = count; b1.style.display = count > 0 ? '' : 'none'; } + if (b2) { b2.textContent = count; b2.classList.toggle('hidden', count === 0); } + } + + // ---------------------------------------------------------- + // HELPER + // ---------------------------------------------------------- + + // Haversine client-seitig (für frisch gemeldete Einträge) + function _haversine(lat1, lon1, lat2, lon2) { + const R = 6_371_000; + const p1 = lat1 * Math.PI / 180; + const p2 = lat2 * Math.PI / 180; + const dp = (lat2 - lat1) * Math.PI / 180; + const dl = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); + } + + function _fmtDate(isoStr) { + if (!isoStr) return ''; + // SQLite speichert "2026-04-12T00:00:00" oder "2026-04-12 00:00:00" + const d = new Date(isoStr.replace(' ', 'T')); + return d.toLocaleDateString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric' + }); + } + + function _escape(str) { + if (!str) return ''; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + // ---------------------------------------------------------- + // PUBLIC + // ---------------------------------------------------------- + return { init, refresh, openNew }; + +})();