diff --git a/backend/database.py b/backend/database.py index 1a70aa5..eeb1add 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1747,6 +1747,71 @@ def _migrate(conn_factory): ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS community_adoption ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + name TEXT NOT NULL, + rasse TEXT, + alter_jahre REAL, + geschlecht TEXT, + foto_url TEXT, + beschreibung TEXT NOT NULL, + gruende TEXT, + ort TEXT, + plz TEXT, + lat REAL, + lon REAL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS community_adoption_interest ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL REFERENCES community_adoption(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + nachricht TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(listing_id, user_id) + ) + """) + + # ---- Wetter-Log (historische Vorhersage-Daten) ---- + conn.execute(""" + CREATE TABLE IF NOT EXISTS weather_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + logged_at TEXT NOT NULL DEFAULT (datetime('now')), + date TEXT NOT NULL, + lat_r REAL NOT NULL, + lon_r REAL NOT NULL, + temp_max REAL, + temp_min REAL, + feels_max REAL, + precip_prob INTEGER, + precip_sum REAL, + wind_kmh REAL, + wind_dir TEXT, + uv_index REAL, + weathercode INTEGER, + weatherdesc TEXT, + sunrise TEXT, + sunset TEXT, + asphalt_temp REAL, + asphalt_warn TEXT, + zecken TEXT, + pollen_erle INTEGER, + pollen_birke INTEGER, + pollen_graeser INTEGER, + pollen_beifuss INTEGER, + pollen_ambrosia INTEGER, + forecast_json TEXT, + UNIQUE(date, lat_r, lon_r) + ) + """) + # ---- Favoriten-Tierarzt + Gesundheitsdokumente ---- conn.execute(""" CREATE TABLE IF NOT EXISTS favorite_vets ( diff --git a/backend/main.py b/backend/main.py index 8b259f7..229a856 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from fastapi.middleware.gzip import GZipMiddleware +from brotli_asgi import BrotliMiddleware from contextlib import asynccontextmanager from database import init_db @@ -134,6 +135,7 @@ class MediaCacheMiddleware(BaseHTTPMiddleware): return response app.add_middleware(MediaCacheMiddleware) +app.add_middleware(BrotliMiddleware, minimum_size=1000, quality=4) app.add_middleware(GZipMiddleware, minimum_size=1000) diff --git a/backend/requirements.txt b/backend/requirements.txt index c4e830c..414ec32 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,3 +15,4 @@ odfpy==1.4.1 polyline==2.0.2 fpdf2==2.8.3 python-dateutil>=2.9 +brotli-asgi==1.4.0 diff --git a/backend/routes/admin.py b/backend/routes/admin.py index cd3fee1..c2ffebb 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -356,11 +356,15 @@ async def list_users( # ------------------------------------------------------------------ @router.patch("/users/{uid}") async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)): - # Rollenwechsel nur für Admins + # Rollenwechsel + Privileg-Flags nur für Admins if data.rolle is not None and user["rolle"] != "admin": raise HTTPException(403, "Rollenwechsel nur für Admins.") if data.rolle and data.rolle not in ("user", "moderator", "admin"): raise HTTPException(400, "Ungültige Rolle.") + if data.is_moderator is not None and user["rolle"] != "admin": + raise HTTPException(403, "is_moderator darf nur von Admins geändert werden.") + if data.is_social_media is not None and user["rolle"] != "admin": + raise HTTPException(403, "is_social_media darf nur von Admins geändert werden.") with db() as conn: target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone() diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py index d742ccc..bde0986 100644 --- a/backend/routes/adoption.py +++ b/backend/routes/adoption.py @@ -13,10 +13,17 @@ import os import math import logging import asyncio +import uuid import httpx from datetime import datetime, timedelta -from fastapi import APIRouter, Query, BackgroundTasks +from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException +from pydantic import BaseModel +from typing import Optional from database import db +from auth import get_current_user +from routes.push import send_push_to_user + +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") logger = logging.getLogger(__name__) router = APIRouter() @@ -290,3 +297,251 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)): except Exception as e: logger.warning(f"Geocode PLZ {plz}: {e}") return {"lat": None, "lon": None, "display": plz} + + +# ================================================================== +# Community Adoption — Privates Weitervermittlungs-Board +# ================================================================== + +class InterestBody(BaseModel): + nachricht: Optional[str] = None + + +# ------------------------------------------------------------------ +# GET /api/adoption/community/my — eigene Inserate +# ------------------------------------------------------------------ +@router.get("/community/my") +def community_my(user=Depends(get_current_user)): + with db() as conn: + rows = conn.execute(""" + SELECT ca.*, + u.name AS besitzer_name, + (SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count + FROM community_adoption ca + JOIN users u ON u.id = ca.user_id + WHERE ca.user_id = ? AND ca.status != 'deleted' + ORDER BY ca.created_at DESC + """, (user["id"],)).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# GET /api/adoption/community — alle aktiven Inserate (mit optionaler Nähe) +# ------------------------------------------------------------------ +@router.get("/community") +def community_list( + lat: Optional[float] = Query(None), + lon: Optional[float] = Query(None), + radius: float = Query(200.0, description="Radius in km (default 200)"), + user=Depends(get_current_user), +): + with db() as conn: + rows = conn.execute(""" + SELECT ca.*, + u.name AS besitzer_name, + (SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count, + (SELECT COUNT(*) FROM community_adoption_interest i2 + WHERE i2.listing_id = ca.id AND i2.user_id = ?) AS _user_interested + FROM community_adoption ca + JOIN users u ON u.id = ca.user_id + WHERE ca.status = 'active' + ORDER BY ca.created_at DESC + LIMIT 50 + """, (user["id"],)).fetchall() + + result = [] + for row in rows: + d = dict(row) + d["user_interested"] = bool(d.pop("_user_interested", 0)) + if lat is not None and lon is not None and d.get("lat") and d.get("lon"): + dist = _haversine(lat, lon, d["lat"], d["lon"]) + d["distanz_km"] = round(dist, 1) + if dist > radius: + continue + else: + d["distanz_km"] = None + result.append(d) + + if lat is not None and lon is not None: + result.sort(key=lambda x: x["distanz_km"] if x["distanz_km"] is not None else 9999) + + return result + + +# ------------------------------------------------------------------ +# POST /api/adoption/community — Inserat erstellen +# ------------------------------------------------------------------ +@router.post("/community", status_code=201) +async def community_create( + name: str = Form(...), + beschreibung: str = Form(...), + rasse: str = Form(""), + alter_jahre: Optional[float] = Form(None), + geschlecht: str = Form(""), + gruende: str = Form(""), + ort: str = Form(""), + plz: str = Form(""), + lat: Optional[float] = Form(None), + lon: Optional[float] = Form(None), + dog_id: Optional[int] = Form(None), + foto: Optional[UploadFile] = File(None), + user=Depends(get_current_user), +): + foto_url = None + + if foto and foto.filename: + MAX_SIZE = 5 * 1024 * 1024 + header = await foto.read(12) + if len(header) < 3: + raise HTTPException(400, "Ungültige Datei") + is_jpeg = header[:3] == b"\xff\xd8\xff" + is_png = header[:4] == b"\x89PNG" + is_webp = header[:4] == b"RIFF" and len(header) >= 12 and header[8:12] == b"WEBP" + if not (is_jpeg or is_png or is_webp): + raise HTTPException(400, "Nur JPEG, PNG oder WebP erlaubt") + rest = await foto.read(MAX_SIZE) + if len(rest) >= MAX_SIZE: + raise HTTPException(400, "Foto zu groß (max 5 MB)") + data = header + rest + + folder = os.path.join(MEDIA_DIR, "adoption") + os.makedirs(folder, exist_ok=True) + filename = f"{uuid.uuid4()}.jpg" + filepath = os.path.join(folder, filename) + with open(filepath, "wb") as f: + f.write(data) + foto_url = f"/media/adoption/{filename}" + + with db() as conn: + cur = conn.execute(""" + INSERT INTO community_adoption + (user_id, dog_id, name, rasse, alter_jahre, geschlecht, + foto_url, beschreibung, gruende, ort, plz, lat, lon) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + user["id"], dog_id, name, rasse or None, alter_jahre, + geschlecht or None, foto_url, beschreibung, + gruende or None, ort or None, plz or None, lat, lon, + )) + new_id = cur.lastrowid + row = conn.execute( + "SELECT * FROM community_adoption WHERE id = ?", (new_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer) +# ------------------------------------------------------------------ +class _StatusBody(BaseModel): + status: str + +@router.patch("/community/{listing_id}") +def community_update_status( + listing_id: int, + body: _StatusBody, + user=Depends(get_current_user), +): + allowed = {"active", "reserved", "vermittelt"} + if body.status not in allowed: + raise HTTPException(400, f"Status muss einer von {allowed} sein") + status = body.status + with db() as conn: + cur = conn.execute(""" + UPDATE community_adoption + SET status = ?, updated_at = datetime('now') + WHERE id = ? AND user_id = ? + """, (status, listing_id, user["id"])) + if cur.rowcount == 0: + raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff") + return {"ok": True} + + +# ------------------------------------------------------------------ +# DELETE /api/adoption/community/{id} — Soft-Delete (nur Besitzer) +# ------------------------------------------------------------------ +@router.delete("/community/{listing_id}") +def community_delete(listing_id: int, user=Depends(get_current_user)): + with db() as conn: + cur = conn.execute(""" + UPDATE community_adoption + SET status = 'deleted', updated_at = datetime('now') + WHERE id = ? AND user_id = ? + """, (listing_id, user["id"])) + if cur.rowcount == 0: + raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff") + return {"ok": True} + + +# ------------------------------------------------------------------ +# POST /api/adoption/community/{id}/interest — Interesse bekunden +# ------------------------------------------------------------------ +@router.post("/community/{listing_id}/interest", status_code=201) +def community_interest(listing_id: int, body: InterestBody = None, user=Depends(get_current_user)): + nachricht = (body.nachricht if body else None) or None + with db() as conn: + listing = conn.execute( + "SELECT id, name, user_id FROM community_adoption WHERE id = ? AND status != 'deleted'", + (listing_id,) + ).fetchone() + if not listing: + raise HTTPException(404, "Inserat nicht gefunden") + if listing["user_id"] == user["id"]: + raise HTTPException(400, "Eigenes Inserat") + try: + conn.execute(""" + INSERT INTO community_adoption_interest (listing_id, user_id, nachricht) + VALUES (?, ?, ?) + """, (listing_id, user["id"], nachricht)) + except Exception: + raise HTTPException(409, "Interesse bereits bekundet") + + try: + send_push_to_user(listing["user_id"], { + "title": "Jemand interessiert sich für deinen Hund \U0001f43e", + "body": f"{user['name']} möchte mehr über {listing['name']} erfahren.", + "url": "/#adoption", + }) + except Exception as e: + logger.warning(f"Push interest: {e}") + + return {"ok": True} + + +# ------------------------------------------------------------------ +# DELETE /api/adoption/community/{id}/interest — Interesse zurückziehen +# ------------------------------------------------------------------ +@router.delete("/community/{listing_id}/interest") +def community_interest_withdraw(listing_id: int, user=Depends(get_current_user)): + with db() as conn: + cur = conn.execute(""" + DELETE FROM community_adoption_interest + WHERE listing_id = ? AND user_id = ? + """, (listing_id, user["id"])) + if cur.rowcount == 0: + raise HTTPException(404, "Kein Interesse gefunden") + return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/adoption/community/{id}/interests — Interessenten (nur Besitzer) +# ------------------------------------------------------------------ +@router.get("/community/{listing_id}/interests") +def community_interests(listing_id: int, user=Depends(get_current_user)): + with db() as conn: + listing = conn.execute( + "SELECT user_id FROM community_adoption WHERE id = ? AND status != 'deleted'", + (listing_id,) + ).fetchone() + if not listing: + raise HTTPException(404, "Inserat nicht gefunden") + if listing["user_id"] != user["id"]: + raise HTTPException(403, "Nur der Besitzer kann Interessenten sehen") + rows = conn.execute(""" + SELECT i.id, i.nachricht, i.created_at, u.id AS user_id, u.name, u.avatar_url + FROM community_adoption_interest i + JOIN users u ON u.id = i.user_id + WHERE i.listing_id = ? + ORDER BY i.created_at ASC + """, (listing_id,)).fetchall() + return [dict(r) for r in rows] diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 13d857d..4772ae6 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -26,12 +26,14 @@ _SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_P def _send_verification_email(email: str, name: str, token: str): if not _SMTP_READY: return + import html as _html from routes.outreach import _send_smtp from mailer import email_html url = f"{_APP_URL}/api/auth/verify-email/{token}" subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse" + _ename = _html.escape(name) body_html = f""" -

Hallo {name},

+

Hallo {_ename},

willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.

@@ -306,13 +308,15 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request): "UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?", (token, expires, user["id"]) ) + import html as _html app_url = os.getenv("APP_URL", "https://banyaro.app") url = f"{app_url}/#reset-password?token={token}" subject = "Ban Yaro — Passwort zurücksetzen" from routes.outreach import _send_smtp from mailer import email_html + _ename = _html.escape(user['name']) body_html = f""" -

Hallo {user['name']},

+

Hallo {_ename},

du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.

diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index c7a9066..a44faa0 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -315,11 +315,13 @@ async def update_dog(dog_id: int, data: DogUpdate, user=Depends(get_current_user values = list(fields.values()) + [dog_id, user["id"]] with db() as conn: - conn.execute( + updated = conn.execute( f"UPDATE dogs SET {set_clause} WHERE id=? AND user_id=?", values - ) + ).rowcount + if not updated: + raise HTTPException(404, "Hund nicht gefunden.") dog = conn.execute( - "SELECT * FROM dogs WHERE id=?", (dog_id,) + "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) ).fetchone() return dict(dog) @@ -413,8 +415,8 @@ async def delete_photo(dog_id: int, user=Depends(get_current_user)): os.remove(path) with db() as conn: conn.execute( - "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=?", - (dog_id,) + "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=? AND user_id=?", + (dog_id, user["id"]) ) diff --git a/backend/routes/forum.py b/backend/routes/forum.py index fe730d5..2834ab0 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -641,7 +641,7 @@ async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_c # GET /api/forum/members/map # ------------------------------------------------------------------ @router.get("/members/map") -async def members_map(): +async def members_map(user=Depends(get_current_user)): with db() as conn: rows = conn.execute( """SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname, diff --git a/backend/routes/jobs.py b/backend/routes/jobs.py index 8714ae2..59c73c2 100644 --- a/backend/routes/jobs.py +++ b/backend/routes/jobs.py @@ -1,5 +1,6 @@ """BAN YARO — Social-Media-Job Bewerbungs-System""" +import html as _html import os import uuid from datetime import datetime, timedelta @@ -98,8 +99,9 @@ async def apply( # Bestätigungs-Mail an Bewerber try: + _name = _html.escape(name) body = f""" -

Hallo {name},

+

Hallo {_name},

deine Bewerbung als Social-Media-Manager/in bei Ban Yaro ist bei uns eingegangen. Wir melden uns bald bei dir! @@ -110,7 +112,7 @@ async def apply( email, "Deine Bewerbung bei Ban Yaro 🐾", email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"), - f"Hallo {name}, deine Bewerbung ist eingegangen!", + f"Hallo {_name}, deine Bewerbung ist eingegangen!", ) except Exception: pass @@ -119,16 +121,22 @@ async def apply( try: admin_email = os.getenv("ADMIN_EMAIL", "") if admin_email: + _ename = _html.escape(name) + _eemail = _html.escape(email) + _edog_name = _html.escape(dog_name) + _edog_rasse = _html.escape(dog_rasse) + _ehandle = _html.escape(social_handle) + _emotivation = _html.escape(motivation[:300]) admin_body = f"""

Neue Job-Bewerbung eingegangen:

- - - - + + + +
Name{name}
E-Mail{email}
Hund{dog_name} ({dog_rasse})
Social{social_handle}
Name{_ename}
E-Mail{_eemail}
Hund{_edog_name} ({_edog_rasse})
Social{_ehandle}
Anhänge{len([f for f in files if f.filename])} Datei(en)
-

{motivation[:300]}{"…" if len(motivation)>300 else ""}

""" +

{_emotivation}{"…" if len(motivation)>300 else ""}

""" await send_email( admin_email, f"[Banyaro Jobs] Neue Bewerbung — {name}", @@ -293,16 +301,17 @@ async def download_doc(app_id: int, doc_id: int, admin=Depends(require_admin)): def _send_status_mail(email: str, name: str, status: str, note: str): import asyncio + _ename = _html.escape(name) texts = { "reviewing": ("Wir schauen uns deine Bewerbung genauer an 🐾", - f"

Hallo {name},

wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!

"), + f"

Hallo {_ename},

wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!

"), "accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉", - f"

Hallo {name},

wir freuen uns, dir mitzuteilen: du bist unser neuer Social-Media-Manager/in für Ban Yaro!
Du erhältst außerdem den Gründer-Status in unserer Community. Willkommen im Team!

"), + f"

Hallo {_ename},

wir freuen uns, dir mitzuteilen: du bist unser neuer Social-Media-Manager/in für Ban Yaro!
Du erhältst außerdem den Gründer-Status in unserer Community. Willkommen im Team!

"), "rejected": ("Deine Bewerbung bei Ban Yaro", - f"

Hallo {name},

vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!

"), + f"

Hallo {_ename},

vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!

"), } - subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"

Hallo {name},

")) - note_html = f'
{note}
' if note else "" + subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"

Hallo {_ename},

")) + note_html = f'
{_html.escape(note)}
' if note else "" body = body_start + note_html async def _send(): diff --git a/backend/routes/litters.py b/backend/routes/litters.py index 82ba96f..ddc810c 100644 --- a/backend/routes/litters.py +++ b/backend/routes/litters.py @@ -265,13 +265,14 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)): eltern = conn.execute( "SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,) ).fetchone() + import html as _html welfare_body = f"""

Kritischer Tierschutz-Hinweis bestätigt

- - - - + + + +
Züchter{zuechter}
Zwinger{zwinger}
Vater{eltern['vater_name'] or '—'}
Mutter{eltern['mutter_name'] or '—'}
Züchter{_html.escape(zuechter)}
Zwinger{_html.escape(zwinger)}
Vater{_html.escape(eltern['vater_name'] or '—')}
Mutter{_html.escape(eltern['mutter_name'] or '—')}
Wurf-ID#{litter_id}
""" try: diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py index 1357a85..fa74871 100644 --- a/backend/routes/moderation.py +++ b/backend/routes/moderation.py @@ -268,6 +268,9 @@ async def mod_poi_edit_action(edit_id: int, data: dict, raise HTTPException(409, "Korrektur wurde bereits bearbeitet.") if action == "approve": + _ALLOWED_POI_FIELDS = {"opening_hours", "phone", "website", "name"} + if edit["field"] not in _ALLOWED_POI_FIELDS: + raise HTTPException(400, f"Ungültiges Feld: {edit['field']}") conn.execute( f"UPDATE osm_pois SET {edit['field']}=?, user_edited=1 WHERE osm_id=?", (edit["new_value"], edit["osm_id"]) diff --git a/backend/routes/streak.py b/backend/routes/streak.py index ea03522..c387a68 100644 --- a/backend/routes/streak.py +++ b/backend/routes/streak.py @@ -29,7 +29,7 @@ async def get_leaderboard(user=Depends(get_current_user)): JOIN dogs d ON d.id = ts.dog_id JOIN users u ON u.id = ts.user_id WHERE ts.current_streak > 0 - AND (d.is_public = 1 OR d.user_id = ts.user_id) + AND d.is_public = 1 ORDER BY ts.current_streak DESC LIMIT 10 """).fetchall() diff --git a/backend/routes/weather.py b/backend/routes/weather.py index 319cfd2..fced719 100644 --- a/backend/routes/weather.py +++ b/backend/routes/weather.py @@ -3,8 +3,9 @@ BAN YARO — Wetter-API GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort """ -from fastapi import APIRouter, Query, HTTPException +from fastapi import APIRouter, Query, HTTPException, Depends import weather as weather_module +from auth import get_current_user router = APIRouter() @@ -18,3 +19,15 @@ async def get_weather( return await weather_module.get_weather_for_location(lat, lon) except Exception as exc: raise HTTPException(503, f'Wetter nicht verfügbar: {exc}') + + +@router.get('/forecast') +async def get_weather_forecast( + lat: float = Query(..., ge=-90, le=90), + lon: float = Query(..., ge=-180, le=180), + user=Depends(get_current_user), +): + try: + return await weather_module.get_forecast(lat, lon) + except Exception as exc: + raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}') diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index bf3c19c..45f5bfb 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -317,19 +317,24 @@ async def submit_foto( if not rights_confirmed: raise HTTPException(400, "Bildrechte-Bestätigung fehlt.") - # Dateiformat prüfen - ct = file.content_type or "" - if not ct.startswith("image/"): - raise HTTPException(400, "Nur Bilddateien erlaubt.") + _IMAGE_MAGIC = [ + b"\xff\xd8\xff", # JPEG + b"\x89PNG\r\n\x1a\n", # PNG + b"RIFF", # WebP (RIFF....WEBP) + b"GIF87a", b"GIF89a", # GIF + ] os.makedirs(SUBMIT_DIR, exist_ok=True) - ts = int(time.time()) - filename = f"{slug}_{user['id']}_{ts}.jpg" - path = os.path.join(SUBMIT_DIR, filename) - + ts = int(time.time()) content = await file.read() if len(content) > 8 * 1024 * 1024: raise HTTPException(400, "Datei zu groß (max. 8 MB).") + + if not any(content.startswith(magic) for magic in _IMAGE_MAGIC): + raise HTTPException(400, "Nur Bilddateien erlaubt (JPEG, PNG, WebP, GIF).") + + filename = f"{slug}_{user['id']}_{ts}.jpg" + path = os.path.join(SUBMIT_DIR, filename) with open(path, "wb") as f: f.write(content) diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index a9189b9..2b8028e 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -270,4 +270,1361 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/static/js/api.js b/backend/static/js/api.js index a40b99d..c6b26da 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -6,69 +6,84 @@ const API = (() => { + // ---------------------------------------------------------- + // Request-Deduplication: gleiche GET-URL nur einmal in-flight + // ---------------------------------------------------------- + const _inflight = new Map(); + // ---------------------------------------------------------- // Interner HTTP-Kern // ---------------------------------------------------------- - async function _request(method, path, body = null, options = {}) { + async function _doRequest(method, path, body, attempt) { const config = { method, headers: { 'Content-Type': 'application/json' }, - credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet + credentials: 'include', }; if (body && !(body instanceof FormData)) { config.body = JSON.stringify(body); } else if (body instanceof FormData) { - delete config.headers['Content-Type']; // Browser setzt multipart/form-data + delete config.headers['Content-Type']; config.body = body; } - // JWT aus localStorage als Bearer (für API-Calls die das brauchen) const token = localStorage.getItem('by_token'); - if (token) { - config.headers['Authorization'] = `Bearer ${token}`; - } + if (token) config.headers['Authorization'] = `Bearer ${token}`; let response; try { response = await fetch(`/api${path}`, config); - } catch (err) { - const offlineMsg = 'Kein Internet — du bist offline.'; - if (window.UI && UI.toast) UI.toast.warning(offlineMsg, 4000); - throw new APIError(offlineMsg, 0, 'network'); + } catch { + // Netzwerkfehler: bei GET bis zu 2 Retry-Versuche + if (method === 'GET' && attempt < 2) { + await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt))); + return _doRequest(method, path, body, attempt + 1); + } + const msg = 'Kein Internet — du bist offline.'; + if (window.UI?.toast) UI.toast.warning(msg, 4000); + throw new APIError(msg, 0, 'network'); } - // 204 No Content if (response.status === 204) return null; let data; - try { - data = await response.json(); - } catch { - data = null; - } + try { data = await response.json(); } catch { data = null; } if (!response.ok) { const message = data?.detail || data?.message || `Fehler ${response.status}`; - // SW gibt bei Offline-Anfragen 503 + 'Offline — keine Verbindung.' zurück - const isOffline = response.status === 503 && message.startsWith('Offline'); - if (isOffline && window.UI && UI.toast) { - UI.toast.warning('Kein Internet — du bist offline.', 4000); + const isSwOffline = response.status === 503 && message.startsWith('Offline'); + + // Retry: GET auf echte 5xx (nicht SW-generierte Offline-503) + if (method === 'GET' && response.status >= 500 && !isSwOffline && attempt < 2) { + await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt))); + return _doRequest(method, path, body, attempt + 1); } - throw new APIError(message, response.status, isOffline ? 'network' : data?.code); + + if (isSwOffline && window.UI?.toast) UI.toast.warning('Kein Internet — du bist offline.', 4000); + throw new APIError(message, response.status, isSwOffline ? 'network' : data?.code); } - // SW hat die Anfrage in die Offline-Queue eingereiht if (data?._queued) { - if (typeof UI !== 'undefined' && UI.toast) { + if (typeof UI !== 'undefined' && UI.toast) UI.toast.info('Offline gespeichert — wird automatisch synchronisiert'); - } return data; } return data; } + async function _request(method, path, body = null) { + // GET-Deduplication: laufende identische Anfragen zusammenfassen + if (method === 'GET') { + if (_inflight.has(path)) return _inflight.get(path); + const promise = _doRequest('GET', path, null, 0).finally(() => _inflight.delete(path)); + _inflight.set(path, promise); + return promise; + } + return _doRequest(method, path, body, 0); + } + // ---------------------------------------------------------- // Öffentliche HTTP-Methoden // ---------------------------------------------------------- @@ -426,8 +441,9 @@ const API = (() => { // WETTER // ---------------------------------------------------------- const weather = { - alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); }, - get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); }, + alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); }, + get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); }, + forecast(lat, lon) { return get(`/weather/forecast?lat=${lat}&lon=${lon}`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/adoption.js b/backend/static/js/pages/adoption.js index 8e1bc3a..b20682e 100644 --- a/backend/static/js/pages/adoption.js +++ b/backend/static/js/pages/adoption.js @@ -17,6 +17,8 @@ window.Page_adoption = (() => { let _activeTab = 'hunde'; let _data = null; // { animals, shelters, has_petfinder } let _loading = false; + let _communityData = null; // [] listings from /adoption/community + let _myListings = null; // [] eigene Inserate // ---------------------------------------------------------- // INIT @@ -90,6 +92,12 @@ window.Page_adoption = (() => { border-bottom:2px solid transparent;font-size:var(--text-sm)"> ${UI.icon('house-line')} Tierheime + @@ -213,12 +221,43 @@ window.Page_adoption = (() => { } } + async function _loadCommunity() { + const content = _container.querySelector('#adp-content'); + if (content) content.innerHTML = UI.skeleton(4); + try { + const url = _lat && _lon + ? `/adoption/community?lat=${_lat}&lon=${_lon}` + : '/adoption/community'; + _communityData = await API.get(url); + if (_appState?.user) { + try { + _myListings = await API.get('/adoption/community/my'); + } catch { + _myListings = []; + } + } + _renderCommunity(content); + } catch { + if (content) content.innerHTML = UI.emptyState({ + icon: 'warning', + title: 'Weitervermittlungs-Inserate konnten nicht geladen werden', + text: 'Bitte versuche es erneut.', + }); + } + } + // ---------------------------------------------------------- // INHALT RENDERN (je nach Tab) // ---------------------------------------------------------- function _renderContent() { const content = _container.querySelector('#adp-content'); if (!content) return; + + if (_activeTab === 'community') { + _loadCommunity(); + return; + } + if (!_data) { _showNoLocation(); return; } if (_activeTab === 'hunde') _renderHunde(content); @@ -455,6 +494,442 @@ window.Page_adoption = (() => { `; } + // ------------------------------------------------------------------ + // TAB: WEITERVERMITTLUNG (Community) + // ------------------------------------------------------------------ + function _renderCommunity(content) { + if (!content) return; + + const listings = _communityData || []; + const isLoggedIn = !!_appState?.user; + + const fabHtml = isLoggedIn ? ` + + ` : ''; + + if (!listings.length) { + content.innerHTML = ` +
+
🐾
+

Noch keine Hunde zur Weitervermittlung

+

+ Hier können Halter Hunde privat zur Weitervermittlung anbieten — + zum Beispiel bei Umzug, Krankheit oder Allergie. +

+ ${isLoggedIn ? ` + + ` : ` +

+ Bitte anmelden, um ein Inserat zu erstellen. +

+ `} +
+ ${fabHtml} + `; + content.querySelector('#adp-empty-create')?.addEventListener('click', _openCreateModal); + content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal); + return; + } + + // Eigene Inserate trennen + const myIds = new Set((_myListings || []).map(l => l.id)); + + content.innerHTML = ` +

+ ${listings.length} Inserat${listings.length !== 1 ? 'e' : ''} zur Weitervermittlung +

+
+ ${listings.map(l => _communityCard(l)).join('')} +
+ + ${isLoggedIn && _myListings && _myListings.length ? ` +
+

Meine Inserate

+
+ ${_myListings.map(l => _myListingRow(l)).join('')} +
+
+ ` : ''} + + ${fabHtml} + `; + + // Interest-Button Events + content.querySelectorAll('[data-adp-interest]').forEach(btn => { + btn.addEventListener('click', () => { + const id = btn.dataset.adpInterest; + const interested = btn.dataset.adpInterested === 'true'; + _handleInterest(id, interested, btn); + }); + }); + + // FAB + content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal); + + // Meine Inserate: Status-Dropdown + Löschen + content.querySelectorAll('[data-adp-status-change]').forEach(sel => { + sel.addEventListener('change', async () => { + const id = sel.dataset.adpStatusChange; + try { + await API.patch(`/adoption/community/${id}`, { status: sel.value }); + UI.toast.success('Status aktualisiert.'); + _loadCommunity(); + } catch { + UI.toast.error('Status konnte nicht aktualisiert werden.'); + } + }); + }); + + content.querySelectorAll('[data-adp-delete]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!window.confirm('Inserat wirklich löschen?')) return; + try { + await API.del(`/adoption/community/${btn.dataset.adpDelete}`); + UI.toast.success('Inserat gelöscht.'); + _communityData = null; + _myListings = null; + _loadCommunity(); + } catch { + UI.toast.error('Löschen fehlgeschlagen.'); + } + }); + }); + } + + function _communityCard(l) { + const foto = l.foto_url + ? `${_esc(l.name)}` + : '
🐾
'; + + const isActive = !l.status || l.status === 'active'; + const statusLabel = l.status === 'reserved' ? 'Reserviert' + : l.status === 'adopted' ? 'Vermittelt' + : ''; + + const alterLabel = l.alter_kategorie === 'welpe' ? 'Welpe <6Mo' + : l.alter_kategorie === 'jung' ? 'Jung 6Mo–2J' + : l.alter_kategorie === 'adult' ? 'Adult 2–8J' + : l.alter_kategorie === 'senior' ? 'Senior >8J' + : ''; + + const genderIcon = l.geschlecht === 'maennlich' ? '♂' + : l.geschlecht === 'weiblich' ? '♀' + : ''; + + const distTxt = l.distanz_km != null ? `${l.distanz_km} km` : ''; + const ort = [l.plz, l.ort].filter(Boolean).join(' '); + + const interestBtn = l.user_interested + ? `` + : ``; + + return ` +
+ +
+ ${foto} + ${!isActive ? ` +
+ + ${_esc(statusLabel)} + +
+ ` : ''} +
+ +
+
+ ${_esc(l.name)} +
+ ${l.rasse ? `
+ ${_esc(l.rasse)} +
` : ''} + +
+ ${alterLabel ? ` + ${_esc(alterLabel)} + ` : ''} + ${genderIcon ? ` + ${genderIcon} + ` : ''} + ${distTxt ? ` + ${_esc(distTxt)} + ` : ''} +
+ ${ort ? `
${_esc(ort)}
` : ''} + ${l.beschreibung ? `
+ ${_esc(l.beschreibung)} +
` : ''} + ${l.interesse_count ? `
+ ❤️ ${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''} +
` : ''} +
+ ${interestBtn} +
+
+
+ `; + } + + function _myListingRow(l) { + const statusOptions = [ + { value: 'active', label: 'Aktiv' }, + { value: 'reserved', label: 'Reserviert' }, + { value: 'adopted', label: 'Vermittelt' }, + ]; + return ` +
+
+
+ ${_esc(l.name)} +
+
+ ${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''} +
+
+ + +
+ `; + } + + // ------------------------------------------------------------------ + // INTERESSE BEKUNDEN / ZURÜCKZIEHEN + // ------------------------------------------------------------------ + async function _handleInterest(id, isInterested, btn) { + if (!_appState?.user) { + UI.toast.error('Bitte anmelden um Interesse zu bekunden.'); + return; + } + + if (isInterested) { + // Interesse zurückziehen + try { + btn.disabled = true; + await API.del(`/adoption/community/${id}/interest`); + UI.toast.success('Interesse zurückgezogen.'); + _communityData = null; + _myListings = null; + _loadCommunity(); + } catch { + UI.toast.error('Fehler beim Zurückziehen des Interesses.'); + btn.disabled = false; + } + return; + } + + // Interesse bekunden — Modal mit optionaler Nachricht + const body = ` +
+

+ Du kannst optional eine Nachricht an den Anbieter schicken. +

+
+ + +
+
+ `; + const footer = ` +
+ + +
+ `; + + UI.modal.open({ title: 'Interesse bekunden', body, footer }); + + document.getElementById('adp-interest-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const submitBtn = document.getElementById('adp-interest-submit'); + const fd = new FormData(e.target); + const payload = { nachricht: fd.get('nachricht') || null }; + if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } + try { + await API.post(`/adoption/community/${id}/interest`, payload); + UI.modal.close(); + UI.toast.success('Interesse gemeldet!'); + _communityData = null; + _myListings = null; + _loadCommunity(); + } catch { + UI.toast.error('Fehler beim Melden des Interesses.'); + if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = `${UI.icon('heart')} Interesse bekunden`; } + } + }); + } + + // ------------------------------------------------------------------ + // INSERAT ERSTELLEN — Modal + // ------------------------------------------------------------------ + function _openCreateModal() { + if (!_appState?.user) { + UI.toast.error('Bitte anmelden um ein Inserat zu erstellen.'); + return; + } + + const body = ` +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
Mindestens 80 Zeichen
+
+
+ + +
+
+ + +
+
+ `; + + const footer = ` +
+ + +
+ `; + + UI.modal.open({ title: 'Hund zur Vermittlung anbieten', body, footer }); + + document.getElementById('adp-create-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const submitBtn = document.getElementById('adp-create-submit'); + const fd = new FormData(e.target); + + // Mindestlänge Beschreibung manuell prüfen (minlength gilt nur für text) + const beschreibung = (fd.get('beschreibung') || '').trim(); + if (beschreibung.length < 80) { + UI.toast.error('Beschreibung muss mindestens 80 Zeichen lang sein.'); + return; + } + + // FormData für multipart aufbauen + const postData = new FormData(); + postData.append('name', fd.get('name') || ''); + postData.append('rasse', fd.get('rasse') || ''); + postData.append('alter_kategorie', fd.get('alter_kategorie') || ''); + postData.append('geschlecht', fd.get('geschlecht') || ''); + postData.append('plz', fd.get('plz') || ''); + postData.append('ort', fd.get('ort') || ''); + postData.append('beschreibung', beschreibung); + postData.append('hintergrund', fd.get('hintergrund') || ''); + if (_lat) postData.append('lat', _lat); + if (_lon) postData.append('lon', _lon); + const fotoFile = document.getElementById('adp-create-foto')?.files?.[0]; + if (fotoFile) postData.append('foto', fotoFile); + + if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } + try { + await API.upload('/adoption/community', postData); + UI.modal.close(); + UI.toast.success('Inserat erstellt!'); + _communityData = null; + _myListings = null; + _loadCommunity(); + } catch (err) { + UI.toast.error(err.message || 'Inserat konnte nicht erstellt werden.'); + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = `${UI.icon('plus')} Inserat erstellen`; + } + } + }); + } + // ---------------------------------------------------------- // HILFSFUNKTIONEN // ---------------------------------------------------------- diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index f8488b6..8829bb6 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -263,6 +263,12 @@ window.Page_settings = (() => { Kalender abonnieren +