diff --git a/backend/database.py b/backend/database.py index 50f50ee..3d9757f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2210,6 +2210,22 @@ def _migrate(conn_factory): except Exception: pass + # Gassi-Treffen Fotos + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS walk_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_walk_photos_walk ON walk_photos(walk_id)") + logger.info("Migration: walk_photos bereit.") + except Exception as e: + logger.warning(f"Migration walk_photos: {e}") + # Versicherungs-Verwaltung try: conn.execute(""" diff --git a/backend/main.py b/backend/main.py index 581b849..1971763 100644 --- a/backend/main.py +++ b/backend/main.py @@ -376,7 +376,7 @@ if STAGING and os.path.isdir(PROD_MEDIA_DIR): else: app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") -APP_VER = "877" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "878" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/walks.py b/backend/routes/walks.py index 85bea3d..03074f3 100644 --- a/backend/routes/walks.py +++ b/backend/routes/walks.py @@ -1,15 +1,17 @@ """BAN YARO — Gassi-Treffen""" -import math +import math, os, uuid import httpx from datetime import date -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Optional, List 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") + router = APIRouter() @@ -371,9 +373,34 @@ async def get_walk(walk_id: int): GROUP BY wp.user_id """, (walk_id,)).fetchall() + # Hunde-Details (Foto + Rasse) pro Teilnehmer + dog_rows = conn.execute(""" + SELECT wpd.user_id, d.name AS dog_name, d.foto_url, d.rasse + FROM walk_participant_dogs wpd + JOIN dogs d ON d.id = wpd.dog_id + WHERE wpd.walk_id = ? + """, (walk_id,)).fetchall() + + # Walk-Fotos + photos = conn.execute( + "SELECT id, user_id, url, created_at FROM walk_photos WHERE walk_id=? ORDER BY created_at", + (walk_id,) + ).fetchall() + + from collections import defaultdict + dogs_by_user = defaultdict(list) + for r in dog_rows: + dogs_by_user[r['user_id']].append({ + 'name': r['dog_name'], 'foto_url': r['foto_url'], 'rasse': r['rasse'] + }) + result = dict(walk) - result['teilnehmer'] = [dict(p) for p in participants] + result['teilnehmer'] = [ + {**dict(p), 'hunde_liste': dogs_by_user.get(p['user_id'], [])} + for p in participants + ] result['teilnehmer_count'] = len(result['teilnehmer']) + result['photos'] = [dict(p) for p in photos] return result @@ -508,3 +535,82 @@ async def leave_walk(walk_id: int, user=Depends(get_current_user)): conn.execute("UPDATE walks SET status = 'offen' WHERE id = ?", (walk_id,)) return {"status": "left", "teilnehmer_count": count} + + +# ------------------------------------------------------------------ +# POST /api/walks/{id}/photos — Foto nach dem Treffen hochladen +# GET /api/walks/{id}/photos — Fotos eines Treffens abrufen +# ------------------------------------------------------------------ +@router.post("/{walk_id}/photos", status_code=201) +async def upload_walk_photo( + walk_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user) +): + with db() as conn: + walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone() + if not walk: + raise HTTPException(404, "Treffen nicht gefunden.") + # Nur Teilnehmer oder Veranstalter dürfen Fotos hochladen + is_participant = conn.execute( + "SELECT 1 FROM walk_participants WHERE walk_id=? AND user_id=?", + (walk_id, user['id']) + ).fetchone() + if not is_participant and walk['user_id'] != user['id']: + raise HTTPException(403, "Nur Teilnehmer können Fotos hochladen.") + + import io + from PIL import Image, ImageOps + try: + import pillow_heif; pillow_heif.register_heif_opener() + except ImportError: + pass + + raw = await file.read() + try: + img = Image.open(io.BytesIO(raw)) + img = ImageOps.exif_transpose(img).convert("RGB") + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=88) + raw = buf.getvalue() + except Exception: + pass + + filename = f"walk_{walk_id}_{uuid.uuid4().hex[:8]}.jpg" + path = os.path.join(MEDIA_DIR, "walks", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(raw) + + url = f"/media/walks/{filename}" + with db() as conn: + cur = conn.execute( + "INSERT INTO walk_photos (walk_id, user_id, url) VALUES (?,?,?)", + (walk_id, user['id'], url) + ) + row = conn.execute("SELECT * FROM walk_photos WHERE id=?", (cur.lastrowid,)).fetchone() + return dict(row) + + +@router.get("/{walk_id}/photos") +async def get_walk_photos(walk_id: int): + with db() as conn: + rows = conn.execute( + "SELECT * FROM walk_photos WHERE walk_id=? ORDER BY created_at", + (walk_id,) + ).fetchall() + return [dict(r) for r in rows] + + +@router.delete("/{walk_id}/photos/{photo_id}", status_code=204) +async def delete_walk_photo(walk_id: int, photo_id: int, user=Depends(get_current_user)): + with db() as conn: + photo = conn.execute( + "SELECT * FROM walk_photos WHERE id=? AND walk_id=?", (photo_id, walk_id) + ).fetchone() + if not photo: + raise HTTPException(404) + walk = conn.execute("SELECT user_id FROM walks WHERE id=?", (walk_id,)).fetchone() + if photo['user_id'] != user['id'] and walk['user_id'] != user['id']: + raise HTTPException(403) + conn.execute("DELETE FROM walk_photos WHERE id=?", (photo_id,)) diff --git a/backend/static/index.html b/backend/static/index.html index eca24fc..10c0040 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -583,10 +583,10 @@ - - - - + + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index b383d66..7a341a1 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -346,6 +346,9 @@ const API = (() => { invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); }, rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); }, participants(id) { return get(`/walks/${id}/participants`); }, + photos(id) { return get(`/walks/${id}/photos`); }, + uploadPhoto(id, formData) { return upload(`/walks/${id}/photos`, formData); }, + deletePhoto(id, photoId) { return del(`/walks/${id}/photos/${photoId}`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 76478d3..759a280 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '877'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '878'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index f08d5e9..2f2fab5 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -393,14 +393,31 @@ window.Page_walks = (() => { const isInvited = !!myRsvp; const invitations = participantData?.invitations ?? []; - // Teilnehmerliste (join-Teilnehmer, klassisch) + // Teilnehmerliste mit Hundefotos const teilnehmerHTML = walk.teilnehmer?.length - ? walk.teilnehmer.map(t => ` -
-
${_avatarInitials(t.user_name)}
- ${UI.escape(t.user_name)} - ${t.hunde ? `${UI.icon('dog')} ${UI.escape(t.hunde)}` : ''} -
`).join('') + ? walk.teilnehmer.map(t => { + const dogsHTML = (t.hunde_liste || []).map(d => { + const av = d.foto_url + ? `${UI.escape(d.name)}` + : `
+ +
`; + return `
+ ${av} + ${UI.escape(d.name)}${d.rasse ? ` · ${UI.escape(d.rasse)}` : ''} +
`; + }).join(''); + return ` +
+
${_avatarInitials(t.user_name)}
+
+
${UI.escape(t.user_name)}
+ ${dogsHTML ? `
${dogsHTML}
` : ''} +
+
`; + }).join('') : ''; // Einladungsliste @@ -468,6 +485,31 @@ window.Page_walks = (() => {
+ +
+
+ + ${(isPast || _isToday(walk.datum)) && (isJoined || isOwn) ? ` + ` : ''} +
+
+ ${(walk.photos || []).length === 0 + ? `

