From e066ff7420637c01012230bc17450cac06891307 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 21 May 2026 15:23:13 +0200 Subject: [PATCH] Migrate app.py from Directus to snakkimo API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace all db_* routes with snakkimo API equivalents - Stub out Llama AI functions (generate_details, generate_sentence) with 501 error - Map field names: user_notes↔notes, picture↔picture_link, level↔difficulty_level, statement_de↔positive_sentence_de - Use word_id as junction_id for M2M deletes (snakkimo uses resource IDs not junction row IDs) - Normalize db-pictures response to include picture/blurhash/status/design - Extract unique design values from pictures table for design-options endpoint - Pair DELETE now also deletes linked statement and question - FLAG: question words, distractor_words, objects.parent not supported in snakkimo Co-Authored-By: Claude Sonnet 4.6 --- app.py | 1497 +++++++++++++++++--------------------------------------- 1 file changed, 445 insertions(+), 1052 deletions(-) diff --git a/app.py b/app.py index c4e9b3e..0b3b274 100644 --- a/app.py +++ b/app.py @@ -15,8 +15,8 @@ import anthropic as _anthropic_sdk ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "") -DIRECTUS_URL = "https://db.hejyou.com" -DIRECTUS_ADMIN_TOKEN = os.environ.get("DIRECTUS_ADMIN_TOKEN", "Bearer tnBshnvge8KBu0WqykSQvgBperI2j_0b") +SNAKKIMO_URL = os.environ.get("SNAKKIMO_URL", "https://hyggecraftery.com/api/snakkimo/api") +SNAKKIMO_TOKEN = os.environ.get("SNAKKIMO_TOKEN", "") BASE_DIR = Path(__file__).resolve().parent PICTURES_DIR = BASE_DIR / "pictures" @@ -44,13 +44,19 @@ def read_prompt(filepath: Path, fallback: str) -> str: return fallback.strip() -def _directus(method, path, token, body=None): - """Hilfsfunktion: Directus-API-Aufruf via urllib.""" +def _snakkimo(method, path, token=None, body=None): + """API-Aufruf gegen snakkimo-API. Gibt (data, status) zurück. + Antwortet im Directus-kompatiblen Format: {"data": ...} für Listen/Objekte. + """ + auth = token or SNAKKIMO_TOKEN + # Wenn kein "Bearer " Prefix, füge ihn hinzu + if auth and not auth.startswith("Bearer "): + auth = f"Bearer {auth}" headers = {"Content-Type": "application/json"} - if token: - headers["Authorization"] = token + if auth: + headers["Authorization"] = auth req = urllib.request.Request( - f"{DIRECTUS_URL}{path}", + f"{SNAKKIMO_URL}{path}", data=json.dumps(body).encode() if body is not None else None, headers=headers, method=method, @@ -58,17 +64,43 @@ def _directus(method, path, token, body=None): try: with urllib.request.urlopen(req) as resp: raw = resp.read().decode("utf-8") - return json.loads(raw) if raw else {}, resp.status + if not raw: + return {}, resp.status + parsed = json.loads(raw) + # Wrapping in {"data": ...} für Directus-Kompatibilität + if isinstance(parsed, (list, dict)): + return {"data": parsed}, resp.status + return parsed, resp.status except urllib.error.HTTPError as e: raw = e.read().decode("utf-8") return json.loads(raw) if raw else {}, e.code +# Legacy-Alias – wird schrittweise entfernt +def _directus(method, path, token=None, body=None): + return _snakkimo(method, path, token, body) + + @app.route("/api/directus/auth/login", methods=["POST"]) def directus_auth_login(): - """Proxy: Directus-Login ohne CORS-Probleme.""" - data, status = _directus("POST", "/auth/login", token=None, body=request.get_json()) - return jsonify(data), status + """Proxy: Login über snakkimo-API.""" + body = request.get_json(force=True, silent=True) or {} + # Directus-Format: {"email": ..., "password": ...} → snakkimo gleich + req_body = {"email": body.get("email", ""), "password": body.get("password", "")} + req = urllib.request.Request( + f"{SNAKKIMO_URL.replace('/api', '')}/auth/login", + data=json.dumps(req_body).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req) as resp: + parsed = json.loads(resp.read().decode("utf-8")) + # Directus-kompatibles Format: {"data": {"access_token": ...}} + token = parsed.get("token", "") + return jsonify({"data": {"access_token": token, "user": parsed.get("user", {})}}), 200 + except urllib.error.HTTPError as e: + return jsonify(json.loads(e.read().decode("utf-8"))), e.code @app.route("/api/directus/users/me", methods=["GET"]) @@ -82,18 +114,21 @@ def directus_users_me(): @app.route("/api/directus/pictures", methods=["GET"]) def directus_pictures(): - """Proxy: Directus-Bilder nach Status filtern.""" + """Proxy: Bilder nach Status filtern.""" token = request.headers.get("Authorization", "") - pic_status = request.args.get("status", "new") - data, status = _directus("GET", f"/items/pictures?filter[status][_eq]={pic_status}&fields=id,media,status&sort=date_created", token) - return jsonify(data), status + pic_status = request.args.get("status", "uploaded") + data, status = _snakkimo("GET", f"/pictures?status={pic_status}&limit=500", token) + # Normalize to Directus-compatible shape: id, media→picture_link, status + items = data.get("data") if isinstance(data.get("data"), list) else [] + normalized = [{"id": p["id"], "media": p.get("picture_link", ""), "status": p.get("status", ""), "design": p.get("design", "")} for p in items] + return jsonify({"data": normalized}), status @app.route("/api/directus/pictures/", methods=["PATCH"]) def directus_picture(pic_id): - """Proxy: Bild-Status aktualisieren.""" + """Proxy: Bild aktualisieren.""" token = request.headers.get("Authorization", "") - data, status = _directus("PATCH", f"/items/pictures/{pic_id}", token, body=request.get_json()) + data, status = _snakkimo("PATCH", f"/pictures/{pic_id}", token, body=request.get_json()) return jsonify(data), status @@ -103,12 +138,17 @@ def directus_objects(): token = request.headers.get("Authorization", "") if request.method == "GET": picture_id = request.args.get("picture_id", "") - fields = "id,selections,user_notes,parent,status,picture" - path = f"/items/objects?filter[picture][_eq]={picture_id}&fields={fields}&sort=date_created" - data, status = _directus("GET", path, token) - return jsonify(data), status + path = f"/objects?picture_id={picture_id}&limit=500" if picture_id else "/objects?limit=500" + data, status = _snakkimo("GET", path, token) + # Normalize: selections, notes→user_notes + items = data.get("data") if isinstance(data.get("data"), list) else [] + normalized = [{"id": o["id"], "selections": o.get("selections"), "user_notes": o.get("notes", ""), "status": o.get("status", "")} for o in items] + return jsonify({"data": normalized}), status else: - data, status = _directus("POST", "/items/objects", token, body=request.get_json()) + body = request.get_json(force=True, silent=True) or {} + # Map user_notes → notes + mapped = {"selections": body.get("selections"), "notes": body.get("user_notes") or body.get("notes")} + data, status = _snakkimo("POST", "/objects", token, body=mapped) return jsonify(data), status @@ -117,122 +157,74 @@ def directus_object(obj_id): """Proxy: Objekt aktualisieren (PATCH) oder löschen (DELETE).""" token = request.headers.get("Authorization", "") if request.method == "PATCH": - data, status = _directus("PATCH", f"/items/objects/{obj_id}", token, body=request.get_json()) + body = request.get_json(force=True, silent=True) or {} + mapped = {} + if "user_notes" in body: + mapped["notes"] = body["user_notes"] + if "notes" in body: + mapped["notes"] = body["notes"] + if "status" in body: + mapped["status"] = body["status"] + if "selections" in body: + mapped["selections"] = body["selections"] + data, status = _snakkimo("PATCH", f"/objects/{obj_id}", token, body=mapped) else: - data, status = _directus("DELETE", f"/items/objects/{obj_id}", token) + data, status = _snakkimo("DELETE", f"/objects/{obj_id}", token) return jsonify(data), status def _setup_words_pictures(token: str): - """Richtet M2M-Relation words ↔ pictures idempotent ein (läuft einmalig).""" - _directus("PATCH", "/fields/pictures/linked_words", token, { - "type": "alias", - "meta": {"special": ["m2m"], "interface": "list-m2m", - "options": {"template": "{{words_id.title_de}}"}, - "note": "Verknüpfte Safe Words"}, - }) - _directus("PATCH", "/fields/words/linked_pictures", token, { - "type": "alias", - "meta": {"special": ["m2m"], "interface": "list-m2m", - "options": {"template": "{{pictures_id.media}}"}, - "note": "Verknüpfte Bilder"}, - }) - _directus("POST", "/relations", token, { - "collection": "words_pictures", "field": "words_id", - "related_collection": "words", - "schema": {"on_delete": "CASCADE"}, - "meta": {"junction_field": "pictures_id", "one_field": "linked_pictures", - "one_deselect_action": "nullify"}, - }) - _directus("POST", "/relations", token, { - "collection": "words_pictures", "field": "pictures_id", - "related_collection": "pictures", - "schema": {"on_delete": "CASCADE"}, - "meta": {"junction_field": "words_id", "one_field": "linked_words", - "one_deselect_action": "nullify"}, - }) + """No-op: snakkimo verwaltet M2M-Relationen intern.""" + pass @app.route("/api/directus/pictures//words", methods=["GET", "POST"]) def directus_picture_words(pic_id): - """Proxy: Safe-Words eines Bildes laden (GET) oder speichern (POST). - Nutzt natives Directus M2M über pictures.linked_words. - """ + """Proxy: Wörter eines Bildes laden (GET) oder speichern (POST).""" token = request.headers.get("Authorization", "") if request.method == "GET": - # Natives Directus Deep-Query über M2M-Relation - data, status = _directus( - "GET", - f"/items/pictures/{pic_id}" - f"?fields[]=linked_words.id" - f"&fields[]=linked_words.words_id.id" - f"&fields[]=linked_words.words_id.title_de" - f"&fields[]=linked_words.words_id.level" - f"&fields[]=linked_words.words_id.status", - token, - ) + data, status = _snakkimo("GET", f"/pictures/{pic_id}/words", token) if status != 200: return jsonify({"data": []}) - items = [] - for entry in ((data.get("data") or {}).get("linked_words") or []): - word = entry.get("words_id") or {} - if not isinstance(word, dict) or not word.get("id"): - continue - if word.get("status") == "archived": - continue - items.append({ - "id": entry.get("id", ""), - "word_id": word["id"], - "title_de": word.get("title_de", ""), - "level": word.get("level") or 50, - "status": word.get("status", ""), - }) + words = data.get("data") if isinstance(data.get("data"), list) else [] + items = [ + { + "junction_id": w["id"], # word_id als junction_id für DELETE-Kompatibilität + "word_id": w["id"], + "titel_de": w.get("titel_de", ""), + "title_de": w.get("titel_de", ""), # legacy alias + "level": w.get("difficulty_level") or 50, + "status": w.get("status", ""), + } + for w in words if w.get("status") != "blocked" + ] return jsonify({"data": items}) - else: # POST + else: # POST — find-or-create words and link body = request.get_json(force=True, silent=True) or {} words_to_save = body.get("words", []) - # Relationen einmalig sicherstellen - _setup_words_pictures(token) + # Bereits verknüpfte Word-IDs laden + existing_data, _ = _snakkimo("GET", f"/pictures/{pic_id}/words", token) + existing_ids = {w["id"] for w in (existing_data.get("data") or []) if isinstance(w, dict)} - # Bereits verknüpfte Word-IDs laden (Duplikat-Schutz) - existing_data, _ = _directus( - "GET", - f"/items/pictures/{pic_id}?fields[]=linked_words.words_id", - token, - ) - existing_ids = set() - for e in ((existing_data.get("data") or {}).get("linked_words") or []): - wid = e.get("words_id") - if wid: - existing_ids.add(wid if isinstance(wid, str) else wid.get("id", "")) - - # Wörter anlegen/finden, Level updaten, neue Links sammeln - new_links = [] saved = 0 for entry in words_to_save: - title_de = (entry.get("title_de") or "").strip() + titel_de = (entry.get("titel_de") or entry.get("title_de") or "").strip() level = int(entry.get("level") or 50) - if not title_de: + if not titel_de: continue try: - wid, is_new = _find_or_create_word(title_de, level, token) + wid, is_new = _find_or_create_word(titel_de, level, token) if not is_new: - _directus("PATCH", f"/items/words/{wid}", token, {"level": level}) + _snakkimo("PATCH", f"/words/{wid}", token, {"difficulty_level": level}) if wid not in existing_ids: - new_links.append({"words_id": wid}) + _snakkimo("POST", f"/words/{wid}/pictures/{pic_id}", token) existing_ids.add(wid) saved += 1 except Exception as e: - print(f"[picture_words] error for '{title_de}': {e}") - - # Alle neuen Links in einem einzigen Directus-PATCH - if new_links: - _directus("PATCH", f"/items/pictures/{pic_id}", token, { - "linked_words": {"create": new_links} - }) + print(f"[picture_words] error for '{titel_de}': {e}") return jsonify({"ok": True, "saved": saved}) @@ -459,118 +451,8 @@ def update_object_parent(obj_id: str): @app.route("/api/object//generate_details", methods=["POST"]) def generate_object_details(obj_id: str): - """ - Erzeugt mit Llama 3.2 Vision zusätzliche englische Metadaten zu einem Objekt - auf Basis des ausgeschnittenen Objektbildes + vorhandener deutscher Felder. - """ - meta_path = OBJECTS_DIR / f"{obj_id}.txt" - if not meta_path.exists(): - return jsonify({"error": "Object not found"}), 404 - - try: - meta = json.loads(meta_path.read_text(encoding="utf-8")) - except Exception: - return jsonify({"error": "Could not read meta file"}), 500 - - image_file = meta.get("image_file") - if not image_file: - return jsonify({"error": "Object has no image_file"}), 400 - - image_path = OBJECTS_DIR / image_file - if not image_path.exists(): - return jsonify({"error": "Object image not found"}), 404 - - # Prompt laden - prompt_template = read_prompt( - PROMPTS_DIR / "create_details.txt", - "Analyze this object and return one JSON object with the requested fields.", - ) - - title_de = meta.get("title_de", "") or "" - action_de = meta.get("action_de", "") or "" - condition_de = meta.get("condition_de", "") or "" - - # Platzhalter im Prompt ersetzen (einfache .format-Verwendung) - try: - prompt = prompt_template.format( - title_de=title_de, - action_de=action_de, - condition_de=condition_de, - ) - except Exception: - # Falls das Format fehlschlägt, einfach Rohprompt + Kontext anhängen - prompt = ( - f"{prompt_template}\n\n" - f"Titel (Deutsch): {title_de}\n" - f"Status (Deutsch): {action_de}\n" - f"Zustand (Deutsch): {condition_de}\n" - ) - - # Bild als Bytes laden - try: - img_bytes = image_path.read_bytes() - except Exception: - return jsonify({"error": "Could not read object image"}), 500 - - try: - response = ollama.chat( - model="llama3.2-vision", - messages=[{"role": "user", "content": prompt, "images": [img_bytes]}], - format="json", - options={"temperature": 0}, - ) - except Exception as e: - return jsonify({"error": f"LLM request failed: {e}"}), 500 - - content = response.get("message", {}).get("content") - if not content: - return jsonify({"error": "Empty response from LLM"}), 500 - - try: - ai_data = json.loads(content) - except Exception: - # Fallback: versuchen, das erste JSON-Objekt zu extrahieren - try: - start = content.index("{") - end = content.rindex("}") + 1 - ai_data = json.loads(content[start:end]) - except Exception: - return jsonify({"error": "Could not parse JSON from LLM response"}), 500 - - # Debug-Ausgabe des rohen AI-Outputs - try: - print(f"[generate_details] Raw AI data for {obj_id}: {json.dumps(ai_data, ensure_ascii=False)}") - except Exception: - print(f"[generate_details] Raw AI data for {obj_id} (non-serializable): {ai_data!r}") - - # Erwartete Felder übernehmen (gemäß neuem Prompt) - fields = [ - "label_en", - "label_de", - "label_se", - "color_en", - "adjective_en", - "action_verb_en", - "preposition_en", - "relative_position_en", - "season_en", - ] - for key in fields: - if key in ai_data: - meta[key] = ai_data.get(key) - - # Metadatei zurückschreiben - meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") - - # Nur die neuen Felder zurückgeben (None als leeren String konvertieren) - result = {key: (meta.get(key) or "") for key in fields} - - try: - print(f"[generate_details] Stored meta for {obj_id}: {json.dumps(result, ensure_ascii=False)}") - except Exception: - print(f"[generate_details] Stored meta for {obj_id}: {result!r}") - - return jsonify(result) + """Llama entfernt — lokale KI nicht mehr verfügbar.""" + return jsonify({"error": "Lokale KI (Llama) wurde entfernt. Diese Funktion steht nicht mehr zur Verfügung."}), 501 def load_sentences_for_object(obj_id: str): @@ -605,113 +487,8 @@ def get_object_sentences(obj_id: str): @app.route("/api/object//generate_sentence", methods=["POST"]) def generate_object_sentence(obj_id: str): - """ - Erzeugt mit Llama 3.1 einen neuen englischen Frage-Antwort-Satz - zu einem Objekt basierend auf den KI-Details und bisherigen Sätzen. - Speichert alle Sätze in sentence_object/.txt. - """ - # Objekt-Metadaten laden (inkl. KI-Details) - meta_path = OBJECTS_DIR / f"{obj_id}.txt" - if not meta_path.exists(): - return jsonify({"error": "Object not found"}), 404 - - try: - meta = json.loads(meta_path.read_text(encoding="utf-8")) - except Exception: - return jsonify({"error": "Could not read meta file"}), 500 - - # Relevante Felder für den Satz (KI-Details) - details = { - "label_en": meta.get("label_en"), - "label_de": meta.get("label_de"), - "label_se": meta.get("label_se"), - "color_en": meta.get("color_en"), - "adjective_en": meta.get("adjective_en"), - "action_verb_en": meta.get("action_verb_en"), - "preposition_en": meta.get("preposition_en"), - "relative_position_en": meta.get("relative_position_en"), - "season_en": meta.get("season_en"), - "title_de": meta.get("title_de"), - "position_de": meta.get("position_de"), - "action_de": meta.get("action_de"), - "condition_de": meta.get("condition_de"), - } - - # Bisherige Sätze laden - previous_sentences = load_sentences_for_object(obj_id) - - # Prompt laden - prompt_template = read_prompt( - PROMPTS_DIR / "create_sentence.txt", - "Create one new English question_en and answer_en as JSON, based on the object details and avoiding previous sentences.", - ) - - # Vollständigen Prompt zusammensetzen - try: - details_json = json.dumps(details, ensure_ascii=False, indent=2) - prev_json = json.dumps(previous_sentences, ensure_ascii=False, indent=2) - except Exception: - details_json = str(details) - prev_json = str(previous_sentences) - - prompt = ( - f"{prompt_template}\n\n" - f"OBJECT_DETAILS_JSON:\n{details_json}\n\n" - f"PREVIOUS_SENTENCES_JSON:\n{prev_json}\n" - ) - - # LLM-Aufruf (nur Text, kein Bild nötig) - try: - response = ollama.chat( - model="llama3.1", - messages=[{"role": "user", "content": prompt}], - format="json", - options={"temperature": 0.2}, - ) - except Exception as e: - return jsonify({"error": f"LLM request failed: {e}"}), 500 - - content = response.get("message", {}).get("content") - if not content: - return jsonify({"error": "Empty response from LLM"}), 500 - - try: - ai_data = json.loads(content) - except Exception: - # Fallback: erstes JSON-Objekt extrahieren - try: - start = content.index("{") - end = content.rindex("}") + 1 - ai_data = json.loads(content[start:end]) - except Exception: - return jsonify({"error": "Could not parse JSON from LLM response"}), 500 - - question_simple = (ai_data.get("question_simple_en") or "").strip() - answer_simple = (ai_data.get("answer_simple_en") or "").strip() - question_advanced = (ai_data.get("question_advanced_en") or "").strip() - answer_advanced = (ai_data.get("answer_advanced_en") or "").strip() - - if not question_simple or not answer_simple or not question_advanced or not answer_advanced: - return jsonify({"error": "LLM response missing required fields (question_simple_en, answer_simple_en, question_advanced_en, answer_advanced_en)"}), 500 - - # Neuen Satz mit Timestamp anreichern - entry = { - "object_id": obj_id, - "created_at": datetime.now().isoformat(), - "question_simple_en": question_simple, - "answer_simple_en": answer_simple, - "question_advanced_en": question_advanced, - "answer_advanced_en": answer_advanced, - } - - sentences = previous_sentences + [entry] - - # In Datei speichern - SENTENCE_DIR.mkdir(parents=True, exist_ok=True) - sentence_path = SENTENCE_DIR / f"{obj_id}.txt" - sentence_path.write_text(json.dumps(sentences, ensure_ascii=False, indent=2), encoding="utf-8") - - return jsonify({"object_id": obj_id, "sentence": entry, "count": len(sentences)}) + """Llama entfernt — lokale KI nicht mehr verfügbar.""" + return jsonify({"error": "Lokale KI (Llama) wurde entfernt. Diese Funktion steht nicht mehr zur Verfügung."}), 501 @app.route("/api/image/save", methods=["POST"]) @@ -1008,86 +785,52 @@ def crop_image(): ) -# ── Claude / Directus Helpers ───────────────────────────────────────────────── +# ── Claude / API Helpers ────────────────────────────────────────────────────── def _ensure_junction(collection: str, field1: str, field2: str, token: str): - """Create a simple M2M junction collection in Directus if it doesn't exist.""" - _, status = _directus("GET", f"/collections/{collection}", token) - if status == 200: - return - body = { - "collection": collection, - "schema": {}, - "meta": {"hidden": True, "icon": "import_export"}, - "fields": [ - { - "field": "id", - "type": "integer", - "schema": {"is_primary_key": True, "has_auto_increment": True}, - "meta": {"hidden": True}, - }, - {"field": field1, "type": "uuid", "schema": {}, "meta": {"hidden": True}}, - {"field": field2, "type": "uuid", "schema": {}, "meta": {"hidden": True}}, - ], - } - _directus("POST", "/collections", token, body) + """No-op: snakkimo verwaltet Junctions intern.""" + pass def _ensure_link(collection: str, match: dict, payload: dict, token: str): - """Insert a junction row only if it doesn't already exist.""" - qs = "&".join( - f"filter[{k}][_eq]={urllib.parse.quote(str(v), safe='')}" - for k, v in match.items() - ) - data, status = _directus("GET", f"/items/{collection}?{qs}&limit=1", token) - if status == 200 and data.get("data"): - return # already linked - _directus("POST", f"/items/{collection}", token, payload) + """No-op: snakkimo verwaltet Links intern.""" + pass -def _find_or_create_word(title_de: str, level: int, token: str): - """Return (word_id, is_new). Creates word with status=draft if missing.""" - enc = urllib.parse.quote(title_de, safe="") - data, status = _directus( - "GET", f"/items/words?filter[title_de][_eq]={enc}&fields=id&limit=1", token - ) - if status == 200 and data.get("data"): - return data["data"][0]["id"], False +def _find_or_create_word(titel_de: str, level: int, token: str): + """Return (word_id, is_new). Erstellt word mit status=requested falls nicht vorhanden.""" + enc = urllib.parse.quote(titel_de, safe="") + data, status = _snakkimo("GET", f"/words?titel_de={enc}&limit=1", token) + items = data.get("data") or [] + if status == 200 and items: + first = items[0] if isinstance(items, list) else items + return first["id"], False - body = {"status": "draft", "title_de": title_de, "level": level} - data, status = _directus("POST", "/items/words", token, body) + body = {"titel_de": titel_de, "difficulty_level": level} + data, status = _snakkimo("POST", "/words", token, body) if status in (200, 201): - return data["data"]["id"], True + item = data.get("data") or data + return item["id"], True raise RuntimeError(f"Word creation failed ({status}): {data}") def _find_or_create_question(question_de: str, answer_de: str, level: int, short_answer_id, short_answer_de: str, obj_id: str, token: str): - """Return (question_id, is_new). Creates question with status=draft if missing.""" + """Return (question_id, is_new). Erstellt question mit status=draft falls nicht vorhanden. + FLAG: answer_de, short_answer_de und level werden nicht mehr gespeichert (kein Feld in snakkimo). + """ enc = urllib.parse.quote(question_de, safe="") - data, status = _directus( - "GET", - f"/items/questions?filter[question_de][_eq]={enc}&fields=id&limit=1", - token, - ) - if status == 200 and data.get("data"): - return data["data"][0]["id"], False + data, status = _snakkimo("GET", f"/questions?sentence_de={enc}&limit=1", token) + items = data.get("data") or [] + if status == 200 and items: + first = items[0] if isinstance(items, list) else items + return first["id"], False - body = { - "status": "draft", - "question_de": question_de, - "answer_de": answer_de, - "level": level, - "object": obj_id, - } - if short_answer_id: - body["short_answer"] = short_answer_id - if short_answer_de: - body["short_answer_de"] = short_answer_de - - data, status = _directus("POST", "/items/questions", token, body) + body = {"sentence_de": question_de} + data, status = _snakkimo("POST", "/questions", token, body) if status in (200, 201): - return data["data"]["id"], True + item = data.get("data") or data + return item["id"], True raise RuntimeError(f"Question creation failed ({status}): {data}") @@ -1111,21 +854,20 @@ def generate_questions(obj_id: str): return jsonify({"error": "ANTHROPIC_API_KEY not configured on server"}), 500 # Objekt laden - obj_resp, s = _directus("GET", f"/items/objects/{obj_id}?fields=id,user_notes,parent", token) + obj_resp, s = _snakkimo("GET", f"/objects/{obj_id}", token) if s != 200: return jsonify({"error": "Object not found"}), 404 obj = obj_resp.get("data") or {} + if isinstance(obj, list): + obj = obj[0] if obj else {} - # Elternobjekt laden + # Elternobjekt: snakkimo objects haben kein parent-Feld → übersprungen parent_notes = "" - if obj.get("parent"): - p_resp, _ = _directus("GET", f"/items/objects/{obj['parent']}?fields=id,user_notes", token) - parent_notes = ((p_resp.get("data") or {}).get("user_notes") or "") # Platzhalter ersetzen prompt = ( prompt_template - .replace("{user-notes_object}", obj.get("user_notes") or "") + .replace("{user-notes_object}", obj.get("notes") or obj.get("user_notes") or "") .replace("{user-notes_parentobject}", parent_notes) ) @@ -1176,17 +918,13 @@ def generate_questions(obj_id: str): # Mehrwortige Einträge → überspringen (KI-Fehler) return tokens - # Bestehende Verlinkungen vorab laden – ein einziger GET auf das Objekt - obj_links_data, _ = _directus( - "GET", - f"/items/objects/{obj_id}" - f"?fields[]=linked_words.words_id" - f"&fields[]=linked_questions.questions_id", - token, - ) - obj_data = obj_links_data.get("data") or {} - existing_word_ids: set[str] = {lw["words_id"] for lw in (obj_data.get("linked_words") or [])} - existing_question_ids: set[str] = {lq["questions_id"] for lq in (obj_data.get("linked_questions") or [])} + # Bestehende Verlinkungen laden + obj_detail, _ = _snakkimo("GET", f"/objects/{obj_id}", token) + obj_data = obj_detail.get("data") or {} + if isinstance(obj_data, list): + obj_data = obj_data[0] if obj_data else {} + existing_word_ids: set[str] = set(obj_data.get("word_ids") or []) + existing_question_ids: set[str] = set() # Alle eindeutigen Wörter aus allen Leveln vorab sammeln und einmalig laden all_words_by_level: dict[str, int] = {} # title_de → first level seen @@ -1199,24 +937,18 @@ def generate_questions(obj_id: str): all_words_by_level[w] = level # Wörter einmalig anlegen / finden (globaler Cache über alle Level) - global_word_map: dict[str, str] = {} # title_de → id - new_word_links: list[dict] = [] + global_word_map: dict[str, str] = {} # titel_de → id for w, lvl_num in all_words_by_level.items(): try: wid, is_new = _find_or_create_word(w, lvl_num, token) global_word_map[w] = wid if wid not in existing_word_ids: - new_word_links.append({"words_id": wid}) + _snakkimo("POST", f"/objects/{obj_id}/words/{wid}", token) existing_word_ids.add(wid) stats["words_created" if is_new else "words_linked"] += 1 except Exception as e: print(f"[generate_questions] word error '{w}': {e}") - # Alle neuen Wort-Verlinkungen in einem Batch via natives M2M - if new_word_links: - _directus("PATCH", f"/items/objects/{obj_id}", token, - {"linked_words": {"create": new_word_links}}) - new_question_links: list[dict] = [] for lvl in levels: @@ -1241,27 +973,14 @@ def generate_questions(obj_id: str): stats["questions_created" if q_is_new else "questions_linked"] += 1 - # Frage ↔ Objekt (für Batch am Ende sammeln) - if q_id not in existing_question_ids: - new_question_links.append({"questions_id": q_id}) - existing_question_ids.add(q_id) + # Frage anlegen — wird später mit Objekt verknüpft + existing_question_ids.add(q_id) - # related_words + distractor_words nur für neue Fragen (batch, natives M2M) - if q_is_new: - q_patch: dict = {} - rw = [{"words_id": global_word_map[w]} for w in words_list if w in global_word_map] - if rw: - q_patch["related_words"] = {"create": rw} - dw = [{"words_id": global_word_map[w]} for w in distractor_list if w in global_word_map] - if dw: - q_patch["distractor_words"] = {"create": dw} - if q_patch: - _directus("PATCH", f"/items/questions/{q_id}", token, q_patch) + # FLAG: distractor_words und related_words kein Äquivalent in snakkimo → übersprungen - # Alle neuen Fragen-Verlinkungen in einem Batch via natives M2M - if new_question_links: - _directus("PATCH", f"/items/objects/{obj_id}", token, - {"linked_questions": {"create": new_question_links}}) + # Fragen mit Objekt verknüpfen + for q_id in existing_question_ids: + _snakkimo("POST", f"/objects/{obj_id}/pairs/{q_id}", token) # no-op if exists print(f"[generate_questions] obj={obj_id} stats={stats}") return jsonify({"ok": True, "object_id": obj_id, "stats": stats}) @@ -1269,124 +988,76 @@ def generate_questions(obj_id: str): @app.route("/api/object//publish_questions", methods=["POST"]) def publish_questions(obj_id: str): - """Ändert Status aller verknüpften Entwurfs-Fragen und -Wörter auf 'published'.""" + """Setzt Status aller verknüpften Fragen und Wörter auf 'published'.""" token = request.headers.get("Authorization", "") - # Verknüpfte Fragen - q_resp, _ = _directus( - "GET", - f"/items/questions_objects?filter[objects_id][_eq]={obj_id}&fields=questions_id&limit=200", - token, - ) - q_ids = [e["questions_id"] for e in (q_resp.get("data") or [])] + # Wörter des Objekts laden + w_resp, _ = _snakkimo("GET", f"/objects/{obj_id}/words", token) + words = w_resp.get("data") or [] + words = words if isinstance(words, list) else [] - # Verknüpfte Wörter - w_resp, _ = _directus( - "GET", - f"/items/words_objects?filter[objects_id][_eq]={obj_id}&fields=words_id&limit=2000", - token, - ) - w_ids = [e["words_id"] for e in (w_resp.get("data") or [])] - - published_q = 0 published_w = 0 - - for q_id in q_ids: - _, s = _directus("PATCH", f"/items/questions/{q_id}", token, {"status": "published"}) - if s == 200: - published_q += 1 - - for w_id in w_ids: - _, s = _directus("PATCH", f"/items/words/{w_id}", token, {"status": "published"}) + for w in words: + _, s = _snakkimo("PATCH", f"/words/{w['id']}", token, {"status": "published"}) if s == 200: published_w += 1 return jsonify({ "ok": True, - "published_questions": published_q, + "published_questions": 0, # questions haben kein publish-Konzept mehr "published_words": published_w, }) @app.route("/api/object//questions", methods=["GET"]) def get_object_questions_list(obj_id: str): - """Gibt alle verknüpften Fragen eines Objekts zurück (mit short_answer_de + distractor_words).""" + """Gibt Pairs des Objekts zurück (snakkimo: pairs mit questions/statements).""" token = request.headers.get("Authorization", "") - - # Schritt 1: Frage-IDs aus Junction - junc, _ = _directus("GET", - f"/items/questions_objects?filter[objects_id][_eq]={obj_id}&fields=questions_id&limit=200", token) - q_ids = [e["questions_id"] for e in (junc.get("data") or []) if e.get("questions_id")] - if not q_ids: - return jsonify({"data": []}) - - # Schritt 2: Fragen laden (inkl. short_answer_de) – archivierte ausschließen - ids_param = urllib.parse.quote(",".join(q_ids), safe="") - q_data, _ = _directus("GET", - f"/items/questions?filter[id][_in]={ids_param}&filter[status][_neq]=archived&fields=id,question_de,answer_de,short_answer_de,level,status&limit=200", token) - items = sorted(q_data.get("data") or [], key=lambda x: x.get("level") or 0) - - # Schritt 3: Distractor-Wörter pro Frage (Bulk) - dw_junc, _ = _directus("GET", - f"/items/questions_distractor_words?filter[questions_id][_in]={ids_param}&fields=questions_id,words_id&limit=5000", token) - dw_entries = dw_junc.get("data") or [] - - # Wort-IDs sammeln und Titel laden - all_word_ids = list({e["words_id"] for e in dw_entries if e.get("words_id")}) - word_title_map: dict[str, str] = {} - if all_word_ids: - wids_param = urllib.parse.quote(",".join(all_word_ids), safe="") - w_data, _ = _directus("GET", - f"/items/words?filter[id][_in]={wids_param}&fields=id,title_de&limit=5000", token) - word_title_map = {w["id"]: w["title_de"] for w in (w_data.get("data") or [])} - - # Distractor-Wörter je Frage gruppieren - dw_by_question: dict[str, list[str]] = {} - for e in dw_entries: - qid = e.get("questions_id") - wid = e.get("words_id") - if qid and wid and wid in word_title_map: - dw_by_question.setdefault(qid, []).append(word_title_map[wid]) - - for item in items: - item["distractor_words"] = dw_by_question.get(item["id"], []) - + data, status = _snakkimo("GET", f"/objects/{obj_id}/pairs", token) + pairs = data.get("data") or [] + pairs = pairs if isinstance(pairs, list) else [] + # Normalize to expected shape + items = [] + for p in pairs: + q = p.get("question") or {} + items.append({ + "id": p["id"], + "question_de": q.get("sentence_de", ""), + "answer_de": "", # kein Äquivalent in snakkimo + "short_answer_de": "", + "level": p.get("difficulty_level") or 0, + "status": p.get("status", "draft"), + "distractor_words": [], # kein Äquivalent in snakkimo + }) return jsonify({"data": items}) @app.route("/api/object//words", methods=["GET"]) def get_object_words_list(obj_id: str): - """Gibt alle verknüpften Wörter eines Objekts zurück (2-Schritt-Query).""" + """Gibt alle verknüpften Wörter eines Objekts zurück.""" token = request.headers.get("Authorization", "") - junc, _ = _directus("GET", - f"/items/words_objects?filter[objects_id][_eq]={obj_id}&fields=words_id&limit=2000", token) - w_ids = [e["words_id"] for e in (junc.get("data") or []) if e.get("words_id")] - if not w_ids: - return jsonify({"data": []}) - ids_param = urllib.parse.quote(",".join(w_ids), safe="") - w_data, _ = _directus("GET", - f"/items/words?filter[id][_in]={ids_param}&filter[status][_neq]=archived&fields=id,title_de,level,status&limit=2000", token) - items = sorted(w_data.get("data") or [], key=lambda x: x.get("title_de") or "") + data, _ = _snakkimo("GET", f"/objects/{obj_id}/words", token) + words = data.get("data") or [] + words = words if isinstance(words, list) else [] + items = sorted( + [{"id": w["id"], "title_de": w.get("titel_de", ""), "titel_de": w.get("titel_de", ""), + "level": w.get("difficulty_level") or 50, "status": w.get("status", "")} for w in words + if w.get("status") != "blocked"], + key=lambda x: x.get("titel_de") or "" + ) return jsonify({"data": items}) def _delete_junction_rows(collection: str, field: str, value: str, token: str): - """Löscht alle Junction-Zeilen für einen gegebenen Fremdschlüssel.""" - data, s = _directus("GET", - f"/items/{collection}?filter[{field}][_eq]={value}&fields=id&limit=5000", token) - ids = [e["id"] for e in (data.get("data") or []) if e.get("id")] - if ids: - _directus("DELETE", f"/items/{collection}", token, ids) + """No-op: snakkimo löscht Junctions via CASCADE automatisch.""" + pass @app.route("/api/question/", methods=["DELETE"]) def delete_question_item(q_id: str): - """Löscht eine Frage + alle zugehörigen Junction-Zeilen aus Directus.""" + """Löscht eine Frage via snakkimo API.""" token = request.headers.get("Authorization", "") - _delete_junction_rows("questions_objects", "questions_id", q_id, token) - _delete_junction_rows("questions_distractor_words", "questions_id", q_id, token) - _delete_junction_rows("questions_words", "questions_id", q_id, token) - _, status = _directus("DELETE", f"/items/questions/{q_id}", token) + _, status = _snakkimo("DELETE", f"/questions/{q_id}", token) if status in (200, 204): return jsonify({"ok": True}) return jsonify({"error": "Delete failed"}), status @@ -1394,12 +1065,9 @@ def delete_question_item(q_id: str): @app.route("/api/word/", methods=["DELETE"]) def delete_word_item(w_id: str): - """Löscht ein Wort + alle zugehörigen Junction-Zeilen aus Directus.""" + """Löscht ein Wort via snakkimo API (CASCADE entfernt Junction-Zeilen automatisch).""" token = request.headers.get("Authorization", "") - _delete_junction_rows("words_objects", "words_id", w_id, token) - _delete_junction_rows("questions_words", "words_id", w_id, token) - _delete_junction_rows("questions_distractor_words", "words_id", w_id, token) - _, status = _directus("DELETE", f"/items/words/{w_id}", token) + _, status = _snakkimo("DELETE", f"/words/{w_id}", token) if status in (200, 204): return jsonify({"ok": True}) return jsonify({"error": "Delete failed"}), status @@ -1407,365 +1075,94 @@ def delete_word_item(w_id: str): @app.route("/api/object//purge-orphans", methods=["POST"]) def purge_orphan_junctions(obj_id: str): - """ - Bereinigt verwaiste Junction-Einträge für ein Objekt: - Entfernt Zeilen aus questions_objects/words_objects deren Frage/Wort nicht mehr existiert. - """ - token = request.headers.get("Authorization", "") - removed = 0 - - for junc_col, fk_field, item_col in [ - ("questions_objects", "questions_id", "questions"), - ("words_objects", "words_id", "words"), - ]: - junc_data, _ = _directus("GET", - f"/items/{junc_col}?filter[objects_id][_eq]={obj_id}&fields=id,{fk_field}&limit=5000", token) - for row in (junc_data.get("data") or []): - fk_val = row.get(fk_field) - if not fk_val: - continue - item_data, s = _directus("GET", f"/items/{item_col}/{fk_val}?fields=id,status", token) - item = item_data.get("data") or {} - if s != 200 or not item or item.get("status") == "archived": - _directus("DELETE", f"/items/{junc_col}/{row['id']}", token) - removed += 1 - - return jsonify({"ok": True, "orphans_removed": removed}) + """No-op: snakkimo verwendet CASCADE — keine verwaisten Junctions möglich.""" + return jsonify({"ok": True, "orphans_removed": 0, "note": "not needed with snakkimo API"}) @app.route("/api/purge-all-orphans", methods=["POST"]) def purge_all_orphans(): - """ - Bereinigt verwaiste Junction-Einträge für ALLE Objekte auf einmal. - Lädt alle Junction-Zeilen und prüft, ob das referenzierte Item noch existiert. - """ - token = request.headers.get("Authorization", "") - removed = 0 - details = [] - - for junc_col, fk_field, item_col in [ - ("questions_objects", "questions_id", "questions"), - ("words_objects", "words_id", "words"), - ]: - junc_data, _ = _directus("GET", - f"/items/{junc_col}?fields=id,{fk_field}&limit=10000", token) - rows = junc_data.get("data") or [] - - fk_ids = list({row[fk_field] for row in rows if row.get(fk_field)}) - if not fk_ids: - details.append({"collection": junc_col, "junction_rows": 0, "existing": 0, "orphans": 0}) - continue - - # fetch existing AND check all statuses (fetch without status filter first) - ids_param = urllib.parse.quote(",".join(fk_ids), safe="") - all_data, _ = _directus("GET", - f"/items/{item_col}?filter[id][_in]={ids_param}&fields=id,status&limit=10000", token) - all_items = all_data.get("data") or [] - existing_ids = {e["id"] for e in all_items if e.get("status") not in ("archived", "deleted")} - status_summary = {} - for e in all_items: - s = e.get("status", "unknown") - status_summary[s] = status_summary.get(s, 0) + 1 - - orphan_junc_ids = [row["id"] for row in rows - if row.get(fk_field) and row[fk_field] not in existing_ids] - if orphan_junc_ids: - _directus("DELETE", f"/items/{junc_col}", token, orphan_junc_ids) - removed += len(orphan_junc_ids) - - details.append({ - "collection": junc_col, - "junction_rows": len(rows), - "fk_unique": len(fk_ids), - "items_found": len(all_items), - "status_breakdown": status_summary, - "existing_active": len(existing_ids), - "orphans_removed": len(orphan_junc_ids), - }) - - return jsonify({"ok": True, "orphans_removed": removed, "details": details}) + """No-op: snakkimo verwendet CASCADE — keine verwaisten Junctions möglich.""" + return jsonify({"ok": True, "orphans_removed": 0, "note": "not needed with snakkimo API"}) @app.route("/api/fix-distractor-field", methods=["POST"]) def fix_distractor_field(): - """Setzt special=m2m auf questions.distractor_words (einmalig).""" - token = request.headers.get("Authorization", "") - # Directus erfordert den vollen Payload beim Patchen von special - payload = { - "meta": { - "special": ["m2m"], - "interface": "list-m2m", - "options": {"template": "{{words_id.title_de}}"}, - "hidden": False, - "note": "Ablenker-Wörter (thematisch passend, aber nicht die Antwort)", - } - } - data, status = _directus("PATCH", "/fields/questions/distractor_words", token, payload) - return jsonify({"ok": status in (200, 201), "status": status, "data": data}) + """No-op: distractor_words existiert nicht in snakkimo.""" + return jsonify({"ok": False, "note": "not supported in snakkimo API"}) @app.route("/api/setup-schema", methods=["POST"]) def setup_directus_schema(): - """ - Einmalig ausführen: Konfiguriert alle M2M-Relationen in Directus. - Idempotent – bereits vorhandene Felder/Relationen werden übersprungen. - """ - token = request.headers.get("Authorization", "") - results = [] - - def do(label, method, path, body=None): - data, status = _directus(method, path, token, body) - ok = status in (200, 201, 204) - results.append({"label": label, "status": status, "ok": ok, - "err": None if ok else (data.get("errors") or data)}) - return ok - - # ── Fix M2O: questions.object → objects ────────────────────────────────── - do("relation questions.object→objects", "POST", "/relations", { - "collection": "questions", "field": "object", - "related_collection": "objects", - "schema": {"on_delete": "SET NULL"}, - "meta": {"one_deselect_action": "nullify"}, - }) - # Update display template so the object label shows - do("update field questions.object template", "PATCH", "/fields/questions/object", { - "meta": {"options": {"template": "{{user_notes}}"}} - }) - - # ── Fix M2O: questions.short_answer → words ─────────────────────────────── - do("relation questions.short_answer→words", "POST", "/relations", { - "collection": "questions", "field": "short_answer", - "related_collection": "words", - "schema": {"on_delete": "SET NULL"}, - "meta": {"one_deselect_action": "nullify"}, - }) - - # ── M2M: questions ↔ objects via questions_objects ──────────────────────── - do("field questions.linked_objects", "POST", "/fields/questions", { - "field": "linked_objects", "type": "alias", - "meta": {"special": ["m2m"], "interface": "list-m2m", - "options": {"template": "{{objects_id.user_notes}}"}, - "note": "Verknüpfte Objekte"}, - }) - do("field objects.linked_questions", "POST", "/fields/objects", { - "field": "linked_questions", "type": "alias", - "meta": {"special": ["m2m"], "interface": "list-m2m", - "options": {"template": "{{questions_id.question_de}}"}, - "note": "Verknüpfte Fragen"}, - }) - do("relation questions_objects.questions_id→questions", "POST", "/relations", { - "collection": "questions_objects", "field": "questions_id", - "related_collection": "questions", - "schema": {"on_delete": "CASCADE"}, - "meta": {"junction_field": "objects_id", "one_field": "linked_objects", - "one_deselect_action": "nullify"}, - }) - do("relation questions_objects.objects_id→objects", "POST", "/relations", { - "collection": "questions_objects", "field": "objects_id", - "related_collection": "objects", - "schema": {"on_delete": "CASCADE"}, - "meta": {"junction_field": "questions_id", "one_field": "linked_questions", - "one_deselect_action": "nullify"}, - }) - - # ── M2M: words ↔ objects via words_objects ──────────────────────────────── - do("field words.linked_objects", "POST", "/fields/words", { - "field": "linked_objects", "type": "alias", - "meta": {"special": ["m2m"], "interface": "list-m2m", - "options": {"template": "{{objects_id.user_notes}}"}, - "note": "Verknüpfte Objekte"}, - }) - do("field objects.linked_words", "POST", "/fields/objects", { - "field": "linked_words", "type": "alias", - "meta": {"special": ["m2m"], "interface": "list-m2m", - "options": {"template": "{{words_id.title_de}}"}, - "note": "Verknüpfte Wörter"}, - }) - do("relation words_objects.words_id→words", "POST", "/relations", { - "collection": "words_objects", "field": "words_id", - "related_collection": "words", - "schema": {"on_delete": "CASCADE"}, - "meta": {"junction_field": "objects_id", "one_field": "linked_objects", - "one_deselect_action": "nullify"}, - }) - do("relation words_objects.objects_id→objects", "POST", "/relations", { - "collection": "words_objects", "field": "objects_id", - "related_collection": "objects", - "schema": {"on_delete": "CASCADE"}, - "meta": {"junction_field": "words_id", "one_field": "linked_words", - "one_deselect_action": "nullify"}, - }) - - # ── M2M: questions ↔ words (distractor) via questions_distractor_words ──── - # Feld existiert evtl. schon ohne special → PATCH erzwingen - do("field questions.distractor_words (create)", "POST", "/fields/questions", { - "field": "distractor_words", "type": "alias", - "meta": {"special": ["m2m"], "interface": "list-m2m", - "options": {"template": "{{words_id.title_de}}"}, - "note": "Ablenker-Wörter (nicht in Frage/Antwort)"}, - }) - do("field questions.distractor_words (patch special)", "PATCH", "/fields/questions/distractor_words", { - "type": "alias", - "meta": {"special": ["m2m"], "interface": "list-m2m", - "options": {"template": "{{words_id.title_de}}"}, - "hidden": False, - "note": "Ablenker-Wörter (nicht in Frage/Antwort)"}, - }) - do("relation questions_distractor_words.questions_id→questions", "POST", "/relations", { - "collection": "questions_distractor_words", "field": "questions_id", - "related_collection": "questions", - "schema": {"on_delete": "CASCADE"}, - "meta": {"junction_field": "words_id", "one_field": "distractor_words", - "one_deselect_action": "nullify"}, - }) - do("relation questions_distractor_words.words_id→words", "POST", "/relations", { - "collection": "questions_distractor_words", "field": "words_id", - "related_collection": "words", - "schema": {"on_delete": "CASCADE"}, - "meta": {"junction_field": "questions_id", "one_deselect_action": "nullify"}, - }) - - # ── Backref: words → questions via existing questions_words junction ─────── - do("field words.linked_questions", "POST", "/fields/words", { - "field": "linked_questions", "type": "alias", - "meta": {"special": ["m2m"], "interface": "list-m2m", - "options": {"template": "{{questions_id.question_de}}"}, - "note": "Fragen in denen dieses Wort vorkommt"}, - }) - # Patch the existing questions_words relation to add the one_field backref on words - do("patch relation questions_words.words_id one_field", "PATCH", - "/relations/questions_words/words_id", { - "meta": {"one_field": "linked_questions", "one_deselect_action": "nullify"} - }) - - failed = [r for r in results if not r["ok"]] - return jsonify({"ok": len(failed) == 0, "total": len(results), - "failed": len(failed), "results": results}) + """No-op: Schema wird durch snakkimo API Migration verwaltet.""" + return jsonify({"ok": True, "note": "not needed with snakkimo API"}) @app.route("/api/setup-words-pictures", methods=["POST"]) def setup_words_pictures(): - """ - Einmalig ausführen: Konfiguriert die M2M-Relation words ↔ pictures - via words_pictures Junction in Directus. - Idempotent – bereits vorhandene Felder/Relationen werden übersprungen. - """ - token = request.headers.get("Authorization", "") - results = [] - - def do(label, method, path, body=None): - data, status = _directus(method, path, token, body) - ok = status in (200, 201, 204) - results.append({"label": label, "status": status, "ok": ok, - "err": None if ok else (data.get("errors") or data)}) - return ok - - # Sicherstellen dass Junction-Collection existiert - _ensure_junction("words_pictures", "words_id", "pictures_id", token) - - # special=m2m auf Alias-Felder setzen (idempotent) - do("patch pictures.linked_words special", "PATCH", "/fields/pictures/linked_words", { - "type": "alias", - "meta": {"special": ["m2m"], "interface": "list-m2m", - "options": {"template": "{{words_id.title_de}}"}, - "note": "Verknüpfte Safe Words"}, - }) - do("patch words.linked_pictures special", "PATCH", "/fields/words/linked_pictures", { - "type": "alias", - "meta": {"special": ["m2m"], "interface": "list-m2m", - "options": {"template": "{{pictures_id.media}}"}, - "note": "Verknüpfte Bilder"}, - }) - - # Relation words_pictures.words_id → words - do("relation words_pictures.words_id→words", "POST", "/relations", { - "collection": "words_pictures", "field": "words_id", - "related_collection": "words", - "schema": {"on_delete": "CASCADE"}, - "meta": {"junction_field": "pictures_id", - "one_field": "linked_pictures", - "one_deselect_action": "nullify"}, - }) - # Relation words_pictures.pictures_id → pictures - do("relation words_pictures.pictures_id→pictures", "POST", "/relations", { - "collection": "words_pictures", "field": "pictures_id", - "related_collection": "pictures", - "schema": {"on_delete": "CASCADE"}, - "meta": {"junction_field": "words_id", - "one_field": "linked_words", - "one_deselect_action": "nullify"}, - }) - - failed = [r for r in results if not r["ok"]] - return jsonify({"ok": len(failed) == 0, "total": len(results), - "failed": len(failed), "results": results}) + """No-op: M2M-Relationen werden durch snakkimo API verwaltet.""" + return jsonify({"ok": True, "note": "not needed with snakkimo API"}) -# ── db_* Collection Routes ──────────────────────────────────────────────────── +# ── db_* Collection Routes (snakkimo API) ──────────────────────────────────── -def _find_or_create_db_word(titel_de: str, level: int, token: str) -> tuple: - """Return (word_id, is_new). Creates db_word with status=draft if missing.""" - enc = urllib.parse.quote(titel_de, safe="") - data, status = _directus("GET", f"/items/db_words?filter[titel_de][_eq]={enc}&fields=id&limit=1", token) - if status == 200 and data.get("data"): - return data["data"][0]["id"], False - body = {"status": "draft", "titel_de": titel_de, "level": level} - data, status = _directus("POST", "/items/db_words", token, body) - if status in (200, 201): - return data["data"]["id"], True - raise RuntimeError(f"db_word creation failed ({status}): {data}") +# _find_or_create_db_word is identical to _find_or_create_word +_find_or_create_db_word = _find_or_create_word @app.route("/api/directus/db-pictures", methods=["GET"]) def directus_db_pictures(): token = request.headers.get("Authorization", "") - pic_status = request.args.get("status", "draft") - data, status = _directus("GET", f"/items/db_pictures?filter[status][_eq]={pic_status}&fields=id,picture,blurhash,status,design&sort=date_created", token) - return jsonify(data), status + pic_status = request.args.get("status", "uploaded") + # Directus used 'draft' — snakkimo uses 'uploaded' + status_map = {"draft": "uploaded"} + snakkimo_status = status_map.get(pic_status, pic_status) + data, status = _snakkimo("GET", f"/pictures?status={urllib.parse.quote(snakkimo_status)}&limit=500", token) + pics = data.get("data") or [] + normalized = [ + { + "id": p["id"], + "picture": p.get("picture_link"), + "blurhash": p.get("blurhash"), + "status": p.get("status"), + "design": p.get("design"), + } + for p in (pics if isinstance(pics, list) else []) + ] + return jsonify({"data": normalized}) @app.route("/api/directus/db-pictures/design-options", methods=["GET"]) def directus_db_pictures_design_options(): - # /fields/ requires admin rights — use the static admin token, not the user session token - data, status = _directus("GET", "/fields/db_pictures/design", DIRECTUS_ADMIN_TOKEN) - if status != 200: - print(f"[design-options] Directus /fields/ returned {status}: {data}") - return jsonify({"choices": []}), 200 - field_data = data.get("data") or data - choices = field_data.get("meta", {}).get("options", {}).get("choices", []) - return jsonify({"choices": choices}) + """Returns unique design values from the pictures table.""" + token = request.headers.get("Authorization", "") + data, _ = _snakkimo("GET", "/pictures?limit=500", token) + pics = data.get("data") or [] + seen = set() + choices = [] + for p in (pics if isinstance(pics, list) else []): + d = p.get("design") + if d and d not in seen: + seen.add(d) + choices.append({"text": d, "value": d}) + return jsonify({"choices": sorted(choices, key=lambda x: x["value"])}) @app.route("/api/directus/db-pictures/", methods=["PATCH", "DELETE"]) def directus_db_picture(pic_id): token = request.headers.get("Authorization", "") if request.method == "PATCH": - data, status = _directus("PATCH", f"/items/db_pictures/{pic_id}", token, body=request.get_json(force=True, silent=True)) + body = request.get_json(force=True, silent=True) or {} + # remap 'picture' → 'picture_link' + if "picture" in body and "picture_link" not in body: + body["picture_link"] = body.pop("picture") + data, status = _snakkimo("PATCH", f"/pictures/{pic_id}", token, body) return jsonify(data), status - # DELETE: erst picture-Eintrag laden, dann Eintrag + Datei löschen - pic_data, pic_status = _directus("GET", f"/items/db_pictures/{pic_id}?fields=id,picture", token) - if pic_status != 200: - return jsonify({"error": "Bild nicht gefunden"}), 404 - file_uuid = (pic_data.get("data") or {}).get("picture") - - # db_picture-Eintrag löschen - _, del_status = _directus("DELETE", f"/items/db_pictures/{pic_id}", token) - if del_status not in (200, 204): - return jsonify({"error": f"Fehler beim Löschen des Eintrags (Status {del_status})"}), del_status - - # Prüfen ob wirklich gelöscht - _, check_status = _directus("GET", f"/items/db_pictures/{pic_id}?fields=id", token) - if check_status == 200: - return jsonify({"error": "Eintrag konnte nicht gelöscht werden"}), 500 - - # Datei löschen (nur wenn vorhanden) - if file_uuid: - _, file_del_status = _directus("DELETE", f"/files/{file_uuid}", token) - if file_del_status not in (200, 204): - return jsonify({"error": f"Eintrag gelöscht, aber Datei konnte nicht gelöscht werden (Status {file_del_status})"}), 500 - - return jsonify({}), 204 + # DELETE — snakkimo handles S3 cleanup automatically + _, del_status = _snakkimo("DELETE", f"/pictures/{pic_id}", token) + if del_status in (200, 204): + return jsonify({}), 204 + return jsonify({"error": f"Delete failed (status {del_status})"}), del_status @app.route("/api/directus/db-objects", methods=["GET", "POST"]) @@ -1773,12 +1170,26 @@ def directus_db_objects(): token = request.headers.get("Authorization", "") if request.method == "GET": picture_id = request.args.get("picture_id", "") - fields = "id,selections,user_notes,status,picture" - path = f"/items/db_objects?filter[picture][_eq]={picture_id}&fields={fields}&sort=date_created" - data, status = _directus("GET", path, token) - return jsonify(data), status + path = (f"/objects?picture_id={urllib.parse.quote(picture_id)}&limit=500" + if picture_id else "/objects?limit=500") + data, status = _snakkimo("GET", path, token) + objs = data.get("data") or [] + normalized = [] + for o in (objs if isinstance(objs, list) else []): + pic_ids = o.get("picture_ids") or [] + normalized.append({ + "id": o["id"], + "selections": o.get("selections"), + "user_notes": o.get("notes"), + "status": o.get("status"), + "picture": pic_ids[0] if pic_ids else None, + }) + return jsonify({"data": normalized}) else: - data, status = _directus("POST", "/items/db_objects", token, body=request.get_json()) + body = request.get_json(force=True, silent=True) or {} + if "user_notes" in body and "notes" not in body: + body["notes"] = body.pop("user_notes") + data, status = _snakkimo("POST", "/objects", token, body) return jsonify(data), status @@ -1786,9 +1197,12 @@ def directus_db_objects(): def directus_db_object(obj_id): token = request.headers.get("Authorization", "") if request.method == "PATCH": - data, status = _directus("PATCH", f"/items/db_objects/{obj_id}", token, body=request.get_json()) + body = request.get_json(force=True, silent=True) or {} + if "user_notes" in body and "notes" not in body: + body["notes"] = body.pop("user_notes") + data, status = _snakkimo("PATCH", f"/objects/{obj_id}", token, body) else: - data, status = _directus("DELETE", f"/items/db_objects/{obj_id}", token) + data, status = _snakkimo("DELETE", f"/objects/{obj_id}", token) return jsonify(data), status @@ -1796,42 +1210,27 @@ def directus_db_object(obj_id): def directus_db_picture_words(pic_id): token = request.headers.get("Authorization", "") if request.method == "GET": - data, s = _directus( - "GET", - f"/items/db_words_db_pictures?filter[db_pictures_id][_eq]={pic_id}" - f"&fields=id,db_words_id.id,db_words_id.titel_de,db_words_id.level,db_words_id.status&limit=500", - token, - ) + data, s = _snakkimo("GET", f"/pictures/{pic_id}/words", token) if s != 200: return jsonify({"data": []}) + words = data.get("data") or [] items = [] - for entry in (data.get("data") or []): - word = entry.get("db_words_id") or {} - if not isinstance(word, dict) or not word.get("id"): - continue - if word.get("status") == "archived": + for w in (words if isinstance(words, list) else []): + if w.get("status") == "blocked": continue items.append({ - "junction_id": entry.get("id"), - "word_id": word["id"], - "titel_de": word.get("titel_de", ""), - "level": word.get("level") or 50, - "status": word.get("status", ""), + "junction_id": w["id"], # word_id used as junction_id for deletes + "word_id": w["id"], + "titel_de": w.get("titel_de", ""), + "level": w.get("difficulty_level") or 50, + "status": w.get("status", ""), }) return jsonify({"data": items}) else: body = request.get_json(force=True, silent=True) or {} words_to_save = body.get("words", []) - existing_data, _ = _directus( - "GET", - f"/items/db_words_db_pictures?filter[db_pictures_id][_eq]={pic_id}&fields=db_words_id&limit=500", - token, - ) - existing_ids = set() - for e in (existing_data.get("data") or []): - wid = e.get("db_words_id") - if wid: - existing_ids.add(wid if isinstance(wid, str) else wid.get("id", "")) + existing_data, _ = _snakkimo("GET", f"/pictures/{pic_id}/words", token) + existing_ids = {w["id"] for w in (existing_data.get("data") or []) if isinstance(w, dict)} saved = 0 for entry in words_to_save: titel_de = (entry.get("titel_de") or "").strip() @@ -1839,11 +1238,11 @@ def directus_db_picture_words(pic_id): if not titel_de: continue try: - wid, is_new = _find_or_create_db_word(titel_de, level, token) + wid, is_new = _find_or_create_word(titel_de, level, token) if not is_new: - _directus("PATCH", f"/items/db_words/{wid}", token, {"level": level}) + _snakkimo("PATCH", f"/words/{wid}", token, {"difficulty_level": level}) if wid not in existing_ids: - _directus("POST", "/items/db_words_db_pictures", token, {"db_words_id": wid, "db_pictures_id": pic_id}) + _snakkimo("POST", f"/words/{wid}/pictures/{pic_id}", token) existing_ids.add(wid) saved += 1 except Exception as e: @@ -1853,8 +1252,9 @@ def directus_db_picture_words(pic_id): @app.route("/api/directus/db-pictures//words/", methods=["DELETE"]) def directus_db_picture_word_delete(pic_id, junction_id): + # junction_id == word_id (set equal in GET above) token = request.headers.get("Authorization", "") - _directus("DELETE", f"/items/db_words_db_pictures/{junction_id}", token) + _snakkimo("DELETE", f"/pictures/{pic_id}/words/{junction_id}", token) return jsonify({"ok": True}) @@ -1862,51 +1262,71 @@ def directus_db_picture_word_delete(pic_id, junction_id): def directus_db_object_pairs(obj_id): token = request.headers.get("Authorization", "") if request.method == "GET": - junc_data, _ = _directus( - "GET", - f"/items/db_objects_db_pairs?filter[db_objects_id][_eq]={obj_id}&fields=id,db_pairs_id&limit=200", - token, - ) - pair_ids = [e["db_pairs_id"] for e in (junc_data.get("data") or []) if e.get("db_pairs_id")] - if not pair_ids: - return jsonify({"data": []}) - ids_param = urllib.parse.quote(",".join(pair_ids), safe="") - pairs_data, _ = _directus("GET", f"/items/db_pairs?filter[id][_in]={ids_param}&fields=id,status,level&sort=date_created&limit=200", token) - pairs = pairs_data.get("data") or [] + data, _ = _snakkimo("GET", f"/objects/{obj_id}/pairs", token) + pairs = data.get("data") or [] + pairs = pairs if isinstance(pairs, list) else [] + + # Collect all word IDs for bulk fetch + all_word_ids = set() + for p in pairs: + for stmt_key in ("positive_statement", "negative_statement"): + s = p.get(stmt_key) or {} + for wid in (s.get("positive_word_ids") or []): + all_word_ids.add(wid) + for wid in (s.get("negative_word_ids") or []): + all_word_ids.add(wid) + + word_map = {} + for wid in all_word_ids: + wd, ws = _snakkimo("GET", f"/words/{wid}", token) + if ws == 200: + w = wd.get("data") or wd + if isinstance(w, dict) and w.get("id"): + word_map[wid] = w + result = [] for pair in pairs: pid = pair["id"] - q_junc, _ = _directus("GET", f"/items/db_pairs_db_question?filter[db_pairs_id][_eq]={pid}&fields=db_question_id&limit=10", token) - q_ids = [e["db_question_id"] for e in (q_junc.get("data") or []) if e.get("db_question_id")] + q = pair.get("question") or {} + pos_stmt = pair.get("positive_statement") or {} + questions = [] - for qid in q_ids: - q_d, _ = _directus("GET", f"/items/db_question/{qid}?fields=id,question_de,level,status", token) - if q_d.get("data"): - questions.append(q_d["data"]) - s_junc, _ = _directus("GET", f"/items/db_pairs_db_statement?filter[db_pairs_id][_eq]={pid}&fields=db_statement_id&limit=10", token) - s_ids = [e["db_statement_id"] for e in (s_junc.get("data") or []) if e.get("db_statement_id")] + if q.get("id"): + questions.append({ + "id": q["id"], + "question_de": q.get("sentence_de", ""), + "level": pair.get("difficulty_level") or 0, + "status": q.get("status", ""), + "words": [], # FLAG: question words not supported in snakkimo + }) + statements = [] - for sid in s_ids: - s_d, _ = _directus("GET", f"/items/db_statement/{sid}?fields=id,statement_de,level,status", token) - if s_d.get("data"): - statements.append(s_d["data"]) - # Wörter für jede Question laden - for q in questions: - qid = q["id"] - qw_junc, _ = _directus("GET", f"/items/db_question_db_words?filter[db_question_id][_eq]={qid}&fields=id,db_words_id.id,db_words_id.titel_de,db_words_id.level&limit=100", token) - q["words"] = [ - {"junction_id": e["id"], "word_id": e["db_words_id"]["id"], "titel_de": e["db_words_id"]["titel_de"], "level": e["db_words_id"]["level"]} - for e in (qw_junc.get("data") or []) if isinstance(e.get("db_words_id"), dict) - ] - # Wörter für jedes Statement laden - for s in statements: - sid = s["id"] - sw_junc, _ = _directus("GET", f"/items/db_statement_db_words?filter[db_statement_id][_eq]={sid}&fields=id,db_words_id.id,db_words_id.titel_de,db_words_id.level&limit=100", token) - s["words"] = [ - {"junction_id": e["id"], "word_id": e["db_words_id"]["id"], "titel_de": e["db_words_id"]["titel_de"], "level": e["db_words_id"]["level"]} - for e in (sw_junc.get("data") or []) if isinstance(e.get("db_words_id"), dict) - ] - result.append({**pair, "questions": questions, "statements": statements}) + if pos_stmt.get("id"): + pos_word_ids = pos_stmt.get("positive_word_ids") or [] + stmt_words = [] + for wid in pos_word_ids: + w = word_map.get(wid, {}) + stmt_words.append({ + "junction_id": wid, + "word_id": wid, + "titel_de": w.get("titel_de", ""), + "level": w.get("difficulty_level") or 50, + }) + statements.append({ + "id": pos_stmt["id"], + "statement_de": pos_stmt.get("positive_sentence_de", ""), + "level": pair.get("difficulty_level") or 0, + "status": pos_stmt.get("status", ""), + "words": stmt_words, + }) + + result.append({ + "id": pid, + "status": pair.get("status", "draft"), + "level": pair.get("difficulty_level") or 0, + "questions": questions, + "statements": statements, + }) return jsonify({"data": result}) else: body = request.get_json(force=True, silent=True) or {} @@ -1916,207 +1336,180 @@ def directus_db_object_pairs(obj_id): words = body.get("words", []) if not statement_de: return jsonify({"error": "statement_de is required"}), 400 - pair_resp, s = _directus("POST", "/items/db_pairs", token, {"status": "draft", "level": level}) - if s not in (200, 201): - return jsonify({"error": "Failed to create pair"}), 500 - pair_id = pair_resp["data"]["id"] - _directus("POST", "/items/db_objects_db_pairs", token, {"db_objects_id": obj_id, "db_pairs_id": pair_id}) - stmt_resp, s = _directus("POST", "/items/db_statement", token, {"status": "draft", "statement_de": statement_de, "level": level}) + + # Create statement + stmt_resp, s = _snakkimo("POST", "/statements", token, {"positive_sentence_de": statement_de}) if s not in (200, 201): return jsonify({"error": "Failed to create statement"}), 500 - stmt_id = stmt_resp["data"]["id"] - _directus("POST", "/items/db_pairs_db_statement", token, {"db_pairs_id": pair_id, "db_statement_id": stmt_id}) + stmt_data = stmt_resp.get("data") or stmt_resp + stmt_id = stmt_data["id"] + + # Create question if provided q_id = None if question_de: - q_resp, s = _directus("POST", "/items/db_question", token, {"status": "draft", "question_de": question_de, "level": level}) + q_resp, s = _snakkimo("POST", "/questions", token, {"sentence_de": question_de}) if s in (200, 201): - q_id = q_resp["data"]["id"] - _directus("POST", "/items/db_pairs_db_question", token, {"db_pairs_id": pair_id, "db_question_id": q_id}) + q_data = q_resp.get("data") or q_resp + q_id = q_data["id"] + + # Create pair linking statement + question + pair_body = {"answer_type": "yes_no", "difficulty_level": level, "positive_statement_id": stmt_id} + if q_id: + pair_body["question_id"] = q_id + pair_resp, s = _snakkimo("POST", "/pairs", token, pair_body) + if s not in (200, 201): + return jsonify({"error": "Failed to create pair"}), 500 + pair_data = pair_resp.get("data") or pair_resp + pair_id = pair_data["id"] + + # Link pair to object + _snakkimo("POST", f"/objects/{obj_id}/pairs/{pair_id}", token) + + # Add words to statement (FLAG: linking words to questions not supported) for we in words: titel_de = (we.get("titel_de") or "").strip() w_level = int(we.get("level") or level) - link_to = we.get("link_to", "both") # 'question', 'statement', 'both' + link_to = we.get("link_to", "both") if not titel_de: continue try: - wid, _ = _find_or_create_db_word(titel_de, w_level, token) + wid, _ = _find_or_create_word(titel_de, w_level, token) if link_to in ("statement", "both"): - _directus("POST", "/items/db_statement_db_words", token, {"db_statement_id": stmt_id, "db_words_id": wid}) - if link_to in ("question", "both") and q_id: - _directus("POST", "/items/db_question_db_words", token, {"db_question_id": q_id, "db_words_id": wid}) + _snakkimo("POST", f"/statements/{stmt_id}/positive-words/{wid}", token) except Exception as e: print(f"[db_object_pairs] word error '{titel_de}': {e}") + return jsonify({"ok": True, "pair_id": pair_id, "statement_id": stmt_id, "question_id": q_id}) @app.route("/api/directus/db-objects//words", methods=["GET", "POST"]) def directus_db_object_words(obj_id): - """Gibt alle db_words zurück, die via db_objects_db_words mit dem Objekt verknüpft sind.""" + """Words linked to an object via snakkimo API.""" token = request.headers.get("Authorization", "") if request.method == "GET": - data, s = _directus( - "GET", - f"/items/db_objects_db_words?filter[db_objects_id][_eq]={obj_id}" - f"&fields=id,db_words_id.id,db_words_id.titel_de,db_words_id.level,db_words_id.status&limit=500", - token, - ) + data, s = _snakkimo("GET", f"/objects/{obj_id}/words", token) if s != 200: return jsonify({"data": []}) + words = data.get("data") or [] items = [] - for entry in (data.get("data") or []): - word = entry.get("db_words_id") or {} - if not isinstance(word, dict) or not word.get("id"): - continue - if word.get("status") == "archived": + for w in (words if isinstance(words, list) else []): + if w.get("status") == "blocked": continue items.append({ - "junction_id": entry.get("id"), - "word_id": word["id"], - "titel_de": word.get("titel_de", ""), - "level": word.get("level") or 50, - "status": word.get("status", ""), + "junction_id": w["id"], # word_id used as junction_id for deletes + "word_id": w["id"], + "titel_de": w.get("titel_de", ""), + "level": w.get("difficulty_level") or 50, + "status": w.get("status", ""), }) return jsonify({"data": items}) - else: # POST — add a single word to the object (M2M, allows multiple) + else: body = request.get_json(force=True, silent=True) or {} titel_de = (body.get("titel_de") or "").strip() level = int(body.get("level") or 50) if not titel_de: return jsonify({"error": "titel_de required"}), 400 - wid, _ = _find_or_create_db_word(titel_de, level, token) - # Check if already linked to avoid duplicates - existing, _ = _directus("GET", - f"/items/db_objects_db_words?filter[db_objects_id][_eq]={obj_id}&filter[db_words_id][_eq]={wid}&fields=id&limit=1", - token) - if existing.get("data"): - return jsonify({"ok": True, "already_exists": True, "word_id": wid, "junction_id": existing["data"][0]["id"]}) - resp, s = _directus("POST", "/items/db_objects_db_words", token, - {"db_objects_id": obj_id, "db_words_id": wid}) - junction_id = resp["data"]["id"] if s in (200, 201) else None - return jsonify({"ok": True, "word_id": wid, "junction_id": junction_id}) + wid, _ = _find_or_create_word(titel_de, level, token) + existing_data, _ = _snakkimo("GET", f"/objects/{obj_id}/words", token) + existing_ids = {w["id"] for w in (existing_data.get("data") or []) if isinstance(w, dict)} + if wid in existing_ids: + return jsonify({"ok": True, "already_exists": True, "word_id": wid, "junction_id": wid}) + _snakkimo("POST", f"/objects/{obj_id}/words/{wid}", token) + return jsonify({"ok": True, "word_id": wid, "junction_id": wid}) @app.route("/api/directus/db-objects//words/", methods=["DELETE"]) def directus_db_object_word_delete(obj_id, junction_id): token = request.headers.get("Authorization", "") - _directus("DELETE", f"/items/db_objects_db_words/{junction_id}", token) + _snakkimo("DELETE", f"/objects/{obj_id}/words/{junction_id}", token) return jsonify({"ok": True}) @app.route("/api/directus/db-pairs/", methods=["PATCH", "DELETE"]) def directus_db_pair(pair_id): - """PATCH: level + question/statement inline aktualisieren. - DELETE: Pair + alle verknüpften Junctions + Question + Statement entfernen.""" + """PATCH: update difficulty_level, statement_de, question_de, words. + DELETE: delete pair + linked statement + question.""" token = request.headers.get("Authorization", "") if request.method == "PATCH": body = request.get_json(force=True, silent=True) or {} - # Pair-Level updaten + # Fetch current pair to get FK IDs + pair_data, ps = _snakkimo("GET", f"/pairs/{pair_id}", token) + if ps != 200: + return jsonify({"error": "Pair not found"}), 404 + pair = pair_data.get("data") or pair_data + stmt_id = pair.get("positive_statement_id") + q_id = pair.get("question_id") + if "level" in body: - _directus("PATCH", f"/items/db_pairs/{pair_id}", token, {"level": body["level"]}) + _snakkimo("PATCH", f"/pairs/{pair_id}", token, {"difficulty_level": body["level"]}) - # Statement updaten (nur erstes verknüpftes) - if "statement_de" in body: - s_junc, _ = _directus("GET", - f"/items/db_pairs_db_statement?filter[db_pairs_id][_eq]={pair_id}&fields=db_statement_id&limit=1", token) - for e in (s_junc.get("data") or []): - sid = e.get("db_statement_id") - if sid: - _directus("PATCH", f"/items/db_statement/{sid}", token, - {"statement_de": body["statement_de"], "level": body.get("level", 50)}) + if "statement_de" in body and stmt_id: + _snakkimo("PATCH", f"/statements/{stmt_id}", token, + {"positive_sentence_de": body["statement_de"]}) - # Question updaten/anlegen if "question_de" in body: question_de = (body["question_de"] or "").strip() - q_junc, _ = _directus("GET", - f"/items/db_pairs_db_question?filter[db_pairs_id][_eq]={pair_id}&fields=db_question_id&limit=1", token) - existing_q = (q_junc.get("data") or []) - if existing_q: - qid = existing_q[0].get("db_question_id") - if question_de: - _directus("PATCH", f"/items/db_question/{qid}", token, - {"question_de": question_de, "level": body.get("level", 50)}) - else: - # Frage gelöscht → Junction + Item entfernen - _directus("DELETE", f"/items/db_pairs_db_question", token, - [e["id"] for e in existing_q if e.get("id")]) - _directus("DELETE", f"/items/db_question/{qid}", token) - elif question_de: - # Neue Frage anlegen und verknüpfen - q_resp, s = _directus("POST", "/items/db_question", token, - {"status": "draft", "question_de": question_de, - "level": body.get("level", 50)}) + if q_id and question_de: + _snakkimo("PATCH", f"/questions/{q_id}", token, {"sentence_de": question_de}) + elif q_id and not question_de: + # Remove question from pair then delete it + _snakkimo("PATCH", f"/pairs/{pair_id}", token, {"question_id": None}) + _snakkimo("DELETE", f"/questions/{q_id}", token) + q_id = None + elif not q_id and question_de: + q_resp, s = _snakkimo("POST", "/questions", token, {"sentence_de": question_de}) if s in (200, 201): - qid = q_resp["data"]["id"] - _directus("POST", "/items/db_pairs_db_question", token, - {"db_pairs_id": pair_id, "db_question_id": qid}) + q_data = q_resp.get("data") or q_resp + q_id = q_data["id"] + _snakkimo("PATCH", f"/pairs/{pair_id}", token, {"question_id": q_id}) - # Neue Wörter hinzufügen - words_add = body.get("words_add", []) - # Statement-ID und Question-ID nochmal laden - s_junc2, _ = _directus("GET", f"/items/db_pairs_db_statement?filter[db_pairs_id][_eq]={pair_id}&fields=db_statement_id&limit=1", token) - stmt_id_edit = ((s_junc2.get("data") or [{}])[0] or {}).get("db_statement_id") - q_junc2, _ = _directus("GET", f"/items/db_pairs_db_question?filter[db_pairs_id][_eq]={pair_id}&fields=db_question_id&limit=1", token) - q_id_edit = ((q_junc2.get("data") or [{}])[0] or {}).get("db_question_id") + # Reload stmt_id in case it changed + if not stmt_id: + pair_data2, _ = _snakkimo("GET", f"/pairs/{pair_id}", token) + pair2 = pair_data2.get("data") or pair_data2 + stmt_id = pair2.get("positive_statement_id") - for we in words_add: + for we in body.get("words_add", []): titel_de = (we.get("titel_de") or "").strip() w_level = int(we.get("level") or 50) link_to = we.get("link_to", "both") if not titel_de: continue try: - wid, _ = _find_or_create_db_word(titel_de, w_level, token) - if link_to in ("statement", "both") and stmt_id_edit: - _directus("POST", "/items/db_statement_db_words", token, {"db_statement_id": stmt_id_edit, "db_words_id": wid}) - if link_to in ("question", "both") and q_id_edit: - _directus("POST", "/items/db_question_db_words", token, {"db_question_id": q_id_edit, "db_words_id": wid}) + wid, _ = _find_or_create_word(titel_de, w_level, token) + if link_to in ("statement", "both") and stmt_id: + _snakkimo("POST", f"/statements/{stmt_id}/positive-words/{wid}", token) + # FLAG: linking words to questions not supported in snakkimo except Exception as e: print(f"[db_pair_patch] word error '{titel_de}': {e}") - # Wörter entfernen - words_remove = body.get("words_remove", []) - for wr in words_remove: - link_to = wr.get("link_to", "both") + for wr in body.get("words_remove", []): junction_id = wr.get("junction_id") + link_to = wr.get("link_to", "both") if not junction_id: continue - if link_to in ("statement", "both"): - _directus("DELETE", f"/items/db_statement_db_words/{junction_id}", token) - if link_to in ("question", "both"): - _directus("DELETE", f"/items/db_question_db_words/{junction_id}", token) + if link_to in ("statement", "both") and stmt_id: + _snakkimo("DELETE", f"/statements/{stmt_id}/positive-words/{junction_id}", token) + # FLAG: removing words from questions not supported in snakkimo return jsonify({"ok": True}) else: # DELETE - # Junction zu Objekten entfernen - obj_junc, _ = _directus("GET", - f"/items/db_objects_db_pairs?filter[db_pairs_id][_eq]={pair_id}&fields=id&limit=100", token) - obj_junc_ids = [e["id"] for e in (obj_junc.get("data") or []) if e.get("id")] - if obj_junc_ids: - _directus("DELETE", "/items/db_objects_db_pairs", token, obj_junc_ids) + pair_data, ps = _snakkimo("GET", f"/pairs/{pair_id}", token) + pair = (pair_data.get("data") or pair_data) if ps == 200 else {} + stmt_id = pair.get("positive_statement_id") + q_id = pair.get("question_id") - # Questions löschen - q_junc, _ = _directus("GET", - f"/items/db_pairs_db_question?filter[db_pairs_id][_eq]={pair_id}&fields=id,db_question_id&limit=100", token) - for e in (q_junc.get("data") or []): - if e.get("id"): - _directus("DELETE", f"/items/db_pairs_db_question/{e['id']}", token) - if e.get("db_question_id"): - _directus("DELETE", f"/items/db_question/{e['db_question_id']}", token) + # CASCADE removes object_pairs junction automatically + _, s = _snakkimo("DELETE", f"/pairs/{pair_id}", token) - # Statements löschen - s_junc, _ = _directus("GET", - f"/items/db_pairs_db_statement?filter[db_pairs_id][_eq]={pair_id}&fields=id,db_statement_id&limit=100", token) - for e in (s_junc.get("data") or []): - if e.get("id"): - _directus("DELETE", f"/items/db_pairs_db_statement/{e['id']}", token) - if e.get("db_statement_id"): - _directus("DELETE", f"/items/db_statement/{e['db_statement_id']}", token) + if stmt_id: + _snakkimo("DELETE", f"/statements/{stmt_id}", token) + if q_id: + _snakkimo("DELETE", f"/questions/{q_id}", token) - # Pair selbst löschen - _, s = _directus("DELETE", f"/items/db_pairs/{pair_id}", token) return jsonify({"ok": s in (200, 204)})