333 lines
13 KiB
Python
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
|