Compare commits
2 commits
de1677154f
...
f378edab5d
| Author | SHA1 | Date | |
|---|---|---|---|
| f378edab5d | |||
| 59856e61a1 |
9 changed files with 1088 additions and 117 deletions
|
|
@ -87,7 +87,7 @@ def get_current_user(
|
|||
user_id = int(payload["sub"])
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified FROM users WHERE id=?",
|
||||
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
|
||||
|
|
@ -131,7 +131,10 @@ def require_admin(user=Depends(get_current_user)):
|
|||
|
||||
|
||||
def require_social_media(user=Depends(get_current_user)):
|
||||
"""Dependency: Social-Media-Manager oder Admin."""
|
||||
if not (user.get("is_social_media") or user["rolle"] == "admin"):
|
||||
"""Dependency: Social-Media-Manager, Luna-Probezugang oder Admin."""
|
||||
from datetime import datetime as _dt
|
||||
trial = user.get("luna_trial_until")
|
||||
trial_active = bool(trial and _dt.utcnow().isoformat() < trial)
|
||||
if not (user.get("is_social_media") or user["rolle"] == "admin" or trial_active):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.")
|
||||
return user
|
||||
|
|
|
|||
|
|
@ -701,7 +701,28 @@ def _migrate(conn_factory):
|
|||
CREATE INDEX IF NOT EXISTS idx_wiki_berichte_rasse ON wiki_berichte(rasse, created_at DESC);
|
||||
""")
|
||||
|
||||
# Hunde-Filme: Bewertungen + Hund des Monats
|
||||
# Hunde-Filme: Katalog + Bewertungen + Hund des Monats
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS movies (
|
||||
id TEXT PRIMARY KEY,
|
||||
titel TEXT NOT NULL,
|
||||
originaltitel TEXT,
|
||||
jahr INTEGER,
|
||||
genre TEXT,
|
||||
typ TEXT NOT NULL DEFAULT 'film',
|
||||
hund_rasse TEXT,
|
||||
stirbt_der_hund INTEGER NOT NULL DEFAULT 0,
|
||||
beschreibung TEXT,
|
||||
bild_emoji TEXT DEFAULT '🐾',
|
||||
imdb_rating REAL,
|
||||
streaming TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_movies_typ ON movies(typ);
|
||||
CREATE INDEX IF NOT EXISTS idx_movies_jahr ON movies(jahr DESC);
|
||||
""")
|
||||
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS movie_votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -1561,6 +1582,35 @@ def _migrate(conn_factory):
|
|||
if 'from_account' not in existing_ol:
|
||||
conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'")
|
||||
|
||||
# Job-Bewerbungen + Luna-Probezugang
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS job_applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
dog_name TEXT,
|
||||
dog_rasse TEXT,
|
||||
social_handle TEXT,
|
||||
motivation TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
admin_note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
reviewed_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_apps_status ON job_applications(status, created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS job_application_docs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_id INTEGER NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
""")
|
||||
existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
|
||||
if 'luna_trial_until' not in existing_u:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN luna_trial_until TEXT")
|
||||
|
||||
# js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress
|
||||
existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
|
||||
if 'js_exercise_id' not in existing_te:
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ logger = logging.getLogger(__name__)
|
|||
async def lifespan(app: FastAPI):
|
||||
logger.info("Ban Yaro startet...")
|
||||
init_db()
|
||||
from routes.movies import seed_movies
|
||||
seed_movies()
|
||||
logger.info(f"KI-Modus: {ki.KI_MODE}")
|
||||
sched.start()
|
||||
yield
|
||||
|
|
@ -186,6 +188,7 @@ from routes.breeder_export import router as breeder_export_router
|
|||
from routes.zucht_ki import router as zucht_ki_router
|
||||
from routes.partner import router as partner_router
|
||||
from routes.outreach import router as outreach_router
|
||||
from routes.jobs import router as jobs_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||
|
|
@ -219,6 +222,7 @@ app.include_router(breeder_export_router, prefix="/api", tags=["Export"])
|
|||
app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"])
|
||||
app.include_router(partner_router, prefix="/api", tags=["Partner"])
|
||||
app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"])
|
||||
app.include_router(jobs_router, prefix="/api/jobs", tags=["Jobs"])
|
||||
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
|
||||
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
|
||||
app.include_router(import_router, prefix="/api/import", tags=["Import"])
|
||||
|
|
|
|||
318
backend/routes/jobs.py
Normal file
318
backend/routes/jobs.py
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
"""BAN YARO — Social-Media-Job Bewerbungs-System"""
|
||||
|
||||
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:
|
||||
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:
|
||||
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">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>"""
|
||||
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"],)
|
||||
)
|
||||
founder_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE is_founder=1"
|
||||
).fetchone()[0]
|
||||
if founder_count < 100:
|
||||
conn.execute(
|
||||
"UPDATE users SET is_founder=1 WHERE id=? AND is_founder=0",
|
||||
(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
|
||||
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>"),
|
||||
"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>"),
|
||||
"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>"),
|
||||
}
|
||||
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 ""
|
||||
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
|
||||
|
|
@ -1,111 +1,245 @@
|
|||
"""BAN YARO — Hunde-Filme Routes"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from database import db
|
||||
from auth import get_current_user, get_current_user_optional
|
||||
from auth import get_current_user, get_current_user_optional, require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hardcoded Film-Daten
|
||||
# Seed-Daten — werden beim ersten Start in die DB geschrieben
|
||||
# ------------------------------------------------------------------
|
||||
FILME = [
|
||||
{"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "bewertung_avg": 4.2},
|
||||
{"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "bewertung_avg": 4.0},
|
||||
{"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "bewertung_avg": 4.5},
|
||||
{"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "bewertung_avg": 4.8},
|
||||
{"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "bewertung_avg": 4.3},
|
||||
{"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "bewertung_avg": 3.8},
|
||||
{"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi/Serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "bewertung_avg": 4.1},
|
||||
{"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "bewertung_avg": 4.0},
|
||||
{"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "bewertung_avg": 3.5},
|
||||
{"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus. Kontroversiell beliebt.", "bild_emoji": "💣", "bewertung_avg": 4.6},
|
||||
{"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "bewertung_avg": 4.4},
|
||||
{"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "bewertung_avg": 4.3},
|
||||
_SEED_FILME = [
|
||||
# ── Originalbestand ──────────────────────────────────────────────
|
||||
{"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "imdb_rating": 7.0},
|
||||
{"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "imdb_rating": 6.4},
|
||||
{"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "imdb_rating": 7.1},
|
||||
{"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "typ": "film", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "imdb_rating": 8.1},
|
||||
{"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "imdb_rating": 7.2},
|
||||
{"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "imdb_rating": 5.9},
|
||||
{"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "imdb_rating": 7.5},
|
||||
{"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "imdb_rating": 7.3},
|
||||
{"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "typ": "film", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "imdb_rating": 5.7},
|
||||
{"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus.", "bild_emoji": "💣", "imdb_rating": 7.4},
|
||||
{"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "imdb_rating": 7.9},
|
||||
{"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "imdb_rating": 7.3},
|
||||
# ── Animation / Kinder ──────────────────────────────────────────
|
||||
{"id": "lady-and-the-tramp", "titel": "Susi und Strolch", "originaltitel": "Lady and the Tramp", "jahr": 1955, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker mit der berühmtesten Spaghetti-Szene der Filmgeschichte.", "bild_emoji": "🍝", "imdb_rating": 7.3, "streaming": "Disney+"},
|
||||
{"id": "fox-and-the-hound", "titel": "Cap und Capper", "originaltitel": "The Fox and the Hound", "jahr": 1981, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Bloodhound", "stirbt_der_hund": False, "beschreibung": "Emotionaler Disney-Film über Freundschaft zwischen Fuchs und Jagdhund — und wie die Welt sie trennt.", "bild_emoji": "🦊", "imdb_rating": 7.2, "streaming": "Disney+"},
|
||||
{"id": "balto", "titel": "Balto", "jahr": 1995, "genre": "Animation/Abenteuer", "typ": "film", "hund_rasse": "Husky/Wolf-Mischling", "stirbt_der_hund": False, "beschreibung": "1925 brachte Schlittenhund Balto lebensrettende Medizin nach Nome, Alaska. Basiert auf einer wahren Heldengeschichte.", "bild_emoji": "🐺", "imdb_rating": 7.1, "streaming": "Amazon Prime"},
|
||||
{"id": "bolt", "titel": "Bolt — Ein Hund für alle Fälle", "originaltitel": "Bolt", "jahr": 2008, "genre": "Animation/Abenteuer", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Ein TV-Superhund glaubt, seine Kräfte seien echt, und reist abenteuerlich quer durch Amerika.", "bild_emoji": "⚡", "imdb_rating": 6.8, "streaming": "Disney+"},
|
||||
{"id": "frankenweenie", "titel": "Frankenweenie", "jahr": 2012, "genre": "Animation/Horrorkomödie","typ": "film","hund_rasse": "Bullterrier", "stirbt_der_hund": True, "beschreibung": "Tim Burtons Stop-Motion-Meisterwerk: Ein Junge erweckt seinen toten Hund mit Wissenschaft wieder zum Leben.", "bild_emoji": "🧟", "imdb_rating": 6.9, "streaming": "Disney+"},
|
||||
{"id": "secret-life-of-pets", "titel": "Pets — Geheimes Leben der Haustiere","originaltitel": "The Secret Life of Pets","jahr": 2016,"genre": "Animation/Komödie","typ": "film","hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Was machen unsere Haustiere, wenn wir nicht zu Hause sind? Rasante Antwort mit Witz und Charme.", "bild_emoji": "🏠", "imdb_rating": 6.5, "streaming": "Amazon Prime"},
|
||||
{"id": "plague-dogs", "titel": "The Plague Dogs", "jahr": 1982, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Labrador / Mischling", "stirbt_der_hund": True, "beschreibung": "Düsterer Animationsfilm für Erwachsene: Zwei Hunde fliehen aus einem Tierversuchs-Labor. Brutal ehrlich, nach Richard Adams.", "bild_emoji": "🚫", "imdb_rating": 7.7},
|
||||
{"id": "paw-patrol-movie", "titel": "PAW Patrol: Der Kinofilm", "originaltitel": "PAW Patrol: The Movie", "jahr": 2021, "genre": "Animation/Kinder", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Die beliebten TV-Rettungshunde auf der großen Leinwand. Für die jüngsten Fans ein Pflichtprogramm.", "bild_emoji": "🚒", "imdb_rating": 6.1, "streaming": "Amazon Prime"},
|
||||
# ── Klassiker vor 1980 ──────────────────────────────────────────
|
||||
{"id": "the-thin-man", "titel": "Der dünne Mann", "originaltitel": "The Thin Man", "jahr": 1934, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Drahthaariger Foxterrier", "stirbt_der_hund": False, "beschreibung": "Hollywood-Klassiker mit Nick und Nora Charles — und Asta, dem witzigsten Hund der Filmgeschichte. Mehrere Fortsetzungen.", "bild_emoji": "🍸", "imdb_rating": 7.9},
|
||||
{"id": "lassie-come-home", "titel": "Lassie kehrt heim", "originaltitel": "Lassie Come Home", "jahr": 1943, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Originalfilm: Ein armes Farmkind muss seinen geliebten Collie verkaufen — Lassie findet trotzdem heim.", "bild_emoji": "🏡", "imdb_rating": 7.1},
|
||||
{"id": "incredible-journey", "titel": "Die unglaubliche Reise", "originaltitel": "The Incredible Journey", "jahr": 1963, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Labrador", "stirbt_der_hund": False, "beschreibung": "Zwei Hunde und eine Katze meistern 400 km kanadische Wildnis — Disney-Abenteuer nach dem Roman von Sheila Burnford.", "bild_emoji": "🗺️", "imdb_rating": 7.0},
|
||||
{"id": "greyfriars-bobby", "titel": "Greyfriars Bobby", "jahr": 1961, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Skye Terrier", "stirbt_der_hund": False, "beschreibung": "Basiert auf der wahren Geschichte des Terriers, der 14 Jahre das Grab seines Herrchens in Edinburgh bewachte.", "bild_emoji": "⛪", "imdb_rating": 7.2, "streaming": "Disney+"},
|
||||
{"id": "sounder", "titel": "Sounder", "jahr": 1972, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Coonhound", "stirbt_der_hund": False, "beschreibung": "Oscar-nominiertes Drama über eine schwarze Farmfamilie in der Great Depression. Ihr Hund Sounder ist das Herz der Geschichte.", "bild_emoji": "🌾", "imdb_rating": 7.5},
|
||||
{"id": "where-red-fern-grows","titel": "Wo der rote Farn wächst", "originaltitel": "Where the Red Fern Grows", "jahr": 1974, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Coonhound", "stirbt_der_hund": True, "beschreibung": "Ein Junge spart jahrelang für zwei Jagdhunde. Kultfilm der amerikanischen Kindheit — das Ende lässt kaum jemanden trocken.", "bild_emoji": "🌿", "imdb_rating": 6.9},
|
||||
{"id": "milo-and-otis", "titel": "Milo und Otis", "originaltitel": "The Adventures of Milo and Otis","jahr": 1986,"genre": "Abenteuer/Familie","typ": "film", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Japanischer Realfilm mit Katze und Hund auf großer Abenteuerreise. Für Kinder ein Klassiker, für Erwachsene nostalgisches Heimweh.", "bild_emoji": "🐾", "imdb_rating": 6.9},
|
||||
{"id": "umberto-d", "titel": "Umberto D.", "jahr": 1952, "genre": "Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Meisterwerk des italienischen Neorealismus: Ein alter Rentner und sein Hund kämpfen würdevoll gegen Armut in Rom.", "bild_emoji": "🇮🇹", "imdb_rating": 8.1, "streaming": "Mubi"},
|
||||
# ── Wahre Geschichten ───────────────────────────────────────────
|
||||
{"id": "homeward-bound", "titel": "Auf dem Weg nach Hause", "originaltitel": "Homeward Bound", "jahr": 1993, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Zwei Hunde und eine Katze kämpfen sich mit Stimmen durch die amerikanische Wildnis nach Hause. Remake des Klassikers.", "bild_emoji": "🏔️", "imdb_rating": 7.0, "streaming": "Disney+"},
|
||||
{"id": "togo", "titel": "Togo", "jahr": 2019, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Sibirischer Husky", "stirbt_der_hund": False, "beschreibung": "Die unbekannte Geschichte hinter dem Balto-Mythos: Der echte Held des Serum-Runs 1925 war Togo. Außergewöhnlicher Disney+-Film.", "bild_emoji": "🛷", "imdb_rating": 7.9, "streaming": "Disney+"},
|
||||
{"id": "red-dog", "titel": "Red Dog", "jahr": 2011, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Kelpie", "stirbt_der_hund": True, "beschreibung": "Australischer Kultfilm über einen echten Wanderhund, der eine Minengemeinschaft im Outback zusammenbrachte. Rauh und herzlich.", "bild_emoji": "🦘", "imdb_rating": 7.3, "streaming": "Amazon Prime"},
|
||||
{"id": "megan-leavey", "titel": "Megan Leavey", "jahr": 2017, "genre": "Biopic/Drama", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Die wahre Geschichte einer US-Marine und ihres Sprengstoff-Suchhundes Rex im Irak-Einsatz. Kate Mara in einer ihrer stärksten Rollen.", "bild_emoji": "🎖️", "imdb_rating": 7.1, "streaming": "Amazon Prime"},
|
||||
{"id": "arthur-the-king", "titel": "Arthur der König", "originaltitel": "Arthur the King", "jahr": 2024, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Mark Wahlberg und ein streunender Hund meistern gemeinsam ein Extremrennen durch die Dominikanische Republik. Inspiriert von wahren Ereignissen.","bild_emoji": "🏆", "imdb_rating": 7.0, "streaming": "Amazon Prime"},
|
||||
{"id": "rescued-by-ruby", "titel": "Gerettet von Ruby", "originaltitel": "Rescued by Ruby", "jahr": 2022, "genre": "Biopic/Familie", "typ": "film", "hund_rasse": "Australian Shepherd / Border Collie","stirbt_der_hund": False,"beschreibung": "Ein Polizist und ein Tierheim-Hund retten sich gegenseitig — wahre Geschichte aus Rhode Island.", "bild_emoji": "🌟", "imdb_rating": 7.2, "streaming": "Netflix"},
|
||||
{"id": "my-dog-skip", "titel": "Mein Hund Skip", "originaltitel": "My Dog Skip", "jahr": 2000, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Jack Russell Terrier", "stirbt_der_hund": True, "beschreibung": "Die Coming-of-Age-Geschichte eines einsamen Jungen im Mississippi der 1940er, der durch seinen Hund Skip Freundschaft findet.", "bild_emoji": "📚", "imdb_rating": 7.0},
|
||||
# ── Arbeitshunde / Polizeihunde ─────────────────────────────────
|
||||
{"id": "turner-and-hooch", "titel": "Turner & Hooch", "jahr": 1989, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Dogue de Bordeaux", "stirbt_der_hund": True, "beschreibung": "Tom Hanks als ordentlicher Detective trifft auf Hooch, den sabbernden Chaoshund. Buddy-Cop-Klassiker mit überraschend emotionalem Ende.", "bild_emoji": "🕵️", "imdb_rating": 6.2, "streaming": "Disney+"},
|
||||
{"id": "k9", "titel": "K-9", "jahr": 1989, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Jim Belushi als Drogenfahnder bekommt zwangsweise den eigenwilligen Schäferhund Jerry als Partner. Klassiker des Buddy-Cop-Genres.", "bild_emoji": "🚔", "imdb_rating": 6.2},
|
||||
{"id": "max", "titel": "Max", "jahr": 2015, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Ein Kriegshund aus Afghanistan wird nach dem Tod seines Handlers von dessen Familie adoptiert. Über Trauma und Vertrauen.", "bild_emoji": "🎗️", "imdb_rating": 6.6, "streaming": "Amazon Prime"},
|
||||
{"id": "dog-2022", "titel": "Dog", "jahr": 2022, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Channing Tatum fährt mit einem traumatisierten Kriegshund quer durch Amerika — roh, komisch und berührend.", "bild_emoji": "🚗", "imdb_rating": 6.5, "streaming": "Amazon Prime"},
|
||||
{"id": "quill", "titel": "Quill — Ein Führhund", "originaltitel": "Quill: The Life of a Guide Dog","jahr": 2004,"genre": "Drama/Familie", "typ": "film", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Japanischer Film über das Leben des Führhundes Quill vom Welpen bis zum Tod. Zeigt die unersetzliche Arbeit von Blindenführhunden.", "bild_emoji": "👁️", "imdb_rating": 7.1},
|
||||
# ── Komödien ────────────────────────────────────────────────────
|
||||
{"id": "beethoven-2", "titel": "Beethoven's 2nd", "jahr": 1993, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Beethoven verliebt sich und bekommt Nachwuchs — vier chaotische Welpen bringen die Familie erneut an den Rand des Nervenzusammenbruchs.", "bild_emoji": "🐶", "imdb_rating": 5.4},
|
||||
{"id": "dog-days-2018", "titel": "Dog Days", "jahr": 2018, "genre": "Komödie/Romanze", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Mehrere Angelenos und ihre Hunde, deren Leben sich charmant verflechten. Leichte Feel-Good-Komödie mit Vanessa Hudgens.", "bild_emoji": "☀️", "imdb_rating": 6.3},
|
||||
{"id": "as-good-as-it-gets", "titel": "Besser geht's nicht", "originaltitel": "As Good as It Gets", "jahr": 1997, "genre": "Komödie/Drama", "typ": "film", "hund_rasse": "Griffon Bruxellois", "stirbt_der_hund": False, "beschreibung": "Jack Nicholson als Misanthrop, der durch einen kleinen Hund namens Verdell sein Herz entdeckt. Oscar-Gewinner, zeitlos witzig.", "bild_emoji": "💊", "imdb_rating": 7.7, "streaming": "Amazon Prime"},
|
||||
{"id": "eat-pray-bark", "titel": "Eat Pray Bark", "jahr": 2026, "genre": "Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Deutsche Komödie über fünf exzentrische Hundebesitzer, die gemeinsam einen Hundetrainer in den Tiroler Bergen aufsuchen. Top 10 in 49 Netflix-Ländern.", "bild_emoji": "🏔️", "imdb_rating": None, "streaming": "Netflix"},
|
||||
{"id": "the-artist", "titel": "The Artist", "jahr": 2011, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Jack Russell Terrier", "stirbt_der_hund": False, "beschreibung": "Oscar-Gewinner als Stummfilm-Hommage. Uggie der Jack Russell stahl allen die Show und gewann den Palm Dog Award in Cannes.", "bild_emoji": "🎬", "imdb_rating": 7.8, "streaming": "Amazon Prime"},
|
||||
# ── Thriller / Action / Horror ──────────────────────────────────
|
||||
{"id": "cujo", "titel": "Cujo", "jahr": 1983, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": True, "beschreibung": "Stephen Kings Roman verfilmt: Ein tollwütiger Bernhardiner terrorisiert eine Mutter und ihr Kind in einem Auto. Klassiker des 80er-Horror.", "bild_emoji": "🩸", "imdb_rating": 6.1, "streaming": "Amazon Prime"},
|
||||
{"id": "white-god", "titel": "White God — Hund ohne Gnade","originaltitel": "White God", "jahr": 2014, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Ungarischer Arthouse-Thriller mit 250 echten Straßenhunden. Ein Mädchen sucht seinen Hund — während die Hunde Rache nehmen. Cannes-Preis.", "bild_emoji": "🔴", "imdb_rating": 6.8},
|
||||
{"id": "dogman-2018", "titel": "Dogman", "jahr": 2018, "genre": "Krimi/Drama", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matteo Garrones preisgekrönter Film: Ein stiller Hundegroomer verstrickt sich mit einem brutalen Kriminellen. Cannes-Gewinner 2018.", "bild_emoji": "✂️", "imdb_rating": 7.2, "streaming": "Amazon Prime"},
|
||||
{"id": "call-of-the-wild", "titel": "Ruf der Wildnis", "originaltitel": "The Call of the Wild", "jahr": 2020, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Saint Bernard Mix (CGI)", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Harrison Ford: Hund Buck wandert vom Salon-Leben in die Wildnis des Klondike. Episch.", "bild_emoji": "🌲", "imdb_rating": 6.7, "streaming": "Disney+"},
|
||||
# ── Deutsche / österreichische Produktionen ─────────────────────
|
||||
{"id": "lassie-neues-abenteuer","titel": "Lassie — Ein neues Abenteuer","jahr": 2023, "genre": "Familie/Abenteuer","typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Deutsche Neuinterpretation: Lassie hilft Kindern dabei, mysteriöse Hundesdiebstähle aufzudecken. Für Kinder und Familien.", "bild_emoji": "🐕", "imdb_rating": 5.6},
|
||||
# ── Neuere Serien ───────────────────────────────────────────────
|
||||
{"id": "turner-hooch-serie", "titel": "Turner & Hooch (Serie)", "originaltitel": "Turner & Hooch", "jahr": 2021, "genre": "Krimi/Komödie", "typ": "serie", "hund_rasse": "Dogue de Bordeaux", "stirbt_der_hund": False, "beschreibung": "Disney+-Sequel zur Filmklassik: Der Sohn des Originals detektiviert mit Hoochs Nachfolger. Nach einer Staffel abgesetzt.", "bild_emoji": "📺", "imdb_rating": 6.6, "streaming": "Disney+"},
|
||||
{"id": "healing-powers-of-dude","titel": "Dude, mein Hund", "originaltitel": "The Healing Powers of Dude", "jahr": 2020, "genre": "Komödie/Familie", "typ": "serie", "hund_rasse": "Labradoodle", "stirbt_der_hund": False, "beschreibung": "Netflix-Jugendserie: Ein Junge mit sozialer Angststörung und sein emotionaler Support-Hund meistern gemeinsam die Middle School.", "bild_emoji": "💙", "imdb_rating": 6.6, "streaming": "Netflix"},
|
||||
{"id": "hudson-rex", "titel": "Hudson & Rex", "jahr": 2019, "genre": "Krimi", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Kanadische Neuauflage von Kommissar Rex: Detective Hudson und sein Schäferhund Rex lösen in Neufundland Verbrechen. Läuft seit 2019.", "bild_emoji": "🍁", "imdb_rating": 7.4},
|
||||
# ── Klassische Serien ───────────────────────────────────────────
|
||||
{"id": "lassie-serie", "titel": "Lassie (TV-Serie)", "originaltitel": "Lassie", "jahr": 1954, "genre": "Familie/Abenteuer", "typ": "serie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Die legendäre CBS-Serie, die 20 Jahre lief und Generationen prägte. Lassies wöchentliche Rettungsaktionen wurden zum Inbegriff des Treue-Hundes.", "bild_emoji": "📡", "imdb_rating": 7.5},
|
||||
{"id": "rin-tin-tin-serie", "titel": "Rin Tin Tin", "originaltitel": "The Adventures of Rin Tin Tin","jahr": 1954, "genre": "Western/Familie", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Legendäre 1950er-Western-Serie. Ein Waisenjunge und sein Schäferhund helfen der US-Kavallerie im Wilden Westen.", "bild_emoji": "🤠", "imdb_rating": 7.0},
|
||||
# ── Dokumentationen ─────────────────────────────────────────────
|
||||
{"id": "dogs-netflix", "titel": "Dogs", "jahr": 2018, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Herzzerreißende Netflix-Dokuserie über die Bindung zwischen Hunden und Menschen weltweit. Sechs Episoden, Tränen garantiert.", "bild_emoji": "❤️", "imdb_rating": 8.0, "streaming": "Netflix"},
|
||||
{"id": "pick-of-the-litter", "titel": "Pick of the Litter", "jahr": 2018, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Labrador", "stirbt_der_hund": False, "beschreibung": "Ein Labrador-Wurf wird zur Führhundausbildung bestimmt. Nicht alle schaffen es — spannend wie ein Spielfilm.", "bild_emoji": "🎗️", "imdb_rating": 7.6, "streaming": "Amazon Prime"},
|
||||
{"id": "inside-mind-of-dog", "titel": "Im Kopf des Hundes", "originaltitel": "Inside the Mind of a Dog", "jahr": 2024, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Wissenschaftsdoku: Was wissen Hunde wirklich über uns? Rob Lowe erzählt, Forscher erklären die Kognition unserer Vierbeiner.", "bild_emoji": "🧠", "imdb_rating": 7.2, "streaming": "Netflix"},
|
||||
{"id": "stray-doku", "titel": "Stray", "jahr": 2020, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Poetische Doku aus Hundeperspektive: Die Kamera folgt drei Straßenhunden durch Istanbul. Philosophisch, still und ungemein berührend.", "bild_emoji": "🕌", "imdb_rating": 6.9, "streaming": "Amazon Prime"},
|
||||
{"id": "dog-by-dog", "titel": "Dog by Dog", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über den milliardenschweren Welpen-Industrie-Komplex in den USA. Folgt dem Geldfluss hinter Puppy Mills.", "bild_emoji": "💰", "imdb_rating": 8.8, "streaming": "Netflix"},
|
||||
{"id": "gunthers-millions", "titel": "Günthers Millionen", "originaltitel": "Gunther's Millions", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Die absurde Netflix-Doku: Erbte ein Schäferhund wirklich eine halbe Milliarde Euro? Die Wahrheit ist noch seltsamer.", "bild_emoji": "💎", "imdb_rating": 5.6, "streaming": "Netflix"},
|
||||
# ── Weitere ─────────────────────────────────────────────────────
|
||||
{"id": "a-dogs-purpose", "titel": "Bailey — Ein Freund fürs Leben","originaltitel": "A Dog's Purpose", "jahr": 2017, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Verschiedene (Labrador, Corgi u.a.)","stirbt_der_hund": True, "beschreibung": "Ein Hund wird mehrfach wiedergeboren und sucht in jeder Inkarnation nach seinem Sinn. Taschentücher-Pflicht.", "bild_emoji": "🔄", "imdb_rating": 7.3, "streaming": "Amazon Prime"},
|
||||
{"id": "a-dogs-journey", "titel": "Bailey 2", "originaltitel": "A Dog's Journey", "jahr": 2019, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Beagle / Bernhardiner u.a.", "stirbt_der_hund": True, "beschreibung": "Fortsetzung: Bailey beschützt in mehreren Leben die Enkelin seines Herrchens. Emotional und rührend.", "bild_emoji": "🔄", "imdb_rating": 7.5, "streaming": "Amazon Prime"},
|
||||
{"id": "art-of-racing", "titel": "Enzo und die wundersame Welt der Menschen","originaltitel": "The Art of Racing in the Rain","jahr": 2019,"genre": "Drama","typ": "film","hund_rasse": "Golden Retriever", "stirbt_der_hund": True, "beschreibung": "Ein Golden Retriever erzählt seine Lebensgeschichte. Philosophisch, witzig, herzzerreißend — Kevin Costner leiht ihm die Stimme.", "bild_emoji": "🏎️", "imdb_rating": 7.6, "streaming": "Disney+"},
|
||||
{"id": "dog-gone", "titel": "Dog Gone", "originaltitel": "Dog Gone", "jahr": 2023, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Vater und Sohn suchen auf dem Appalachian Trail ihren verlorenen kranken Hund — und finden dabei zueinander. Wahre Geschichte.", "bild_emoji": "🥾", "imdb_rating": 6.1, "streaming": "Netflix"},
|
||||
{"id": "white-fang", "titel": "Wolfsblut", "originaltitel": "White Fang", "jahr": 1991, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Wolf-Hund-Hybrid", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Ethan Hawke: Ein Wolf-Hund-Hybrid findet in Alaska zwischen Wildnis und Menschenwelt seinen Platz.", "bild_emoji": "❄️", "imdb_rating": 6.7},
|
||||
{"id": "because-of-winn-dixie","titel": "Winn-Dixie", "originaltitel": "Because of Winn-Dixie", "jahr": 2005, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein einsames Mädchen findet einen streunenden Hund im Supermarkt — und mit ihm eine ganze Gemeinschaft.", "bild_emoji": "🛒", "imdb_rating": 6.4},
|
||||
{"id": "lassie-2005", "titel": "Lassie (2005)", "jahr": 2005, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Britische Neuverfilmung mit Peter O'Toole. Lassie flieht aus Schottland und findet den langen Weg nach Yorkshire. Atmosphärisch.", "bild_emoji": "🏴", "imdb_rating": 6.7},
|
||||
]
|
||||
|
||||
PROMIS = [
|
||||
{"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre lang täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"},
|
||||
{"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund", "bekannt_fuer": "Filmhund der 1920er-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"},
|
||||
{"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Wurde zur sowjetischen Weltraumpionierin.", "emoji": "🚀"},
|
||||
_SEED_PROMIS = [
|
||||
{"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"},
|
||||
{"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund","bekannt_fuer": "Filmhund der 1920er. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"},
|
||||
{"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Sowjetische Weltraumpionierin.", "emoji": "🚀"},
|
||||
{"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"},
|
||||
{"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (1800–1812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"},
|
||||
{"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre lang das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": "⛪"},
|
||||
{"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": "⛪"},
|
||||
{"name": "Balto", "rasse": "Siberian Husky", "bekannt_fuer": "Führte 1925 den letzten Abschnitt des Serum-Runs nach Nome, Alaska. Statue im Central Park New York.", "emoji": "🛷"},
|
||||
{"name": "Togo", "rasse": "Siberian Husky", "bekannt_fuer": "Der echte Held des Serum-Runs 1925 — legte die schwierigste Strecke zurück, blieb aber lange unbekannt.", "emoji": "🏅"},
|
||||
{"name": "Asta", "rasse": "Drahthaariger Foxterrier","bekannt_fuer": "Filmhund in der 'Dünner Mann'-Reihe (1934–1947). Hollywood-Ikone der klassischen Ära.", "emoji": "🎩"},
|
||||
{"name": "Lassie", "rasse": "Rough Collie", "bekannt_fuer": "Meistverfilmter Hund der Geschichte. Erster Vierbeiner mit einem Stern auf dem Hollywood Walk of Fame.", "emoji": "⭐"},
|
||||
]
|
||||
|
||||
|
||||
def seed_movies():
|
||||
"""Füllt die movies-Tabelle beim ersten Start (idempotent per INSERT OR IGNORE)."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
with db() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM movies").fetchone()[0]
|
||||
if count == 0:
|
||||
for i, f in enumerate(_SEED_FILME):
|
||||
conn.execute("""
|
||||
INSERT OR IGNORE INTO movies
|
||||
(id, titel, originaltitel, jahr, genre, typ, hund_rasse,
|
||||
stirbt_der_hund, beschreibung, bild_emoji, imdb_rating,
|
||||
streaming, sort_order)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
f["id"], f["titel"], f.get("originaltitel"),
|
||||
f.get("jahr"), f.get("genre"), f.get("typ", "film"),
|
||||
f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0,
|
||||
f.get("beschreibung"), f.get("bild_emoji", "🐾"),
|
||||
f.get("imdb_rating"), f.get("streaming"), i,
|
||||
))
|
||||
logger.info(f"movies: {len(_SEED_FILME)} Filme geseedet.")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ------------------------------------------------------------------
|
||||
class FilmVoteRequest(BaseModel):
|
||||
bewertung: int # 1–5
|
||||
|
||||
|
||||
class HundDesMonatsVoteRequest(BaseModel):
|
||||
dog_id: int
|
||||
|
||||
class MovieCreate(BaseModel):
|
||||
id: str
|
||||
titel: str
|
||||
originaltitel: Optional[str] = None
|
||||
jahr: Optional[int] = None
|
||||
genre: Optional[str] = None
|
||||
typ: str = "film"
|
||||
hund_rasse: Optional[str] = None
|
||||
stirbt_der_hund: bool = False
|
||||
beschreibung: Optional[str] = None
|
||||
bild_emoji: str = "🐾"
|
||||
imdb_rating: Optional[float] = None
|
||||
streaming: Optional[str] = None
|
||||
|
||||
class MovieUpdate(BaseModel):
|
||||
titel: Optional[str] = None
|
||||
originaltitel: Optional[str] = None
|
||||
jahr: Optional[int] = None
|
||||
genre: Optional[str] = None
|
||||
typ: Optional[str] = None
|
||||
hund_rasse: Optional[str] = None
|
||||
stirbt_der_hund: Optional[bool] = None
|
||||
beschreibung: Optional[str] = None
|
||||
bild_emoji: Optional[str] = None
|
||||
imdb_rating: Optional[float] = None
|
||||
streaming: Optional[str] = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/movies/filme — Film-Liste mit optionaler User-Bewertung
|
||||
# GET /api/movies/filme
|
||||
# ------------------------------------------------------------------
|
||||
_SORT_COLS = {
|
||||
"titel": "m.titel ASC",
|
||||
"jahr_desc": "m.jahr DESC",
|
||||
"jahr_asc": "m.jahr ASC",
|
||||
"imdb": "m.imdb_rating DESC",
|
||||
"bewertung": "community_avg DESC",
|
||||
"default": "m.sort_order ASC, m.jahr DESC",
|
||||
}
|
||||
|
||||
@router.get("/filme")
|
||||
async def get_filme(user=Depends(get_current_user_optional)):
|
||||
user_ratings = {}
|
||||
community_avgs = {}
|
||||
async def get_filme(
|
||||
sort: str = Query("default"),
|
||||
typ: str = Query("alle"), # alle | film | serie | doku
|
||||
user = Depends(get_current_user_optional),
|
||||
):
|
||||
order = _SORT_COLS.get(sort, _SORT_COLS["default"])
|
||||
|
||||
where = ""
|
||||
params: list = []
|
||||
if typ != "alle":
|
||||
where = "WHERE m.typ = ?"
|
||||
params.append(typ)
|
||||
|
||||
with db() as conn:
|
||||
if user:
|
||||
rows = conn.execute(
|
||||
"SELECT film_id, bewertung FROM movie_votes WHERE user_id=?",
|
||||
(user["id"],),
|
||||
).fetchall()
|
||||
user_ratings = {r["film_id"]: r["bewertung"] for r in rows}
|
||||
|
||||
avg_rows = conn.execute(
|
||||
"SELECT film_id, AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes GROUP BY film_id"
|
||||
).fetchall()
|
||||
community_avgs = {r["film_id"]: {"avg": round(r["avg_bew"], 1), "cnt": r["cnt"]} for r in avg_rows}
|
||||
rows = conn.execute(f"""
|
||||
SELECT m.*,
|
||||
COALESCE(AVG(v.bewertung), 0) AS community_avg,
|
||||
COUNT(v.id) AS bewertung_cnt,
|
||||
uv.bewertung AS user_rating
|
||||
FROM movies m
|
||||
LEFT JOIN movie_votes v ON v.film_id = m.id
|
||||
LEFT JOIN movie_votes uv ON uv.film_id = m.id
|
||||
AND uv.user_id = ?
|
||||
{where}
|
||||
GROUP BY m.id
|
||||
ORDER BY {order}
|
||||
""", [user["id"] if user else None] + params).fetchall()
|
||||
|
||||
result = []
|
||||
for film in FILME:
|
||||
f = dict(film)
|
||||
f["user_rating"] = user_ratings.get(film["id"])
|
||||
if film["id"] in community_avgs:
|
||||
f["bewertung_avg"] = community_avgs[film["id"]]["avg"]
|
||||
f["bewertung_cnt"] = community_avgs[film["id"]]["cnt"]
|
||||
else:
|
||||
f["bewertung_cnt"] = 0
|
||||
result.append(f)
|
||||
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["stirbt_der_hund"] = bool(d["stirbt_der_hund"])
|
||||
d["bewertung_avg"] = round(d["community_avg"] or 0, 1)
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/movies/filme/{film_id}/vote — Bewertung abgeben (Upsert)
|
||||
# POST /api/movies/filme/{film_id}/vote
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/filme/{film_id}/vote")
|
||||
async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_current_user)):
|
||||
if not any(f["id"] == film_id for f in FILME):
|
||||
raise HTTPException(404, "Film nicht gefunden.")
|
||||
if data.bewertung < 1 or data.bewertung > 5:
|
||||
raise HTTPException(400, "Bewertung muss zwischen 1 und 5 liegen.")
|
||||
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO movie_votes (user_id, film_id, bewertung)
|
||||
if not conn.execute("SELECT 1 FROM movies WHERE id=?", (film_id,)).fetchone():
|
||||
raise HTTPException(404, "Film nicht gefunden.")
|
||||
conn.execute("""
|
||||
INSERT INTO movie_votes (user_id, film_id, bewertung)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung""",
|
||||
(user["id"], film_id, data.bewertung),
|
||||
)
|
||||
ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung
|
||||
""", (user["id"], film_id, data.bewertung))
|
||||
row = conn.execute(
|
||||
"SELECT AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes WHERE film_id=?",
|
||||
(film_id,),
|
||||
).fetchone()
|
||||
|
||||
return {
|
||||
"film_id": film_id,
|
||||
"bewertung_avg": round(row["avg_bew"], 1) if row["avg_bew"] else data.bewertung,
|
||||
|
|
@ -115,15 +249,60 @@ async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_curren
|
|||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/movies/hund-des-monats — Top-Votes des aktuellen Monats
|
||||
# Admin: CRUD für Filme
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/filme", status_code=201)
|
||||
async def create_film(data: MovieCreate, admin=Depends(require_admin)):
|
||||
with db() as conn:
|
||||
max_order = conn.execute("SELECT COALESCE(MAX(sort_order),0) FROM movies").fetchone()[0]
|
||||
try:
|
||||
conn.execute("""
|
||||
INSERT INTO movies (id, titel, originaltitel, jahr, genre, typ, hund_rasse,
|
||||
stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, streaming, sort_order)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (data.id, data.titel, data.originaltitel, data.jahr, data.genre, data.typ,
|
||||
data.hund_rasse, 1 if data.stirbt_der_hund else 0, data.beschreibung,
|
||||
data.bild_emoji, data.imdb_rating, data.streaming, max_order + 1))
|
||||
except Exception:
|
||||
raise HTTPException(400, "Film-ID bereits vorhanden.")
|
||||
return {"ok": True}
|
||||
|
||||
@router.patch("/filme/{film_id}")
|
||||
async def update_film(film_id: str, data: MovieUpdate, admin=Depends(require_admin)):
|
||||
updates = {k: v for k, v in data.model_dump(exclude_none=True).items()}
|
||||
if "stirbt_der_hund" in updates:
|
||||
updates["stirbt_der_hund"] = 1 if updates["stirbt_der_hund"] else 0
|
||||
if not updates:
|
||||
return {"ok": True}
|
||||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||||
with db() as conn:
|
||||
conn.execute(f"UPDATE movies SET {set_clause} WHERE id=?", (*updates.values(), film_id))
|
||||
return {"ok": True}
|
||||
|
||||
@router.delete("/filme/{film_id}")
|
||||
async def delete_film(film_id: str, admin=Depends(require_admin)):
|
||||
with db() as conn:
|
||||
conn.execute("DELETE FROM movies WHERE id=?", (film_id,))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/movies/promis — Berühmte Hunde (aus Seed-Daten)
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/promis")
|
||||
async def get_promis():
|
||||
return _SEED_PROMIS
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hund des Monats
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/hund-des-monats")
|
||||
async def get_hund_des_monats(user=Depends(get_current_user_optional)):
|
||||
monat = datetime.now().strftime("%Y-%m")
|
||||
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name,
|
||||
rows = conn.execute("""
|
||||
SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name,
|
||||
COUNT(v.id) as stimmen
|
||||
FROM hund_des_monats_votes v
|
||||
JOIN dogs d ON d.id = v.dog_id
|
||||
|
|
@ -131,10 +310,8 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)):
|
|||
WHERE v.monat = ?
|
||||
GROUP BY v.dog_id
|
||||
ORDER BY stimmen DESC
|
||||
LIMIT 10""",
|
||||
(monat,),
|
||||
).fetchall()
|
||||
|
||||
LIMIT 10
|
||||
""", (monat,)).fetchall()
|
||||
user_vote = None
|
||||
if user:
|
||||
row = conn.execute(
|
||||
|
|
@ -143,43 +320,25 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)):
|
|||
).fetchone()
|
||||
if row:
|
||||
user_vote = row["dog_id"]
|
||||
|
||||
return {
|
||||
"monat": monat,
|
||||
"top": [dict(r) for r in rows],
|
||||
"user_vote": user_vote,
|
||||
}
|
||||
return {"monat": monat, "top": [dict(r) for r in rows], "user_vote": user_vote}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/movies/hund-des-monats/vote — Abstimmen (Auth required)
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/hund-des-monats/vote")
|
||||
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
|
||||
monat = datetime.now().strftime("%Y-%m")
|
||||
|
||||
with db() as conn:
|
||||
# Prüfen ob Hund existiert und entweder dem User gehört oder öffentlich ist
|
||||
dog = conn.execute(
|
||||
"SELECT id, user_id, is_public FROM dogs WHERE id=?",
|
||||
(data.dog_id,),
|
||||
).fetchone()
|
||||
dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404, "Hund nicht gefunden.")
|
||||
if dog["user_id"] != user["id"] and not dog["is_public"]:
|
||||
raise HTTPException(403, "Dieser Hund ist nicht öffentlich.")
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
|
||||
conn.execute("""
|
||||
INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id""",
|
||||
(user["id"], data.dog_id, monat),
|
||||
)
|
||||
|
||||
# Aktuelle Stimmenanzahl für den gewählten Hund
|
||||
ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id
|
||||
""", (user["id"], data.dog_id, monat))
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM hund_des_monats_votes WHERE dog_id=? AND monat=?",
|
||||
(data.dog_id, monat),
|
||||
).fetchone()
|
||||
|
||||
return {"dog_id": data.dog_id, "monat": monat, "stimmen": row["cnt"]}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ const App = (() => {
|
|||
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
|
||||
'zucht-profil': { title: 'Hunde-Profil', module: null },
|
||||
gruender: { title: '100 Gründer', module: null },
|
||||
jobs: { title: 'Wir suchen dich', module: null },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ window.Page_admin = (() => {
|
|||
{ id: 'social', label: 'Social Media', icon: 'camera' },
|
||||
{ id: 'analytics', label: 'Analytics', icon: 'target' },
|
||||
{ id: 'system', label: 'System', icon: 'gear' },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
|
||||
{ id: 'jobs', label: 'Scheduler', icon: 'clock' },
|
||||
{ id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' },
|
||||
{ id: 'partner', label: 'Partner', icon: 'handshake' },
|
||||
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
|
||||
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
||||
|
|
@ -93,6 +94,7 @@ window.Page_admin = (() => {
|
|||
case 'partner': await _renderPartner(el); break;
|
||||
case 'outreach': await _renderOutreach(el); break;
|
||||
case 'audit': await _renderAudit(el); break;
|
||||
case 'bewerbungen': await _renderBewerbungen(el); break;
|
||||
}
|
||||
} catch (e) {
|
||||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
||||
|
|
@ -2375,6 +2377,125 @@ window.Page_admin = (() => {
|
|||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// BEWERBUNGEN — Social-Media-Job
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderBewerbungen(el) {
|
||||
let _statusFilter = 'pending';
|
||||
|
||||
async function _load() {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4);flex-wrap:wrap;align-items:center">
|
||||
${['pending','reviewing','accepted','rejected','alle'].map(s => `
|
||||
<button class="btn btn-sm ${s===_statusFilter?'btn-primary':'btn-ghost'} adm-bew-filter" data-s="${s}">
|
||||
${s==='pending'?'⏳ Neu':s==='reviewing'?'🔍 In Prüfung':s==='accepted'?'✅ Angenommen':s==='rejected'?'❌ Abgelehnt':'Alle'}
|
||||
</button>`).join('')}
|
||||
</div>
|
||||
<div id="adm-bew-list">${UI.skeleton(3)}</div>`;
|
||||
|
||||
el.querySelectorAll('.adm-bew-filter').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_statusFilter = btn.dataset.s;
|
||||
_load();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`);
|
||||
const list = el.querySelector('#adm-bew-list');
|
||||
if (!rows.length) {
|
||||
list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.');
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rows.map(r => `
|
||||
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-4)" data-id="${r.id}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-3)">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:700;font-size:var(--text-base)">${_esc(r.name)}
|
||||
${r.username ? `<span style="color:var(--c-text-muted);font-weight:400;font-size:var(--text-sm)">(@${_esc(r.username)})</span>` : ''}
|
||||
</div>
|
||||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:2px">
|
||||
${_esc(r.email)} · @${_esc(r.social_handle||'—')}
|
||||
${r.dog_name ? ` · 🐕 ${_esc(r.dog_name)} (${_esc(r.dog_rasse||'')})` : ''}
|
||||
</div>
|
||||
<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:2px">
|
||||
${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge
|
||||
</div>
|
||||
<div style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3)">
|
||||
${_esc((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);min-width:120px">
|
||||
<button class="btn btn-sm btn-primary adm-bew-view" data-id="${r.id}">Details</button>
|
||||
<select class="form-control adm-bew-status" data-id="${r.id}"
|
||||
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
|
||||
<option value="pending" ${r.status==='pending' ?'selected':''}>⏳ Neu</option>
|
||||
<option value="reviewing" ${r.status==='reviewing'?'selected':''}>🔍 Prüfung</option>
|
||||
<option value="accepted" ${r.status==='accepted' ?'selected':''}>✅ Angenommen</option>
|
||||
<option value="rejected" ${r.status==='rejected' ?'selected':''}>❌ Abgelehnt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
list.querySelectorAll('.adm-bew-status').forEach(sel => {
|
||||
sel.addEventListener('change', async () => {
|
||||
const id = sel.dataset.id;
|
||||
await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value });
|
||||
UI.toast.success('Status aktualisiert.');
|
||||
setTimeout(_load, 500);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.adm-bew-view').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id;
|
||||
const app = await API.get(`/jobs/admin/applications/${id}`);
|
||||
const docsHtml = app.docs?.length
|
||||
? app.docs.map(d => `<a href="/api/jobs/admin/applications/${id}/docs/${d.id}"
|
||||
target="_blank" style="display:block;color:var(--c-primary);font-size:var(--text-sm);margin:4px 0">
|
||||
📎 ${_esc(d.filename)}</a>`).join('')
|
||||
: '<span style="color:var(--c-text-muted);font-size:var(--text-sm)">Keine Anhänge</span>';
|
||||
|
||||
UI.modal.open({
|
||||
title: `Bewerbung — ${_esc(app.name)}`,
|
||||
body: `
|
||||
<div style="display:grid;gap:var(--space-3)">
|
||||
<div><b>E-Mail:</b> ${_esc(app.email)}</div>
|
||||
<div><b>Social:</b> @${_esc(app.social_handle||'—')}</div>
|
||||
${app.dog_name ? `<div><b>Hund:</b> ${_esc(app.dog_name)} (${_esc(app.dog_rasse||'')})</div>` : ''}
|
||||
<div><b>Motivation:</b><br>
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);
|
||||
margin-top:var(--space-1);font-size:var(--text-sm);white-space:pre-wrap">${_esc(app.motivation)}</div>
|
||||
</div>
|
||||
<div><b>Anhänge:</b><br>${docsHtml}</div>
|
||||
<div>
|
||||
<b>Admin-Notiz:</b>
|
||||
<textarea id="adm-bew-note" class="form-control" rows="2" style="margin-top:var(--space-1)"
|
||||
placeholder="Interne Notiz / Nachricht an Bewerber">${_esc(app.admin_note||'')}</textarea>
|
||||
</div>
|
||||
</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
||||
<button class="btn btn-primary" id="adm-bew-save-note">Notiz speichern</button>`,
|
||||
});
|
||||
document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => {
|
||||
const note = document.getElementById('adm-bew-note')?.value || '';
|
||||
await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note });
|
||||
UI.toast.success('Notiz gespeichert.');
|
||||
UI.modal.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
await _load();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
|
|
|
|||
260
backend/static/js/pages/jobs.js
Normal file
260
backend/static/js/pages/jobs.js
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Social-Media-Job Bewerbung
|
||||
============================================================ */
|
||||
|
||||
window.Page_jobs = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
await _render();
|
||||
}
|
||||
|
||||
async function _render() {
|
||||
// Bestehende Bewerbung prüfen (nur wenn eingeloggt)
|
||||
let existingApp = null;
|
||||
let trialStatus = null;
|
||||
if (_appState.user) {
|
||||
try {
|
||||
const r = await API.get('/jobs/my-application');
|
||||
existingApp = r.application;
|
||||
trialStatus = await API.get('/jobs/luna-trial-status');
|
||||
} catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
_container.innerHTML = `
|
||||
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="text-align:center;margin-bottom:var(--space-6)">
|
||||
<div style="font-size:48px;margin-bottom:var(--space-3)">🐾</div>
|
||||
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-2)">
|
||||
Social-Media-Manager/in gesucht
|
||||
</h1>
|
||||
<p style="color:var(--c-text-secondary);margin:0">
|
||||
Werde das Gesicht von Ban Yaro auf Instagram & TikTok
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stellenbeschreibung -->
|
||||
<div class="card" style="margin-bottom:var(--space-4)">
|
||||
<div style="padding:var(--space-5)">
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Die Stelle</h2>
|
||||
<div style="display:grid;gap:var(--space-3)">
|
||||
${_infoRow('📍', 'Remote', '100 % flexibel — du arbeitest wann und wie du willst')}
|
||||
${_infoRow('📅', 'Umfang', '1–2 Posts pro Woche auf Instagram & TikTok')}
|
||||
${_infoRow('💶', 'Vergütung', '50 € / Monat — wächst mit der Community')}
|
||||
${_infoRow('🤖', 'Luna an deiner Seite', 'Unser KI-Assistent schreibt Captions, generiert Post-Ideen und Hashtags — du wählst aus und postest')}
|
||||
${_infoRow('⭐', 'Gründer-Status', 'Du wirst Teil der ersten 100 Gründer — für immer')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Luna-Probezugang Teaser -->
|
||||
<div style="background:linear-gradient(135deg,var(--c-primary),#e8a857);border-radius:var(--radius-lg);
|
||||
padding:var(--space-5);margin-bottom:var(--space-4);color:#fff">
|
||||
<div style="font-size:var(--text-lg);font-weight:800;margin-bottom:var(--space-2)">
|
||||
🤖 Luna 14 Tage kostenlos testen
|
||||
</div>
|
||||
<p style="margin:0;opacity:.9;font-size:var(--text-sm)">
|
||||
Mit deiner Bewerbung schalten wir dir sofort den vollen Zugang zu Luna frei —
|
||||
unserem KI-Assistenten für Social-Media-Posts. Probiere ihn einfach aus,
|
||||
bevor du dich entscheidest.
|
||||
</p>
|
||||
${trialStatus?.active ? `<div style="margin-top:var(--space-3);background:rgba(255,255,255,.2);
|
||||
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);font-weight:700;font-size:var(--text-sm)">
|
||||
✅ Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Wen wir suchen -->
|
||||
<div class="card" style="margin-bottom:var(--space-4)">
|
||||
<div style="padding:var(--space-5)">
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Wen wir suchen</h2>
|
||||
<ul style="margin:0;padding-left:var(--space-5);display:grid;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||||
<li>Du hast einen Hund — und liebst ihn sehr 🐕</li>
|
||||
<li>Du bist auf Instagram oder TikTok zuhause (nicht professionell, aber aktiv)</li>
|
||||
<li>Du schreibst gerne und authentisch auf Deutsch</li>
|
||||
<li>Du hast Lust, eine junge App bekannt zu machen — aus Überzeugung</li>
|
||||
<li>Kein Lebenslauf nötig. Kein Bewerbungs-Anschreiben. Einfach du.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bewerbungsformular oder Status -->
|
||||
${existingApp ? _renderStatus(existingApp) : _renderForm()}
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!existingApp) {
|
||||
_bindForm();
|
||||
}
|
||||
}
|
||||
|
||||
function _infoRow(icon, label, text) {
|
||||
return `
|
||||
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
|
||||
<span style="font-size:20px;line-height:1.4">${icon}</span>
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:var(--text-sm)">${label}</div>
|
||||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${text}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderStatus(app) {
|
||||
const statusMap = {
|
||||
pending: { icon: '⏳', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' },
|
||||
reviewing: { icon: '🔍', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' },
|
||||
accepted: { icon: '🎉', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' },
|
||||
rejected: { icon: '😔', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' },
|
||||
};
|
||||
const s = statusMap[app.status] || statusMap.pending;
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-5);text-align:center">
|
||||
<div style="font-size:40px;margin-bottom:var(--space-3)">${s.icon}</div>
|
||||
<div style="font-weight:700;color:${s.color};font-size:var(--text-lg);margin-bottom:var(--space-2)">${s.text}</div>
|
||||
<div style="color:var(--c-text-muted);font-size:var(--text-xs)">
|
||||
Bewerbung eingereicht: ${app.created_at?.slice(0,10)}
|
||||
</div>
|
||||
${app.admin_note ? `<div style="margin-top:var(--space-3);background:var(--c-surface-2);
|
||||
border-radius:var(--radius-md);padding:var(--space-3);font-size:var(--text-sm);
|
||||
color:var(--c-text-secondary);text-align:left">${UI.esc(app.admin_note)}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderForm() {
|
||||
const u = _appState.user;
|
||||
return `
|
||||
<div class="card">
|
||||
<div style="padding:var(--space-5)">
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">
|
||||
Jetzt bewerben
|
||||
</h2>
|
||||
<form id="jobs-form" novalidate>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Dein Name *</label>
|
||||
<input class="form-control" type="text" name="name"
|
||||
value="${u ? UI.esc(u.name) : ''}" placeholder="Vorname oder Nickname" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">E-Mail *</label>
|
||||
<input class="form-control" type="email" name="email"
|
||||
value="${u ? UI.esc(u.email || '') : ''}" placeholder="deine@email.de" required>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group" style="margin:0">
|
||||
<label class="form-label">Hunde-Name</label>
|
||||
<input class="form-control" type="text" name="dog_name" placeholder="z. B. Bella">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0">
|
||||
<label class="form-label">Rasse</label>
|
||||
<input class="form-control" type="text" name="dog_rasse" placeholder="z. B. Labrador">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Instagram oder TikTok Handle *</label>
|
||||
<div style="position:relative">
|
||||
<span style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
|
||||
color:var(--c-text-muted)">@</span>
|
||||
<input class="form-control" type="text" name="social_handle"
|
||||
style="padding-left:var(--space-7)" placeholder="dein_handle" required>
|
||||
</div>
|
||||
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
Dein öffentliches Profil auf Instagram oder TikTok
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Warum du? *</label>
|
||||
<textarea class="form-control" name="motivation" rows="5"
|
||||
placeholder="Erzähl uns kurz wer du bist, was dich an Ban Yaro begeistert und was du dir von der Stelle vorstellst. Kein formeller Ton nötig — schreib einfach wie du sprichst." required
|
||||
style="resize:vertical"></textarea>
|
||||
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
Mindestens 80 Zeichen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Anhänge (optional)</label>
|
||||
<input class="form-control" type="file" name="files" id="jobs-files"
|
||||
multiple accept=".pdf,.jpg,.jpeg,.png,.webp,.mp4,.mov"
|
||||
style="padding:var(--space-2)">
|
||||
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund — max. 3 Dateien, je 10 MB.
|
||||
PDF, Bild oder Video.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
${!u ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||
padding:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
margin-bottom:var(--space-4)">
|
||||
💡 <b>Tipp:</b> Wenn du dich vorher
|
||||
<a href="#" id="jobs-login-link" style="color:var(--c-primary)">anmeldest oder registrierst</a>,
|
||||
bekommst du sofort den 14-tägigen Luna-Probezugang.
|
||||
</div>` : ''}
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
|
||||
Bewerbung absenden + Luna freischalten 🚀
|
||||
</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _bindForm() {
|
||||
document.getElementById('jobs-login-link')?.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (window.App) App.navigate('settings');
|
||||
});
|
||||
|
||||
document.getElementById('jobs-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('[type="submit"]');
|
||||
const fd = new FormData(e.target);
|
||||
|
||||
// Dateien aus file-input übernehmen
|
||||
const fileInput = document.getElementById('jobs-files');
|
||||
if (fileInput?.files?.length) {
|
||||
fd.delete('files');
|
||||
for (const f of fileInput.files) fd.append('files', f);
|
||||
}
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const resp = await fetch('/api/jobs/apply', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` },
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Fehler beim Absenden.');
|
||||
}
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.luna_trial) {
|
||||
UI.toast.success('🎉 Bewerbung eingegangen! Dein Luna-Probezugang ist jetzt aktiv.');
|
||||
// User-State aktualisieren damit Luna sofort zugänglich ist
|
||||
if (_appState.user && window.API) {
|
||||
try { _appState.user = await API.auth.me(); } catch { /* ignore */ }
|
||||
}
|
||||
} else {
|
||||
UI.toast.success('Bewerbung eingegangen! Wir melden uns bald.');
|
||||
}
|
||||
|
||||
await _render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
|
@ -13,6 +13,8 @@ window.Page_movies = (() => {
|
|||
let _filme = [];
|
||||
let _activeTab = 'filme';
|
||||
let _filter = 'alle';
|
||||
let _typ = 'alle'; // alle | film | serie | doku
|
||||
let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
|
|
@ -70,20 +72,44 @@ window.Page_movies = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// TAB 1: FILME
|
||||
// ----------------------------------------------------------
|
||||
async function _loadFilme() {
|
||||
_filme = await API.get(`/movies/filme?sort=${_sort}&typ=${_typ}`);
|
||||
}
|
||||
|
||||
async function _renderFilme(content) {
|
||||
try {
|
||||
_filme = await API.get('/movies/filme');
|
||||
await _loadFilme();
|
||||
} catch {
|
||||
content.innerHTML = UI.emptyState({ icon: 'film-slate', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' });
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="movies-controls">
|
||||
<div class="movies-filter-row">
|
||||
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
|
||||
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</button>
|
||||
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> Hund überlebt</button>
|
||||
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg>+</button>
|
||||
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg> Top</button>
|
||||
</div>
|
||||
<div class="movies-filter-row" style="margin-top:var(--space-2)">
|
||||
<button class="movies-type-btn${_typ === 'alle' ? ' movies-filter-btn--active' : ''}" data-typ="alle">Alle Typen</button>
|
||||
<button class="movies-type-btn${_typ === 'film' ? ' movies-filter-btn--active' : ''}" data-typ="film">🎬 Filme</button>
|
||||
<button class="movies-type-btn${_typ === 'serie' ? ' movies-filter-btn--active' : ''}" data-typ="serie">📺 Serien</button>
|
||||
<button class="movies-type-btn${_typ === 'doku' ? ' movies-filter-btn--active' : ''}" data-typ="doku">🎥 Dokus</button>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);margin-top:var(--space-2)">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px;color:var(--c-text-muted)"><use href="/icons/phosphor.svg#sort-ascending"></use></svg>
|
||||
<select id="movies-sort" class="form-control" style="flex:1;font-size:var(--text-sm);padding:var(--space-2) var(--space-3)">
|
||||
<option value="default" ${_sort==='default' ?'selected':''}>Empfohlen</option>
|
||||
<option value="bewertung" ${_sort==='bewertung' ?'selected':''}>Community-Bewertung</option>
|
||||
<option value="imdb" ${_sort==='imdb' ?'selected':''}>IMDb-Bewertung</option>
|
||||
<option value="jahr_desc" ${_sort==='jahr_desc' ?'selected':''}>Neueste zuerst</option>
|
||||
<option value="jahr_asc" ${_sort==='jahr_asc' ?'selected':''}>Älteste zuerst</option>
|
||||
<option value="titel" ${_sort==='titel' ?'selected':''}>Titel A–Z</option>
|
||||
</select>
|
||||
<span id="movies-count" style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-grid" id="movie-grid"></div>
|
||||
`;
|
||||
|
|
@ -97,6 +123,26 @@ window.Page_movies = (() => {
|
|||
});
|
||||
});
|
||||
|
||||
content.querySelectorAll('.movies-type-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
_typ = btn.dataset.typ;
|
||||
content.querySelectorAll('.movies-type-btn').forEach(b => b.classList.remove('movies-filter-btn--active'));
|
||||
btn.classList.add('movies-filter-btn--active');
|
||||
const grid = content.querySelector('#movie-grid');
|
||||
grid.innerHTML = UI.skeleton(3);
|
||||
await _loadFilme();
|
||||
_renderMovieGrid(grid);
|
||||
});
|
||||
});
|
||||
|
||||
content.querySelector('#movies-sort')?.addEventListener('change', async e => {
|
||||
_sort = e.target.value;
|
||||
const grid = content.querySelector('#movie-grid');
|
||||
grid.innerHTML = UI.skeleton(3);
|
||||
await _loadFilme();
|
||||
_renderMovieGrid(grid);
|
||||
});
|
||||
|
||||
_renderMovieGrid(content.querySelector('#movie-grid'));
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +152,10 @@ window.Page_movies = (() => {
|
|||
|
||||
if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
|
||||
if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund);
|
||||
if (_filter === 'top') list = list.filter(f => f.bewertung_avg >= 4.0);
|
||||
if (_filter === 'top') list = list.filter(f => (f.imdb_rating || 0) >= 7.5 || f.bewertung_avg >= 4.0);
|
||||
|
||||
const countEl = document.getElementById('movies-count');
|
||||
if (countEl) countEl.textContent = `${list.length} Einträge`;
|
||||
|
||||
if (list.length === 0) {
|
||||
grid.innerHTML = `<div style="grid-column:1/-1;padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">Keine Filme für diesen Filter.</div>`;
|
||||
|
|
@ -130,18 +179,24 @@ window.Page_movies = (() => {
|
|||
function _movieCard(film) {
|
||||
const stirbt = film.stirbt_der_hund;
|
||||
const tag = stirbt
|
||||
? `<div class="movie-tag-stirbt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> ACHTUNG: Der Hund stirbt</div>`
|
||||
: `<div class="movie-tag-ueberlebt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Der Hund überlebt</div>`;
|
||||
? `<div class="movie-tag-stirbt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</div>`
|
||||
: `<div class="movie-tag-ueberlebt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Hund überlebt</div>`;
|
||||
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false);
|
||||
const typLabel = film.typ === 'serie' ? '📺 Serie' : film.typ === 'doku' ? '🎥 Doku' : '';
|
||||
const imdb = film.imdb_rating ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">IMDb ${film.imdb_rating}</span>` : '';
|
||||
const streaming = film.streaming ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(film.streaming)}</span>` : '';
|
||||
|
||||
return `
|
||||
<div class="movie-card" data-film-id="${_esc(film.id)}">
|
||||
<div class="movie-card-emoji">${film.bild_emoji}</div>
|
||||
<div class="movie-card-body">
|
||||
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
|
||||
<div class="movie-card-genre">${_esc(film.genre)}</div>
|
||||
<div class="movie-card-genre" style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap">
|
||||
<span>${_esc(film.genre)}</span>${typLabel ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${typLabel}</span>` : ''}
|
||||
</div>
|
||||
<div class="movie-card-rasse"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${_esc(film.hund_rasse)}</div>
|
||||
${tag}
|
||||
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-1)">${imdb}${streaming}</div>
|
||||
<div class="movie-card-stars">${stars}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue