Chore: Sprint32-36 Zwischenstand — alle Änderungen aus dieser Session committen
This commit is contained in:
parent
f4052fbb7d
commit
747c353444
20 changed files with 3115 additions and 63 deletions
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue