Feature: 3 Community-Features — Foto-Challenge, Stamm-Gassis, Rassen-Chip (SW by-v700)

- Foto-Challenge der Woche: DB-Tabellen, routes/challenges.py (current/submit/vote/winners),
  Scheduler-Job jeden Montag 08:00, walks.js Challenge-Tab mit Banner, Galerie, Voting-Herz
- Gassi-Zeiten-Pool: DB-Tabelle gassi_zeiten, routes/gassi_zeiten.py (CRUD + Umkreis),
  walks.js Stamm-Gassis-Tab mit Karten, Wochentag-Selector, Mitmachen→Chat
- Rassen-Treffen-Chip: GET /api/friends/same-breed, dog-profile.js zeigt Chip
  wenn andere User gleiche Rasse haben, Klick → Forum mit Rassen-Suche vorausgefüllt
This commit is contained in:
rene 2026-05-04 21:09:35 +02:00
parent d6206d378e
commit aa4849d947
10 changed files with 1322 additions and 22 deletions

View file

@ -0,0 +1,304 @@
"""BAN YARO — Foto-Challenge der Woche"""
import os
import uuid
import logging
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
from media_utils import convert_media, generate_preview
logger = logging.getLogger(__name__)
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
CHALLENGE_DIR = os.path.join(MEDIA_DIR, "challenges")
_CHALLENGE_THEMEN = [
"Bestes Schnüffel-Foto 👃",
"Action-Aufnahme 🏃",
"Schlafendes Tier 😴",
"Gassi im Regen 🌧️",
"Hundeblick in die Kamera 👀",
"Spielzeit mit Freunden 🐕",
"Herbstspaziergang 🍂",
"Beste Sprung-Aufnahme 🦘",
"Hund am Wasser 🌊",
"Erstes Mal im Schnee ❄️",
"Genuss-Moment 🦴",
"Versteckt im Gebüsch 🌿",
"Tierisches Selfie 🤳",
"Hund & Kind 👶",
"Hund & Katze zusammen 🐱",
"Der beste Buddel-Moment 🐾",
"Freude beim Apportieren 🎾",
"Hund in seiner Lieblingshöhle 🛋️",
"Sonnenuntergangs-Gassi 🌅",
"Hundebegegnung auf dem Spaziergang 🐕🐕",
"Ausdrucksstarker Hundeblick 😍",
"Hund im Herbstlaub 🍁",
"Welpenfoto 🍼",
"Seniorenhund im Porträt 👴",
"Lustigste Schlafposition 💤",
"Hund trägt etwas 🎀",
"Hund + Besitzer Spiegelfoto 🪞",
"Hund auf Abenteuer 🏕️",
"Beste Lauf-Action 💨",
"Hund im Café ☕",
]
def _current_week_monday() -> str:
today = date.today()
monday = today - timedelta(days=today.weekday())
return monday.isoformat()
def _current_week_sunday() -> str:
monday = date.fromisoformat(_current_week_monday())
return (monday + timedelta(days=6)).isoformat()
def _ensure_current_challenge(conn) -> int:
"""Stellt sicher dass eine Challenge für die aktuelle Woche existiert. Gibt die ID zurück."""
monday = _current_week_monday()
sunday = _current_week_sunday()
existing = conn.execute(
"SELECT id FROM foto_challenge WHERE start_date = ?", (monday,)
).fetchone()
if existing:
return existing["id"]
# Thema aus Rotation wählen (Wochennummer % Anzahl Themen)
week_num = date.today().isocalendar()[1]
thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)]
cur = conn.execute(
"INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) "
"VALUES (?, ?, ?, ?, NULL)",
(thema, f"Diese Woche: {thema}", monday, sunday)
)
return cur.lastrowid
# ------------------------------------------------------------------
# GET /api/challenges/current
# ------------------------------------------------------------------
@router.get("/current")
async def get_current_challenge(user=Depends(get_current_user_optional)):
with db() as conn:
challenge_id = _ensure_current_challenge(conn)
challenge = conn.execute(
"SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,)
).fetchone()
submissions = conn.execute("""
SELECT cs.id, cs.user_id, cs.dog_id, cs.foto_url, cs.caption, cs.votes, cs.created_at,
u.name AS user_name, u.avatar_url,
d.name AS dog_name, d.foto_url AS dog_foto_url
FROM challenge_submissions cs
LEFT JOIN users u ON u.id = cs.user_id
LEFT JOIN dogs d ON d.id = cs.dog_id
WHERE cs.challenge_id = ?
ORDER BY cs.votes DESC, cs.created_at ASC
""", (challenge_id,)).fetchall()
my_submission = None
my_votes = set()
if user:
mine = conn.execute(
"SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?",
(challenge_id, user["id"])
).fetchone()
if mine:
my_submission = mine["id"]
voted_rows = conn.execute(
"SELECT cv.submission_id FROM challenge_votes cv "
"JOIN challenge_submissions cs ON cs.id = cv.submission_id "
"WHERE cv.user_id = ? AND cs.challenge_id = ?",
(user["id"], challenge_id)
).fetchall()
my_votes = {r["submission_id"] for r in voted_rows}
# Countdown bis Sonntag
end = date.fromisoformat(challenge["end_date"])
days_left = (end - date.today()).days + 1
result_subs = []
for s in submissions:
sd = dict(s)
sd["i_voted"] = (sd["id"] in my_votes) if user else False
result_subs.append(sd)
return {
"challenge": dict(challenge),
"submissions": result_subs,
"my_submission_id": my_submission,
"days_left": max(0, days_left),
}
# ------------------------------------------------------------------
# POST /api/challenges/{id}/submit
# ------------------------------------------------------------------
@router.post("/{challenge_id}/submit", status_code=201)
async def submit_photo(
challenge_id: int,
caption: Optional[str] = Form(None),
dog_id: Optional[int] = Form(None),
foto: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
challenge = conn.execute(
"SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,)
).fetchone()
if not challenge:
raise HTTPException(404, "Challenge nicht gefunden.")
today = date.today().isoformat()
if today > challenge["end_date"]:
raise HTTPException(400, "Die Challenge ist bereits beendet.")
# Doppelt-Check
existing = conn.execute(
"SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?",
(challenge_id, user["id"])
).fetchone()
if existing:
raise HTTPException(409, "Du hast bereits ein Foto eingereicht.")
# Foto speichern
os.makedirs(CHALLENGE_DIR, exist_ok=True)
orig_filename = foto.filename or "foto.jpg"
ext = os.path.splitext(orig_filename)[1] or ".jpg"
base = uuid.uuid4().hex
raw = await foto.read()
# HEIC→JPEG Konvertierung falls nötig
try:
converted, out_ext = convert_media(raw, orig_filename)
except Exception:
converted, out_ext = raw, ext
save_filename = f"{base}{out_ext}"
save_path = os.path.join(CHALLENGE_DIR, save_filename)
with open(save_path, "wb") as f:
f.write(converted)
foto_url = f"/media/challenges/{save_filename}"
# Preview
try:
preview = generate_preview(converted, out_ext)
if preview:
prev_path = os.path.join(CHALLENGE_DIR, f"{base}_preview.webp")
with open(prev_path, "wb") as f:
f.write(preview)
except Exception:
pass
with db() as conn:
cur = conn.execute(
"INSERT INTO challenge_submissions (challenge_id, user_id, dog_id, foto_url, caption) "
"VALUES (?, ?, ?, ?, ?)",
(challenge_id, user["id"], dog_id, foto_url, caption)
)
row = conn.execute("""
SELECT cs.*, u.name AS user_name, d.name AS dog_name
FROM challenge_submissions cs
LEFT JOIN users u ON u.id = cs.user_id
LEFT JOIN dogs d ON d.id = cs.dog_id
WHERE cs.id = ?
""", (cur.lastrowid,)).fetchone()
return dict(row)
# ------------------------------------------------------------------
# POST /api/challenges/submissions/{id}/vote — Toggle-Vote
# ------------------------------------------------------------------
@router.post("/submissions/{submission_id}/vote")
async def vote_submission(submission_id: int, user=Depends(get_current_user)):
with db() as conn:
sub = conn.execute(
"SELECT * FROM challenge_submissions WHERE id = ?", (submission_id,)
).fetchone()
if not sub:
raise HTTPException(404, "Einreichung nicht gefunden.")
if sub["user_id"] == user["id"]:
raise HTTPException(400, "Du kannst nicht für dein eigenes Foto abstimmen.")
existing = conn.execute(
"SELECT id FROM challenge_votes WHERE submission_id=? AND user_id=?",
(submission_id, user["id"])
).fetchone()
if existing:
# Toggle: Vote entfernen
conn.execute(
"DELETE FROM challenge_votes WHERE submission_id=? AND user_id=?",
(submission_id, user["id"])
)
conn.execute(
"UPDATE challenge_submissions SET votes = MAX(0, votes - 1) WHERE id=?",
(submission_id,)
)
voted = False
else:
conn.execute(
"INSERT INTO challenge_votes (submission_id, user_id) VALUES (?, ?)",
(submission_id, user["id"])
)
conn.execute(
"UPDATE challenge_submissions SET votes = votes + 1 WHERE id=?",
(submission_id,)
)
voted = True
votes = conn.execute(
"SELECT votes FROM challenge_submissions WHERE id=?", (submission_id,)
).fetchone()["votes"]
return {"voted": voted, "votes": votes}
# ------------------------------------------------------------------
# GET /api/challenges/winners — letzte 4 Gewinner
# ------------------------------------------------------------------
@router.get("/winners")
async def get_winners():
with db() as conn:
# Vergangene Challenges (ohne aktuelle Woche)
monday = _current_week_monday()
challenges = conn.execute(
"SELECT id, thema, start_date, end_date FROM foto_challenge "
"WHERE end_date < ? ORDER BY end_date DESC LIMIT 4",
(monday,)
).fetchall()
winners = []
for ch in challenges:
winner = conn.execute("""
SELECT cs.id, cs.user_id, cs.foto_url, cs.caption, cs.votes,
u.name AS user_name, u.avatar_url,
d.name AS dog_name
FROM challenge_submissions cs
LEFT JOIN users u ON u.id = cs.user_id
LEFT JOIN dogs d ON d.id = cs.dog_id
WHERE cs.challenge_id = ?
ORDER BY cs.votes DESC, cs.created_at ASC
LIMIT 1
""", (ch["id"],)).fetchone()
winners.append({
"challenge": dict(ch),
"winner": dict(winner) if winner else None,
})
return winners

View file

@ -342,3 +342,56 @@ async def remove_friend(friend_user_id: int, user=Depends(get_current_user)):
AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
""", (uid, friend_user_id, friend_user_id, uid))
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/friends/same-breed — andere User mit gleicher Rasse
# ------------------------------------------------------------------
@router.get("/same-breed")
async def same_breed(user=Depends(get_current_user)):
"""Findet andere User mit Hunden derselben Rasse. Gibt Anzahl + Forum-Suche zurück."""
uid = user["id"]
with db() as conn:
# Rassen des eingeloggten Users
my_dogs = conn.execute(
"SELECT rasse FROM dogs WHERE user_id=? AND rasse IS NOT NULL AND rasse != ''",
(uid,)
).fetchall()
if not my_dogs:
return {"count": 0, "rassen": [], "forum_query": None}
rassen = list({d["rasse"].strip() for d in my_dogs if d["rasse"]})
# Andere User (nicht ich) die eine dieser Rassen haben
ph = ",".join("?" * len(rassen))
count_row = conn.execute(f"""
SELECT COUNT(DISTINCT d.user_id) AS cnt
FROM dogs d
WHERE d.user_id != ?
AND d.rasse IN ({ph})
""", (uid, *rassen)).fetchone()
count = count_row["cnt"] if count_row else 0
# Für jede Rasse: wie viele andere User
rassen_detail = []
for rasse in rassen:
n = conn.execute(
"SELECT COUNT(DISTINCT user_id) AS cnt FROM dogs "
"WHERE user_id != ? AND rasse = ?",
(uid, rasse)
).fetchone()["cnt"]
if n > 0:
rassen_detail.append({"rasse": rasse, "count": n})
rassen_detail.sort(key=lambda x: -x["count"])
# Forum-Suche-Link für die häufigste Rasse
forum_query = rassen_detail[0]["rasse"] if rassen_detail else None
return {
"count": count,
"rassen": rassen_detail,
"forum_query": forum_query,
}

View file

@ -0,0 +1,190 @@
"""BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)"""
import json
import math
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, List
from database import db
from auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
def _haversine(lat1, lon1, lat2, lon2):
R = 6_371_000
p1, p2 = math.radians(lat1), 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))
class GassiZeitCreate(BaseModel):
dog_id: Optional[int] = None
wochentage: List[str] # ["mo", "mi", "fr"]
uhrzeit: str # "17:00"
ort_name: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
radius_m: int = 500
notiz: Optional[str] = None
class GassiZeitUpdate(BaseModel):
aktiv: Optional[int] = None
# ------------------------------------------------------------------
# GET /api/gassi-zeiten — alle in der Nähe (oder eigene)
# ------------------------------------------------------------------
@router.get("")
async def list_gassi_zeiten(
lat: Optional[float] = None,
lon: Optional[float] = None,
radius: int = 5000, # Meter
nur_eigene: bool = False,
user=Depends(get_current_user),
):
with db() as conn:
if nur_eigene:
rows = conn.execute("""
SELECT gz.*, u.name AS user_name, u.avatar_url,
d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse
FROM gassi_zeiten gz
LEFT JOIN users u ON u.id = gz.user_id
LEFT JOIN dogs d ON d.id = gz.dog_id
WHERE gz.user_id = ?
ORDER BY gz.uhrzeit ASC
""", (user["id"],)).fetchall()
else:
rows = conn.execute("""
SELECT gz.*, u.name AS user_name, u.avatar_url,
d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse
FROM gassi_zeiten gz
LEFT JOIN users u ON u.id = gz.user_id
LEFT JOIN dogs d ON d.id = gz.dog_id
WHERE gz.aktiv = 1
ORDER BY gz.uhrzeit ASC
""").fetchall()
result = []
for r in rows:
d = dict(r)
# wochentage JSON parsen
try:
d["wochentage"] = json.loads(d["wochentage"]) if isinstance(d["wochentage"], str) else d["wochentage"]
except Exception:
d["wochentage"] = []
d["is_mine"] = (d["user_id"] == user["id"])
# Distanz-Filter
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"])
if not nur_eigene and dist > radius:
continue
d["distance_m"] = int(dist)
else:
d["distance_m"] = None
result.append(d)
# Sortierung: eigene zuerst, dann nach Distanz
result.sort(key=lambda x: (0 if x["is_mine"] else 1, x.get("distance_m") or 99999))
return result
# ------------------------------------------------------------------
# POST /api/gassi-zeiten — eigene Zeit anlegen
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def create_gassi_zeit(data: GassiZeitCreate, user=Depends(get_current_user)):
if not data.wochentage:
raise HTTPException(400, "Mindestens ein Wochentag muss angegeben werden.")
if not data.uhrzeit:
raise HTTPException(400, "Uhrzeit muss angegeben werden.")
wochentage_json = json.dumps(data.wochentage)
with db() as conn:
# Hund-Prüfung
if data.dog_id:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (data.dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(403, "Hund nicht gefunden oder gehört nicht dir.")
cur = conn.execute("""
INSERT INTO gassi_zeiten (user_id, dog_id, wochentage, uhrzeit,
ort_name, lat, lon, radius_m, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (user["id"], data.dog_id, wochentage_json, data.uhrzeit,
data.ort_name, data.lat, data.lon, data.radius_m, data.notiz))
row = conn.execute("""
SELECT gz.*, u.name AS user_name, d.name AS dog_name, d.foto_url AS dog_foto_url
FROM gassi_zeiten gz
LEFT JOIN users u ON u.id = gz.user_id
LEFT JOIN dogs d ON d.id = gz.dog_id
WHERE gz.id = ?
""", (cur.lastrowid,)).fetchone()
result = dict(row)
try:
result["wochentage"] = json.loads(result["wochentage"])
except Exception:
pass
result["is_mine"] = True
return result
# ------------------------------------------------------------------
# PATCH /api/gassi-zeiten/{id} — pausieren / aktivieren
# ------------------------------------------------------------------
@router.patch("/{gz_id}")
async def update_gassi_zeit(gz_id: int, data: GassiZeitUpdate, user=Depends(get_current_user)):
with db() as conn:
gz = conn.execute(
"SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
).fetchone()
if not gz:
raise HTTPException(404, "Gassi-Zeit nicht gefunden.")
if gz["user_id"] != user["id"]:
raise HTTPException(403, "Nicht deine Gassi-Zeit.")
updates = data.model_dump(exclude_none=True)
if updates:
cols = ", ".join(f"{k} = ?" for k in updates)
conn.execute(f"UPDATE gassi_zeiten SET {cols} WHERE id=?", [*updates.values(), gz_id])
row = conn.execute(
"SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
).fetchone()
result = dict(row)
try:
result["wochentage"] = json.loads(result["wochentage"])
except Exception:
pass
result["is_mine"] = True
return result
# ------------------------------------------------------------------
# DELETE /api/gassi-zeiten/{id}
# ------------------------------------------------------------------
@router.delete("/{gz_id}", status_code=204)
async def delete_gassi_zeit(gz_id: int, user=Depends(get_current_user)):
with db() as conn:
gz = conn.execute(
"SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
).fetchone()
if not gz:
raise HTTPException(404, "Gassi-Zeit nicht gefunden.")
if gz["user_id"] != user["id"]:
raise HTTPException(403, "Nicht deine Gassi-Zeit.")
conn.execute("DELETE FROM gassi_zeiten WHERE id=?", (gz_id,))