Noch keine Fotos.

` + : (walk.photos || []).map(p => ` +
+ + ${p.user_id === _appState.user?.id || isOwn ? ` + + ` : ''} +
`).join('')} +
+
+

Veranstaltet von ${UI.escape(walk.veranstalter_name || 'Unbekannt')}

@@ -525,6 +567,49 @@ window.Page_walks = (() => { document.getElementById('wd-close')?.addEventListener('click', UI.modal.close); + // Foto-Upload + document.getElementById('wd-photo-input')?.addEventListener('change', async function() { + if (!this.files.length) return; + const file = this.files[0]; + const formData = new FormData(); + formData.append('file', file); + try { + const photo = await API.walks.uploadPhoto(walk.id, formData); + const grid = document.getElementById('wd-photos-grid'); + if (grid) { + grid.querySelector('p')?.remove(); + const div = document.createElement('div'); + div.style.cssText = 'position:relative;aspect-ratio:1'; + div.innerHTML = ` + + `; + grid.appendChild(div); + _bindPhotoDel(walk.id, div); + UI.toast.success('Foto hochgeladen.'); + } + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Hochladen.'); + } + this.value = ''; + }); + + // Foto löschen — alle bestehenden Buttons + function _bindPhotoDel(walkId, container) { + container.querySelectorAll('.wd-photo-del').forEach(btn => { + btn.addEventListener('click', async () => { + if (!window.confirm('Foto löschen?')) return; + try { + await API.walks.deletePhoto(walkId, parseInt(btn.dataset.photoId)); + btn.closest('[style*="aspect-ratio"]')?.remove(); + UI.toast.success('Foto gelöscht.'); + } catch (err) { UI.toast.error(err.message || 'Fehler.'); } + }); + }); + } + _bindPhotoDel(walk.id, document); + document.getElementById('wd-login')?.addEventListener('click', () => { UI.modal.close(); App.navigate('settings'); diff --git a/backend/static/sw.js b/backend/static/sw.js index d6ce60f..91c4cd8 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v877'; +const CACHE_VERSION = 'by-v878'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache