banyaro/backend/routes/jobs.py

333 lines
13 KiB
Python

"""BAN YARO — Social-Media-Job Bewerbungs-System"""
import html as _html
import os
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional, require_admin
from mailer import send_email, email_html
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
JOBS_DIR = os.path.join(MEDIA_DIR, "jobs")
TRIAL_DAYS = 14
MAX_FILES = 3
MAX_FILE_MB = 10
os.makedirs(JOBS_DIR, exist_ok=True)
_ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"}
# ------------------------------------------------------------------
# POST /api/jobs/apply
# ------------------------------------------------------------------
async def apply(
name: str = Form(...),
email: str = Form(...),
dog_name: str = Form(""),
dog_rasse: str = Form(""),
social_handle: str = Form(...),
motivation: str = Form(...),
files: list[UploadFile] = File(default=[]),
user = Depends(get_current_user_optional),
):
if len(motivation.strip()) < 80:
raise HTTPException(400, "Bitte schreibe etwas mehr über dich (mindestens 80 Zeichen).")
if len(files) > MAX_FILES:
raise HTTPException(400, f"Maximal {MAX_FILES} Dateien erlaubt.")
user_id = user["id"] if user else None
# Doppelbewerbung verhindern
if user_id:
with db() as conn:
existing = conn.execute(
"SELECT id FROM job_applications WHERE user_id=? AND status NOT IN ('rejected')",
(user_id,)
).fetchone()
if existing:
raise HTTPException(400, "Du hast bereits eine aktive Bewerbung eingereicht.")
with db() as conn:
cur = conn.execute("""
INSERT INTO job_applications
(user_id, name, email, dog_name, dog_rasse, social_handle, motivation)
VALUES (?,?,?,?,?,?,?)
""", (user_id, name.strip(), email.strip(), dog_name.strip(),
dog_rasse.strip(), social_handle.strip(), motivation.strip()))
app_id = cur.lastrowid
# Dokumente speichern
app_dir = os.path.join(JOBS_DIR, str(app_id))
os.makedirs(app_dir, exist_ok=True)
for f in files:
if not f.filename:
continue
ext = os.path.splitext(f.filename)[1].lower()
if ext not in _ALLOWED_EXT:
continue
size = 0
safe_name = f"{uuid.uuid4().hex}{ext}"
dest = os.path.join(app_dir, safe_name)
with open(dest, "wb") as out:
while chunk := await f.read(65536):
size += len(chunk)
if size > MAX_FILE_MB * 1024 * 1024:
out.close()
os.remove(dest)
raise HTTPException(400, f"Datei zu groß (max. {MAX_FILE_MB} MB).")
out.write(chunk)
conn.execute("""
INSERT INTO job_application_docs (application_id, filename, file_path)
VALUES (?,?,?)
""", (app_id, f.filename, dest))
# Luna-Probezugang: 14 Tage ab sofort
if user_id:
trial_until = (datetime.utcnow() + timedelta(days=TRIAL_DAYS)).isoformat()
conn.execute(
"UPDATE users SET luna_trial_until=? WHERE id=?",
(trial_until, user_id)
)
# 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">
deine Bewerbung als Social-Media-Manager/in bei Ban Yaro ist bei uns eingegangen.
Wir melden uns bald bei dir!
</p>
{"<p style='margin:0 0 16px;background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;border-radius:0 8px 8px 0'><b>🎉 Luna-Probezugang aktiviert!</b><br>Du hast für 14 Tage kostenlos Zugang zu Luna, unserem KI-Social-Media-Assistenten. Logge dich ein und probiere ihn aus.</p>" if user_id else ""}
<p style="margin:0;color:#666;font-size:14px">Das Ban Yaro Team</p>"""
await send_email(
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!",
)
except Exception:
pass
# Admin benachrichtigen
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>{_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">{_emotivation}{"" if len(motivation)>300 else ""}</p>"""
await send_email(
admin_email,
f"[Banyaro Jobs] Neue Bewerbung — {name}",
email_html(admin_body, cta_url="https://banyaro.app/#admin", cta_label="Im Admin-Bereich prüfen"),
f"Neue Bewerbung von {name} ({email})",
)
except Exception:
pass
return {
"ok": True,
"application_id": app_id,
"luna_trial": user_id is not None,
"trial_days": TRIAL_DAYS,
}
# FastAPI braucht expliziten Router-Decorator
router.add_api_route("/apply", apply, methods=["POST"], status_code=201)
# ------------------------------------------------------------------
# GET /api/jobs/my-application
# ------------------------------------------------------------------
@router.get("/my-application")
async def my_application(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"""SELECT id, status, admin_note, created_at
FROM job_applications WHERE user_id=?
ORDER BY created_at DESC LIMIT 1""",
(user["id"],)
).fetchone()
if not row:
return {"application": None}
return {"application": dict(row)}
# ------------------------------------------------------------------
# GET /api/jobs/luna-trial-status
# ------------------------------------------------------------------
@router.get("/luna-trial-status")
async def luna_trial_status(user=Depends(get_current_user)):
from datetime import datetime as _dt
trial = user.get("luna_trial_until")
if not trial:
return {"active": False}
remaining = (_dt.fromisoformat(trial) - _dt.utcnow()).days
return {"active": remaining > 0, "until": trial, "remaining_days": max(0, remaining)}
# ------------------------------------------------------------------
# Admin: Bewerbungen verwalten
# ------------------------------------------------------------------
@router.get("/admin/applications")
async def list_applications(
status: str = "pending",
admin = Depends(require_admin),
):
where = "" if status == "alle" else "WHERE a.status=?"
params = [] if status == "alle" else [status]
with db() as conn:
rows = conn.execute(f"""
SELECT a.*, u.name AS username,
COUNT(d.id) AS doc_count
FROM job_applications a
LEFT JOIN users u ON u.id = a.user_id
LEFT JOIN job_application_docs d ON d.application_id = a.id
{where}
GROUP BY a.id
ORDER BY a.created_at DESC
""", params).fetchall()
return [dict(r) for r in rows]
@router.get("/admin/applications/{app_id}")
async def get_application(app_id: int, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute(
"""SELECT a.*, u.name AS username, u.email AS user_email
FROM job_applications a
LEFT JOIN users u ON u.id = a.user_id
WHERE a.id=?""",
(app_id,)
).fetchone()
if not row:
raise HTTPException(404)
docs = conn.execute(
"SELECT id, filename, uploaded_at FROM job_application_docs WHERE application_id=?",
(app_id,)
).fetchall()
return {**dict(row), "docs": [dict(d) for d in docs]}
@router.patch("/admin/applications/{app_id}")
async def update_application(
app_id: int,
status: Optional[str] = None,
admin_note: Optional[str] = None,
admin = Depends(require_admin),
):
valid = {"pending", "reviewing", "accepted", "rejected"}
if status and status not in valid:
raise HTTPException(400, f"Ungültiger Status. Erlaubt: {valid}")
with db() as conn:
row = conn.execute(
"SELECT user_id, email, name, status FROM job_applications WHERE id=?",
(app_id,)
).fetchone()
if not row:
raise HTTPException(404)
updates: dict = {"reviewed_at": datetime.utcnow().isoformat()}
if status:
updates["status"] = status
if admin_note is not None:
updates["admin_note"] = admin_note
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE job_applications SET {set_clause} WHERE id=?",
(*updates.values(), app_id)
)
# Bei Annahme: is_social_media aktivieren + Gründer-Status
if status == "accepted" and row["user_id"]:
conn.execute(
"UPDATE users SET is_social_media=1 WHERE id=?",
(row["user_id"],)
)
# Atomare Gründer-Vergabe inkl. founder_number — Race-frei via Sub-Query
# (konsistent mit dogs.py / partner.py).
conn.execute(
"""UPDATE users
SET is_founder = 1,
founder_number = (
SELECT IFNULL(MAX(founder_number), 0) + 1
FROM users WHERE is_founder = 1
)
WHERE id = ?
AND (is_founder IS NULL OR is_founder = 0)
AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < 100""",
(row["user_id"],)
)
# Status-Mail an Bewerber
try:
if status in ("accepted", "rejected", "reviewing"):
_send_status_mail(row["email"], row["name"], status, admin_note or "")
except Exception:
pass
return {"ok": True}
@router.get("/admin/applications/{app_id}/docs/{doc_id}")
async def download_doc(app_id: int, doc_id: int, admin=Depends(require_admin)):
with db() as conn:
doc = conn.execute(
"SELECT file_path, filename FROM job_application_docs WHERE id=? AND application_id=?",
(doc_id, app_id)
).fetchone()
if not doc or not os.path.exists(doc["file_path"]):
raise HTTPException(404)
return FileResponse(doc["file_path"], filename=doc["filename"])
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>{_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>{_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>{_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 {_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():
await send_email(email, subj, email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"), subj)
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(_send())
else:
loop.run_until_complete(_send())
except Exception:
pass