Notiz-Medien & Sprachnachrichten: Fotos/Videos/Dateien + Audio an Notizen
Wiederverwendbarer UI.noteMediaAttacher für beide Notiz-Stellen (UI.noteModal
+ Notizblock-Seite). note_media-Tabelle + POST/DELETE /api/notes/{id}/media
(vor der gierigen /{parent_type}/{parent_id}-Route). Audio per MediaRecorder,
serverseitig nach m4a/AAC transkodiert (ffmpeg) — iOS spielt Chrome-Opus-webm
nicht ab. UI.lightbox global eingeführt. Mikrofon-Policy microphone=(self) +
CSP media-src 'self' blob:, Datenschutz v6. Disk-Cleanup für note_media bei
Notiz-, Account- und Admin-User-Delete. Reine Medien-Notiz ohne Text erlaubt.
noteModal-Bug gefixt: notes.get() liefert Array -> existing[0] statt
existing?.id (verhinderte Bearbeiten, erzeugte Duplikate). 12 neue Tests.
admin.py enthält außerdem KI-Vision-Statusfelder aus paralleler Arbeit
(nicht sauber trennbar ohne interaktives Staging).
This commit is contained in:
parent
203da50e1d
commit
e86d89f3d9
12 changed files with 947 additions and 59 deletions
|
|
@ -483,6 +483,16 @@ async def delete_user(uid: int, user=Depends(require_admin)):
|
|||
conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
|
||||
# Notiz-Medien: erst Dateien von Disk, dann DB-Zeilen (note_media + notes).
|
||||
import os as _os
|
||||
from media_utils import delete_media_files
|
||||
_nm_urls = [r["url"] for r in conn.execute(
|
||||
"SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?",
|
||||
(uid,)
|
||||
).fetchall()]
|
||||
delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _nm_urls)
|
||||
conn.execute("DELETE FROM note_media WHERE note_id IN (SELECT id FROM notes WHERE user_id=?)", (uid,))
|
||||
conn.execute("DELETE FROM notes WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
_audit(conn, user, "user_delete", f"user:{uid} ({target['name']})")
|
||||
|
||||
|
|
@ -728,7 +738,7 @@ async def ki_history(user=Depends(require_mod)):
|
|||
@router.get("/ki/status")
|
||||
async def ki_status(user=Depends(require_mod)):
|
||||
import httpx
|
||||
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY
|
||||
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, VISION_MODEL, ANTHROPIC_KEY
|
||||
|
||||
result = {
|
||||
"mode": KI_MODE,
|
||||
|
|
@ -737,6 +747,7 @@ async def ki_status(user=Depends(require_mod)):
|
|||
"local_reachable": False,
|
||||
"local_model_loaded": None,
|
||||
"cloud_model": CLOUD_MODEL,
|
||||
"vision_model": VISION_MODEL,
|
||||
"cloud_key_set": bool(ANTHROPIC_KEY),
|
||||
}
|
||||
|
||||
|
|
@ -944,7 +955,7 @@ async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)):
|
|||
async def wiki_evaluate(sample: int = 20, user=Depends(require_mod)):
|
||||
from scraper.breed_evaluator import evaluate_enrichment
|
||||
sample = max(5, min(sample, 50))
|
||||
return await evaluate_enrichment(sample_size=sample)
|
||||
return await evaluate_enrichment(sample_size=sample, user_id=user["id"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,24 +1,33 @@
|
|||
"""BAN YARO — Notizen Routes"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Any, List
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
from timeutils import safe_client_time
|
||||
from media_utils import (convert_media, extract_video_thumb, safe_media_path,
|
||||
validate_upload, validate_audio, to_m4a,
|
||||
generate_preview, get_image_size)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ------------------------------------------------------------------
|
||||
class NoteCreate(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=5000)
|
||||
# Leerer Text erlaubt: eine reine Medien-Notiz (nur Foto/Sprachnachricht)
|
||||
# wird zuerst leer angelegt, dann werden die Medien angehängt.
|
||||
text: str = Field("", max_length=5000)
|
||||
meta_json: Optional[Any] = None
|
||||
location_name: Optional[str] = Field(None, max_length=300)
|
||||
parent_label: Optional[str] = Field(None, max_length=200)
|
||||
|
|
@ -35,16 +44,81 @@ class NoteUpdate(BaseModel):
|
|||
# ------------------------------------------------------------------
|
||||
# Hilfsfunktionen
|
||||
# ------------------------------------------------------------------
|
||||
def _serialize(row) -> dict:
|
||||
def _serialize(row, media_map: Optional[dict] = None) -> dict:
|
||||
d = dict(row)
|
||||
if d.get("meta_json") and isinstance(d["meta_json"], str):
|
||||
try:
|
||||
d["meta_json"] = json.loads(d["meta_json"])
|
||||
except Exception:
|
||||
pass
|
||||
if media_map is not None:
|
||||
d["media_items"] = media_map.get(d["id"], [])
|
||||
return d
|
||||
|
||||
|
||||
def _fetch_note_media(conn, note_ids: list) -> dict:
|
||||
"""Lädt alle Medien zu den gegebenen Notiz-IDs als {note_id: [items]}."""
|
||||
if not note_ids:
|
||||
return {}
|
||||
placeholders = ",".join("?" * len(note_ids))
|
||||
rows = conn.execute(
|
||||
f"""SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s
|
||||
FROM note_media WHERE note_id IN ({placeholders})
|
||||
ORDER BY sort_order, id""",
|
||||
note_ids
|
||||
).fetchall()
|
||||
out: dict = {}
|
||||
for r in rows:
|
||||
out.setdefault(r["note_id"], []).append(dict(r))
|
||||
return out
|
||||
|
||||
|
||||
def _guess_note_media_type(content_type: str, filename: str) -> str:
|
||||
ct = (content_type or "").lower()
|
||||
if ct == "application/pdf" or (filename or "").lower().endswith(".pdf"):
|
||||
return "pdf"
|
||||
if ct.startswith("audio/"):
|
||||
return "audio"
|
||||
if ct.startswith("video/"):
|
||||
return "video"
|
||||
if ct.startswith("image/"):
|
||||
return "image"
|
||||
ext = os.path.splitext(filename or "")[1].lower()
|
||||
if ext in {".mp4", ".mov", ".webm", ".m4v", ".avi"}:
|
||||
return "video"
|
||||
if ext in {".m4a", ".aac", ".mp3", ".ogg", ".oga", ".wav", ".opus"}:
|
||||
return "audio"
|
||||
if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"}:
|
||||
return "image"
|
||||
return "file"
|
||||
|
||||
|
||||
def _delete_note_media_file(url: str) -> None:
|
||||
"""Löscht eine Mediendatei + zugehörige Preview/Thumb-Leichen von Disk."""
|
||||
file_path = safe_media_path(MEDIA_DIR, url)
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError:
|
||||
pass
|
||||
base = os.path.splitext(file_path)[0]
|
||||
for leftover in (base + "_preview.webp", base + "_thumb.jpg"):
|
||||
try:
|
||||
os.remove(leftover)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _own_note(note_id: int, user_id: int, conn):
|
||||
row = conn.execute(
|
||||
"SELECT id FROM notes WHERE id=? AND user_id=?", (note_id, user_id)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Notiz nicht gefunden.")
|
||||
return row
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/notes — Gesamt-Notizblock mit Filtern
|
||||
# Alias: GET /api/notes/all/0 (Rückwärtskompatibilität)
|
||||
|
|
@ -97,8 +171,9 @@ async def list_all_notes_filtered(
|
|||
f"SELECT * FROM notes WHERE {where} ORDER BY {order}",
|
||||
params
|
||||
).fetchall()
|
||||
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
|
||||
|
||||
return [_serialize(r) for r in rows]
|
||||
return [_serialize(r, media_map) for r in rows]
|
||||
|
||||
|
||||
@router.get("/all/0")
|
||||
|
|
@ -109,7 +184,8 @@ async def list_all_notes(user=Depends(get_current_user)):
|
|||
"SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
return [_serialize(r) for r in rows]
|
||||
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
|
||||
return [_serialize(r, media_map) for r in rows]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -169,6 +245,99 @@ async def ki_analyse(user=Depends(get_current_user)):
|
|||
return {"suggestions": suggestions, "note_count": note_count}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Medien-Anhänge an Notizen (Bild/Video/Audio/Datei)
|
||||
# WICHTIG: Diese Routen MÜSSEN vor /{parent_type}/{parent_id} stehen,
|
||||
# sonst matcht POST /123/media als parent_type=123, parent_id="media"!
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/{note_id}/media")
|
||||
async def upload_note_media(note_id: int,
|
||||
file: UploadFile = File(...),
|
||||
user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
_own_note(note_id, user["id"], conn)
|
||||
|
||||
ct = (file.content_type or "").lower().split(";")[0].strip()
|
||||
raw_data = await file.read()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if ct.startswith("audio/"):
|
||||
media_type = "audio"
|
||||
try:
|
||||
validate_audio(raw_data, ct)
|
||||
except ValueError as e:
|
||||
raise HTTPException(415, str(e))
|
||||
src_ext = os.path.splitext(file.filename or "")[1].lower() or ".webm"
|
||||
raw_data, ext = await loop.run_in_executor(None, lambda: to_m4a(raw_data, src_ext))
|
||||
else:
|
||||
# Bild/Video/PDF/sonstige Datei — gleiche Pipeline wie Tagebuch.
|
||||
try:
|
||||
validate_upload(raw_data, file.filename or "")
|
||||
except ValueError as e:
|
||||
raise HTTPException(415, str(e))
|
||||
media_type = _guess_note_media_type(ct, file.filename or "")
|
||||
raw_data, ext = await loop.run_in_executor(
|
||||
None, lambda: convert_media(raw_data, file.filename or "")
|
||||
)
|
||||
if not ext:
|
||||
ext = ".bin"
|
||||
|
||||
filename = f"note_{note_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
path = os.path.join(MEDIA_DIR, "notes", filename)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
|
||||
def _write_bytes(p: str, data: bytes) -> None:
|
||||
with open(p, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
await loop.run_in_executor(None, lambda: _write_bytes(path, raw_data))
|
||||
|
||||
img_size = None
|
||||
if media_type == "video":
|
||||
await loop.run_in_executor(None, lambda: extract_video_thumb(path))
|
||||
elif media_type == "image":
|
||||
preview_bytes = await loop.run_in_executor(None, lambda: generate_preview(raw_data, ext))
|
||||
if preview_bytes:
|
||||
preview_path = os.path.splitext(path)[0] + "_preview.webp"
|
||||
await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes))
|
||||
img_size = await loop.run_in_executor(None, lambda: get_image_size(raw_data))
|
||||
|
||||
media_url = f"/media/notes/{filename}"
|
||||
with db() as conn:
|
||||
max_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) FROM note_media WHERE note_id=?",
|
||||
(note_id,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"""INSERT INTO note_media (note_id, url, media_type, sort_order, img_width, img_height)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(note_id, media_url, media_type, max_order + 1,
|
||||
img_size[0] if img_size else None, img_size[1] if img_size else None)
|
||||
)
|
||||
row = conn.execute(
|
||||
"""SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s
|
||||
FROM note_media WHERE note_id=? ORDER BY id DESC LIMIT 1""",
|
||||
(note_id,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/{note_id}/media/{media_id}", status_code=204)
|
||||
async def delete_note_media(note_id: int, media_id: int,
|
||||
user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
_own_note(note_id, user["id"], conn)
|
||||
row = conn.execute(
|
||||
"SELECT id, url FROM note_media WHERE id=? AND note_id=?",
|
||||
(media_id, note_id)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Medium nicht gefunden.")
|
||||
_delete_note_media_file(row["url"])
|
||||
conn.execute("DELETE FROM note_media WHERE id=?", (media_id,))
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/notes/{parent_type}/{parent_id}
|
||||
# parent_id kann ein Integer oder ein String-Schlüssel sein.
|
||||
|
|
@ -184,7 +353,8 @@ async def list_notes(parent_type: str, parent_id: str,
|
|||
ORDER BY created_at DESC""",
|
||||
(user["id"], parent_type, parent_id)
|
||||
).fetchall()
|
||||
return [_serialize(r) for r in rows]
|
||||
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
|
||||
return [_serialize(r, media_map) for r in rows]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -193,9 +363,6 @@ async def list_notes(parent_type: str, parent_id: str,
|
|||
@router.post("/{parent_type}/{parent_id}", status_code=201)
|
||||
async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
|
||||
user=Depends(get_current_user)):
|
||||
if not data.text.strip():
|
||||
raise HTTPException(400, "Notiz darf nicht leer sein.")
|
||||
|
||||
meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None
|
||||
now = safe_client_time(data.client_time)
|
||||
|
||||
|
|
@ -212,7 +379,7 @@ async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
|
|||
"SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1",
|
||||
(user["id"], parent_type, parent_id)
|
||||
).fetchone()
|
||||
return _serialize(row)
|
||||
return _serialize(row, {}) # frisch erstellt → media_items=[]; Upload folgt separat
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -230,8 +397,7 @@ async def update_note(note_id: int, data: NoteUpdate,
|
|||
|
||||
updates = {}
|
||||
if data.text is not None:
|
||||
if not data.text.strip():
|
||||
raise HTTPException(400, "Notiz darf nicht leer sein.")
|
||||
# Leer erlaubt — Medien können die Notiz tragen.
|
||||
updates["text"] = data.text.strip()
|
||||
if data.meta_json is not None:
|
||||
updates["meta_json"] = json.dumps(data.meta_json)
|
||||
|
|
@ -241,14 +407,16 @@ async def update_note(note_id: int, data: NoteUpdate,
|
|||
updates["parent_label"] = data.parent_label
|
||||
|
||||
if not updates:
|
||||
return _serialize(note)
|
||||
media_map = _fetch_note_media(conn, [note_id])
|
||||
return _serialize(note, media_map)
|
||||
|
||||
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||||
values = list(updates.values()) + [note_id]
|
||||
conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values)
|
||||
row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone()
|
||||
return _serialize(row)
|
||||
media_map = _fetch_note_media(conn, [note_id])
|
||||
return _serialize(row, media_map)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -262,5 +430,8 @@ async def delete_note(note_id: int, user=Depends(get_current_user)):
|
|||
).fetchone()
|
||||
if not note:
|
||||
raise HTTPException(404, "Notiz nicht gefunden.")
|
||||
# Medien-Dateien von Disk räumen (FK-Cascade löscht nur die DB-Zeilen).
|
||||
for m in conn.execute("SELECT url FROM note_media WHERE note_id=?", (note_id,)).fetchall():
|
||||
_delete_note_media_file(m["url"])
|
||||
conn.execute("DELETE FROM notes WHERE id=?", (note_id,))
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -216,6 +216,16 @@ async def delete_account(user=Depends(get_current_user)):
|
|||
if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS:
|
||||
conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,))
|
||||
|
||||
# note_media-Dateien von Disk räumen — der FK-Cascade beim users-DELETE
|
||||
# entfernt nur die DB-Zeilen, nicht die Dateien.
|
||||
import os as _os
|
||||
from media_utils import delete_media_files
|
||||
_note_media_urls = [r["url"] for r in conn.execute(
|
||||
"SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?",
|
||||
(uid,)
|
||||
).fetchall()]
|
||||
delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _note_media_urls)
|
||||
|
||||
# Räumt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab.
|
||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
return {"status": "deleted"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue