Feature: Gassi-Hundefotos bei Teilnehmern + Fotos nach dem Treffen (SW by-v878)

This commit is contained in:
rene 2026-05-12 17:04:43 +02:00
parent b6a644ac3a
commit 44ba51cd38
8 changed files with 230 additions and 20 deletions

View file

@ -2210,6 +2210,22 @@ def _migrate(conn_factory):
except Exception: except Exception:
pass 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 # Versicherungs-Verwaltung
try: try:
conn.execute(""" conn.execute("""

View file

@ -376,7 +376,7 @@ if STAGING and os.path.isdir(PROD_MEDIA_DIR):
else: else:
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") 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") @app.get("/.well-known/assetlinks.json")
async def assetlinks(): async def assetlinks():

View file

@ -1,15 +1,17 @@
"""BAN YARO — Gassi-Treffen""" """BAN YARO — Gassi-Treffen"""
import math import math, os, uuid
import httpx import httpx
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
from routes.push import send_push_to_user from routes.push import send_push_to_user
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
router = APIRouter() router = APIRouter()
@ -371,9 +373,34 @@ async def get_walk(walk_id: int):
GROUP BY wp.user_id GROUP BY wp.user_id
""", (walk_id,)).fetchall() """, (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 = 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['teilnehmer_count'] = len(result['teilnehmer'])
result['photos'] = [dict(p) for p in photos]
return result 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,)) conn.execute("UPDATE walks SET status = 'offen' WHERE id = ?", (walk_id,))
return {"status": "left", "teilnehmer_count": count} 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,))

View file

@ -101,9 +101,9 @@
</script> </script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=877"> <link rel="stylesheet" href="/css/design-system.css?v=878">
<link rel="stylesheet" href="/css/layout.css?v=877"> <link rel="stylesheet" href="/css/layout.css?v=878">
<link rel="stylesheet" href="/css/components.css?v=877"> <link rel="stylesheet" href="/css/components.css?v=878">
</head> </head>
<body> <body>
@ -583,10 +583,10 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=877"></script> <script src="/js/api.js?v=878"></script>
<script src="/js/ui.js?v=877"></script> <script src="/js/ui.js?v=878"></script>
<script src="/js/app.js?v=877"></script> <script src="/js/app.js?v=878"></script>
<script src="/js/worlds.js?v=877"></script> <script src="/js/worlds.js?v=878"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->

View file

@ -346,6 +346,9 @@ const API = (() => {
invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); }, invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); },
rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); }, rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); },
participants(id) { return get(`/walks/${id}/participants`); }, 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}`); },
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen // Cache-Bust-Parameter nach Update-Reload sofort entfernen

View file

@ -393,14 +393,31 @@ window.Page_walks = (() => {
const isInvited = !!myRsvp; const isInvited = !!myRsvp;
const invitations = participantData?.invitations ?? []; const invitations = participantData?.invitations ?? [];
// Teilnehmerliste (join-Teilnehmer, klassisch) // Teilnehmerliste mit Hundefotos
const teilnehmerHTML = walk.teilnehmer?.length const teilnehmerHTML = walk.teilnehmer?.length
? walk.teilnehmer.map(t => ` ? walk.teilnehmer.map(t => {
<div class="walks-participant"> const dogsHTML = (t.hunde_liste || []).map(d => {
<div class="walks-inv-avatar walks-inv-avatar--sm">${_avatarInitials(t.user_name)}</div> const av = d.foto_url
<span class="walks-participant-name">${UI.escape(t.user_name)}</span> ? `<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
${t.hunde ? `<span class="walks-participant-hunde">${UI.icon('dog')} ${UI.escape(t.hunde)}</span>` : ''} style="width:28px;height:28px;border-radius:50%;object-fit:cover;flex-shrink:0;border:1.5px solid var(--c-border)">`
</div>`).join('') : `<div style="width:28px;height:28px;border-radius:50%;background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1.5px solid var(--c-border)">
<svg class="ph-icon" style="width:14px;height:14px;color:var(--c-text-muted)" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
</div>`;
return `<div style="display:flex;align-items:center;gap:4px">
${av}
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(d.name)}${d.rasse ? ` · ${UI.escape(d.rasse)}` : ''}</span>
</div>`;
}).join('');
return `
<div class="walks-participant">
<div class="walks-inv-avatar walks-inv-avatar--sm">${_avatarInitials(t.user_name)}</div>
<div style="flex:1;min-width:0">
<div class="walks-participant-name">${UI.escape(t.user_name)}</div>
${dogsHTML ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:4px">${dogsHTML}</div>` : ''}
</div>
</div>`;
}).join('')
: ''; : '';
// Einladungsliste // Einladungsliste
@ -468,6 +485,31 @@ window.Page_walks = (() => {
<div id="wd-rating-${walk.id}"></div> <div id="wd-rating-${walk.id}"></div>
</div> </div>
<!-- Fotos nach dem Treffen -->
<div class="walks-detail-section" id="wd-photos-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<div class="walks-detail-section-label" style="margin-bottom:0">${UI.icon('images')} Fotos</div>
${(isPast || _isToday(walk.datum)) && (isJoined || isOwn) ? `
<label style="cursor:pointer">
<input type="file" id="wd-photo-input" accept="image/*" style="display:none">
<span class="btn btn-secondary btn-sm">${UI.icon('camera')} Foto hinzufügen</span>
</label>` : ''}
</div>
<div id="wd-photos-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin-top:var(--space-2)">
${(walk.photos || []).length === 0
? `<p style="grid-column:1/-1;color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Fotos.</p>`
: (walk.photos || []).map(p => `
<div style="position:relative;aspect-ratio:1">
<img src="${UI.escape(p.url)}" style="width:100%;height:100%;object-fit:cover;border-radius:var(--radius-sm);cursor:pointer"
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(p.url)}' }], 0)">
${p.user_id === _appState.user?.id || isOwn ? `
<button type="button" class="wd-photo-del" data-photo-id="${p.id}"
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:50%;width:20px;height:20px;cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;padding:0"></button>
` : ''}
</div>`).join('')}
</div>
</div>
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)"> <p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Veranstaltet von ${UI.escape(walk.veranstalter_name || 'Unbekannt')} Veranstaltet von ${UI.escape(walk.veranstalter_name || 'Unbekannt')}
</p> </p>
@ -525,6 +567,49 @@ window.Page_walks = (() => {
document.getElementById('wd-close')?.addEventListener('click', UI.modal.close); 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 = `
<img src="${UI.escape(photo.url)}" style="width:100%;height:100%;object-fit:cover;border-radius:var(--radius-sm);cursor:pointer"
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(photo.url)}' }], 0)">
<button type="button" class="wd-photo-del" data-photo-id="${photo.id}"
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:50%;width:20px;height:20px;cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;padding:0"></button>`;
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', () => { document.getElementById('wd-login')?.addEventListener('click', () => {
UI.modal.close(); UI.modal.close();
App.navigate('settings'); App.navigate('settings');

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v877'; const CACHE_VERSION = 'by-v878';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache