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
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
|
||||
<p style="margin:0 0 16px">Hallo <b>{_ename}</b>,</p>
|
||||
<p style="margin:0 0 16px">
|
||||
willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
|
||||
</p>
|
||||
|
|
@ -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"""
|
||||
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
||||
<p style="margin:0 0 16px">Hallo <b>{_ename}</b>,</p>
|
||||
<p style="margin:0 0 16px">
|
||||
du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
|
||||
<p style="margin:0 0 16px">Hallo <b>{_name}</b>,</p>
|
||||
<p style="margin:0 0 16px">
|
||||
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"""
|
||||
<p style="margin:0 0 12px"><b>Neue Job-Bewerbung eingegangen:</b></p>
|
||||
<table style="font-size:14px;border-collapse:collapse;width:100%">
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#888">Name</td><td><b>{name}</b></td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#888">E-Mail</td><td>{email}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#888">Hund</td><td>{dog_name} ({dog_rasse})</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#888">Social</td><td>{social_handle}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#888">Name</td><td><b>{_ename}</b></td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#888">E-Mail</td><td>{_eemail}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#888">Hund</td><td>{_edog_name} ({_edog_rasse})</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#888">Social</td><td>{_ehandle}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#888">Anhänge</td><td>{len([f for f in files if f.filename])} Datei(en)</td></tr>
|
||||
</table>
|
||||
<p style="margin:12px 0 0;font-size:14px;color:#444">{motivation[:300]}{"…" if len(motivation)>300 else ""}</p>"""
|
||||
<p style="margin:12px 0 0;font-size:14px;color:#444">{_emotivation}{"…" if len(motivation)>300 else ""}</p>"""
|
||||
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"<p>Hallo <b>{name}</b>,</p><p>wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!</p>"),
|
||||
f"<p>Hallo <b>{_ename}</b>,</p><p>wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!</p>"),
|
||||
"accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉",
|
||||
f"<p>Hallo <b>{name}</b>,</p><p>wir freuen uns, dir mitzuteilen: <b>du bist unser neuer Social-Media-Manager/in für Ban Yaro!</b><br>Du erhältst außerdem den <b>Gründer-Status</b> in unserer Community. Willkommen im Team!</p>"),
|
||||
f"<p>Hallo <b>{_ename}</b>,</p><p>wir freuen uns, dir mitzuteilen: <b>du bist unser neuer Social-Media-Manager/in für Ban Yaro!</b><br>Du erhältst außerdem den <b>Gründer-Status</b> in unserer Community. Willkommen im Team!</p>"),
|
||||
"rejected": ("Deine Bewerbung bei Ban Yaro",
|
||||
f"<p>Hallo <b>{name}</b>,</p><p>vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!</p>"),
|
||||
f"<p>Hallo <b>{_ename}</b>,</p><p>vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!</p>"),
|
||||
}
|
||||
subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"<p>Hallo {name},</p>"))
|
||||
note_html = f'<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;border-radius:0 8px 8px 0;margin:12px 0">{note}</div>' if note else ""
|
||||
subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"<p>Hallo {_ename},</p>"))
|
||||
note_html = f'<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;border-radius:0 8px 8px 0;margin:12px 0">{_html.escape(note)}</div>' if note else ""
|
||||
body = body_start + note_html
|
||||
|
||||
async def _send():
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<p style="margin:0 0 12px"><b>Kritischer Tierschutz-Hinweis bestätigt</b></p>
|
||||
<table style="font-size:14px;border-collapse:collapse;width:100%">
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Züchter</td><td style="padding:5px 0"><b>{zuechter}</b></td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{zwinger}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Vater</td><td style="padding:5px 0">{eltern['vater_name'] or '—'}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Mutter</td><td style="padding:5px 0">{eltern['mutter_name'] or '—'}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Züchter</td><td style="padding:5px 0"><b>{_html.escape(zuechter)}</b></td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{_html.escape(zwinger)}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Vater</td><td style="padding:5px 0">{_html.escape(eltern['vater_name'] or '—')}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Mutter</td><td style="padding:5px 0">{_html.escape(eltern['mutter_name'] or '—')}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Wurf-ID</td><td style="padding:5px 0">#{litter_id}</td></tr>
|
||||
</table>"""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue