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 : `+ ${_escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''} +
` + : ''} +${_escape(r.beschreibung)}
` + : ''} + +