Orphan-Junction-Cleanup + Refresh-Button für Fragen/Wörter
- Backend: DELETE question/word räumt alle Junction-Zeilen mit auf - Backend: /purge-orphans bereinigt verwaiste Junctions per Objekt - Frontend: reloadQW ruft purgeOrphans vor dem Neu-Laden auf - Frontend: ↺-Button in Wörter- und Fragen-Sidebar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
46
app.py
46
app.py
@@ -1242,10 +1242,22 @@ def get_object_words_list(obj_id: str):
|
|||||||
return jsonify({"data": items})
|
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)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/question/<q_id>", methods=["DELETE"])
|
@app.route("/api/question/<q_id>", methods=["DELETE"])
|
||||||
def delete_question_item(q_id: str):
|
def delete_question_item(q_id: str):
|
||||||
"""Löscht eine Frage aus Directus."""
|
"""Löscht eine Frage + alle zugehörigen Junction-Zeilen aus Directus."""
|
||||||
token = request.headers.get("Authorization", "")
|
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 = _directus("DELETE", f"/items/questions/{q_id}", token)
|
||||||
if status in (200, 204):
|
if status in (200, 204):
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
@@ -1254,14 +1266,44 @@ def delete_question_item(q_id: str):
|
|||||||
|
|
||||||
@app.route("/api/word/<w_id>", methods=["DELETE"])
|
@app.route("/api/word/<w_id>", methods=["DELETE"])
|
||||||
def delete_word_item(w_id: str):
|
def delete_word_item(w_id: str):
|
||||||
"""Löscht ein Wort aus Directus."""
|
"""Löscht ein Wort + alle zugehörigen Junction-Zeilen aus Directus."""
|
||||||
token = request.headers.get("Authorization", "")
|
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 = _directus("DELETE", f"/items/words/{w_id}", token)
|
||||||
if status in (200, 204):
|
if status in (200, 204):
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
return jsonify({"error": "Delete failed"}), status
|
return jsonify({"error": "Delete failed"}), status
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/object/<obj_id>/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", token)
|
||||||
|
if s != 200 or not item_data.get("data"):
|
||||||
|
_directus("DELETE", f"/items/{junc_col}/{row['id']}", token)
|
||||||
|
removed += 1
|
||||||
|
|
||||||
|
return jsonify({"ok": True, "orphans_removed": removed})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/fix-distractor-field", methods=["POST"])
|
@app.route("/api/fix-distractor-field", methods=["POST"])
|
||||||
def fix_distractor_field():
|
def fix_distractor_field():
|
||||||
"""Setzt special=m2m auf questions.distractor_words (einmalig)."""
|
"""Setzt special=m2m auf questions.distractor_words (einmalig)."""
|
||||||
|
|||||||
@@ -281,3 +281,13 @@ export async function deleteWord(wId: string, token: string): Promise<void> {
|
|||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Fehler beim Löschen des Worts')
|
if (!res.ok) throw new Error('Fehler beim Löschen des Worts')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function purgeOrphans(objId: string, token: string): Promise<{ orphans_removed: number }> {
|
||||||
|
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/purge-orphans`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Bereinigen')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
getObjectWords,
|
getObjectWords,
|
||||||
deleteQuestion,
|
deleteQuestion,
|
||||||
deleteWord,
|
deleteWord,
|
||||||
|
purgeOrphans,
|
||||||
type GenerateStats,
|
type GenerateStats,
|
||||||
type ObjectQuestion,
|
type ObjectQuestion,
|
||||||
type ObjectWord,
|
type ObjectWord,
|
||||||
@@ -180,6 +181,7 @@ export default function GenerateIt() {
|
|||||||
|
|
||||||
const reloadQW = (objId: string) => {
|
const reloadQW = (objId: string) => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
|
purgeOrphans(objId, token).catch(console.error)
|
||||||
getObjectQuestions(objId, token).then(setQuestions).catch(console.error)
|
getObjectQuestions(objId, token).then(setQuestions).catch(console.error)
|
||||||
getObjectWords(objId, token).then(setObjWords).catch(console.error)
|
getObjectWords(objId, token).then(setObjWords).catch(console.error)
|
||||||
}
|
}
|
||||||
@@ -439,9 +441,12 @@ export default function GenerateIt() {
|
|||||||
{/* Words sidebar */}
|
{/* Words sidebar */}
|
||||||
<aside className="sidebar sidebar--words">
|
<aside className="sidebar sidebar--words">
|
||||||
<div className="sidebar-panel" style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<div className="sidebar-panel" style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
<h3 className="sidebar-heading">
|
<h3 className="sidebar-heading" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
Wörter
|
Wörter
|
||||||
{objWords.length > 0 && <span className="badge">{objWords.length}</span>}
|
{objWords.length > 0 && <span className="badge">{objWords.length}</span>}
|
||||||
|
{selectedObjId && (
|
||||||
|
<button onClick={() => reloadQW(selectedObjId)} style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--muted)', padding: '0 2px' }} title="Neu laden">↺</button>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
{objWords.length === 0 ? (
|
{objWords.length === 0 ? (
|
||||||
<div className="empty-state">–</div>
|
<div className="empty-state">–</div>
|
||||||
@@ -468,9 +473,12 @@ export default function GenerateIt() {
|
|||||||
{/* Questions sidebar */}
|
{/* Questions sidebar */}
|
||||||
<aside className="sidebar sidebar--right" style={{ width: 300, minWidth: 240 }}>
|
<aside className="sidebar sidebar--right" style={{ width: 300, minWidth: 240 }}>
|
||||||
<div className="sidebar-panel" style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<div className="sidebar-panel" style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
<h3 className="sidebar-heading">
|
<h3 className="sidebar-heading" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
Fragen
|
Fragen
|
||||||
{questions.length > 0 && <span className="badge">{questions.length}</span>}
|
{questions.length > 0 && <span className="badge">{questions.length}</span>}
|
||||||
|
{selectedObjId && (
|
||||||
|
<button onClick={() => reloadQW(selectedObjId)} style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--muted)', padding: '0 2px' }} title="Neu laden">↺</button>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
{questions.length === 0 ? (
|
{questions.length === 0 ? (
|
||||||
<div className="empty-state">Klicke „Generate it".</div>
|
<div className="empty-state">Klicke „Generate it".</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user