Chore: Sprint32-36 Zwischenstand — alle Änderungen aus dieser Session committen

This commit is contained in:
rene 2026-05-03 11:09:39 +02:00
parent f4052fbb7d
commit 747c353444
20 changed files with 3115 additions and 63 deletions

View file

@ -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()

View file

@ -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]

View file

@ -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>

View file

@ -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"])
)

View file

@ -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,

View file

@ -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():

View file

@ -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:

View file

@ -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"])

View file

@ -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()

View file

@ -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}')

View file

@ -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)