[split-required] Split 700-870 LOC files across all services
backend-lehrer (11 files): - llm_gateway/routes/schools.py (867 → 5), recording_api.py (848 → 6) - messenger_api.py (840 → 5), print_generator.py (824 → 5) - unit_analytics_api.py (751 → 5), classroom/routes/context.py (726 → 4) - llm_gateway/routes/edu_search_seeds.py (710 → 4) klausur-service (12 files): - ocr_labeling_api.py (845 → 4), metrics_db.py (833 → 4) - legal_corpus_api.py (790 → 4), page_crop.py (758 → 3) - mail/ai_service.py (747 → 4), github_crawler.py (767 → 3) - trocr_service.py (730 → 4), full_compliance_pipeline.py (723 → 4) - dsfa_rag_api.py (715 → 4), ocr_pipeline_auto.py (705 → 4) website (6 pages): - audit-checklist (867 → 8), content (806 → 6) - screen-flow (790 → 4), scraper (789 → 5) - zeugnisse (776 → 5), modules (745 → 4) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
193
backend-lehrer/ai_processing/print_cloze.py
Normal file
193
backend-lehrer/ai_processing/print_cloze.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
AI Processing - Print Version Generator: Cloze (Lueckentext).
|
||||
|
||||
Generates printable HTML for cloze/fill-in-the-blank worksheets.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import random
|
||||
import logging
|
||||
|
||||
from .core import BEREINIGT_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_print_version_cloze(cloze_path: Path, include_answers: bool = False) -> Path:
|
||||
"""
|
||||
Generiert eine druckbare HTML-Version der Lueckentexte.
|
||||
|
||||
Args:
|
||||
cloze_path: Pfad zur *_cloze.json Datei
|
||||
include_answers: True fuer Loesungsblatt (fuer Eltern)
|
||||
|
||||
Returns:
|
||||
Pfad zur generierten HTML-Datei
|
||||
"""
|
||||
if not cloze_path.exists():
|
||||
raise FileNotFoundError(f"Cloze-Datei nicht gefunden: {cloze_path}")
|
||||
|
||||
cloze_data = json.loads(cloze_path.read_text(encoding="utf-8"))
|
||||
items = cloze_data.get("cloze_items", [])
|
||||
metadata = cloze_data.get("metadata", {})
|
||||
|
||||
title = metadata.get("source_title", "Arbeitsblatt")
|
||||
subject = metadata.get("subject", "")
|
||||
grade = metadata.get("grade_level", "")
|
||||
total_gaps = metadata.get("total_gaps", 0)
|
||||
|
||||
html_parts = []
|
||||
html_parts.append("""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>""" + title + """ - Lueckentext</title>
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
.page-break { page-break-before: always; }
|
||||
}
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
h1 { font-size: 24px; margin-bottom: 8px; }
|
||||
.meta { color: #666; margin-bottom: 24px; }
|
||||
.cloze-item {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cloze-number {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cloze-sentence {
|
||||
font-size: 16px;
|
||||
line-height: 2;
|
||||
}
|
||||
.gap {
|
||||
display: inline-block;
|
||||
min-width: 80px;
|
||||
border-bottom: 2px solid #333;
|
||||
margin: 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.gap-filled {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #4caf50;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.translation {
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background: #e3f2fd;
|
||||
border-left: 3px solid #2196f3;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
.translation-label {
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.word-bank {
|
||||
margin-top: 32px;
|
||||
padding: 16px;
|
||||
background: #fff3e0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.word-bank-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.word {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
margin: 4px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
""")
|
||||
|
||||
# Header
|
||||
version_text = "Loesungsblatt" if include_answers else "Lueckentext"
|
||||
html_parts.append(f"<h1>{title} - {version_text}</h1>")
|
||||
meta_parts = []
|
||||
if subject:
|
||||
meta_parts.append(f"Fach: {subject}")
|
||||
if grade:
|
||||
meta_parts.append(f"Klasse: {grade}")
|
||||
meta_parts.append(f"Luecken gesamt: {total_gaps}")
|
||||
html_parts.append(f"<div class='meta'>{' | '.join(meta_parts)}</div>")
|
||||
|
||||
# Sammle alle Lueckenwoerter fuer Wortbank
|
||||
all_words = []
|
||||
|
||||
# Lueckentexte
|
||||
for idx, item in enumerate(items, 1):
|
||||
html_parts.append("<div class='cloze-item'>")
|
||||
html_parts.append(f"<div class='cloze-number'>{idx}.</div>")
|
||||
|
||||
gaps = item.get("gaps", [])
|
||||
sentence = item.get("sentence_with_gaps", "")
|
||||
|
||||
if include_answers:
|
||||
# Loesungsblatt: Luecken mit Antworten fuellen
|
||||
for gap in gaps:
|
||||
word = gap.get("word", "")
|
||||
sentence = sentence.replace("___", f"<span class='gap-filled'>{word}</span>", 1)
|
||||
else:
|
||||
# Fragenblatt: Luecken als Linien
|
||||
sentence = sentence.replace("___", "<span class='gap'> </span>")
|
||||
# Woerter fuer Wortbank sammeln
|
||||
for gap in gaps:
|
||||
all_words.append(gap.get("word", ""))
|
||||
|
||||
html_parts.append(f"<div class='cloze-sentence'>{sentence}</div>")
|
||||
|
||||
# Uebersetzung anzeigen
|
||||
translation = item.get("translation", {})
|
||||
if translation:
|
||||
lang_name = translation.get("language_name", "Uebersetzung")
|
||||
full_sentence = translation.get("full_sentence", "")
|
||||
if full_sentence:
|
||||
html_parts.append("<div class='translation'>")
|
||||
html_parts.append(f"<div class='translation-label'>{lang_name}:</div>")
|
||||
html_parts.append(full_sentence)
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</div>")
|
||||
|
||||
# Wortbank (nur fuer Fragenblatt)
|
||||
if not include_answers and all_words:
|
||||
random.shuffle(all_words) # Mische die Woerter
|
||||
html_parts.append("<div class='word-bank'>")
|
||||
html_parts.append("<div class='word-bank-title'>Wortbank (diese Woerter fehlen):</div>")
|
||||
for word in all_words:
|
||||
html_parts.append(f"<span class='word'>{word}</span>")
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</body></html>")
|
||||
|
||||
# Speichern
|
||||
suffix = "_cloze_solutions.html" if include_answers else "_cloze_print.html"
|
||||
out_name = cloze_path.stem.replace("_cloze", "") + suffix
|
||||
out_path = BEREINIGT_DIR / out_name
|
||||
out_path.write_text("\n".join(html_parts), encoding="utf-8")
|
||||
|
||||
logger.info(f"Cloze Print-Version gespeichert: {out_path.name}")
|
||||
return out_path
|
||||
@@ -1,824 +1,22 @@
|
||||
"""
|
||||
AI Processing - Print Version Generator.
|
||||
AI Processing - Print Version Generator — Barrel Re-export.
|
||||
|
||||
Generiert druckbare HTML-Versionen für verschiedene Arbeitsblatt-Typen.
|
||||
Generiert druckbare HTML-Versionen fuer verschiedene Arbeitsblatt-Typen.
|
||||
Split into:
|
||||
- print_qa.py: Q&A print generation
|
||||
- print_cloze.py: Cloze/Lueckentext print generation
|
||||
- print_mc.py: Multiple Choice print generation
|
||||
- print_worksheet.py: General worksheet print generation
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import random
|
||||
import logging
|
||||
|
||||
from .core import BEREINIGT_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_print_version_qa(qa_path: Path, include_answers: bool = False) -> Path:
|
||||
"""
|
||||
Generiert eine druckbare HTML-Version der Frage-Antwort-Paare.
|
||||
|
||||
Args:
|
||||
qa_path: Pfad zur *_qa.json Datei
|
||||
include_answers: True für Lösungsblatt (für Eltern)
|
||||
|
||||
Returns:
|
||||
Pfad zur generierten HTML-Datei
|
||||
"""
|
||||
if not qa_path.exists():
|
||||
raise FileNotFoundError(f"Q&A-Datei nicht gefunden: {qa_path}")
|
||||
|
||||
qa_data = json.loads(qa_path.read_text(encoding="utf-8"))
|
||||
items = qa_data.get("qa_items", [])
|
||||
metadata = qa_data.get("metadata", {})
|
||||
|
||||
title = metadata.get("source_title", "Arbeitsblatt")
|
||||
subject = metadata.get("subject", "")
|
||||
grade = metadata.get("grade_level", "")
|
||||
|
||||
html_parts = []
|
||||
html_parts.append("""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>""" + title + """ - Fragen</title>
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
.page-break { page-break-before: always; }
|
||||
}
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { font-size: 24px; margin-bottom: 8px; }
|
||||
.meta { color: #666; margin-bottom: 24px; }
|
||||
.question-block {
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px dashed #ccc;
|
||||
}
|
||||
.question-number {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.question-text {
|
||||
font-size: 16px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.answer-space {
|
||||
border: 1px solid #ddd;
|
||||
min-height: 60px;
|
||||
margin-top: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.answer-lines {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.answer-line {
|
||||
border-bottom: 1px solid #999;
|
||||
height: 28px;
|
||||
}
|
||||
.answer {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #e8f5e9;
|
||||
border-left: 3px solid #4caf50;
|
||||
}
|
||||
.key-terms {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.key-terms span {
|
||||
background: #fff3e0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
""")
|
||||
|
||||
# Header
|
||||
version_text = "Lösungsblatt" if include_answers else "Fragenblatt"
|
||||
html_parts.append(f"<h1>{title} - {version_text}</h1>")
|
||||
meta_parts = []
|
||||
if subject:
|
||||
meta_parts.append(f"Fach: {subject}")
|
||||
if grade:
|
||||
meta_parts.append(f"Klasse: {grade}")
|
||||
meta_parts.append(f"Anzahl Fragen: {len(items)}")
|
||||
html_parts.append(f"<div class='meta'>{' | '.join(meta_parts)}</div>")
|
||||
|
||||
# Fragen
|
||||
for idx, item in enumerate(items, 1):
|
||||
html_parts.append("<div class='question-block'>")
|
||||
html_parts.append(f"<div class='question-number'>Frage {idx}</div>")
|
||||
html_parts.append(f"<div class='question-text'>{item.get('question', '')}</div>")
|
||||
|
||||
if include_answers:
|
||||
# Lösungsblatt: Antwort anzeigen
|
||||
html_parts.append(f"<div class='answer'><strong>Antwort:</strong> {item.get('answer', '')}</div>")
|
||||
# Schlüsselbegriffe
|
||||
key_terms = item.get("key_terms", [])
|
||||
if key_terms:
|
||||
terms_html = " ".join([f"<span>{term}</span>" for term in key_terms])
|
||||
html_parts.append(f"<div class='key-terms'>Wichtige Begriffe: {terms_html}</div>")
|
||||
else:
|
||||
# Fragenblatt: Antwortlinien
|
||||
html_parts.append("<div class='answer-lines'>")
|
||||
for _ in range(3):
|
||||
html_parts.append("<div class='answer-line'></div>")
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</body></html>")
|
||||
|
||||
# Speichern
|
||||
suffix = "_qa_solutions.html" if include_answers else "_qa_print.html"
|
||||
out_name = qa_path.stem.replace("_qa", "") + suffix
|
||||
out_path = BEREINIGT_DIR / out_name
|
||||
out_path.write_text("\n".join(html_parts), encoding="utf-8")
|
||||
|
||||
logger.info(f"Print-Version gespeichert: {out_path.name}")
|
||||
return out_path
|
||||
|
||||
|
||||
def generate_print_version_cloze(cloze_path: Path, include_answers: bool = False) -> Path:
|
||||
"""
|
||||
Generiert eine druckbare HTML-Version der Lückentexte.
|
||||
|
||||
Args:
|
||||
cloze_path: Pfad zur *_cloze.json Datei
|
||||
include_answers: True für Lösungsblatt (für Eltern)
|
||||
|
||||
Returns:
|
||||
Pfad zur generierten HTML-Datei
|
||||
"""
|
||||
if not cloze_path.exists():
|
||||
raise FileNotFoundError(f"Cloze-Datei nicht gefunden: {cloze_path}")
|
||||
|
||||
cloze_data = json.loads(cloze_path.read_text(encoding="utf-8"))
|
||||
items = cloze_data.get("cloze_items", [])
|
||||
metadata = cloze_data.get("metadata", {})
|
||||
|
||||
title = metadata.get("source_title", "Arbeitsblatt")
|
||||
subject = metadata.get("subject", "")
|
||||
grade = metadata.get("grade_level", "")
|
||||
total_gaps = metadata.get("total_gaps", 0)
|
||||
|
||||
html_parts = []
|
||||
html_parts.append("""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>""" + title + """ - Lückentext</title>
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
.page-break { page-break-before: always; }
|
||||
}
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
h1 { font-size: 24px; margin-bottom: 8px; }
|
||||
.meta { color: #666; margin-bottom: 24px; }
|
||||
.cloze-item {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cloze-number {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cloze-sentence {
|
||||
font-size: 16px;
|
||||
line-height: 2;
|
||||
}
|
||||
.gap {
|
||||
display: inline-block;
|
||||
min-width: 80px;
|
||||
border-bottom: 2px solid #333;
|
||||
margin: 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.gap-filled {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #4caf50;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.translation {
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background: #e3f2fd;
|
||||
border-left: 3px solid #2196f3;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
.translation-label {
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.word-bank {
|
||||
margin-top: 32px;
|
||||
padding: 16px;
|
||||
background: #fff3e0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.word-bank-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.word {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
margin: 4px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
""")
|
||||
|
||||
# Header
|
||||
version_text = "Lösungsblatt" if include_answers else "Lückentext"
|
||||
html_parts.append(f"<h1>{title} - {version_text}</h1>")
|
||||
meta_parts = []
|
||||
if subject:
|
||||
meta_parts.append(f"Fach: {subject}")
|
||||
if grade:
|
||||
meta_parts.append(f"Klasse: {grade}")
|
||||
meta_parts.append(f"Lücken gesamt: {total_gaps}")
|
||||
html_parts.append(f"<div class='meta'>{' | '.join(meta_parts)}</div>")
|
||||
|
||||
# Sammle alle Lückenwörter für Wortbank
|
||||
all_words = []
|
||||
|
||||
# Lückentexte
|
||||
for idx, item in enumerate(items, 1):
|
||||
html_parts.append("<div class='cloze-item'>")
|
||||
html_parts.append(f"<div class='cloze-number'>{idx}.</div>")
|
||||
|
||||
gaps = item.get("gaps", [])
|
||||
sentence = item.get("sentence_with_gaps", "")
|
||||
|
||||
if include_answers:
|
||||
# Lösungsblatt: Lücken mit Antworten füllen
|
||||
for gap in gaps:
|
||||
word = gap.get("word", "")
|
||||
sentence = sentence.replace("___", f"<span class='gap-filled'>{word}</span>", 1)
|
||||
else:
|
||||
# Fragenblatt: Lücken als Linien
|
||||
sentence = sentence.replace("___", "<span class='gap'> </span>")
|
||||
# Wörter für Wortbank sammeln
|
||||
for gap in gaps:
|
||||
all_words.append(gap.get("word", ""))
|
||||
|
||||
html_parts.append(f"<div class='cloze-sentence'>{sentence}</div>")
|
||||
|
||||
# Übersetzung anzeigen
|
||||
translation = item.get("translation", {})
|
||||
if translation:
|
||||
lang_name = translation.get("language_name", "Übersetzung")
|
||||
full_sentence = translation.get("full_sentence", "")
|
||||
if full_sentence:
|
||||
html_parts.append("<div class='translation'>")
|
||||
html_parts.append(f"<div class='translation-label'>{lang_name}:</div>")
|
||||
html_parts.append(full_sentence)
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</div>")
|
||||
|
||||
# Wortbank (nur für Fragenblatt)
|
||||
if not include_answers and all_words:
|
||||
random.shuffle(all_words) # Mische die Wörter
|
||||
html_parts.append("<div class='word-bank'>")
|
||||
html_parts.append("<div class='word-bank-title'>Wortbank (diese Wörter fehlen):</div>")
|
||||
for word in all_words:
|
||||
html_parts.append(f"<span class='word'>{word}</span>")
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</body></html>")
|
||||
|
||||
# Speichern
|
||||
suffix = "_cloze_solutions.html" if include_answers else "_cloze_print.html"
|
||||
out_name = cloze_path.stem.replace("_cloze", "") + suffix
|
||||
out_path = BEREINIGT_DIR / out_name
|
||||
out_path.write_text("\n".join(html_parts), encoding="utf-8")
|
||||
|
||||
logger.info(f"Cloze Print-Version gespeichert: {out_path.name}")
|
||||
return out_path
|
||||
|
||||
|
||||
def generate_print_version_mc(mc_path: Path, include_answers: bool = False) -> str:
|
||||
"""
|
||||
Generiert eine druckbare HTML-Version der Multiple-Choice-Fragen.
|
||||
|
||||
Args:
|
||||
mc_path: Pfad zur *_mc.json Datei
|
||||
include_answers: True für Lösungsblatt mit markierten richtigen Antworten
|
||||
|
||||
Returns:
|
||||
HTML-String (zum direkten Ausliefern)
|
||||
"""
|
||||
if not mc_path.exists():
|
||||
raise FileNotFoundError(f"MC-Datei nicht gefunden: {mc_path}")
|
||||
|
||||
mc_data = json.loads(mc_path.read_text(encoding="utf-8"))
|
||||
questions = mc_data.get("questions", [])
|
||||
metadata = mc_data.get("metadata", {})
|
||||
|
||||
title = metadata.get("source_title", "Arbeitsblatt")
|
||||
subject = metadata.get("subject", "")
|
||||
grade = metadata.get("grade_level", "")
|
||||
|
||||
html_parts = []
|
||||
html_parts.append("""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>""" + title + """ - Multiple Choice</title>
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
.page-break { page-break-before: always; }
|
||||
body { font-size: 14pt; }
|
||||
}
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
color: #000;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 2px solid #000;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.meta {
|
||||
color: #333;
|
||||
margin-bottom: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.instructions {
|
||||
background: #f5f5f5;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.question-block {
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.question-number {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
color: #000;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.question-text {
|
||||
font-size: 16px;
|
||||
margin: 8px 0 16px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.options {
|
||||
margin-left: 20px;
|
||||
}
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
.option-correct {
|
||||
background: #e8f5e9;
|
||||
border-color: #4caf50;
|
||||
border-width: 2px;
|
||||
}
|
||||
.option-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #333;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.option-checkbox.checked::after {
|
||||
content: "✓";
|
||||
font-weight: bold;
|
||||
color: #4caf50;
|
||||
}
|
||||
.option-label {
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
min-width: 24px;
|
||||
}
|
||||
.option-text {
|
||||
flex: 1;
|
||||
}
|
||||
.explanation {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #e3f2fd;
|
||||
border-left: 3px solid #2196f3;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
.answer-key {
|
||||
margin-top: 40px;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.answer-key-title {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid #999;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.answer-key-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.answer-key-item {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.answer-key-q {
|
||||
font-weight: bold;
|
||||
}
|
||||
.answer-key-a {
|
||||
color: #4caf50;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
""")
|
||||
|
||||
# Header
|
||||
version_text = "Lösungsblatt" if include_answers else "Multiple Choice Test"
|
||||
html_parts.append(f"<h1>{title}</h1>")
|
||||
html_parts.append(f"<div class='meta'><strong>{version_text}</strong>")
|
||||
if subject:
|
||||
html_parts.append(f" | Fach: {subject}")
|
||||
if grade:
|
||||
html_parts.append(f" | Klasse: {grade}")
|
||||
html_parts.append(f" | Anzahl Fragen: {len(questions)}</div>")
|
||||
|
||||
if not include_answers:
|
||||
html_parts.append("<div class='instructions'>")
|
||||
html_parts.append("<strong>Anleitung:</strong> Kreuze bei jeder Frage die richtige Antwort an. ")
|
||||
html_parts.append("Es ist immer nur eine Antwort richtig.")
|
||||
html_parts.append("</div>")
|
||||
|
||||
# Fragen
|
||||
for idx, q in enumerate(questions, 1):
|
||||
html_parts.append("<div class='question-block'>")
|
||||
html_parts.append(f"<div class='question-number'>Frage {idx}</div>")
|
||||
html_parts.append(f"<div class='question-text'>{q.get('question', '')}</div>")
|
||||
|
||||
html_parts.append("<div class='options'>")
|
||||
correct_answer = q.get("correct_answer", "")
|
||||
|
||||
for opt in q.get("options", []):
|
||||
opt_id = opt.get("id", "")
|
||||
is_correct = opt_id == correct_answer
|
||||
|
||||
opt_class = "option"
|
||||
checkbox_class = "option-checkbox"
|
||||
if include_answers and is_correct:
|
||||
opt_class += " option-correct"
|
||||
checkbox_class += " checked"
|
||||
|
||||
html_parts.append(f"<div class='{opt_class}'>")
|
||||
html_parts.append(f"<div class='{checkbox_class}'></div>")
|
||||
html_parts.append(f"<span class='option-label'>{opt_id})</span>")
|
||||
html_parts.append(f"<span class='option-text'>{opt.get('text', '')}</span>")
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</div>")
|
||||
|
||||
# Erklärung nur bei Lösungsblatt
|
||||
if include_answers and q.get("explanation"):
|
||||
html_parts.append(f"<div class='explanation'><strong>Erklärung:</strong> {q.get('explanation')}</div>")
|
||||
|
||||
html_parts.append("</div>")
|
||||
|
||||
# Lösungsschlüssel (kompakt) - nur bei Lösungsblatt
|
||||
if include_answers:
|
||||
html_parts.append("<div class='answer-key'>")
|
||||
html_parts.append("<div class='answer-key-title'>Lösungsschlüssel</div>")
|
||||
html_parts.append("<div class='answer-key-grid'>")
|
||||
for idx, q in enumerate(questions, 1):
|
||||
html_parts.append("<div class='answer-key-item'>")
|
||||
html_parts.append(f"<span class='answer-key-q'>{idx}.</span> ")
|
||||
html_parts.append(f"<span class='answer-key-a'>{q.get('correct_answer', '')}</span>")
|
||||
html_parts.append("</div>")
|
||||
html_parts.append("</div>")
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</body></html>")
|
||||
|
||||
return "\n".join(html_parts)
|
||||
|
||||
|
||||
def generate_print_version_worksheet(analysis_path: Path) -> str:
|
||||
"""
|
||||
Generiert eine druckoptimierte HTML-Version des Arbeitsblatts.
|
||||
|
||||
Eigenschaften:
|
||||
- Große, gut lesbare Schrift (16pt)
|
||||
- Schwarz-weiß / Graustufen-tauglich
|
||||
- Klare Struktur für Druck
|
||||
- Keine interaktiven Elemente
|
||||
|
||||
Args:
|
||||
analysis_path: Pfad zur *_analyse.json Datei
|
||||
|
||||
Returns:
|
||||
HTML-String zum direkten Ausliefern
|
||||
"""
|
||||
if not analysis_path.exists():
|
||||
raise FileNotFoundError(f"Analysedatei nicht gefunden: {analysis_path}")
|
||||
|
||||
try:
|
||||
data = json.loads(analysis_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"Analyse-Datei enthält kein gültiges JSON: {analysis_path}\n{e}") from e
|
||||
|
||||
title = data.get("title") or "Arbeitsblatt"
|
||||
subject = data.get("subject") or ""
|
||||
grade_level = data.get("grade_level") or ""
|
||||
instructions = data.get("instructions") or ""
|
||||
tasks = data.get("tasks", []) or []
|
||||
canonical_text = data.get("canonical_text") or ""
|
||||
printed_blocks = data.get("printed_blocks") or []
|
||||
|
||||
html_parts = []
|
||||
html_parts.append("""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>""" + title + """</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm;
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
font-size: 14pt !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.no-print { display: none !important; }
|
||||
.page-break { page-break-before: always; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: Arial, "Helvetica Neue", sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 30px;
|
||||
line-height: 1.7;
|
||||
font-size: 16px;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 8px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 3px solid #000;
|
||||
}
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
margin: 28px 0 12px 0;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #666;
|
||||
}
|
||||
.meta {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.meta span {
|
||||
margin-right: 20px;
|
||||
}
|
||||
.instructions {
|
||||
margin: 20px 0;
|
||||
padding: 16px;
|
||||
border: 2px solid #333;
|
||||
background: #f5f5f5;
|
||||
font-size: 15px;
|
||||
}
|
||||
.instructions-label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.text-section {
|
||||
margin: 24px 0;
|
||||
}
|
||||
.text-block {
|
||||
margin-bottom: 16px;
|
||||
text-align: justify;
|
||||
}
|
||||
.text-block-title {
|
||||
font-weight: bold;
|
||||
font-size: 17px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.task-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
.task {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
border: 1px solid #999;
|
||||
background: #fafafa;
|
||||
}
|
||||
.task-header {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed #666;
|
||||
}
|
||||
.task-content {
|
||||
font-size: 15px;
|
||||
}
|
||||
.gap-line {
|
||||
display: inline-block;
|
||||
border-bottom: 2px solid #000;
|
||||
min-width: 100px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
.answer-lines {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.answer-line {
|
||||
border-bottom: 1px solid #333;
|
||||
height: 36px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #ccc;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
/* Print Button - versteckt beim Drucken */
|
||||
.print-button {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 24px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.print-button:hover {
|
||||
background: #555;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="print-button no-print" onclick="window.print()">🖨️ Drucken</button>
|
||||
""")
|
||||
|
||||
# Titel
|
||||
html_parts.append(f"<h1>{title}</h1>")
|
||||
|
||||
# Meta-Informationen
|
||||
meta_parts = []
|
||||
if subject:
|
||||
meta_parts.append(f"<span><strong>Fach:</strong> {subject}</span>")
|
||||
if grade_level:
|
||||
meta_parts.append(f"<span><strong>Klasse:</strong> {grade_level}</span>")
|
||||
if meta_parts:
|
||||
html_parts.append(f"<div class='meta'>{''.join(meta_parts)}</div>")
|
||||
|
||||
# Arbeitsanweisung
|
||||
if instructions:
|
||||
html_parts.append("<div class='instructions'>")
|
||||
html_parts.append("<div class='instructions-label'>Arbeitsanweisung:</div>")
|
||||
html_parts.append(f"<div>{instructions}</div>")
|
||||
html_parts.append("</div>")
|
||||
|
||||
# Haupttext / gedruckte Blöcke
|
||||
if printed_blocks:
|
||||
html_parts.append("<section class='text-section'>")
|
||||
for block in printed_blocks:
|
||||
role = (block.get("role") or "body").lower()
|
||||
text = (block.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if role == "title":
|
||||
html_parts.append(f"<div class='text-block'><div class='text-block-title'>{text}</div></div>")
|
||||
else:
|
||||
html_parts.append(f"<div class='text-block'>{text}</div>")
|
||||
html_parts.append("</section>")
|
||||
elif canonical_text:
|
||||
html_parts.append("<section class='text-section'>")
|
||||
paragraphs = [
|
||||
p.strip()
|
||||
for p in canonical_text.replace("\r\n", "\n").split("\n\n")
|
||||
if p.strip()
|
||||
]
|
||||
for p in paragraphs:
|
||||
html_parts.append(f"<div class='text-block'>{p}</div>")
|
||||
html_parts.append("</section>")
|
||||
|
||||
# Aufgaben
|
||||
if tasks:
|
||||
html_parts.append("<section class='task-section'>")
|
||||
html_parts.append("<h2>Aufgaben</h2>")
|
||||
|
||||
for idx, task in enumerate(tasks, start=1):
|
||||
t_type = task.get("type") or "Aufgabe"
|
||||
desc = task.get("description") or ""
|
||||
text_with_gaps = task.get("text_with_gaps")
|
||||
|
||||
html_parts.append("<div class='task'>")
|
||||
|
||||
# Task-Header
|
||||
type_label = {
|
||||
"fill_in_blank": "Lückentext",
|
||||
"multiple_choice": "Multiple Choice",
|
||||
"free_text": "Freitext",
|
||||
"matching": "Zuordnung",
|
||||
"labeling": "Beschriftung",
|
||||
"calculation": "Rechnung",
|
||||
"other": "Aufgabe"
|
||||
}.get(t_type, t_type)
|
||||
|
||||
html_parts.append(f"<div class='task-header'>Aufgabe {idx}: {type_label}</div>")
|
||||
|
||||
if desc:
|
||||
html_parts.append(f"<div class='task-content'>{desc}</div>")
|
||||
|
||||
if text_with_gaps:
|
||||
rendered = text_with_gaps.replace("___", "<span class='gap-line'> </span>")
|
||||
html_parts.append(f"<div class='task-content' style='margin-top:12px;'>{rendered}</div>")
|
||||
|
||||
# Antwortlinien für Freitext-Aufgaben
|
||||
if t_type in ["free_text", "other"] or (not text_with_gaps and not desc):
|
||||
html_parts.append("<div class='answer-lines'>")
|
||||
for _ in range(3):
|
||||
html_parts.append("<div class='answer-line'></div>")
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</section>")
|
||||
|
||||
# Fußzeile
|
||||
html_parts.append("<div class='footer'>")
|
||||
html_parts.append("Dieses Arbeitsblatt wurde automatisch aus einem Scan rekonstruiert.")
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</body></html>")
|
||||
|
||||
return "\n".join(html_parts)
|
||||
from .print_qa import generate_print_version_qa
|
||||
from .print_cloze import generate_print_version_cloze
|
||||
from .print_mc import generate_print_version_mc
|
||||
from .print_worksheet import generate_print_version_worksheet
|
||||
|
||||
__all__ = [
|
||||
"generate_print_version_qa",
|
||||
"generate_print_version_cloze",
|
||||
"generate_print_version_mc",
|
||||
"generate_print_version_worksheet",
|
||||
]
|
||||
|
||||
240
backend-lehrer/ai_processing/print_mc.py
Normal file
240
backend-lehrer/ai_processing/print_mc.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
AI Processing - Print Version Generator: Multiple Choice.
|
||||
|
||||
Generates printable HTML for multiple-choice worksheets.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_print_version_mc(mc_path: Path, include_answers: bool = False) -> str:
|
||||
"""
|
||||
Generiert eine druckbare HTML-Version der Multiple-Choice-Fragen.
|
||||
|
||||
Args:
|
||||
mc_path: Pfad zur *_mc.json Datei
|
||||
include_answers: True fuer Loesungsblatt mit markierten richtigen Antworten
|
||||
|
||||
Returns:
|
||||
HTML-String (zum direkten Ausliefern)
|
||||
"""
|
||||
if not mc_path.exists():
|
||||
raise FileNotFoundError(f"MC-Datei nicht gefunden: {mc_path}")
|
||||
|
||||
mc_data = json.loads(mc_path.read_text(encoding="utf-8"))
|
||||
questions = mc_data.get("questions", [])
|
||||
metadata = mc_data.get("metadata", {})
|
||||
|
||||
title = metadata.get("source_title", "Arbeitsblatt")
|
||||
subject = metadata.get("subject", "")
|
||||
grade = metadata.get("grade_level", "")
|
||||
|
||||
html_parts = []
|
||||
html_parts.append("""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>""" + title + """ - Multiple Choice</title>
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
.page-break { page-break-before: always; }
|
||||
body { font-size: 14pt; }
|
||||
}
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
color: #000;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 2px solid #000;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.meta {
|
||||
color: #333;
|
||||
margin-bottom: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.instructions {
|
||||
background: #f5f5f5;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.question-block {
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.question-number {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
color: #000;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.question-text {
|
||||
font-size: 16px;
|
||||
margin: 8px 0 16px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.options {
|
||||
margin-left: 20px;
|
||||
}
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
.option-correct {
|
||||
background: #e8f5e9;
|
||||
border-color: #4caf50;
|
||||
border-width: 2px;
|
||||
}
|
||||
.option-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #333;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.option-checkbox.checked::after {
|
||||
content: "\u2713";
|
||||
font-weight: bold;
|
||||
color: #4caf50;
|
||||
}
|
||||
.option-label {
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
min-width: 24px;
|
||||
}
|
||||
.option-text {
|
||||
flex: 1;
|
||||
}
|
||||
.explanation {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #e3f2fd;
|
||||
border-left: 3px solid #2196f3;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
.answer-key {
|
||||
margin-top: 40px;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.answer-key-title {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid #999;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.answer-key-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.answer-key-item {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.answer-key-q {
|
||||
font-weight: bold;
|
||||
}
|
||||
.answer-key-a {
|
||||
color: #4caf50;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
""")
|
||||
|
||||
# Header
|
||||
version_text = "Loesungsblatt" if include_answers else "Multiple Choice Test"
|
||||
html_parts.append(f"<h1>{title}</h1>")
|
||||
html_parts.append(f"<div class='meta'><strong>{version_text}</strong>")
|
||||
if subject:
|
||||
html_parts.append(f" | Fach: {subject}")
|
||||
if grade:
|
||||
html_parts.append(f" | Klasse: {grade}")
|
||||
html_parts.append(f" | Anzahl Fragen: {len(questions)}</div>")
|
||||
|
||||
if not include_answers:
|
||||
html_parts.append("<div class='instructions'>")
|
||||
html_parts.append("<strong>Anleitung:</strong> Kreuze bei jeder Frage die richtige Antwort an. ")
|
||||
html_parts.append("Es ist immer nur eine Antwort richtig.")
|
||||
html_parts.append("</div>")
|
||||
|
||||
# Fragen
|
||||
for idx, q in enumerate(questions, 1):
|
||||
html_parts.append("<div class='question-block'>")
|
||||
html_parts.append(f"<div class='question-number'>Frage {idx}</div>")
|
||||
html_parts.append(f"<div class='question-text'>{q.get('question', '')}</div>")
|
||||
|
||||
html_parts.append("<div class='options'>")
|
||||
correct_answer = q.get("correct_answer", "")
|
||||
|
||||
for opt in q.get("options", []):
|
||||
opt_id = opt.get("id", "")
|
||||
is_correct = opt_id == correct_answer
|
||||
|
||||
opt_class = "option"
|
||||
checkbox_class = "option-checkbox"
|
||||
if include_answers and is_correct:
|
||||
opt_class += " option-correct"
|
||||
checkbox_class += " checked"
|
||||
|
||||
html_parts.append(f"<div class='{opt_class}'>")
|
||||
html_parts.append(f"<div class='{checkbox_class}'></div>")
|
||||
html_parts.append(f"<span class='option-label'>{opt_id})</span>")
|
||||
html_parts.append(f"<span class='option-text'>{opt.get('text', '')}</span>")
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</div>")
|
||||
|
||||
# Erklaerung nur bei Loesungsblatt
|
||||
if include_answers and q.get("explanation"):
|
||||
html_parts.append(f"<div class='explanation'><strong>Erklaerung:</strong> {q.get('explanation')}</div>")
|
||||
|
||||
html_parts.append("</div>")
|
||||
|
||||
# Loesungsschluessel (kompakt) - nur bei Loesungsblatt
|
||||
if include_answers:
|
||||
html_parts.append("<div class='answer-key'>")
|
||||
html_parts.append("<div class='answer-key-title'>Loesungsschluessel</div>")
|
||||
html_parts.append("<div class='answer-key-grid'>")
|
||||
for idx, q in enumerate(questions, 1):
|
||||
html_parts.append("<div class='answer-key-item'>")
|
||||
html_parts.append(f"<span class='answer-key-q'>{idx}.</span> ")
|
||||
html_parts.append(f"<span class='answer-key-a'>{q.get('correct_answer', '')}</span>")
|
||||
html_parts.append("</div>")
|
||||
html_parts.append("</div>")
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</body></html>")
|
||||
|
||||
return "\n".join(html_parts)
|
||||
149
backend-lehrer/ai_processing/print_qa.py
Normal file
149
backend-lehrer/ai_processing/print_qa.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
AI Processing - Print Version Generator: Q&A.
|
||||
|
||||
Generates printable HTML for question-answer worksheets.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import logging
|
||||
|
||||
from .core import BEREINIGT_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_print_version_qa(qa_path: Path, include_answers: bool = False) -> Path:
|
||||
"""
|
||||
Generiert eine druckbare HTML-Version der Frage-Antwort-Paare.
|
||||
|
||||
Args:
|
||||
qa_path: Pfad zur *_qa.json Datei
|
||||
include_answers: True fuer Loesungsblatt (fuer Eltern)
|
||||
|
||||
Returns:
|
||||
Pfad zur generierten HTML-Datei
|
||||
"""
|
||||
if not qa_path.exists():
|
||||
raise FileNotFoundError(f"Q&A-Datei nicht gefunden: {qa_path}")
|
||||
|
||||
qa_data = json.loads(qa_path.read_text(encoding="utf-8"))
|
||||
items = qa_data.get("qa_items", [])
|
||||
metadata = qa_data.get("metadata", {})
|
||||
|
||||
title = metadata.get("source_title", "Arbeitsblatt")
|
||||
subject = metadata.get("subject", "")
|
||||
grade = metadata.get("grade_level", "")
|
||||
|
||||
html_parts = []
|
||||
html_parts.append("""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>""" + title + """ - Fragen</title>
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
.page-break { page-break-before: always; }
|
||||
}
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { font-size: 24px; margin-bottom: 8px; }
|
||||
.meta { color: #666; margin-bottom: 24px; }
|
||||
.question-block {
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px dashed #ccc;
|
||||
}
|
||||
.question-number {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.question-text {
|
||||
font-size: 16px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.answer-space {
|
||||
border: 1px solid #ddd;
|
||||
min-height: 60px;
|
||||
margin-top: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.answer-lines {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.answer-line {
|
||||
border-bottom: 1px solid #999;
|
||||
height: 28px;
|
||||
}
|
||||
.answer {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #e8f5e9;
|
||||
border-left: 3px solid #4caf50;
|
||||
}
|
||||
.key-terms {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.key-terms span {
|
||||
background: #fff3e0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
""")
|
||||
|
||||
# Header
|
||||
version_text = "Loesungsblatt" if include_answers else "Fragenblatt"
|
||||
html_parts.append(f"<h1>{title} - {version_text}</h1>")
|
||||
meta_parts = []
|
||||
if subject:
|
||||
meta_parts.append(f"Fach: {subject}")
|
||||
if grade:
|
||||
meta_parts.append(f"Klasse: {grade}")
|
||||
meta_parts.append(f"Anzahl Fragen: {len(items)}")
|
||||
html_parts.append(f"<div class='meta'>{' | '.join(meta_parts)}</div>")
|
||||
|
||||
# Fragen
|
||||
for idx, item in enumerate(items, 1):
|
||||
html_parts.append("<div class='question-block'>")
|
||||
html_parts.append(f"<div class='question-number'>Frage {idx}</div>")
|
||||
html_parts.append(f"<div class='question-text'>{item.get('question', '')}</div>")
|
||||
|
||||
if include_answers:
|
||||
# Loesungsblatt: Antwort anzeigen
|
||||
html_parts.append(f"<div class='answer'><strong>Antwort:</strong> {item.get('answer', '')}</div>")
|
||||
# Schluesselbegriffe
|
||||
key_terms = item.get("key_terms", [])
|
||||
if key_terms:
|
||||
terms_html = " ".join([f"<span>{term}</span>" for term in key_terms])
|
||||
html_parts.append(f"<div class='key-terms'>Wichtige Begriffe: {terms_html}</div>")
|
||||
else:
|
||||
# Fragenblatt: Antwortlinien
|
||||
html_parts.append("<div class='answer-lines'>")
|
||||
for _ in range(3):
|
||||
html_parts.append("<div class='answer-line'></div>")
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</body></html>")
|
||||
|
||||
# Speichern
|
||||
suffix = "_qa_solutions.html" if include_answers else "_qa_print.html"
|
||||
out_name = qa_path.stem.replace("_qa", "") + suffix
|
||||
out_path = BEREINIGT_DIR / out_name
|
||||
out_path.write_text("\n".join(html_parts), encoding="utf-8")
|
||||
|
||||
logger.info(f"Print-Version gespeichert: {out_path.name}")
|
||||
return out_path
|
||||
294
backend-lehrer/ai_processing/print_worksheet.py
Normal file
294
backend-lehrer/ai_processing/print_worksheet.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
AI Processing - Print Version Generator: Worksheet.
|
||||
|
||||
Generates print-optimized HTML for general worksheets from analysis data.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_print_version_worksheet(analysis_path: Path) -> str:
|
||||
"""
|
||||
Generiert eine druckoptimierte HTML-Version des Arbeitsblatts.
|
||||
|
||||
Eigenschaften:
|
||||
- Grosse, gut lesbare Schrift (16pt)
|
||||
- Schwarz-weiss / Graustufen-tauglich
|
||||
- Klare Struktur fuer Druck
|
||||
- Keine interaktiven Elemente
|
||||
|
||||
Args:
|
||||
analysis_path: Pfad zur *_analyse.json Datei
|
||||
|
||||
Returns:
|
||||
HTML-String zum direkten Ausliefern
|
||||
"""
|
||||
if not analysis_path.exists():
|
||||
raise FileNotFoundError(f"Analysedatei nicht gefunden: {analysis_path}")
|
||||
|
||||
try:
|
||||
data = json.loads(analysis_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"Analyse-Datei enthaelt kein gueltiges JSON: {analysis_path}\n{e}") from e
|
||||
|
||||
title = data.get("title") or "Arbeitsblatt"
|
||||
subject = data.get("subject") or ""
|
||||
grade_level = data.get("grade_level") or ""
|
||||
instructions = data.get("instructions") or ""
|
||||
tasks = data.get("tasks", []) or []
|
||||
canonical_text = data.get("canonical_text") or ""
|
||||
printed_blocks = data.get("printed_blocks") or []
|
||||
|
||||
html_parts = []
|
||||
html_parts.append(_build_html_head(title))
|
||||
|
||||
# Titel
|
||||
html_parts.append(f"<h1>{title}</h1>")
|
||||
|
||||
# Meta-Informationen
|
||||
meta_parts = []
|
||||
if subject:
|
||||
meta_parts.append(f"<span><strong>Fach:</strong> {subject}</span>")
|
||||
if grade_level:
|
||||
meta_parts.append(f"<span><strong>Klasse:</strong> {grade_level}</span>")
|
||||
if meta_parts:
|
||||
html_parts.append(f"<div class='meta'>{''.join(meta_parts)}</div>")
|
||||
|
||||
# Arbeitsanweisung
|
||||
if instructions:
|
||||
html_parts.append("<div class='instructions'>")
|
||||
html_parts.append("<div class='instructions-label'>Arbeitsanweisung:</div>")
|
||||
html_parts.append(f"<div>{instructions}</div>")
|
||||
html_parts.append("</div>")
|
||||
|
||||
# Haupttext / gedruckte Bloecke
|
||||
_build_text_section(html_parts, printed_blocks, canonical_text)
|
||||
|
||||
# Aufgaben
|
||||
_build_tasks_section(html_parts, tasks)
|
||||
|
||||
# Fusszeile
|
||||
html_parts.append("<div class='footer'>")
|
||||
html_parts.append("Dieses Arbeitsblatt wurde automatisch aus einem Scan rekonstruiert.")
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</body></html>")
|
||||
|
||||
return "\n".join(html_parts)
|
||||
|
||||
|
||||
def _build_html_head(title: str) -> str:
|
||||
"""Build the HTML head with print-optimized styles."""
|
||||
return """<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>""" + title + """</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm;
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
font-size: 14pt !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.no-print { display: none !important; }
|
||||
.page-break { page-break-before: always; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: Arial, "Helvetica Neue", sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 30px;
|
||||
line-height: 1.7;
|
||||
font-size: 16px;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 8px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 3px solid #000;
|
||||
}
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
margin: 28px 0 12px 0;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #666;
|
||||
}
|
||||
.meta {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.meta span {
|
||||
margin-right: 20px;
|
||||
}
|
||||
.instructions {
|
||||
margin: 20px 0;
|
||||
padding: 16px;
|
||||
border: 2px solid #333;
|
||||
background: #f5f5f5;
|
||||
font-size: 15px;
|
||||
}
|
||||
.instructions-label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.text-section {
|
||||
margin: 24px 0;
|
||||
}
|
||||
.text-block {
|
||||
margin-bottom: 16px;
|
||||
text-align: justify;
|
||||
}
|
||||
.text-block-title {
|
||||
font-weight: bold;
|
||||
font-size: 17px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.task-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
.task {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
border: 1px solid #999;
|
||||
background: #fafafa;
|
||||
}
|
||||
.task-header {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed #666;
|
||||
}
|
||||
.task-content {
|
||||
font-size: 15px;
|
||||
}
|
||||
.gap-line {
|
||||
display: inline-block;
|
||||
border-bottom: 2px solid #000;
|
||||
min-width: 100px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
.answer-lines {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.answer-line {
|
||||
border-bottom: 1px solid #333;
|
||||
height: 36px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #ccc;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
/* Print Button - versteckt beim Drucken */
|
||||
.print-button {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 24px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.print-button:hover {
|
||||
background: #555;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="print-button no-print" onclick="window.print()">Drucken</button>
|
||||
"""
|
||||
|
||||
|
||||
def _build_text_section(html_parts: list, printed_blocks: list, canonical_text: str):
|
||||
"""Build the text section from printed blocks or canonical text."""
|
||||
if printed_blocks:
|
||||
html_parts.append("<section class='text-section'>")
|
||||
for block in printed_blocks:
|
||||
role = (block.get("role") or "body").lower()
|
||||
text = (block.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if role == "title":
|
||||
html_parts.append(f"<div class='text-block'><div class='text-block-title'>{text}</div></div>")
|
||||
else:
|
||||
html_parts.append(f"<div class='text-block'>{text}</div>")
|
||||
html_parts.append("</section>")
|
||||
elif canonical_text:
|
||||
html_parts.append("<section class='text-section'>")
|
||||
paragraphs = [
|
||||
p.strip()
|
||||
for p in canonical_text.replace("\r\n", "\n").split("\n\n")
|
||||
if p.strip()
|
||||
]
|
||||
for p in paragraphs:
|
||||
html_parts.append(f"<div class='text-block'>{p}</div>")
|
||||
html_parts.append("</section>")
|
||||
|
||||
|
||||
def _build_tasks_section(html_parts: list, tasks: list):
|
||||
"""Build the tasks section."""
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
html_parts.append("<section class='task-section'>")
|
||||
html_parts.append("<h2>Aufgaben</h2>")
|
||||
|
||||
type_labels = {
|
||||
"fill_in_blank": "Lueckentext",
|
||||
"multiple_choice": "Multiple Choice",
|
||||
"free_text": "Freitext",
|
||||
"matching": "Zuordnung",
|
||||
"labeling": "Beschriftung",
|
||||
"calculation": "Rechnung",
|
||||
"other": "Aufgabe"
|
||||
}
|
||||
|
||||
for idx, task in enumerate(tasks, start=1):
|
||||
t_type = task.get("type") or "Aufgabe"
|
||||
desc = task.get("description") or ""
|
||||
text_with_gaps = task.get("text_with_gaps")
|
||||
|
||||
html_parts.append("<div class='task'>")
|
||||
|
||||
type_label = type_labels.get(t_type, t_type)
|
||||
html_parts.append(f"<div class='task-header'>Aufgabe {idx}: {type_label}</div>")
|
||||
|
||||
if desc:
|
||||
html_parts.append(f"<div class='task-content'>{desc}</div>")
|
||||
|
||||
if text_with_gaps:
|
||||
rendered = text_with_gaps.replace("___", "<span class='gap-line'> </span>")
|
||||
html_parts.append(f"<div class='task-content' style='margin-top:12px;'>{rendered}</div>")
|
||||
|
||||
# Antwortlinien fuer Freitext-Aufgaben
|
||||
if t_type in ["free_text", "other"] or (not text_with_gaps and not desc):
|
||||
html_parts.append("<div class='answer-lines'>")
|
||||
for _ in range(3):
|
||||
html_parts.append("<div class='answer-line'></div>")
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</div>")
|
||||
|
||||
html_parts.append("</section>")
|
||||
@@ -1,726 +1,25 @@
|
||||
"""
|
||||
Classroom API - Context Routes
|
||||
Classroom API - Context Routes — Barrel Re-export.
|
||||
|
||||
Split into submodules:
|
||||
- context_core.py — Teacher context, onboarding endpoints
|
||||
- context_events.py — Events & routines CRUD
|
||||
- context_static.py — Static data, suggestions, sidebar, school year path
|
||||
|
||||
School year context, events, routines, and suggestions endpoints (Phase 8).
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from fastapi import APIRouter
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
|
||||
from classroom_engine import (
|
||||
FEDERAL_STATES,
|
||||
SCHOOL_TYPES,
|
||||
MacroPhaseEnum,
|
||||
)
|
||||
|
||||
from ..models import (
|
||||
TeacherContextResponse,
|
||||
SchoolInfo,
|
||||
SchoolYearInfo,
|
||||
MacroPhaseInfo,
|
||||
CoreCounts,
|
||||
ContextFlags,
|
||||
UpdateContextRequest,
|
||||
CreateEventRequest,
|
||||
EventResponse,
|
||||
CreateRoutineRequest,
|
||||
RoutineResponse,
|
||||
)
|
||||
from ..services.persistence import (
|
||||
init_db_if_needed,
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from .context_core import router as _core_router
|
||||
from .context_events import router as _events_router
|
||||
from .context_static import router as _static_router
|
||||
|
||||
# Combine all sub-routers into a single router for backwards compatibility.
|
||||
# The consumer imports `from .routes.context import router as context_router`.
|
||||
router = APIRouter(tags=["Context"])
|
||||
router.include_router(_core_router)
|
||||
router.include_router(_events_router)
|
||||
router.include_router(_static_router)
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Database session dependency."""
|
||||
if DB_ENABLED and SessionLocal:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
yield None
|
||||
|
||||
|
||||
def _get_macro_phase_label(phase) -> str:
|
||||
"""Gibt den Anzeigenamen einer Makro-Phase zurueck."""
|
||||
labels = {
|
||||
"onboarding": "Einrichtung",
|
||||
"schuljahresstart": "Schuljahresstart",
|
||||
"unterrichtsaufbau": "Unterrichtsaufbau",
|
||||
"leistungsphase_1": "Leistungsphase 1",
|
||||
"halbjahresabschluss": "Halbjahresabschluss",
|
||||
"leistungsphase_2": "Leistungsphase 2",
|
||||
"jahresabschluss": "Jahresabschluss",
|
||||
}
|
||||
phase_value = phase.value if hasattr(phase, 'value') else str(phase)
|
||||
return labels.get(phase_value, phase_value)
|
||||
|
||||
|
||||
# === Context Endpoints ===
|
||||
|
||||
@router.get("/v1/context", response_model=TeacherContextResponse)
|
||||
async def get_teacher_context(
|
||||
teacher_id: str = Query(..., description="Teacher ID"),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Liefert den aktuellen Makro-Kontext eines Lehrers.
|
||||
|
||||
Der Kontext beinhaltet:
|
||||
- Schul-Informationen (Bundesland, Schulart)
|
||||
- Schuljahr-Daten (aktuelles Jahr, Woche)
|
||||
- Makro-Phase (ONBOARDING bis JAHRESABSCHLUSS)
|
||||
- Zaehler (Klassen, geplante Klausuren, etc.)
|
||||
- Status-Flags (Onboarding abgeschlossen, etc.)
|
||||
"""
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository, SchoolyearEventRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
context = repo.get_or_create(teacher_id)
|
||||
|
||||
# Zaehler berechnen
|
||||
event_repo = SchoolyearEventRepository(db)
|
||||
upcoming_exams = event_repo.get_upcoming(teacher_id, days=30)
|
||||
exams_count = len([e for e in upcoming_exams if e.event_type.value == "exam"])
|
||||
|
||||
return TeacherContextResponse(
|
||||
schema_version="1.0",
|
||||
teacher_id=teacher_id,
|
||||
school=SchoolInfo(
|
||||
federal_state=context.federal_state or "BY",
|
||||
federal_state_name=FEDERAL_STATES.get(context.federal_state, ""),
|
||||
school_type=context.school_type or "gymnasium",
|
||||
school_type_name=SCHOOL_TYPES.get(context.school_type, ""),
|
||||
),
|
||||
school_year=SchoolYearInfo(
|
||||
id=context.schoolyear or "2024-2025",
|
||||
start=context.schoolyear_start.isoformat() if context.schoolyear_start else None,
|
||||
current_week=context.current_week or 1,
|
||||
),
|
||||
macro_phase=MacroPhaseInfo(
|
||||
id=context.macro_phase.value,
|
||||
label=_get_macro_phase_label(context.macro_phase),
|
||||
confidence=1.0,
|
||||
),
|
||||
core_counts=CoreCounts(
|
||||
classes=1 if context.has_classes else 0,
|
||||
exams_scheduled=exams_count,
|
||||
corrections_pending=0,
|
||||
),
|
||||
flags=ContextFlags(
|
||||
onboarding_completed=context.onboarding_completed,
|
||||
has_classes=context.has_classes,
|
||||
has_schedule=context.has_schedule,
|
||||
is_exam_period=context.is_exam_period,
|
||||
is_before_holidays=context.is_before_holidays,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get teacher context: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Laden des Kontexts: {e}")
|
||||
|
||||
# Fallback ohne DB
|
||||
return TeacherContextResponse(
|
||||
schema_version="1.0",
|
||||
teacher_id=teacher_id,
|
||||
school=SchoolInfo(
|
||||
federal_state="BY",
|
||||
federal_state_name="Bayern",
|
||||
school_type="gymnasium",
|
||||
school_type_name="Gymnasium",
|
||||
),
|
||||
school_year=SchoolYearInfo(
|
||||
id="2024-2025",
|
||||
start=None,
|
||||
current_week=1,
|
||||
),
|
||||
macro_phase=MacroPhaseInfo(
|
||||
id="onboarding",
|
||||
label="Einrichtung",
|
||||
confidence=1.0,
|
||||
),
|
||||
core_counts=CoreCounts(),
|
||||
flags=ContextFlags(),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/v1/context", response_model=TeacherContextResponse)
|
||||
async def update_teacher_context(
|
||||
teacher_id: str,
|
||||
request: UpdateContextRequest,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Aktualisiert den Kontext eines Lehrers.
|
||||
"""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
|
||||
# Validierung
|
||||
if request.federal_state and request.federal_state not in FEDERAL_STATES:
|
||||
raise HTTPException(status_code=400, detail=f"Ungueltiges Bundesland: {request.federal_state}")
|
||||
if request.school_type and request.school_type not in SCHOOL_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Ungueltige Schulart: {request.school_type}")
|
||||
|
||||
# Parse datetime if provided
|
||||
schoolyear_start = None
|
||||
if request.schoolyear_start:
|
||||
schoolyear_start = datetime.fromisoformat(request.schoolyear_start.replace('Z', '+00:00'))
|
||||
|
||||
repo.update_context(
|
||||
teacher_id=teacher_id,
|
||||
federal_state=request.federal_state,
|
||||
school_type=request.school_type,
|
||||
schoolyear=request.schoolyear,
|
||||
schoolyear_start=schoolyear_start,
|
||||
macro_phase=request.macro_phase,
|
||||
current_week=request.current_week,
|
||||
)
|
||||
|
||||
return await get_teacher_context(teacher_id, db)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update teacher context: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Aktualisieren: {e}")
|
||||
|
||||
|
||||
@router.post("/v1/context/complete-onboarding")
|
||||
async def complete_onboarding(
|
||||
teacher_id: str = Query(...),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Markiert das Onboarding als abgeschlossen."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"success": True, "macro_phase": "schuljahresstart", "note": "DB not available"}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
context = repo.complete_onboarding(teacher_id)
|
||||
return {
|
||||
"success": True,
|
||||
"macro_phase": context.macro_phase.value,
|
||||
"teacher_id": teacher_id,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to complete onboarding: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.post("/v1/context/reset-onboarding")
|
||||
async def reset_onboarding(
|
||||
teacher_id: str = Query(...),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Setzt das Onboarding zurueck (fuer Tests)."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"success": True, "macro_phase": "onboarding", "note": "DB not available"}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
context = repo.get_or_create(teacher_id)
|
||||
context.onboarding_completed = False
|
||||
context.macro_phase = MacroPhaseEnum.ONBOARDING
|
||||
db.commit()
|
||||
db.refresh(context)
|
||||
return {
|
||||
"success": True,
|
||||
"macro_phase": "onboarding",
|
||||
"teacher_id": teacher_id,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reset onboarding: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
# === Events Endpoints ===
|
||||
|
||||
@router.get("/v1/events")
|
||||
async def get_events(
|
||||
teacher_id: str = Query(...),
|
||||
status: Optional[str] = None,
|
||||
event_type: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Holt Events eines Lehrers."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"events": [], "count": 0}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import SchoolyearEventRepository
|
||||
repo = SchoolyearEventRepository(db)
|
||||
events = repo.get_by_teacher(teacher_id, status=status, event_type=event_type, limit=limit)
|
||||
return {
|
||||
"events": [repo.to_dict(e) for e in events],
|
||||
"count": len(events),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get events: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.get("/v1/events/upcoming")
|
||||
async def get_upcoming_events(
|
||||
teacher_id: str = Query(...),
|
||||
days: int = 30,
|
||||
limit: int = 10,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Holt anstehende Events der naechsten X Tage."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"events": [], "count": 0}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import SchoolyearEventRepository
|
||||
repo = SchoolyearEventRepository(db)
|
||||
events = repo.get_upcoming(teacher_id, days=days, limit=limit)
|
||||
return {
|
||||
"events": [repo.to_dict(e) for e in events],
|
||||
"count": len(events),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get upcoming events: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.post("/v1/events", response_model=EventResponse)
|
||||
async def create_event(
|
||||
teacher_id: str,
|
||||
request: CreateEventRequest,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Erstellt ein neues Schuljahr-Event."""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import SchoolyearEventRepository
|
||||
repo = SchoolyearEventRepository(db)
|
||||
start_date = datetime.fromisoformat(request.start_date.replace('Z', '+00:00'))
|
||||
end_date = None
|
||||
if request.end_date:
|
||||
end_date = datetime.fromisoformat(request.end_date.replace('Z', '+00:00'))
|
||||
|
||||
event = repo.create(
|
||||
teacher_id=teacher_id,
|
||||
title=request.title,
|
||||
event_type=request.event_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
class_id=request.class_id,
|
||||
subject=request.subject,
|
||||
description=request.description,
|
||||
needs_preparation=request.needs_preparation,
|
||||
reminder_days_before=request.reminder_days_before,
|
||||
)
|
||||
|
||||
return EventResponse(
|
||||
id=event.id,
|
||||
teacher_id=event.teacher_id,
|
||||
event_type=event.event_type.value,
|
||||
title=event.title,
|
||||
description=event.description,
|
||||
start_date=event.start_date.isoformat(),
|
||||
end_date=event.end_date.isoformat() if event.end_date else None,
|
||||
class_id=event.class_id,
|
||||
subject=event.subject,
|
||||
status=event.status.value,
|
||||
needs_preparation=event.needs_preparation,
|
||||
preparation_done=event.preparation_done,
|
||||
reminder_days_before=event.reminder_days_before,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create event: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.delete("/v1/events/{event_id}")
|
||||
async def delete_event(event_id: str, db=Depends(get_db)):
|
||||
"""Loescht ein Event."""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import SchoolyearEventRepository
|
||||
repo = SchoolyearEventRepository(db)
|
||||
if repo.delete(event_id):
|
||||
return {"success": True, "deleted_id": event_id}
|
||||
raise HTTPException(status_code=404, detail="Event nicht gefunden")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete event: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
# === Routines Endpoints ===
|
||||
|
||||
@router.get("/v1/routines")
|
||||
async def get_routines(
|
||||
teacher_id: str = Query(...),
|
||||
is_active: bool = True,
|
||||
routine_type: Optional[str] = None,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Holt Routinen eines Lehrers."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"routines": [], "count": 0}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import RecurringRoutineRepository
|
||||
repo = RecurringRoutineRepository(db)
|
||||
routines = repo.get_by_teacher(teacher_id, is_active=is_active, routine_type=routine_type)
|
||||
return {
|
||||
"routines": [repo.to_dict(r) for r in routines],
|
||||
"count": len(routines),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get routines: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.get("/v1/routines/today")
|
||||
async def get_today_routines(teacher_id: str = Query(...), db=Depends(get_db)):
|
||||
"""Holt Routinen die heute stattfinden."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"routines": [], "count": 0}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import RecurringRoutineRepository
|
||||
repo = RecurringRoutineRepository(db)
|
||||
routines = repo.get_today(teacher_id)
|
||||
return {
|
||||
"routines": [repo.to_dict(r) for r in routines],
|
||||
"count": len(routines),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get today's routines: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.post("/v1/routines", response_model=RoutineResponse)
|
||||
async def create_routine(
|
||||
teacher_id: str,
|
||||
request: CreateRoutineRequest,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Erstellt eine neue wiederkehrende Routine."""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import RecurringRoutineRepository
|
||||
repo = RecurringRoutineRepository(db)
|
||||
routine = repo.create(
|
||||
teacher_id=teacher_id,
|
||||
title=request.title,
|
||||
routine_type=request.routine_type,
|
||||
recurrence_pattern=request.recurrence_pattern,
|
||||
day_of_week=request.day_of_week,
|
||||
day_of_month=request.day_of_month,
|
||||
time_of_day=request.time_of_day,
|
||||
duration_minutes=request.duration_minutes,
|
||||
description=request.description,
|
||||
)
|
||||
|
||||
return RoutineResponse(
|
||||
id=routine.id,
|
||||
teacher_id=routine.teacher_id,
|
||||
routine_type=routine.routine_type.value,
|
||||
title=routine.title,
|
||||
description=routine.description,
|
||||
recurrence_pattern=routine.recurrence_pattern.value,
|
||||
day_of_week=routine.day_of_week,
|
||||
day_of_month=routine.day_of_month,
|
||||
time_of_day=routine.time_of_day.isoformat() if routine.time_of_day else None,
|
||||
duration_minutes=routine.duration_minutes,
|
||||
is_active=routine.is_active,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create routine: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.delete("/v1/routines/{routine_id}")
|
||||
async def delete_routine(routine_id: str, db=Depends(get_db)):
|
||||
"""Loescht eine Routine."""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import RecurringRoutineRepository
|
||||
repo = RecurringRoutineRepository(db)
|
||||
if repo.delete(routine_id):
|
||||
return {"success": True, "deleted_id": routine_id}
|
||||
raise HTTPException(status_code=404, detail="Routine nicht gefunden")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete routine: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
# === Static Data Endpoints ===
|
||||
|
||||
@router.get("/v1/federal-states")
|
||||
async def get_federal_states():
|
||||
"""Gibt alle Bundeslaender zurueck."""
|
||||
return {
|
||||
"federal_states": [{"id": k, "name": v} for k, v in FEDERAL_STATES.items()]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/school-types")
|
||||
async def get_school_types():
|
||||
"""Gibt alle Schularten zurueck."""
|
||||
return {
|
||||
"school_types": [{"id": k, "name": v} for k, v in SCHOOL_TYPES.items()]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/macro-phases")
|
||||
async def get_macro_phases():
|
||||
"""Gibt alle Makro-Phasen mit Beschreibungen zurueck."""
|
||||
phases = [
|
||||
{"id": "onboarding", "label": "Einrichtung", "description": "Ersteinrichtung (Klassen, Stundenplan)", "order": 1},
|
||||
{"id": "schuljahresstart", "label": "Schuljahresstart", "description": "Erste 2-3 Wochen des Schuljahres", "order": 2},
|
||||
{"id": "unterrichtsaufbau", "label": "Unterrichtsaufbau", "description": "Routinen etablieren, erste Bewertungen", "order": 3},
|
||||
{"id": "leistungsphase_1", "label": "Leistungsphase 1", "description": "Erste Klassenarbeiten und Klausuren", "order": 4},
|
||||
{"id": "halbjahresabschluss", "label": "Halbjahresabschluss", "description": "Notenschluss, Zeugnisse, Konferenzen", "order": 5},
|
||||
{"id": "leistungsphase_2", "label": "Leistungsphase 2", "description": "Zweites Halbjahr, Pruefungsvorbereitung", "order": 6},
|
||||
{"id": "jahresabschluss", "label": "Jahresabschluss", "description": "Finale Noten, Versetzung, Schuljahresende", "order": 7},
|
||||
]
|
||||
return {"macro_phases": phases}
|
||||
|
||||
|
||||
@router.get("/v1/event-types")
|
||||
async def get_event_types():
|
||||
"""Gibt alle Event-Typen zurueck."""
|
||||
types = [
|
||||
{"id": "exam", "label": "Klassenarbeit/Klausur"},
|
||||
{"id": "parent_evening", "label": "Elternabend"},
|
||||
{"id": "trip", "label": "Klassenfahrt/Ausflug"},
|
||||
{"id": "project", "label": "Projektwoche"},
|
||||
{"id": "internship", "label": "Praktikum"},
|
||||
{"id": "presentation", "label": "Referate/Praesentationen"},
|
||||
{"id": "sports_day", "label": "Sporttag"},
|
||||
{"id": "school_festival", "label": "Schulfest"},
|
||||
{"id": "parent_consultation", "label": "Elternsprechtag"},
|
||||
{"id": "grade_deadline", "label": "Notenschluss"},
|
||||
{"id": "report_cards", "label": "Zeugnisausgabe"},
|
||||
{"id": "holiday_start", "label": "Ferienbeginn"},
|
||||
{"id": "holiday_end", "label": "Ferienende"},
|
||||
{"id": "other", "label": "Sonstiges"},
|
||||
]
|
||||
return {"event_types": types}
|
||||
|
||||
|
||||
@router.get("/v1/routine-types")
|
||||
async def get_routine_types():
|
||||
"""Gibt alle Routine-Typen zurueck."""
|
||||
types = [
|
||||
{"id": "teacher_conference", "label": "Lehrerkonferenz"},
|
||||
{"id": "subject_conference", "label": "Fachkonferenz"},
|
||||
{"id": "office_hours", "label": "Sprechstunde"},
|
||||
{"id": "team_meeting", "label": "Teamsitzung"},
|
||||
{"id": "supervision", "label": "Pausenaufsicht"},
|
||||
{"id": "correction_time", "label": "Korrekturzeit"},
|
||||
{"id": "prep_time", "label": "Vorbereitungszeit"},
|
||||
{"id": "other", "label": "Sonstiges"},
|
||||
]
|
||||
return {"routine_types": types}
|
||||
|
||||
|
||||
# === Suggestions & Sidebar ===
|
||||
|
||||
@router.get("/v1/suggestions")
|
||||
async def get_suggestions(
|
||||
teacher_id: str = Query(...),
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Generiert kontextbasierte Vorschlaege fuer einen Lehrer."""
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.suggestions import SuggestionGenerator
|
||||
generator = SuggestionGenerator(db)
|
||||
result = generator.generate(teacher_id, limit=limit)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate suggestions: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
return {
|
||||
"active_contexts": [],
|
||||
"suggestions": [],
|
||||
"signals_summary": {
|
||||
"macro_phase": "onboarding",
|
||||
"current_week": 1,
|
||||
"has_classes": False,
|
||||
"exams_soon": 0,
|
||||
"routines_today": 0,
|
||||
},
|
||||
"total_suggestions": 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/sidebar")
|
||||
async def get_sidebar(
|
||||
teacher_id: str = Query(...),
|
||||
mode: str = Query("companion"),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Generiert das dynamische Sidebar-Model."""
|
||||
if mode == "companion":
|
||||
now_relevant = []
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.suggestions import SuggestionGenerator
|
||||
generator = SuggestionGenerator(db)
|
||||
result = generator.generate(teacher_id, limit=5)
|
||||
now_relevant = [
|
||||
{
|
||||
"id": s["id"],
|
||||
"label": s["title"],
|
||||
"state": "recommended" if s["priority"] > 70 else "default",
|
||||
"badge": s.get("badge"),
|
||||
"icon": s.get("icon", "lightbulb"),
|
||||
"action_url": s.get("action_url"),
|
||||
}
|
||||
for s in result.get("suggestions", [])
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get suggestions for sidebar: {e}")
|
||||
|
||||
return {
|
||||
"mode": "companion",
|
||||
"sections": [
|
||||
{"id": "SEARCH", "type": "search_bar", "placeholder": "Suchen..."},
|
||||
{
|
||||
"id": "NOW_RELEVANT",
|
||||
"type": "list",
|
||||
"title": "Jetzt relevant",
|
||||
"items": now_relevant if now_relevant else [
|
||||
{"id": "no_suggestions", "label": "Keine Vorschlaege", "state": "default", "icon": "check_circle"}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "ALL_MODULES",
|
||||
"type": "folder",
|
||||
"label": "Alle Module",
|
||||
"icon": "folder",
|
||||
"collapsed": True,
|
||||
"items": [
|
||||
{"id": "lesson", "label": "Stundenmodus", "icon": "timer"},
|
||||
{"id": "classes", "label": "Klassen", "icon": "groups"},
|
||||
{"id": "exams", "label": "Klausuren", "icon": "quiz"},
|
||||
{"id": "grades", "label": "Noten", "icon": "calculate"},
|
||||
{"id": "calendar", "label": "Kalender", "icon": "calendar_month"},
|
||||
{"id": "materials", "label": "Materialien", "icon": "folder_open"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "QUICK_ACTIONS",
|
||||
"type": "actions",
|
||||
"title": "Kurzaktionen",
|
||||
"items": [
|
||||
{"id": "scan", "label": "Scan hochladen", "icon": "upload_file"},
|
||||
{"id": "note", "label": "Notiz erstellen", "icon": "note_add"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"mode": "classic",
|
||||
"sections": [
|
||||
{
|
||||
"id": "NAVIGATION",
|
||||
"type": "tree",
|
||||
"items": [
|
||||
{"id": "dashboard", "label": "Dashboard", "icon": "dashboard", "url": "/dashboard"},
|
||||
{"id": "lesson", "label": "Stundenmodus", "icon": "timer", "url": "/lesson"},
|
||||
{"id": "classes", "label": "Klassen", "icon": "groups", "url": "/classes"},
|
||||
{"id": "exams", "label": "Klausuren", "icon": "quiz", "url": "/exams"},
|
||||
{"id": "grades", "label": "Noten", "icon": "calculate", "url": "/grades"},
|
||||
{"id": "calendar", "label": "Kalender", "icon": "calendar_month", "url": "/calendar"},
|
||||
{"id": "materials", "label": "Materialien", "icon": "folder_open", "url": "/materials"},
|
||||
{"id": "settings", "label": "Einstellungen", "icon": "settings", "url": "/settings"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/path")
|
||||
async def get_schoolyear_path(teacher_id: str = Query(...), db=Depends(get_db)):
|
||||
"""Generiert den Schuljahres-Pfad mit Meilensteinen."""
|
||||
current_phase = "onboarding"
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
context = repo.get_or_create(teacher_id)
|
||||
current_phase = context.macro_phase.value
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get context for path: {e}")
|
||||
|
||||
phase_order = [
|
||||
"onboarding", "schuljahresstart", "unterrichtsaufbau",
|
||||
"leistungsphase_1", "halbjahresabschluss", "leistungsphase_2", "jahresabschluss",
|
||||
]
|
||||
|
||||
current_index = phase_order.index(current_phase) if current_phase in phase_order else 0
|
||||
|
||||
milestones = [
|
||||
{"id": "MS_START", "label": "Start", "phase": "onboarding", "icon": "flag"},
|
||||
{"id": "MS_SETUP", "label": "Einrichtung", "phase": "schuljahresstart", "icon": "tune"},
|
||||
{"id": "MS_ROUTINE", "label": "Routinen", "phase": "unterrichtsaufbau", "icon": "repeat"},
|
||||
{"id": "MS_EXAM_1", "label": "Klausuren", "phase": "leistungsphase_1", "icon": "quiz"},
|
||||
{"id": "MS_HALFYEAR", "label": "Halbjahr", "phase": "halbjahresabschluss", "icon": "event"},
|
||||
{"id": "MS_EXAM_2", "label": "Pruefungen", "phase": "leistungsphase_2", "icon": "school"},
|
||||
{"id": "MS_END", "label": "Abschluss", "phase": "jahresabschluss", "icon": "celebration"},
|
||||
]
|
||||
|
||||
for milestone in milestones:
|
||||
phase = milestone["phase"]
|
||||
phase_index = phase_order.index(phase) if phase in phase_order else 999
|
||||
if phase_index < current_index:
|
||||
milestone["status"] = "done"
|
||||
elif phase_index == current_index:
|
||||
milestone["status"] = "current"
|
||||
else:
|
||||
milestone["status"] = "upcoming"
|
||||
|
||||
current_milestone_id = next(
|
||||
(m["id"] for m in milestones if m["status"] == "current"),
|
||||
milestones[0]["id"]
|
||||
)
|
||||
|
||||
progress = int((current_index / (len(phase_order) - 1)) * 100) if len(phase_order) > 1 else 0
|
||||
|
||||
return {
|
||||
"milestones": milestones,
|
||||
"current_milestone_id": current_milestone_id,
|
||||
"progress_percent": progress,
|
||||
"current_phase": current_phase,
|
||||
}
|
||||
__all__ = ["router"]
|
||||
|
||||
247
backend-lehrer/classroom/routes/context_core.py
Normal file
247
backend-lehrer/classroom/routes/context_core.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Classroom API - Context Core Routes
|
||||
|
||||
Teacher context, onboarding endpoints (Phase 8).
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
|
||||
from classroom_engine import (
|
||||
FEDERAL_STATES,
|
||||
SCHOOL_TYPES,
|
||||
MacroPhaseEnum,
|
||||
)
|
||||
|
||||
from ..models import (
|
||||
TeacherContextResponse,
|
||||
SchoolInfo,
|
||||
SchoolYearInfo,
|
||||
MacroPhaseInfo,
|
||||
CoreCounts,
|
||||
ContextFlags,
|
||||
UpdateContextRequest,
|
||||
)
|
||||
from ..services.persistence import (
|
||||
init_db_if_needed,
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Context"])
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Database session dependency."""
|
||||
if DB_ENABLED and SessionLocal:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
yield None
|
||||
|
||||
|
||||
def _get_macro_phase_label(phase) -> str:
|
||||
"""Gibt den Anzeigenamen einer Makro-Phase zurueck."""
|
||||
labels = {
|
||||
"onboarding": "Einrichtung",
|
||||
"schuljahresstart": "Schuljahresstart",
|
||||
"unterrichtsaufbau": "Unterrichtsaufbau",
|
||||
"leistungsphase_1": "Leistungsphase 1",
|
||||
"halbjahresabschluss": "Halbjahresabschluss",
|
||||
"leistungsphase_2": "Leistungsphase 2",
|
||||
"jahresabschluss": "Jahresabschluss",
|
||||
}
|
||||
phase_value = phase.value if hasattr(phase, 'value') else str(phase)
|
||||
return labels.get(phase_value, phase_value)
|
||||
|
||||
|
||||
# === Context Endpoints ===
|
||||
|
||||
@router.get("/v1/context", response_model=TeacherContextResponse)
|
||||
async def get_teacher_context(
|
||||
teacher_id: str = Query(..., description="Teacher ID"),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Liefert den aktuellen Makro-Kontext eines Lehrers.
|
||||
|
||||
Der Kontext beinhaltet:
|
||||
- Schul-Informationen (Bundesland, Schulart)
|
||||
- Schuljahr-Daten (aktuelles Jahr, Woche)
|
||||
- Makro-Phase (ONBOARDING bis JAHRESABSCHLUSS)
|
||||
- Zaehler (Klassen, geplante Klausuren, etc.)
|
||||
- Status-Flags (Onboarding abgeschlossen, etc.)
|
||||
"""
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository, SchoolyearEventRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
context = repo.get_or_create(teacher_id)
|
||||
|
||||
# Zaehler berechnen
|
||||
event_repo = SchoolyearEventRepository(db)
|
||||
upcoming_exams = event_repo.get_upcoming(teacher_id, days=30)
|
||||
exams_count = len([e for e in upcoming_exams if e.event_type.value == "exam"])
|
||||
|
||||
return TeacherContextResponse(
|
||||
schema_version="1.0",
|
||||
teacher_id=teacher_id,
|
||||
school=SchoolInfo(
|
||||
federal_state=context.federal_state or "BY",
|
||||
federal_state_name=FEDERAL_STATES.get(context.federal_state, ""),
|
||||
school_type=context.school_type or "gymnasium",
|
||||
school_type_name=SCHOOL_TYPES.get(context.school_type, ""),
|
||||
),
|
||||
school_year=SchoolYearInfo(
|
||||
id=context.schoolyear or "2024-2025",
|
||||
start=context.schoolyear_start.isoformat() if context.schoolyear_start else None,
|
||||
current_week=context.current_week or 1,
|
||||
),
|
||||
macro_phase=MacroPhaseInfo(
|
||||
id=context.macro_phase.value,
|
||||
label=_get_macro_phase_label(context.macro_phase),
|
||||
confidence=1.0,
|
||||
),
|
||||
core_counts=CoreCounts(
|
||||
classes=1 if context.has_classes else 0,
|
||||
exams_scheduled=exams_count,
|
||||
corrections_pending=0,
|
||||
),
|
||||
flags=ContextFlags(
|
||||
onboarding_completed=context.onboarding_completed,
|
||||
has_classes=context.has_classes,
|
||||
has_schedule=context.has_schedule,
|
||||
is_exam_period=context.is_exam_period,
|
||||
is_before_holidays=context.is_before_holidays,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get teacher context: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Laden des Kontexts: {e}")
|
||||
|
||||
# Fallback ohne DB
|
||||
return TeacherContextResponse(
|
||||
schema_version="1.0",
|
||||
teacher_id=teacher_id,
|
||||
school=SchoolInfo(
|
||||
federal_state="BY",
|
||||
federal_state_name="Bayern",
|
||||
school_type="gymnasium",
|
||||
school_type_name="Gymnasium",
|
||||
),
|
||||
school_year=SchoolYearInfo(
|
||||
id="2024-2025",
|
||||
start=None,
|
||||
current_week=1,
|
||||
),
|
||||
macro_phase=MacroPhaseInfo(
|
||||
id="onboarding",
|
||||
label="Einrichtung",
|
||||
confidence=1.0,
|
||||
),
|
||||
core_counts=CoreCounts(),
|
||||
flags=ContextFlags(),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/v1/context", response_model=TeacherContextResponse)
|
||||
async def update_teacher_context(
|
||||
teacher_id: str,
|
||||
request: UpdateContextRequest,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Aktualisiert den Kontext eines Lehrers.
|
||||
"""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
|
||||
# Validierung
|
||||
if request.federal_state and request.federal_state not in FEDERAL_STATES:
|
||||
raise HTTPException(status_code=400, detail=f"Ungueltiges Bundesland: {request.federal_state}")
|
||||
if request.school_type and request.school_type not in SCHOOL_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Ungueltige Schulart: {request.school_type}")
|
||||
|
||||
# Parse datetime if provided
|
||||
schoolyear_start = None
|
||||
if request.schoolyear_start:
|
||||
schoolyear_start = datetime.fromisoformat(request.schoolyear_start.replace('Z', '+00:00'))
|
||||
|
||||
repo.update_context(
|
||||
teacher_id=teacher_id,
|
||||
federal_state=request.federal_state,
|
||||
school_type=request.school_type,
|
||||
schoolyear=request.schoolyear,
|
||||
schoolyear_start=schoolyear_start,
|
||||
macro_phase=request.macro_phase,
|
||||
current_week=request.current_week,
|
||||
)
|
||||
|
||||
return await get_teacher_context(teacher_id, db)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update teacher context: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Aktualisieren: {e}")
|
||||
|
||||
|
||||
@router.post("/v1/context/complete-onboarding")
|
||||
async def complete_onboarding(
|
||||
teacher_id: str = Query(...),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Markiert das Onboarding als abgeschlossen."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"success": True, "macro_phase": "schuljahresstart", "note": "DB not available"}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
context = repo.complete_onboarding(teacher_id)
|
||||
return {
|
||||
"success": True,
|
||||
"macro_phase": context.macro_phase.value,
|
||||
"teacher_id": teacher_id,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to complete onboarding: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.post("/v1/context/reset-onboarding")
|
||||
async def reset_onboarding(
|
||||
teacher_id: str = Query(...),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Setzt das Onboarding zurueck (fuer Tests)."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"success": True, "macro_phase": "onboarding", "note": "DB not available"}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
context = repo.get_or_create(teacher_id)
|
||||
context.onboarding_completed = False
|
||||
context.macro_phase = MacroPhaseEnum.ONBOARDING
|
||||
db.commit()
|
||||
db.refresh(context)
|
||||
return {
|
||||
"success": True,
|
||||
"macro_phase": "onboarding",
|
||||
"teacher_id": teacher_id,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reset onboarding: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
266
backend-lehrer/classroom/routes/context_events.py
Normal file
266
backend-lehrer/classroom/routes/context_events.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Classroom API - Events & Routines Routes
|
||||
|
||||
School year events, recurring routines endpoints (Phase 8).
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
|
||||
from ..models import (
|
||||
CreateEventRequest,
|
||||
EventResponse,
|
||||
CreateRoutineRequest,
|
||||
RoutineResponse,
|
||||
)
|
||||
from ..services.persistence import (
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Context"])
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Database session dependency."""
|
||||
if DB_ENABLED and SessionLocal:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
yield None
|
||||
|
||||
|
||||
# === Events Endpoints ===
|
||||
|
||||
@router.get("/v1/events")
|
||||
async def get_events(
|
||||
teacher_id: str = Query(...),
|
||||
status: Optional[str] = None,
|
||||
event_type: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Holt Events eines Lehrers."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"events": [], "count": 0}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import SchoolyearEventRepository
|
||||
repo = SchoolyearEventRepository(db)
|
||||
events = repo.get_by_teacher(teacher_id, status=status, event_type=event_type, limit=limit)
|
||||
return {
|
||||
"events": [repo.to_dict(e) for e in events],
|
||||
"count": len(events),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get events: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.get("/v1/events/upcoming")
|
||||
async def get_upcoming_events(
|
||||
teacher_id: str = Query(...),
|
||||
days: int = 30,
|
||||
limit: int = 10,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Holt anstehende Events der naechsten X Tage."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"events": [], "count": 0}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import SchoolyearEventRepository
|
||||
repo = SchoolyearEventRepository(db)
|
||||
events = repo.get_upcoming(teacher_id, days=days, limit=limit)
|
||||
return {
|
||||
"events": [repo.to_dict(e) for e in events],
|
||||
"count": len(events),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get upcoming events: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.post("/v1/events", response_model=EventResponse)
|
||||
async def create_event(
|
||||
teacher_id: str,
|
||||
request: CreateEventRequest,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Erstellt ein neues Schuljahr-Event."""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import SchoolyearEventRepository
|
||||
repo = SchoolyearEventRepository(db)
|
||||
start_date = datetime.fromisoformat(request.start_date.replace('Z', '+00:00'))
|
||||
end_date = None
|
||||
if request.end_date:
|
||||
end_date = datetime.fromisoformat(request.end_date.replace('Z', '+00:00'))
|
||||
|
||||
event = repo.create(
|
||||
teacher_id=teacher_id,
|
||||
title=request.title,
|
||||
event_type=request.event_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
class_id=request.class_id,
|
||||
subject=request.subject,
|
||||
description=request.description,
|
||||
needs_preparation=request.needs_preparation,
|
||||
reminder_days_before=request.reminder_days_before,
|
||||
)
|
||||
|
||||
return EventResponse(
|
||||
id=event.id,
|
||||
teacher_id=event.teacher_id,
|
||||
event_type=event.event_type.value,
|
||||
title=event.title,
|
||||
description=event.description,
|
||||
start_date=event.start_date.isoformat(),
|
||||
end_date=event.end_date.isoformat() if event.end_date else None,
|
||||
class_id=event.class_id,
|
||||
subject=event.subject,
|
||||
status=event.status.value,
|
||||
needs_preparation=event.needs_preparation,
|
||||
preparation_done=event.preparation_done,
|
||||
reminder_days_before=event.reminder_days_before,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create event: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.delete("/v1/events/{event_id}")
|
||||
async def delete_event(event_id: str, db=Depends(get_db)):
|
||||
"""Loescht ein Event."""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import SchoolyearEventRepository
|
||||
repo = SchoolyearEventRepository(db)
|
||||
if repo.delete(event_id):
|
||||
return {"success": True, "deleted_id": event_id}
|
||||
raise HTTPException(status_code=404, detail="Event nicht gefunden")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete event: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
# === Routines Endpoints ===
|
||||
|
||||
@router.get("/v1/routines")
|
||||
async def get_routines(
|
||||
teacher_id: str = Query(...),
|
||||
is_active: bool = True,
|
||||
routine_type: Optional[str] = None,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Holt Routinen eines Lehrers."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"routines": [], "count": 0}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import RecurringRoutineRepository
|
||||
repo = RecurringRoutineRepository(db)
|
||||
routines = repo.get_by_teacher(teacher_id, is_active=is_active, routine_type=routine_type)
|
||||
return {
|
||||
"routines": [repo.to_dict(r) for r in routines],
|
||||
"count": len(routines),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get routines: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.get("/v1/routines/today")
|
||||
async def get_today_routines(teacher_id: str = Query(...), db=Depends(get_db)):
|
||||
"""Holt Routinen die heute stattfinden."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"routines": [], "count": 0}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import RecurringRoutineRepository
|
||||
repo = RecurringRoutineRepository(db)
|
||||
routines = repo.get_today(teacher_id)
|
||||
return {
|
||||
"routines": [repo.to_dict(r) for r in routines],
|
||||
"count": len(routines),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get today's routines: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.post("/v1/routines", response_model=RoutineResponse)
|
||||
async def create_routine(
|
||||
teacher_id: str,
|
||||
request: CreateRoutineRequest,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Erstellt eine neue wiederkehrende Routine."""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import RecurringRoutineRepository
|
||||
repo = RecurringRoutineRepository(db)
|
||||
routine = repo.create(
|
||||
teacher_id=teacher_id,
|
||||
title=request.title,
|
||||
routine_type=request.routine_type,
|
||||
recurrence_pattern=request.recurrence_pattern,
|
||||
day_of_week=request.day_of_week,
|
||||
day_of_month=request.day_of_month,
|
||||
time_of_day=request.time_of_day,
|
||||
duration_minutes=request.duration_minutes,
|
||||
description=request.description,
|
||||
)
|
||||
|
||||
return RoutineResponse(
|
||||
id=routine.id,
|
||||
teacher_id=routine.teacher_id,
|
||||
routine_type=routine.routine_type.value,
|
||||
title=routine.title,
|
||||
description=routine.description,
|
||||
recurrence_pattern=routine.recurrence_pattern.value,
|
||||
day_of_week=routine.day_of_week,
|
||||
day_of_month=routine.day_of_month,
|
||||
time_of_day=routine.time_of_day.isoformat() if routine.time_of_day else None,
|
||||
duration_minutes=routine.duration_minutes,
|
||||
is_active=routine.is_active,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create routine: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.delete("/v1/routines/{routine_id}")
|
||||
async def delete_routine(routine_id: str, db=Depends(get_db)):
|
||||
"""Loescht eine Routine."""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import RecurringRoutineRepository
|
||||
repo = RecurringRoutineRepository(db)
|
||||
if repo.delete(routine_id):
|
||||
return {"success": True, "deleted_id": routine_id}
|
||||
raise HTTPException(status_code=404, detail="Routine nicht gefunden")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete routine: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
281
backend-lehrer/classroom/routes/context_static.py
Normal file
281
backend-lehrer/classroom/routes/context_static.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Classroom API - Static Data, Suggestions & Sidebar Routes
|
||||
|
||||
Federal states, school types, macro phases, event/routine types,
|
||||
suggestions, sidebar model, and school year path.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
|
||||
from classroom_engine import FEDERAL_STATES, SCHOOL_TYPES
|
||||
|
||||
from ..services.persistence import (
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Context"])
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Database session dependency."""
|
||||
if DB_ENABLED and SessionLocal:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
yield None
|
||||
|
||||
|
||||
# === Static Data Endpoints ===
|
||||
|
||||
@router.get("/v1/federal-states")
|
||||
async def get_federal_states():
|
||||
"""Gibt alle Bundeslaender zurueck."""
|
||||
return {
|
||||
"federal_states": [{"id": k, "name": v} for k, v in FEDERAL_STATES.items()]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/school-types")
|
||||
async def get_school_types():
|
||||
"""Gibt alle Schularten zurueck."""
|
||||
return {
|
||||
"school_types": [{"id": k, "name": v} for k, v in SCHOOL_TYPES.items()]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/macro-phases")
|
||||
async def get_macro_phases():
|
||||
"""Gibt alle Makro-Phasen mit Beschreibungen zurueck."""
|
||||
phases = [
|
||||
{"id": "onboarding", "label": "Einrichtung", "description": "Ersteinrichtung (Klassen, Stundenplan)", "order": 1},
|
||||
{"id": "schuljahresstart", "label": "Schuljahresstart", "description": "Erste 2-3 Wochen des Schuljahres", "order": 2},
|
||||
{"id": "unterrichtsaufbau", "label": "Unterrichtsaufbau", "description": "Routinen etablieren, erste Bewertungen", "order": 3},
|
||||
{"id": "leistungsphase_1", "label": "Leistungsphase 1", "description": "Erste Klassenarbeiten und Klausuren", "order": 4},
|
||||
{"id": "halbjahresabschluss", "label": "Halbjahresabschluss", "description": "Notenschluss, Zeugnisse, Konferenzen", "order": 5},
|
||||
{"id": "leistungsphase_2", "label": "Leistungsphase 2", "description": "Zweites Halbjahr, Pruefungsvorbereitung", "order": 6},
|
||||
{"id": "jahresabschluss", "label": "Jahresabschluss", "description": "Finale Noten, Versetzung, Schuljahresende", "order": 7},
|
||||
]
|
||||
return {"macro_phases": phases}
|
||||
|
||||
|
||||
@router.get("/v1/event-types")
|
||||
async def get_event_types():
|
||||
"""Gibt alle Event-Typen zurueck."""
|
||||
types = [
|
||||
{"id": "exam", "label": "Klassenarbeit/Klausur"},
|
||||
{"id": "parent_evening", "label": "Elternabend"},
|
||||
{"id": "trip", "label": "Klassenfahrt/Ausflug"},
|
||||
{"id": "project", "label": "Projektwoche"},
|
||||
{"id": "internship", "label": "Praktikum"},
|
||||
{"id": "presentation", "label": "Referate/Praesentationen"},
|
||||
{"id": "sports_day", "label": "Sporttag"},
|
||||
{"id": "school_festival", "label": "Schulfest"},
|
||||
{"id": "parent_consultation", "label": "Elternsprechtag"},
|
||||
{"id": "grade_deadline", "label": "Notenschluss"},
|
||||
{"id": "report_cards", "label": "Zeugnisausgabe"},
|
||||
{"id": "holiday_start", "label": "Ferienbeginn"},
|
||||
{"id": "holiday_end", "label": "Ferienende"},
|
||||
{"id": "other", "label": "Sonstiges"},
|
||||
]
|
||||
return {"event_types": types}
|
||||
|
||||
|
||||
@router.get("/v1/routine-types")
|
||||
async def get_routine_types():
|
||||
"""Gibt alle Routine-Typen zurueck."""
|
||||
types = [
|
||||
{"id": "teacher_conference", "label": "Lehrerkonferenz"},
|
||||
{"id": "subject_conference", "label": "Fachkonferenz"},
|
||||
{"id": "office_hours", "label": "Sprechstunde"},
|
||||
{"id": "team_meeting", "label": "Teamsitzung"},
|
||||
{"id": "supervision", "label": "Pausenaufsicht"},
|
||||
{"id": "correction_time", "label": "Korrekturzeit"},
|
||||
{"id": "prep_time", "label": "Vorbereitungszeit"},
|
||||
{"id": "other", "label": "Sonstiges"},
|
||||
]
|
||||
return {"routine_types": types}
|
||||
|
||||
|
||||
# === Suggestions & Sidebar ===
|
||||
|
||||
@router.get("/v1/suggestions")
|
||||
async def get_suggestions(
|
||||
teacher_id: str = Query(...),
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Generiert kontextbasierte Vorschlaege fuer einen Lehrer."""
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.suggestions import SuggestionGenerator
|
||||
generator = SuggestionGenerator(db)
|
||||
result = generator.generate(teacher_id, limit=limit)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate suggestions: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
return {
|
||||
"active_contexts": [],
|
||||
"suggestions": [],
|
||||
"signals_summary": {
|
||||
"macro_phase": "onboarding",
|
||||
"current_week": 1,
|
||||
"has_classes": False,
|
||||
"exams_soon": 0,
|
||||
"routines_today": 0,
|
||||
},
|
||||
"total_suggestions": 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/sidebar")
|
||||
async def get_sidebar(
|
||||
teacher_id: str = Query(...),
|
||||
mode: str = Query("companion"),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Generiert das dynamische Sidebar-Model."""
|
||||
if mode == "companion":
|
||||
now_relevant = []
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.suggestions import SuggestionGenerator
|
||||
generator = SuggestionGenerator(db)
|
||||
result = generator.generate(teacher_id, limit=5)
|
||||
now_relevant = [
|
||||
{
|
||||
"id": s["id"],
|
||||
"label": s["title"],
|
||||
"state": "recommended" if s["priority"] > 70 else "default",
|
||||
"badge": s.get("badge"),
|
||||
"icon": s.get("icon", "lightbulb"),
|
||||
"action_url": s.get("action_url"),
|
||||
}
|
||||
for s in result.get("suggestions", [])
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get suggestions for sidebar: {e}")
|
||||
|
||||
return {
|
||||
"mode": "companion",
|
||||
"sections": [
|
||||
{"id": "SEARCH", "type": "search_bar", "placeholder": "Suchen..."},
|
||||
{
|
||||
"id": "NOW_RELEVANT",
|
||||
"type": "list",
|
||||
"title": "Jetzt relevant",
|
||||
"items": now_relevant if now_relevant else [
|
||||
{"id": "no_suggestions", "label": "Keine Vorschlaege", "state": "default", "icon": "check_circle"}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "ALL_MODULES",
|
||||
"type": "folder",
|
||||
"label": "Alle Module",
|
||||
"icon": "folder",
|
||||
"collapsed": True,
|
||||
"items": [
|
||||
{"id": "lesson", "label": "Stundenmodus", "icon": "timer"},
|
||||
{"id": "classes", "label": "Klassen", "icon": "groups"},
|
||||
{"id": "exams", "label": "Klausuren", "icon": "quiz"},
|
||||
{"id": "grades", "label": "Noten", "icon": "calculate"},
|
||||
{"id": "calendar", "label": "Kalender", "icon": "calendar_month"},
|
||||
{"id": "materials", "label": "Materialien", "icon": "folder_open"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "QUICK_ACTIONS",
|
||||
"type": "actions",
|
||||
"title": "Kurzaktionen",
|
||||
"items": [
|
||||
{"id": "scan", "label": "Scan hochladen", "icon": "upload_file"},
|
||||
{"id": "note", "label": "Notiz erstellen", "icon": "note_add"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"mode": "classic",
|
||||
"sections": [
|
||||
{
|
||||
"id": "NAVIGATION",
|
||||
"type": "tree",
|
||||
"items": [
|
||||
{"id": "dashboard", "label": "Dashboard", "icon": "dashboard", "url": "/dashboard"},
|
||||
{"id": "lesson", "label": "Stundenmodus", "icon": "timer", "url": "/lesson"},
|
||||
{"id": "classes", "label": "Klassen", "icon": "groups", "url": "/classes"},
|
||||
{"id": "exams", "label": "Klausuren", "icon": "quiz", "url": "/exams"},
|
||||
{"id": "grades", "label": "Noten", "icon": "calculate", "url": "/grades"},
|
||||
{"id": "calendar", "label": "Kalender", "icon": "calendar_month", "url": "/calendar"},
|
||||
{"id": "materials", "label": "Materialien", "icon": "folder_open", "url": "/materials"},
|
||||
{"id": "settings", "label": "Einstellungen", "icon": "settings", "url": "/settings"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/path")
|
||||
async def get_schoolyear_path(teacher_id: str = Query(...), db=Depends(get_db)):
|
||||
"""Generiert den Schuljahres-Pfad mit Meilensteinen."""
|
||||
current_phase = "onboarding"
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
context = repo.get_or_create(teacher_id)
|
||||
current_phase = context.macro_phase.value
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get context for path: {e}")
|
||||
|
||||
phase_order = [
|
||||
"onboarding", "schuljahresstart", "unterrichtsaufbau",
|
||||
"leistungsphase_1", "halbjahresabschluss", "leistungsphase_2", "jahresabschluss",
|
||||
]
|
||||
|
||||
current_index = phase_order.index(current_phase) if current_phase in phase_order else 0
|
||||
|
||||
milestones = [
|
||||
{"id": "MS_START", "label": "Start", "phase": "onboarding", "icon": "flag"},
|
||||
{"id": "MS_SETUP", "label": "Einrichtung", "phase": "schuljahresstart", "icon": "tune"},
|
||||
{"id": "MS_ROUTINE", "label": "Routinen", "phase": "unterrichtsaufbau", "icon": "repeat"},
|
||||
{"id": "MS_EXAM_1", "label": "Klausuren", "phase": "leistungsphase_1", "icon": "quiz"},
|
||||
{"id": "MS_HALFYEAR", "label": "Halbjahr", "phase": "halbjahresabschluss", "icon": "event"},
|
||||
{"id": "MS_EXAM_2", "label": "Pruefungen", "phase": "leistungsphase_2", "icon": "school"},
|
||||
{"id": "MS_END", "label": "Abschluss", "phase": "jahresabschluss", "icon": "celebration"},
|
||||
]
|
||||
|
||||
for milestone in milestones:
|
||||
phase = milestone["phase"]
|
||||
phase_index = phase_order.index(phase) if phase in phase_order else 999
|
||||
if phase_index < current_index:
|
||||
milestone["status"] = "done"
|
||||
elif phase_index == current_index:
|
||||
milestone["status"] = "current"
|
||||
else:
|
||||
milestone["status"] = "upcoming"
|
||||
|
||||
current_milestone_id = next(
|
||||
(m["id"] for m in milestones if m["status"] == "current"),
|
||||
milestones[0]["id"]
|
||||
)
|
||||
|
||||
progress = int((current_index / (len(phase_order) - 1)) * 100) if len(phase_order) > 1 else 0
|
||||
|
||||
return {
|
||||
"milestones": milestones,
|
||||
"current_milestone_id": current_milestone_id,
|
||||
"progress_percent": progress,
|
||||
"current_phase": current_phase,
|
||||
}
|
||||
386
backend-lehrer/llm_gateway/routes/edu_search_crud.py
Normal file
386
backend-lehrer/llm_gateway/routes/edu_search_crud.py
Normal file
@@ -0,0 +1,386 @@
|
||||
"""
|
||||
EduSearch Seeds CRUD Routes.
|
||||
|
||||
List, get, create, update, delete, and bulk import for seed URLs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
import asyncpg
|
||||
|
||||
from .edu_search_models import (
|
||||
CategoryResponse,
|
||||
SeedCreate,
|
||||
SeedUpdate,
|
||||
SeedResponse,
|
||||
SeedsListResponse,
|
||||
BulkImportRequest,
|
||||
BulkImportResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["edu-search"])
|
||||
|
||||
# Database connection pool
|
||||
_pool: Optional[asyncpg.Pool] = None
|
||||
|
||||
|
||||
async def get_db_pool() -> asyncpg.Pool:
|
||||
"""Get or create database connection pool."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
database_url = os.environ.get("DATABASE_URL")
|
||||
if not database_url:
|
||||
raise RuntimeError("DATABASE_URL nicht konfiguriert - bitte via Vault oder Umgebungsvariable setzen")
|
||||
_pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10)
|
||||
return _pool
|
||||
|
||||
|
||||
@router.get("/categories", response_model=List[CategoryResponse])
|
||||
async def list_categories():
|
||||
"""List all seed categories."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT id, name, display_name, description, icon, sort_order, is_active
|
||||
FROM edu_search_categories
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY sort_order
|
||||
""")
|
||||
return [
|
||||
CategoryResponse(
|
||||
id=str(row["id"]),
|
||||
name=row["name"],
|
||||
display_name=row["display_name"],
|
||||
description=row["description"],
|
||||
icon=row["icon"],
|
||||
sort_order=row["sort_order"],
|
||||
is_active=row["is_active"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/seeds", response_model=SeedsListResponse)
|
||||
async def list_seeds(
|
||||
category: Optional[str] = Query(None, description="Filter by category name"),
|
||||
state: Optional[str] = Query(None, description="Filter by state code"),
|
||||
enabled: Optional[bool] = Query(None, description="Filter by enabled status"),
|
||||
search: Optional[str] = Query(None, description="Search in name/url"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
"""List seeds with optional filtering and pagination."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Build WHERE clause
|
||||
conditions = []
|
||||
params = []
|
||||
param_idx = 1
|
||||
|
||||
if category:
|
||||
conditions.append(f"c.name = ${param_idx}")
|
||||
params.append(category)
|
||||
param_idx += 1
|
||||
|
||||
if state:
|
||||
conditions.append(f"s.state = ${param_idx}")
|
||||
params.append(state)
|
||||
param_idx += 1
|
||||
|
||||
if enabled is not None:
|
||||
conditions.append(f"s.enabled = ${param_idx}")
|
||||
params.append(enabled)
|
||||
param_idx += 1
|
||||
|
||||
if search:
|
||||
conditions.append(f"(s.name ILIKE ${param_idx} OR s.url ILIKE ${param_idx})")
|
||||
params.append(f"%{search}%")
|
||||
param_idx += 1
|
||||
|
||||
where_clause = " AND ".join(conditions) if conditions else "TRUE"
|
||||
|
||||
# Count total
|
||||
count_query = f"""
|
||||
SELECT COUNT(*) FROM edu_search_seeds s
|
||||
LEFT JOIN edu_search_categories c ON s.category_id = c.id
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
total = await conn.fetchval(count_query, *params)
|
||||
|
||||
# Get paginated results
|
||||
offset = (page - 1) * page_size
|
||||
params.extend([page_size, offset])
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
s.id, s.url, s.name, s.description,
|
||||
c.name as category, c.display_name as category_display_name,
|
||||
s.source_type, s.scope, s.state, s.trust_boost, s.enabled,
|
||||
s.crawl_depth, s.crawl_frequency, s.last_crawled_at,
|
||||
s.last_crawl_status, s.last_crawl_docs, s.total_documents,
|
||||
s.created_at, s.updated_at
|
||||
FROM edu_search_seeds s
|
||||
LEFT JOIN edu_search_categories c ON s.category_id = c.id
|
||||
WHERE {where_clause}
|
||||
ORDER BY c.sort_order, s.name
|
||||
LIMIT ${param_idx} OFFSET ${param_idx + 1}
|
||||
"""
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
|
||||
seeds = [_row_to_seed_response(row) for row in rows]
|
||||
|
||||
return SeedsListResponse(
|
||||
seeds=seeds,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/seeds/{seed_id}", response_model=SeedResponse)
|
||||
async def get_seed(seed_id: str):
|
||||
"""Get a single seed by ID."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT
|
||||
s.id, s.url, s.name, s.description,
|
||||
c.name as category, c.display_name as category_display_name,
|
||||
s.source_type, s.scope, s.state, s.trust_boost, s.enabled,
|
||||
s.crawl_depth, s.crawl_frequency, s.last_crawled_at,
|
||||
s.last_crawl_status, s.last_crawl_docs, s.total_documents,
|
||||
s.created_at, s.updated_at
|
||||
FROM edu_search_seeds s
|
||||
LEFT JOIN edu_search_categories c ON s.category_id = c.id
|
||||
WHERE s.id = $1
|
||||
""", seed_id)
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Seed nicht gefunden")
|
||||
|
||||
return _row_to_seed_response(row)
|
||||
|
||||
|
||||
@router.post("/seeds", response_model=SeedResponse, status_code=201)
|
||||
async def create_seed(seed: SeedCreate):
|
||||
"""Create a new seed URL."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
category_id = None
|
||||
if seed.category_name:
|
||||
category_id = await conn.fetchval(
|
||||
"SELECT id FROM edu_search_categories WHERE name = $1",
|
||||
seed.category_name
|
||||
)
|
||||
|
||||
try:
|
||||
row = await conn.fetchrow("""
|
||||
INSERT INTO edu_search_seeds (
|
||||
url, name, description, category_id, source_type, scope,
|
||||
state, trust_boost, enabled, crawl_depth, crawl_frequency
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id, created_at, updated_at
|
||||
""",
|
||||
seed.url, seed.name, seed.description, category_id,
|
||||
seed.source_type, seed.scope, seed.state, seed.trust_boost,
|
||||
seed.enabled, seed.crawl_depth, seed.crawl_frequency
|
||||
)
|
||||
except asyncpg.UniqueViolationError:
|
||||
raise HTTPException(status_code=409, detail="URL existiert bereits")
|
||||
|
||||
return SeedResponse(
|
||||
id=str(row["id"]),
|
||||
url=seed.url,
|
||||
name=seed.name,
|
||||
description=seed.description,
|
||||
category=seed.category_name,
|
||||
category_display_name=None,
|
||||
source_type=seed.source_type,
|
||||
scope=seed.scope,
|
||||
state=seed.state,
|
||||
trust_boost=seed.trust_boost,
|
||||
enabled=seed.enabled,
|
||||
crawl_depth=seed.crawl_depth,
|
||||
crawl_frequency=seed.crawl_frequency,
|
||||
last_crawled_at=None,
|
||||
last_crawl_status=None,
|
||||
last_crawl_docs=0,
|
||||
total_documents=0,
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/seeds/{seed_id}", response_model=SeedResponse)
|
||||
async def update_seed(seed_id: str, seed: SeedUpdate):
|
||||
"""Update an existing seed."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
updates = []
|
||||
params = []
|
||||
param_idx = 1
|
||||
|
||||
if seed.url is not None:
|
||||
updates.append(f"url = ${param_idx}")
|
||||
params.append(seed.url)
|
||||
param_idx += 1
|
||||
|
||||
if seed.name is not None:
|
||||
updates.append(f"name = ${param_idx}")
|
||||
params.append(seed.name)
|
||||
param_idx += 1
|
||||
|
||||
if seed.description is not None:
|
||||
updates.append(f"description = ${param_idx}")
|
||||
params.append(seed.description)
|
||||
param_idx += 1
|
||||
|
||||
if seed.category_name is not None:
|
||||
category_id = await conn.fetchval(
|
||||
"SELECT id FROM edu_search_categories WHERE name = $1",
|
||||
seed.category_name
|
||||
)
|
||||
updates.append(f"category_id = ${param_idx}")
|
||||
params.append(category_id)
|
||||
param_idx += 1
|
||||
|
||||
if seed.source_type is not None:
|
||||
updates.append(f"source_type = ${param_idx}")
|
||||
params.append(seed.source_type)
|
||||
param_idx += 1
|
||||
|
||||
if seed.scope is not None:
|
||||
updates.append(f"scope = ${param_idx}")
|
||||
params.append(seed.scope)
|
||||
param_idx += 1
|
||||
|
||||
if seed.state is not None:
|
||||
updates.append(f"state = ${param_idx}")
|
||||
params.append(seed.state)
|
||||
param_idx += 1
|
||||
|
||||
if seed.trust_boost is not None:
|
||||
updates.append(f"trust_boost = ${param_idx}")
|
||||
params.append(seed.trust_boost)
|
||||
param_idx += 1
|
||||
|
||||
if seed.enabled is not None:
|
||||
updates.append(f"enabled = ${param_idx}")
|
||||
params.append(seed.enabled)
|
||||
param_idx += 1
|
||||
|
||||
if seed.crawl_depth is not None:
|
||||
updates.append(f"crawl_depth = ${param_idx}")
|
||||
params.append(seed.crawl_depth)
|
||||
param_idx += 1
|
||||
|
||||
if seed.crawl_frequency is not None:
|
||||
updates.append(f"crawl_frequency = ${param_idx}")
|
||||
params.append(seed.crawl_frequency)
|
||||
param_idx += 1
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||||
|
||||
updates.append("updated_at = NOW()")
|
||||
params.append(seed_id)
|
||||
|
||||
query = f"""
|
||||
UPDATE edu_search_seeds
|
||||
SET {", ".join(updates)}
|
||||
WHERE id = ${param_idx}
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
result = await conn.fetchrow(query, *params)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Seed nicht gefunden")
|
||||
|
||||
# Return updated seed
|
||||
return await get_seed(seed_id)
|
||||
|
||||
|
||||
@router.delete("/seeds/{seed_id}")
|
||||
async def delete_seed(seed_id: str):
|
||||
"""Delete a seed."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM edu_search_seeds WHERE id = $1",
|
||||
seed_id
|
||||
)
|
||||
if result == "DELETE 0":
|
||||
raise HTTPException(status_code=404, detail="Seed nicht gefunden")
|
||||
|
||||
return {"status": "deleted", "id": seed_id}
|
||||
|
||||
|
||||
@router.post("/seeds/bulk-import", response_model=BulkImportResponse)
|
||||
async def bulk_import_seeds(request: BulkImportRequest):
|
||||
"""Bulk import seeds (skip duplicates)."""
|
||||
pool = await get_db_pool()
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Pre-fetch all category IDs
|
||||
categories = {}
|
||||
rows = await conn.fetch("SELECT id, name FROM edu_search_categories")
|
||||
for row in rows:
|
||||
categories[row["name"]] = row["id"]
|
||||
|
||||
for seed in request.seeds:
|
||||
try:
|
||||
category_id = categories.get(seed.category_name) if seed.category_name else None
|
||||
|
||||
await conn.execute("""
|
||||
INSERT INTO edu_search_seeds (
|
||||
url, name, description, category_id, source_type, scope,
|
||||
state, trust_boost, enabled, crawl_depth, crawl_frequency
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (url) DO NOTHING
|
||||
""",
|
||||
seed.url, seed.name, seed.description, category_id,
|
||||
seed.source_type, seed.scope, seed.state, seed.trust_boost,
|
||||
seed.enabled, seed.crawl_depth, seed.crawl_frequency
|
||||
)
|
||||
imported += 1
|
||||
except asyncpg.UniqueViolationError:
|
||||
skipped += 1
|
||||
except Exception as e:
|
||||
errors.append(f"{seed.url}: {str(e)}")
|
||||
|
||||
return BulkImportResponse(imported=imported, skipped=skipped, errors=errors)
|
||||
|
||||
|
||||
def _row_to_seed_response(row) -> SeedResponse:
|
||||
"""Convert a database row to SeedResponse."""
|
||||
return SeedResponse(
|
||||
id=str(row["id"]),
|
||||
url=row["url"],
|
||||
name=row["name"],
|
||||
description=row["description"],
|
||||
category=row["category"],
|
||||
category_display_name=row["category_display_name"],
|
||||
source_type=row["source_type"],
|
||||
scope=row["scope"],
|
||||
state=row["state"],
|
||||
trust_boost=float(row["trust_boost"]),
|
||||
enabled=row["enabled"],
|
||||
crawl_depth=row["crawl_depth"],
|
||||
crawl_frequency=row["crawl_frequency"],
|
||||
last_crawled_at=row["last_crawled_at"],
|
||||
last_crawl_status=row["last_crawl_status"],
|
||||
last_crawl_docs=row["last_crawl_docs"] or 0,
|
||||
total_documents=row["total_documents"] or 0,
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
137
backend-lehrer/llm_gateway/routes/edu_search_models.py
Normal file
137
backend-lehrer/llm_gateway/routes/edu_search_models.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
EduSearch Seeds Pydantic Models.
|
||||
|
||||
Request/Response models for the education search seed URL API.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
"""Category response model."""
|
||||
id: str
|
||||
name: str
|
||||
display_name: str
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
|
||||
|
||||
class SeedBase(BaseModel):
|
||||
"""Base seed model for creation/update."""
|
||||
url: str = Field(..., max_length=500)
|
||||
name: str = Field(..., max_length=255)
|
||||
description: Optional[str] = None
|
||||
category_name: Optional[str] = Field(None, description="Category name (federal, states, etc.)")
|
||||
source_type: str = Field("GOV", description="GOV, EDU, UNI, etc.")
|
||||
scope: str = Field("FEDERAL", description="FEDERAL, STATE, etc.")
|
||||
state: Optional[str] = Field(None, max_length=5, description="State code (BW, BY, etc.)")
|
||||
trust_boost: float = Field(0.50, ge=0.0, le=1.0)
|
||||
enabled: bool = True
|
||||
crawl_depth: int = Field(2, ge=1, le=5)
|
||||
crawl_frequency: str = Field("weekly", description="hourly, daily, weekly, monthly")
|
||||
|
||||
|
||||
class SeedCreate(SeedBase):
|
||||
"""Seed creation model."""
|
||||
pass
|
||||
|
||||
|
||||
class SeedUpdate(BaseModel):
|
||||
"""Seed update model (all fields optional)."""
|
||||
url: Optional[str] = Field(None, max_length=500)
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
description: Optional[str] = None
|
||||
category_name: Optional[str] = None
|
||||
source_type: Optional[str] = None
|
||||
scope: Optional[str] = None
|
||||
state: Optional[str] = Field(None, max_length=5)
|
||||
trust_boost: Optional[float] = Field(None, ge=0.0, le=1.0)
|
||||
enabled: Optional[bool] = None
|
||||
crawl_depth: Optional[int] = Field(None, ge=1, le=5)
|
||||
crawl_frequency: Optional[str] = None
|
||||
|
||||
|
||||
class SeedResponse(BaseModel):
|
||||
"""Seed response model."""
|
||||
id: str
|
||||
url: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
category_display_name: Optional[str] = None
|
||||
source_type: str
|
||||
scope: str
|
||||
state: Optional[str] = None
|
||||
trust_boost: float
|
||||
enabled: bool
|
||||
crawl_depth: int
|
||||
crawl_frequency: str
|
||||
last_crawled_at: Optional[datetime] = None
|
||||
last_crawl_status: Optional[str] = None
|
||||
last_crawl_docs: int = 0
|
||||
total_documents: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SeedsListResponse(BaseModel):
|
||||
"""List response with pagination info."""
|
||||
seeds: List[SeedResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class StatsResponse(BaseModel):
|
||||
"""Crawl statistics response."""
|
||||
total_seeds: int
|
||||
enabled_seeds: int
|
||||
total_documents: int
|
||||
seeds_by_category: dict
|
||||
seeds_by_state: dict
|
||||
last_crawl_time: Optional[datetime] = None
|
||||
|
||||
|
||||
class BulkImportRequest(BaseModel):
|
||||
"""Bulk import request."""
|
||||
seeds: List[SeedCreate]
|
||||
|
||||
|
||||
class BulkImportResponse(BaseModel):
|
||||
"""Bulk import response."""
|
||||
imported: int
|
||||
skipped: int
|
||||
errors: List[str]
|
||||
|
||||
|
||||
class CrawlStatusUpdate(BaseModel):
|
||||
"""Crawl status update from edu-search-service."""
|
||||
seed_url: str = Field(..., description="The seed URL that was crawled")
|
||||
status: str = Field(..., description="Crawl status: success, error, partial")
|
||||
documents_crawled: int = Field(0, ge=0, description="Number of documents crawled")
|
||||
error_message: Optional[str] = Field(None, description="Error message if status is error")
|
||||
crawl_duration_seconds: float = Field(0.0, ge=0.0, description="Duration of the crawl in seconds")
|
||||
|
||||
|
||||
class CrawlStatusResponse(BaseModel):
|
||||
"""Response for crawl status update."""
|
||||
success: bool
|
||||
seed_url: str
|
||||
message: str
|
||||
|
||||
|
||||
class BulkCrawlStatusUpdate(BaseModel):
|
||||
"""Bulk crawl status update."""
|
||||
updates: List[CrawlStatusUpdate]
|
||||
|
||||
|
||||
class BulkCrawlStatusResponse(BaseModel):
|
||||
"""Response for bulk crawl status update."""
|
||||
updated: int
|
||||
failed: int
|
||||
errors: List[str]
|
||||
@@ -1,710 +1,58 @@
|
||||
"""
|
||||
EduSearch Seeds API Routes.
|
||||
EduSearch Seeds API Routes — Barrel Re-export.
|
||||
|
||||
Split into submodules:
|
||||
- edu_search_models.py — Pydantic request/response models
|
||||
- edu_search_crud.py — CRUD endpoints (list, get, create, update, delete, bulk import)
|
||||
- edu_search_status.py — Stats, export for crawler, crawl status feedback
|
||||
|
||||
CRUD operations for managing education search crawler seed URLs.
|
||||
Direct database access to PostgreSQL.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
import asyncpg
|
||||
from .edu_search_crud import router as _crud_router, get_db_pool
|
||||
from .edu_search_status import router as _status_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# Re-export models for consumers that import types from this module
|
||||
from .edu_search_models import (
|
||||
CategoryResponse,
|
||||
SeedBase,
|
||||
SeedCreate,
|
||||
SeedUpdate,
|
||||
SeedResponse,
|
||||
SeedsListResponse,
|
||||
StatsResponse,
|
||||
BulkImportRequest,
|
||||
BulkImportResponse,
|
||||
CrawlStatusUpdate,
|
||||
CrawlStatusResponse,
|
||||
BulkCrawlStatusUpdate,
|
||||
BulkCrawlStatusResponse,
|
||||
)
|
||||
|
||||
# Combine both sub-routers into a single router for backwards compatibility.
|
||||
# The consumer imports `from .edu_search_seeds import router as edu_search_seeds_router`.
|
||||
router = APIRouter(prefix="/edu-search", tags=["edu-search"])
|
||||
|
||||
# Database connection pool
|
||||
_pool: Optional[asyncpg.Pool] = None
|
||||
|
||||
|
||||
async def get_db_pool() -> asyncpg.Pool:
|
||||
"""Get or create database connection pool."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
database_url = os.environ.get("DATABASE_URL")
|
||||
if not database_url:
|
||||
raise RuntimeError("DATABASE_URL nicht konfiguriert - bitte via Vault oder Umgebungsvariable setzen")
|
||||
_pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10)
|
||||
return _pool
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
"""Category response model."""
|
||||
id: str
|
||||
name: str
|
||||
display_name: str
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
|
||||
|
||||
class SeedBase(BaseModel):
|
||||
"""Base seed model for creation/update."""
|
||||
url: str = Field(..., max_length=500)
|
||||
name: str = Field(..., max_length=255)
|
||||
description: Optional[str] = None
|
||||
category_name: Optional[str] = Field(None, description="Category name (federal, states, etc.)")
|
||||
source_type: str = Field("GOV", description="GOV, EDU, UNI, etc.")
|
||||
scope: str = Field("FEDERAL", description="FEDERAL, STATE, etc.")
|
||||
state: Optional[str] = Field(None, max_length=5, description="State code (BW, BY, etc.)")
|
||||
trust_boost: float = Field(0.50, ge=0.0, le=1.0)
|
||||
enabled: bool = True
|
||||
crawl_depth: int = Field(2, ge=1, le=5)
|
||||
crawl_frequency: str = Field("weekly", description="hourly, daily, weekly, monthly")
|
||||
|
||||
|
||||
class SeedCreate(SeedBase):
|
||||
"""Seed creation model."""
|
||||
pass
|
||||
|
||||
|
||||
class SeedUpdate(BaseModel):
|
||||
"""Seed update model (all fields optional)."""
|
||||
url: Optional[str] = Field(None, max_length=500)
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
description: Optional[str] = None
|
||||
category_name: Optional[str] = None
|
||||
source_type: Optional[str] = None
|
||||
scope: Optional[str] = None
|
||||
state: Optional[str] = Field(None, max_length=5)
|
||||
trust_boost: Optional[float] = Field(None, ge=0.0, le=1.0)
|
||||
enabled: Optional[bool] = None
|
||||
crawl_depth: Optional[int] = Field(None, ge=1, le=5)
|
||||
crawl_frequency: Optional[str] = None
|
||||
|
||||
|
||||
class SeedResponse(BaseModel):
|
||||
"""Seed response model."""
|
||||
id: str
|
||||
url: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
category_display_name: Optional[str] = None
|
||||
source_type: str
|
||||
scope: str
|
||||
state: Optional[str] = None
|
||||
trust_boost: float
|
||||
enabled: bool
|
||||
crawl_depth: int
|
||||
crawl_frequency: str
|
||||
last_crawled_at: Optional[datetime] = None
|
||||
last_crawl_status: Optional[str] = None
|
||||
last_crawl_docs: int = 0
|
||||
total_documents: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SeedsListResponse(BaseModel):
|
||||
"""List response with pagination info."""
|
||||
seeds: List[SeedResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class StatsResponse(BaseModel):
|
||||
"""Crawl statistics response."""
|
||||
total_seeds: int
|
||||
enabled_seeds: int
|
||||
total_documents: int
|
||||
seeds_by_category: dict
|
||||
seeds_by_state: dict
|
||||
last_crawl_time: Optional[datetime] = None
|
||||
|
||||
|
||||
class BulkImportRequest(BaseModel):
|
||||
"""Bulk import request."""
|
||||
seeds: List[SeedCreate]
|
||||
|
||||
|
||||
class BulkImportResponse(BaseModel):
|
||||
"""Bulk import response."""
|
||||
imported: int
|
||||
skipped: int
|
||||
errors: List[str]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/categories", response_model=List[CategoryResponse])
|
||||
async def list_categories():
|
||||
"""List all seed categories."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT id, name, display_name, description, icon, sort_order, is_active
|
||||
FROM edu_search_categories
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY sort_order
|
||||
""")
|
||||
return [
|
||||
CategoryResponse(
|
||||
id=str(row["id"]),
|
||||
name=row["name"],
|
||||
display_name=row["display_name"],
|
||||
description=row["description"],
|
||||
icon=row["icon"],
|
||||
sort_order=row["sort_order"],
|
||||
is_active=row["is_active"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/seeds", response_model=SeedsListResponse)
|
||||
async def list_seeds(
|
||||
category: Optional[str] = Query(None, description="Filter by category name"),
|
||||
state: Optional[str] = Query(None, description="Filter by state code"),
|
||||
enabled: Optional[bool] = Query(None, description="Filter by enabled status"),
|
||||
search: Optional[str] = Query(None, description="Search in name/url"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
"""List seeds with optional filtering and pagination."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Build WHERE clause
|
||||
conditions = []
|
||||
params = []
|
||||
param_idx = 1
|
||||
|
||||
if category:
|
||||
conditions.append(f"c.name = ${param_idx}")
|
||||
params.append(category)
|
||||
param_idx += 1
|
||||
|
||||
if state:
|
||||
conditions.append(f"s.state = ${param_idx}")
|
||||
params.append(state)
|
||||
param_idx += 1
|
||||
|
||||
if enabled is not None:
|
||||
conditions.append(f"s.enabled = ${param_idx}")
|
||||
params.append(enabled)
|
||||
param_idx += 1
|
||||
|
||||
if search:
|
||||
conditions.append(f"(s.name ILIKE ${param_idx} OR s.url ILIKE ${param_idx})")
|
||||
params.append(f"%{search}%")
|
||||
param_idx += 1
|
||||
|
||||
where_clause = " AND ".join(conditions) if conditions else "TRUE"
|
||||
|
||||
# Count total
|
||||
count_query = f"""
|
||||
SELECT COUNT(*) FROM edu_search_seeds s
|
||||
LEFT JOIN edu_search_categories c ON s.category_id = c.id
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
total = await conn.fetchval(count_query, *params)
|
||||
|
||||
# Get paginated results
|
||||
offset = (page - 1) * page_size
|
||||
params.extend([page_size, offset])
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
s.id, s.url, s.name, s.description,
|
||||
c.name as category, c.display_name as category_display_name,
|
||||
s.source_type, s.scope, s.state, s.trust_boost, s.enabled,
|
||||
s.crawl_depth, s.crawl_frequency, s.last_crawled_at,
|
||||
s.last_crawl_status, s.last_crawl_docs, s.total_documents,
|
||||
s.created_at, s.updated_at
|
||||
FROM edu_search_seeds s
|
||||
LEFT JOIN edu_search_categories c ON s.category_id = c.id
|
||||
WHERE {where_clause}
|
||||
ORDER BY c.sort_order, s.name
|
||||
LIMIT ${param_idx} OFFSET ${param_idx + 1}
|
||||
"""
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
|
||||
seeds = [
|
||||
SeedResponse(
|
||||
id=str(row["id"]),
|
||||
url=row["url"],
|
||||
name=row["name"],
|
||||
description=row["description"],
|
||||
category=row["category"],
|
||||
category_display_name=row["category_display_name"],
|
||||
source_type=row["source_type"],
|
||||
scope=row["scope"],
|
||||
state=row["state"],
|
||||
trust_boost=float(row["trust_boost"]),
|
||||
enabled=row["enabled"],
|
||||
crawl_depth=row["crawl_depth"],
|
||||
crawl_frequency=row["crawl_frequency"],
|
||||
last_crawled_at=row["last_crawled_at"],
|
||||
last_crawl_status=row["last_crawl_status"],
|
||||
last_crawl_docs=row["last_crawl_docs"] or 0,
|
||||
total_documents=row["total_documents"] or 0,
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return SeedsListResponse(
|
||||
seeds=seeds,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/seeds/{seed_id}", response_model=SeedResponse)
|
||||
async def get_seed(seed_id: str):
|
||||
"""Get a single seed by ID."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT
|
||||
s.id, s.url, s.name, s.description,
|
||||
c.name as category, c.display_name as category_display_name,
|
||||
s.source_type, s.scope, s.state, s.trust_boost, s.enabled,
|
||||
s.crawl_depth, s.crawl_frequency, s.last_crawled_at,
|
||||
s.last_crawl_status, s.last_crawl_docs, s.total_documents,
|
||||
s.created_at, s.updated_at
|
||||
FROM edu_search_seeds s
|
||||
LEFT JOIN edu_search_categories c ON s.category_id = c.id
|
||||
WHERE s.id = $1
|
||||
""", seed_id)
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Seed nicht gefunden")
|
||||
|
||||
return SeedResponse(
|
||||
id=str(row["id"]),
|
||||
url=row["url"],
|
||||
name=row["name"],
|
||||
description=row["description"],
|
||||
category=row["category"],
|
||||
category_display_name=row["category_display_name"],
|
||||
source_type=row["source_type"],
|
||||
scope=row["scope"],
|
||||
state=row["state"],
|
||||
trust_boost=float(row["trust_boost"]),
|
||||
enabled=row["enabled"],
|
||||
crawl_depth=row["crawl_depth"],
|
||||
crawl_frequency=row["crawl_frequency"],
|
||||
last_crawled_at=row["last_crawled_at"],
|
||||
last_crawl_status=row["last_crawl_status"],
|
||||
last_crawl_docs=row["last_crawl_docs"] or 0,
|
||||
total_documents=row["total_documents"] or 0,
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/seeds", response_model=SeedResponse, status_code=201)
|
||||
async def create_seed(seed: SeedCreate):
|
||||
"""Create a new seed URL."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Get category ID if provided
|
||||
category_id = None
|
||||
if seed.category_name:
|
||||
category_id = await conn.fetchval(
|
||||
"SELECT id FROM edu_search_categories WHERE name = $1",
|
||||
seed.category_name
|
||||
)
|
||||
|
||||
try:
|
||||
row = await conn.fetchrow("""
|
||||
INSERT INTO edu_search_seeds (
|
||||
url, name, description, category_id, source_type, scope,
|
||||
state, trust_boost, enabled, crawl_depth, crawl_frequency
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id, created_at, updated_at
|
||||
""",
|
||||
seed.url, seed.name, seed.description, category_id,
|
||||
seed.source_type, seed.scope, seed.state, seed.trust_boost,
|
||||
seed.enabled, seed.crawl_depth, seed.crawl_frequency
|
||||
)
|
||||
except asyncpg.UniqueViolationError:
|
||||
raise HTTPException(status_code=409, detail="URL existiert bereits")
|
||||
|
||||
return SeedResponse(
|
||||
id=str(row["id"]),
|
||||
url=seed.url,
|
||||
name=seed.name,
|
||||
description=seed.description,
|
||||
category=seed.category_name,
|
||||
category_display_name=None,
|
||||
source_type=seed.source_type,
|
||||
scope=seed.scope,
|
||||
state=seed.state,
|
||||
trust_boost=seed.trust_boost,
|
||||
enabled=seed.enabled,
|
||||
crawl_depth=seed.crawl_depth,
|
||||
crawl_frequency=seed.crawl_frequency,
|
||||
last_crawled_at=None,
|
||||
last_crawl_status=None,
|
||||
last_crawl_docs=0,
|
||||
total_documents=0,
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/seeds/{seed_id}", response_model=SeedResponse)
|
||||
async def update_seed(seed_id: str, seed: SeedUpdate):
|
||||
"""Update an existing seed."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Build update statement dynamically
|
||||
updates = []
|
||||
params = []
|
||||
param_idx = 1
|
||||
|
||||
if seed.url is not None:
|
||||
updates.append(f"url = ${param_idx}")
|
||||
params.append(seed.url)
|
||||
param_idx += 1
|
||||
|
||||
if seed.name is not None:
|
||||
updates.append(f"name = ${param_idx}")
|
||||
params.append(seed.name)
|
||||
param_idx += 1
|
||||
|
||||
if seed.description is not None:
|
||||
updates.append(f"description = ${param_idx}")
|
||||
params.append(seed.description)
|
||||
param_idx += 1
|
||||
|
||||
if seed.category_name is not None:
|
||||
category_id = await conn.fetchval(
|
||||
"SELECT id FROM edu_search_categories WHERE name = $1",
|
||||
seed.category_name
|
||||
)
|
||||
updates.append(f"category_id = ${param_idx}")
|
||||
params.append(category_id)
|
||||
param_idx += 1
|
||||
|
||||
if seed.source_type is not None:
|
||||
updates.append(f"source_type = ${param_idx}")
|
||||
params.append(seed.source_type)
|
||||
param_idx += 1
|
||||
|
||||
if seed.scope is not None:
|
||||
updates.append(f"scope = ${param_idx}")
|
||||
params.append(seed.scope)
|
||||
param_idx += 1
|
||||
|
||||
if seed.state is not None:
|
||||
updates.append(f"state = ${param_idx}")
|
||||
params.append(seed.state)
|
||||
param_idx += 1
|
||||
|
||||
if seed.trust_boost is not None:
|
||||
updates.append(f"trust_boost = ${param_idx}")
|
||||
params.append(seed.trust_boost)
|
||||
param_idx += 1
|
||||
|
||||
if seed.enabled is not None:
|
||||
updates.append(f"enabled = ${param_idx}")
|
||||
params.append(seed.enabled)
|
||||
param_idx += 1
|
||||
|
||||
if seed.crawl_depth is not None:
|
||||
updates.append(f"crawl_depth = ${param_idx}")
|
||||
params.append(seed.crawl_depth)
|
||||
param_idx += 1
|
||||
|
||||
if seed.crawl_frequency is not None:
|
||||
updates.append(f"crawl_frequency = ${param_idx}")
|
||||
params.append(seed.crawl_frequency)
|
||||
param_idx += 1
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||||
|
||||
updates.append("updated_at = NOW()")
|
||||
params.append(seed_id)
|
||||
|
||||
query = f"""
|
||||
UPDATE edu_search_seeds
|
||||
SET {", ".join(updates)}
|
||||
WHERE id = ${param_idx}
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
result = await conn.fetchrow(query, *params)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Seed nicht gefunden")
|
||||
|
||||
# Return updated seed
|
||||
return await get_seed(seed_id)
|
||||
|
||||
|
||||
@router.delete("/seeds/{seed_id}")
|
||||
async def delete_seed(seed_id: str):
|
||||
"""Delete a seed."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM edu_search_seeds WHERE id = $1",
|
||||
seed_id
|
||||
)
|
||||
if result == "DELETE 0":
|
||||
raise HTTPException(status_code=404, detail="Seed nicht gefunden")
|
||||
|
||||
return {"status": "deleted", "id": seed_id}
|
||||
|
||||
|
||||
@router.post("/seeds/bulk-import", response_model=BulkImportResponse)
|
||||
async def bulk_import_seeds(request: BulkImportRequest):
|
||||
"""Bulk import seeds (skip duplicates)."""
|
||||
pool = await get_db_pool()
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Pre-fetch all category IDs
|
||||
categories = {}
|
||||
rows = await conn.fetch("SELECT id, name FROM edu_search_categories")
|
||||
for row in rows:
|
||||
categories[row["name"]] = row["id"]
|
||||
|
||||
for seed in request.seeds:
|
||||
try:
|
||||
category_id = categories.get(seed.category_name) if seed.category_name else None
|
||||
|
||||
await conn.execute("""
|
||||
INSERT INTO edu_search_seeds (
|
||||
url, name, description, category_id, source_type, scope,
|
||||
state, trust_boost, enabled, crawl_depth, crawl_frequency
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (url) DO NOTHING
|
||||
""",
|
||||
seed.url, seed.name, seed.description, category_id,
|
||||
seed.source_type, seed.scope, seed.state, seed.trust_boost,
|
||||
seed.enabled, seed.crawl_depth, seed.crawl_frequency
|
||||
)
|
||||
imported += 1
|
||||
except asyncpg.UniqueViolationError:
|
||||
skipped += 1
|
||||
except Exception as e:
|
||||
errors.append(f"{seed.url}: {str(e)}")
|
||||
|
||||
return BulkImportResponse(imported=imported, skipped=skipped, errors=errors)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=StatsResponse)
|
||||
async def get_stats():
|
||||
"""Get crawl statistics."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Basic counts
|
||||
total = await conn.fetchval("SELECT COUNT(*) FROM edu_search_seeds")
|
||||
enabled = await conn.fetchval("SELECT COUNT(*) FROM edu_search_seeds WHERE enabled = TRUE")
|
||||
total_docs = await conn.fetchval("SELECT COALESCE(SUM(total_documents), 0) FROM edu_search_seeds")
|
||||
|
||||
# By category
|
||||
cat_rows = await conn.fetch("""
|
||||
SELECT c.name, COUNT(s.id) as count
|
||||
FROM edu_search_categories c
|
||||
LEFT JOIN edu_search_seeds s ON c.id = s.category_id
|
||||
GROUP BY c.name
|
||||
""")
|
||||
by_category = {row["name"]: row["count"] for row in cat_rows}
|
||||
|
||||
# By state
|
||||
state_rows = await conn.fetch("""
|
||||
SELECT COALESCE(state, 'federal') as state, COUNT(*) as count
|
||||
FROM edu_search_seeds
|
||||
GROUP BY state
|
||||
""")
|
||||
by_state = {row["state"]: row["count"] for row in state_rows}
|
||||
|
||||
# Last crawl time
|
||||
last_crawl = await conn.fetchval(
|
||||
"SELECT MAX(last_crawled_at) FROM edu_search_seeds"
|
||||
)
|
||||
|
||||
return StatsResponse(
|
||||
total_seeds=total,
|
||||
enabled_seeds=enabled,
|
||||
total_documents=total_docs,
|
||||
seeds_by_category=by_category,
|
||||
seeds_by_state=by_state,
|
||||
last_crawl_time=last_crawl,
|
||||
)
|
||||
|
||||
|
||||
# Export for external use (edu-search-service)
|
||||
@router.get("/seeds/export/for-crawler")
|
||||
async def export_seeds_for_crawler():
|
||||
"""Export enabled seeds in format suitable for crawler."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT
|
||||
s.url, s.trust_boost, s.source_type, s.scope, s.state,
|
||||
s.crawl_depth, c.name as category
|
||||
FROM edu_search_seeds s
|
||||
LEFT JOIN edu_search_categories c ON s.category_id = c.id
|
||||
WHERE s.enabled = TRUE
|
||||
ORDER BY s.trust_boost DESC
|
||||
""")
|
||||
|
||||
return {
|
||||
"seeds": [
|
||||
{
|
||||
"url": row["url"],
|
||||
"trust": float(row["trust_boost"]),
|
||||
"source": row["source_type"],
|
||||
"scope": row["scope"],
|
||||
"state": row["state"],
|
||||
"depth": row["crawl_depth"],
|
||||
"category": row["category"],
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"total": len(rows),
|
||||
"exported_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Crawl Status Feedback (from edu-search-service)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CrawlStatusUpdate(BaseModel):
|
||||
"""Crawl status update from edu-search-service."""
|
||||
seed_url: str = Field(..., description="The seed URL that was crawled")
|
||||
status: str = Field(..., description="Crawl status: success, error, partial")
|
||||
documents_crawled: int = Field(0, ge=0, description="Number of documents crawled")
|
||||
error_message: Optional[str] = Field(None, description="Error message if status is error")
|
||||
crawl_duration_seconds: float = Field(0.0, ge=0.0, description="Duration of the crawl in seconds")
|
||||
|
||||
|
||||
class CrawlStatusResponse(BaseModel):
|
||||
"""Response for crawl status update."""
|
||||
success: bool
|
||||
seed_url: str
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/seeds/crawl-status", response_model=CrawlStatusResponse)
|
||||
async def update_crawl_status(update: CrawlStatusUpdate):
|
||||
"""Update crawl status for a seed URL (called by edu-search-service)."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Find the seed by URL
|
||||
seed = await conn.fetchrow(
|
||||
"SELECT id, total_documents FROM edu_search_seeds WHERE url = $1",
|
||||
update.seed_url
|
||||
)
|
||||
|
||||
if not seed:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Seed nicht gefunden: {update.seed_url}"
|
||||
)
|
||||
|
||||
# Update the seed with crawl status
|
||||
new_total = (seed["total_documents"] or 0) + update.documents_crawled
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE edu_search_seeds
|
||||
SET
|
||||
last_crawled_at = NOW(),
|
||||
last_crawl_status = $2,
|
||||
last_crawl_docs = $3,
|
||||
total_documents = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
""", seed["id"], update.status, update.documents_crawled, new_total)
|
||||
|
||||
logger.info(
|
||||
f"Crawl status updated: {update.seed_url} - "
|
||||
f"status={update.status}, docs={update.documents_crawled}, "
|
||||
f"duration={update.crawl_duration_seconds:.1f}s"
|
||||
)
|
||||
|
||||
return CrawlStatusResponse(
|
||||
success=True,
|
||||
seed_url=update.seed_url,
|
||||
message=f"Status aktualisiert: {update.documents_crawled} Dokumente gecrawlt"
|
||||
)
|
||||
|
||||
|
||||
class BulkCrawlStatusUpdate(BaseModel):
|
||||
"""Bulk crawl status update."""
|
||||
updates: List[CrawlStatusUpdate]
|
||||
|
||||
|
||||
class BulkCrawlStatusResponse(BaseModel):
|
||||
"""Response for bulk crawl status update."""
|
||||
updated: int
|
||||
failed: int
|
||||
errors: List[str]
|
||||
|
||||
|
||||
@router.post("/seeds/crawl-status/bulk", response_model=BulkCrawlStatusResponse)
|
||||
async def bulk_update_crawl_status(request: BulkCrawlStatusUpdate):
|
||||
"""Bulk update crawl status for multiple seeds."""
|
||||
pool = await get_db_pool()
|
||||
updated = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
for update in request.updates:
|
||||
try:
|
||||
seed = await conn.fetchrow(
|
||||
"SELECT id, total_documents FROM edu_search_seeds WHERE url = $1",
|
||||
update.seed_url
|
||||
)
|
||||
|
||||
if not seed:
|
||||
failed += 1
|
||||
errors.append(f"Seed nicht gefunden: {update.seed_url}")
|
||||
continue
|
||||
|
||||
new_total = (seed["total_documents"] or 0) + update.documents_crawled
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE edu_search_seeds
|
||||
SET
|
||||
last_crawled_at = NOW(),
|
||||
last_crawl_status = $2,
|
||||
last_crawl_docs = $3,
|
||||
total_documents = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
""", seed["id"], update.status, update.documents_crawled, new_total)
|
||||
|
||||
updated += 1
|
||||
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
errors.append(f"{update.seed_url}: {str(e)}")
|
||||
|
||||
logger.info(f"Bulk crawl status update: {updated} updated, {failed} failed")
|
||||
|
||||
return BulkCrawlStatusResponse(
|
||||
updated=updated,
|
||||
failed=failed,
|
||||
errors=errors
|
||||
)
|
||||
router.include_router(_crud_router)
|
||||
router.include_router(_status_router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"get_db_pool",
|
||||
# Models
|
||||
"CategoryResponse",
|
||||
"SeedBase",
|
||||
"SeedCreate",
|
||||
"SeedUpdate",
|
||||
"SeedResponse",
|
||||
"SeedsListResponse",
|
||||
"StatsResponse",
|
||||
"BulkImportRequest",
|
||||
"BulkImportResponse",
|
||||
"CrawlStatusUpdate",
|
||||
"CrawlStatusResponse",
|
||||
"BulkCrawlStatusUpdate",
|
||||
"BulkCrawlStatusResponse",
|
||||
]
|
||||
|
||||
198
backend-lehrer/llm_gateway/routes/edu_search_status.py
Normal file
198
backend-lehrer/llm_gateway/routes/edu_search_status.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
EduSearch Seeds Stats & Crawl Status Routes.
|
||||
|
||||
Statistics, export for crawler, and crawl status feedback endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import asyncpg
|
||||
|
||||
from .edu_search_models import (
|
||||
StatsResponse,
|
||||
CrawlStatusUpdate,
|
||||
CrawlStatusResponse,
|
||||
BulkCrawlStatusUpdate,
|
||||
BulkCrawlStatusResponse,
|
||||
)
|
||||
from .edu_search_crud import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["edu-search"])
|
||||
|
||||
|
||||
@router.get("/stats", response_model=StatsResponse)
|
||||
async def get_stats():
|
||||
"""Get crawl statistics."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Basic counts
|
||||
total = await conn.fetchval("SELECT COUNT(*) FROM edu_search_seeds")
|
||||
enabled = await conn.fetchval("SELECT COUNT(*) FROM edu_search_seeds WHERE enabled = TRUE")
|
||||
total_docs = await conn.fetchval("SELECT COALESCE(SUM(total_documents), 0) FROM edu_search_seeds")
|
||||
|
||||
# By category
|
||||
cat_rows = await conn.fetch("""
|
||||
SELECT c.name, COUNT(s.id) as count
|
||||
FROM edu_search_categories c
|
||||
LEFT JOIN edu_search_seeds s ON c.id = s.category_id
|
||||
GROUP BY c.name
|
||||
""")
|
||||
by_category = {row["name"]: row["count"] for row in cat_rows}
|
||||
|
||||
# By state
|
||||
state_rows = await conn.fetch("""
|
||||
SELECT COALESCE(state, 'federal') as state, COUNT(*) as count
|
||||
FROM edu_search_seeds
|
||||
GROUP BY state
|
||||
""")
|
||||
by_state = {row["state"]: row["count"] for row in state_rows}
|
||||
|
||||
# Last crawl time
|
||||
last_crawl = await conn.fetchval(
|
||||
"SELECT MAX(last_crawled_at) FROM edu_search_seeds"
|
||||
)
|
||||
|
||||
return StatsResponse(
|
||||
total_seeds=total,
|
||||
enabled_seeds=enabled,
|
||||
total_documents=total_docs,
|
||||
seeds_by_category=by_category,
|
||||
seeds_by_state=by_state,
|
||||
last_crawl_time=last_crawl,
|
||||
)
|
||||
|
||||
|
||||
# Export for external use (edu-search-service)
|
||||
@router.get("/seeds/export/for-crawler")
|
||||
async def export_seeds_for_crawler():
|
||||
"""Export enabled seeds in format suitable for crawler."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT
|
||||
s.url, s.trust_boost, s.source_type, s.scope, s.state,
|
||||
s.crawl_depth, c.name as category
|
||||
FROM edu_search_seeds s
|
||||
LEFT JOIN edu_search_categories c ON s.category_id = c.id
|
||||
WHERE s.enabled = TRUE
|
||||
ORDER BY s.trust_boost DESC
|
||||
""")
|
||||
|
||||
return {
|
||||
"seeds": [
|
||||
{
|
||||
"url": row["url"],
|
||||
"trust": float(row["trust_boost"]),
|
||||
"source": row["source_type"],
|
||||
"scope": row["scope"],
|
||||
"state": row["state"],
|
||||
"depth": row["crawl_depth"],
|
||||
"category": row["category"],
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"total": len(rows),
|
||||
"exported_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Crawl Status Feedback (from edu-search-service)
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/seeds/crawl-status", response_model=CrawlStatusResponse)
|
||||
async def update_crawl_status(update: CrawlStatusUpdate):
|
||||
"""Update crawl status for a seed URL (called by edu-search-service)."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Find the seed by URL
|
||||
seed = await conn.fetchrow(
|
||||
"SELECT id, total_documents FROM edu_search_seeds WHERE url = $1",
|
||||
update.seed_url
|
||||
)
|
||||
|
||||
if not seed:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Seed nicht gefunden: {update.seed_url}"
|
||||
)
|
||||
|
||||
# Update the seed with crawl status
|
||||
new_total = (seed["total_documents"] or 0) + update.documents_crawled
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE edu_search_seeds
|
||||
SET
|
||||
last_crawled_at = NOW(),
|
||||
last_crawl_status = $2,
|
||||
last_crawl_docs = $3,
|
||||
total_documents = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
""", seed["id"], update.status, update.documents_crawled, new_total)
|
||||
|
||||
logger.info(
|
||||
f"Crawl status updated: {update.seed_url} - "
|
||||
f"status={update.status}, docs={update.documents_crawled}, "
|
||||
f"duration={update.crawl_duration_seconds:.1f}s"
|
||||
)
|
||||
|
||||
return CrawlStatusResponse(
|
||||
success=True,
|
||||
seed_url=update.seed_url,
|
||||
message=f"Status aktualisiert: {update.documents_crawled} Dokumente gecrawlt"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/seeds/crawl-status/bulk", response_model=BulkCrawlStatusResponse)
|
||||
async def bulk_update_crawl_status(request: BulkCrawlStatusUpdate):
|
||||
"""Bulk update crawl status for multiple seeds."""
|
||||
pool = await get_db_pool()
|
||||
updated = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
for update in request.updates:
|
||||
try:
|
||||
seed = await conn.fetchrow(
|
||||
"SELECT id, total_documents FROM edu_search_seeds WHERE url = $1",
|
||||
update.seed_url
|
||||
)
|
||||
|
||||
if not seed:
|
||||
failed += 1
|
||||
errors.append(f"Seed nicht gefunden: {update.seed_url}")
|
||||
continue
|
||||
|
||||
new_total = (seed["total_documents"] or 0) + update.documents_crawled
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE edu_search_seeds
|
||||
SET
|
||||
last_crawled_at = NOW(),
|
||||
last_crawl_status = $2,
|
||||
last_crawl_docs = $3,
|
||||
total_documents = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
""", seed["id"], update.status, update.documents_crawled, new_total)
|
||||
|
||||
updated += 1
|
||||
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
errors.append(f"{update.seed_url}: {str(e)}")
|
||||
|
||||
logger.info(f"Bulk crawl status update: {updated} updated, {failed} failed")
|
||||
|
||||
return BulkCrawlStatusResponse(
|
||||
updated=updated,
|
||||
failed=failed,
|
||||
errors=errors
|
||||
)
|
||||
@@ -1,867 +1,38 @@
|
||||
"""
|
||||
Schools API Routes.
|
||||
Schools API Routes — Barrel Re-export.
|
||||
|
||||
CRUD operations for managing German schools (~40,000 schools).
|
||||
Direct database access to PostgreSQL.
|
||||
Split into:
|
||||
- schools_models.py: Pydantic models
|
||||
- schools_db.py: Database connection pool
|
||||
- schools_crud.py: School CRUD & stats routes
|
||||
- schools_staff.py: Staff CRUD & search routes
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
import asyncpg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from .schools_crud import router as _crud_router
|
||||
from .schools_staff import router as _staff_router
|
||||
|
||||
# Single router that merges both sub-module routers
|
||||
router = APIRouter(prefix="/schools", tags=["schools"])
|
||||
|
||||
# Database connection pool
|
||||
_pool: Optional[asyncpg.Pool] = None
|
||||
|
||||
|
||||
async def get_db_pool() -> asyncpg.Pool:
|
||||
"""Get or create database connection pool."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
database_url = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://breakpilot:breakpilot123@postgres:5432/breakpilot_db"
|
||||
)
|
||||
_pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10)
|
||||
return _pool
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SchoolTypeResponse(BaseModel):
|
||||
"""School type response model."""
|
||||
id: str
|
||||
name: str
|
||||
name_short: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class SchoolBase(BaseModel):
|
||||
"""Base school model for creation/update."""
|
||||
name: str = Field(..., max_length=255)
|
||||
school_number: Optional[str] = Field(None, max_length=20)
|
||||
school_type_id: Optional[str] = None
|
||||
school_type_raw: Optional[str] = None
|
||||
state: str = Field(..., max_length=10)
|
||||
district: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
street: Optional[str] = None
|
||||
address_full: Optional[str] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
website: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
fax: Optional[str] = None
|
||||
principal_name: Optional[str] = None
|
||||
principal_title: Optional[str] = None
|
||||
principal_email: Optional[str] = None
|
||||
principal_phone: Optional[str] = None
|
||||
secretary_name: Optional[str] = None
|
||||
secretary_email: Optional[str] = None
|
||||
secretary_phone: Optional[str] = None
|
||||
student_count: Optional[int] = None
|
||||
teacher_count: Optional[int] = None
|
||||
class_count: Optional[int] = None
|
||||
founded_year: Optional[int] = None
|
||||
is_public: bool = True
|
||||
is_all_day: Optional[bool] = None
|
||||
has_inclusion: Optional[bool] = None
|
||||
languages: Optional[List[str]] = None
|
||||
specializations: Optional[List[str]] = None
|
||||
source: Optional[str] = None
|
||||
source_url: Optional[str] = None
|
||||
|
||||
|
||||
class SchoolCreate(SchoolBase):
|
||||
"""School creation model."""
|
||||
pass
|
||||
|
||||
|
||||
class SchoolUpdate(BaseModel):
|
||||
"""School update model (all fields optional)."""
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
school_number: Optional[str] = None
|
||||
school_type_id: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
district: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
street: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
principal_name: Optional[str] = None
|
||||
student_count: Optional[int] = None
|
||||
teacher_count: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class SchoolResponse(BaseModel):
|
||||
"""School response model."""
|
||||
id: str
|
||||
name: str
|
||||
school_number: Optional[str] = None
|
||||
school_type: Optional[str] = None
|
||||
school_type_short: Optional[str] = None
|
||||
school_category: Optional[str] = None
|
||||
state: str
|
||||
district: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
street: Optional[str] = None
|
||||
address_full: Optional[str] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
website: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
fax: Optional[str] = None
|
||||
principal_name: Optional[str] = None
|
||||
principal_email: Optional[str] = None
|
||||
student_count: Optional[int] = None
|
||||
teacher_count: Optional[int] = None
|
||||
is_public: bool = True
|
||||
is_all_day: Optional[bool] = None
|
||||
staff_count: int = 0
|
||||
source: Optional[str] = None
|
||||
crawled_at: Optional[datetime] = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SchoolsListResponse(BaseModel):
|
||||
"""List response with pagination info."""
|
||||
schools: List[SchoolResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class SchoolStaffBase(BaseModel):
|
||||
"""Base school staff model."""
|
||||
first_name: Optional[str] = None
|
||||
last_name: str
|
||||
full_name: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
position_type: Optional[str] = None
|
||||
subjects: Optional[List[str]] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
class SchoolStaffCreate(SchoolStaffBase):
|
||||
"""School staff creation model."""
|
||||
school_id: str
|
||||
|
||||
|
||||
class SchoolStaffResponse(SchoolStaffBase):
|
||||
"""School staff response model."""
|
||||
id: str
|
||||
school_id: str
|
||||
school_name: Optional[str] = None
|
||||
profile_url: Optional[str] = None
|
||||
photo_url: Optional[str] = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SchoolStaffListResponse(BaseModel):
|
||||
"""Staff list response."""
|
||||
staff: List[SchoolStaffResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class SchoolStatsResponse(BaseModel):
|
||||
"""School statistics response."""
|
||||
total_schools: int
|
||||
total_staff: int
|
||||
schools_by_state: dict
|
||||
schools_by_type: dict
|
||||
schools_with_website: int
|
||||
schools_with_email: int
|
||||
schools_with_principal: int
|
||||
total_students: int
|
||||
total_teachers: int
|
||||
last_crawl_time: Optional[datetime] = None
|
||||
|
||||
|
||||
class BulkImportRequest(BaseModel):
|
||||
"""Bulk import request."""
|
||||
schools: List[SchoolCreate]
|
||||
|
||||
|
||||
class BulkImportResponse(BaseModel):
|
||||
"""Bulk import response."""
|
||||
imported: int
|
||||
updated: int
|
||||
skipped: int
|
||||
errors: List[str]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# School Type Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/types", response_model=List[SchoolTypeResponse])
|
||||
async def list_school_types():
|
||||
"""List all school types."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT id, name, name_short, category, description
|
||||
FROM school_types
|
||||
ORDER BY category, name
|
||||
""")
|
||||
return [
|
||||
SchoolTypeResponse(
|
||||
id=str(row["id"]),
|
||||
name=row["name"],
|
||||
name_short=row["name_short"],
|
||||
category=row["category"],
|
||||
description=row["description"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# School Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=SchoolsListResponse)
|
||||
async def list_schools(
|
||||
state: Optional[str] = Query(None, description="Filter by state code (BW, BY, etc.)"),
|
||||
school_type: Optional[str] = Query(None, description="Filter by school type name"),
|
||||
city: Optional[str] = Query(None, description="Filter by city"),
|
||||
district: Optional[str] = Query(None, description="Filter by district"),
|
||||
postal_code: Optional[str] = Query(None, description="Filter by postal code prefix"),
|
||||
search: Optional[str] = Query(None, description="Search in name, city"),
|
||||
has_email: Optional[bool] = Query(None, description="Filter schools with email"),
|
||||
has_website: Optional[bool] = Query(None, description="Filter schools with website"),
|
||||
is_public: Optional[bool] = Query(None, description="Filter public/private schools"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
"""List schools with optional filtering and pagination."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Build WHERE clause
|
||||
conditions = ["s.is_active = TRUE"]
|
||||
params = []
|
||||
param_idx = 1
|
||||
|
||||
if state:
|
||||
conditions.append(f"s.state = ${param_idx}")
|
||||
params.append(state.upper())
|
||||
param_idx += 1
|
||||
|
||||
if school_type:
|
||||
conditions.append(f"st.name = ${param_idx}")
|
||||
params.append(school_type)
|
||||
param_idx += 1
|
||||
|
||||
if city:
|
||||
conditions.append(f"LOWER(s.city) = LOWER(${param_idx})")
|
||||
params.append(city)
|
||||
param_idx += 1
|
||||
|
||||
if district:
|
||||
conditions.append(f"LOWER(s.district) LIKE LOWER(${param_idx})")
|
||||
params.append(f"%{district}%")
|
||||
param_idx += 1
|
||||
|
||||
if postal_code:
|
||||
conditions.append(f"s.postal_code LIKE ${param_idx}")
|
||||
params.append(f"{postal_code}%")
|
||||
param_idx += 1
|
||||
|
||||
if search:
|
||||
conditions.append(f"""
|
||||
(LOWER(s.name) LIKE LOWER(${param_idx})
|
||||
OR LOWER(s.city) LIKE LOWER(${param_idx})
|
||||
OR LOWER(s.district) LIKE LOWER(${param_idx}))
|
||||
""")
|
||||
params.append(f"%{search}%")
|
||||
param_idx += 1
|
||||
|
||||
if has_email is not None:
|
||||
if has_email:
|
||||
conditions.append("s.email IS NOT NULL")
|
||||
else:
|
||||
conditions.append("s.email IS NULL")
|
||||
|
||||
if has_website is not None:
|
||||
if has_website:
|
||||
conditions.append("s.website IS NOT NULL")
|
||||
else:
|
||||
conditions.append("s.website IS NULL")
|
||||
|
||||
if is_public is not None:
|
||||
conditions.append(f"s.is_public = ${param_idx}")
|
||||
params.append(is_public)
|
||||
param_idx += 1
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
# Count total
|
||||
count_query = f"""
|
||||
SELECT COUNT(*) FROM schools s
|
||||
LEFT JOIN school_types st ON s.school_type_id = st.id
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
total = await conn.fetchval(count_query, *params)
|
||||
|
||||
# Fetch schools
|
||||
offset = (page - 1) * page_size
|
||||
query = f"""
|
||||
SELECT
|
||||
s.id, s.name, s.school_number, s.state, s.district, s.city,
|
||||
s.postal_code, s.street, s.address_full, s.latitude, s.longitude,
|
||||
s.website, s.email, s.phone, s.fax,
|
||||
s.principal_name, s.principal_email,
|
||||
s.student_count, s.teacher_count,
|
||||
s.is_public, s.is_all_day, s.source, s.crawled_at,
|
||||
s.is_active, s.created_at, s.updated_at,
|
||||
st.name as school_type, st.name_short as school_type_short, st.category as school_category,
|
||||
(SELECT COUNT(*) FROM school_staff ss WHERE ss.school_id = s.id AND ss.is_active = TRUE) as staff_count
|
||||
FROM schools s
|
||||
LEFT JOIN school_types st ON s.school_type_id = st.id
|
||||
WHERE {where_clause}
|
||||
ORDER BY s.state, s.city, s.name
|
||||
LIMIT ${param_idx} OFFSET ${param_idx + 1}
|
||||
"""
|
||||
params.extend([page_size, offset])
|
||||
rows = await conn.fetch(query, *params)
|
||||
|
||||
schools = [
|
||||
SchoolResponse(
|
||||
id=str(row["id"]),
|
||||
name=row["name"],
|
||||
school_number=row["school_number"],
|
||||
school_type=row["school_type"],
|
||||
school_type_short=row["school_type_short"],
|
||||
school_category=row["school_category"],
|
||||
state=row["state"],
|
||||
district=row["district"],
|
||||
city=row["city"],
|
||||
postal_code=row["postal_code"],
|
||||
street=row["street"],
|
||||
address_full=row["address_full"],
|
||||
latitude=row["latitude"],
|
||||
longitude=row["longitude"],
|
||||
website=row["website"],
|
||||
email=row["email"],
|
||||
phone=row["phone"],
|
||||
fax=row["fax"],
|
||||
principal_name=row["principal_name"],
|
||||
principal_email=row["principal_email"],
|
||||
student_count=row["student_count"],
|
||||
teacher_count=row["teacher_count"],
|
||||
is_public=row["is_public"],
|
||||
is_all_day=row["is_all_day"],
|
||||
staff_count=row["staff_count"],
|
||||
source=row["source"],
|
||||
crawled_at=row["crawled_at"],
|
||||
is_active=row["is_active"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return SchoolsListResponse(
|
||||
schools=schools,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=SchoolStatsResponse)
|
||||
async def get_school_stats():
|
||||
"""Get school statistics."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Total schools and staff
|
||||
totals = await conn.fetchrow("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM schools WHERE is_active = TRUE) as total_schools,
|
||||
(SELECT COUNT(*) FROM school_staff WHERE is_active = TRUE) as total_staff,
|
||||
(SELECT COUNT(*) FROM schools WHERE is_active = TRUE AND website IS NOT NULL) as with_website,
|
||||
(SELECT COUNT(*) FROM schools WHERE is_active = TRUE AND email IS NOT NULL) as with_email,
|
||||
(SELECT COUNT(*) FROM schools WHERE is_active = TRUE AND principal_name IS NOT NULL) as with_principal,
|
||||
(SELECT COALESCE(SUM(student_count), 0) FROM schools WHERE is_active = TRUE) as total_students,
|
||||
(SELECT COALESCE(SUM(teacher_count), 0) FROM schools WHERE is_active = TRUE) as total_teachers,
|
||||
(SELECT MAX(crawled_at) FROM schools) as last_crawl
|
||||
""")
|
||||
|
||||
# By state
|
||||
state_rows = await conn.fetch("""
|
||||
SELECT state, COUNT(*) as count
|
||||
FROM schools
|
||||
WHERE is_active = TRUE
|
||||
GROUP BY state
|
||||
ORDER BY state
|
||||
""")
|
||||
schools_by_state = {row["state"]: row["count"] for row in state_rows}
|
||||
|
||||
# By type
|
||||
type_rows = await conn.fetch("""
|
||||
SELECT COALESCE(st.name, 'Unbekannt') as type_name, COUNT(*) as count
|
||||
FROM schools s
|
||||
LEFT JOIN school_types st ON s.school_type_id = st.id
|
||||
WHERE s.is_active = TRUE
|
||||
GROUP BY st.name
|
||||
ORDER BY count DESC
|
||||
""")
|
||||
schools_by_type = {row["type_name"]: row["count"] for row in type_rows}
|
||||
|
||||
return SchoolStatsResponse(
|
||||
total_schools=totals["total_schools"],
|
||||
total_staff=totals["total_staff"],
|
||||
schools_by_state=schools_by_state,
|
||||
schools_by_type=schools_by_type,
|
||||
schools_with_website=totals["with_website"],
|
||||
schools_with_email=totals["with_email"],
|
||||
schools_with_principal=totals["with_principal"],
|
||||
total_students=totals["total_students"],
|
||||
total_teachers=totals["total_teachers"],
|
||||
last_crawl_time=totals["last_crawl"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{school_id}", response_model=SchoolResponse)
|
||||
async def get_school(school_id: str):
|
||||
"""Get a single school by ID."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT
|
||||
s.id, s.name, s.school_number, s.state, s.district, s.city,
|
||||
s.postal_code, s.street, s.address_full, s.latitude, s.longitude,
|
||||
s.website, s.email, s.phone, s.fax,
|
||||
s.principal_name, s.principal_email,
|
||||
s.student_count, s.teacher_count,
|
||||
s.is_public, s.is_all_day, s.source, s.crawled_at,
|
||||
s.is_active, s.created_at, s.updated_at,
|
||||
st.name as school_type, st.name_short as school_type_short, st.category as school_category,
|
||||
(SELECT COUNT(*) FROM school_staff ss WHERE ss.school_id = s.id AND ss.is_active = TRUE) as staff_count
|
||||
FROM schools s
|
||||
LEFT JOIN school_types st ON s.school_type_id = st.id
|
||||
WHERE s.id = $1
|
||||
""", school_id)
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="School not found")
|
||||
|
||||
return SchoolResponse(
|
||||
id=str(row["id"]),
|
||||
name=row["name"],
|
||||
school_number=row["school_number"],
|
||||
school_type=row["school_type"],
|
||||
school_type_short=row["school_type_short"],
|
||||
school_category=row["school_category"],
|
||||
state=row["state"],
|
||||
district=row["district"],
|
||||
city=row["city"],
|
||||
postal_code=row["postal_code"],
|
||||
street=row["street"],
|
||||
address_full=row["address_full"],
|
||||
latitude=row["latitude"],
|
||||
longitude=row["longitude"],
|
||||
website=row["website"],
|
||||
email=row["email"],
|
||||
phone=row["phone"],
|
||||
fax=row["fax"],
|
||||
principal_name=row["principal_name"],
|
||||
principal_email=row["principal_email"],
|
||||
student_count=row["student_count"],
|
||||
teacher_count=row["teacher_count"],
|
||||
is_public=row["is_public"],
|
||||
is_all_day=row["is_all_day"],
|
||||
staff_count=row["staff_count"],
|
||||
source=row["source"],
|
||||
crawled_at=row["crawled_at"],
|
||||
is_active=row["is_active"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk-import", response_model=BulkImportResponse)
|
||||
async def bulk_import_schools(request: BulkImportRequest):
|
||||
"""Bulk import schools. Updates existing schools based on school_number + state."""
|
||||
pool = await get_db_pool()
|
||||
imported = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Get school type mapping
|
||||
type_rows = await conn.fetch("SELECT id, name FROM school_types")
|
||||
type_map = {row["name"].lower(): str(row["id"]) for row in type_rows}
|
||||
|
||||
for school in request.schools:
|
||||
try:
|
||||
# Find school type ID
|
||||
school_type_id = None
|
||||
if school.school_type_raw:
|
||||
school_type_id = type_map.get(school.school_type_raw.lower())
|
||||
|
||||
# Check if school exists (by school_number + state, or by name + city + state)
|
||||
existing = None
|
||||
if school.school_number:
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id FROM schools WHERE school_number = $1 AND state = $2",
|
||||
school.school_number, school.state
|
||||
)
|
||||
if not existing and school.city:
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id FROM schools WHERE LOWER(name) = LOWER($1) AND LOWER(city) = LOWER($2) AND state = $3",
|
||||
school.name, school.city, school.state
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update existing school
|
||||
await conn.execute("""
|
||||
UPDATE schools SET
|
||||
name = $2,
|
||||
school_type_id = COALESCE($3, school_type_id),
|
||||
school_type_raw = COALESCE($4, school_type_raw),
|
||||
district = COALESCE($5, district),
|
||||
city = COALESCE($6, city),
|
||||
postal_code = COALESCE($7, postal_code),
|
||||
street = COALESCE($8, street),
|
||||
address_full = COALESCE($9, address_full),
|
||||
latitude = COALESCE($10, latitude),
|
||||
longitude = COALESCE($11, longitude),
|
||||
website = COALESCE($12, website),
|
||||
email = COALESCE($13, email),
|
||||
phone = COALESCE($14, phone),
|
||||
fax = COALESCE($15, fax),
|
||||
principal_name = COALESCE($16, principal_name),
|
||||
principal_title = COALESCE($17, principal_title),
|
||||
principal_email = COALESCE($18, principal_email),
|
||||
principal_phone = COALESCE($19, principal_phone),
|
||||
student_count = COALESCE($20, student_count),
|
||||
teacher_count = COALESCE($21, teacher_count),
|
||||
is_public = $22,
|
||||
source = COALESCE($23, source),
|
||||
source_url = COALESCE($24, source_url),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
""",
|
||||
existing["id"],
|
||||
school.name,
|
||||
school_type_id,
|
||||
school.school_type_raw,
|
||||
school.district,
|
||||
school.city,
|
||||
school.postal_code,
|
||||
school.street,
|
||||
school.address_full,
|
||||
school.latitude,
|
||||
school.longitude,
|
||||
school.website,
|
||||
school.email,
|
||||
school.phone,
|
||||
school.fax,
|
||||
school.principal_name,
|
||||
school.principal_title,
|
||||
school.principal_email,
|
||||
school.principal_phone,
|
||||
school.student_count,
|
||||
school.teacher_count,
|
||||
school.is_public,
|
||||
school.source,
|
||||
school.source_url,
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
# Insert new school
|
||||
await conn.execute("""
|
||||
INSERT INTO schools (
|
||||
name, school_number, school_type_id, school_type_raw,
|
||||
state, district, city, postal_code, street, address_full,
|
||||
latitude, longitude, website, email, phone, fax,
|
||||
principal_name, principal_title, principal_email, principal_phone,
|
||||
student_count, teacher_count, is_public,
|
||||
source, source_url, crawled_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
|
||||
$21, $22, $23, $24, $25, NOW()
|
||||
)
|
||||
""",
|
||||
school.name,
|
||||
school.school_number,
|
||||
school_type_id,
|
||||
school.school_type_raw,
|
||||
school.state,
|
||||
school.district,
|
||||
school.city,
|
||||
school.postal_code,
|
||||
school.street,
|
||||
school.address_full,
|
||||
school.latitude,
|
||||
school.longitude,
|
||||
school.website,
|
||||
school.email,
|
||||
school.phone,
|
||||
school.fax,
|
||||
school.principal_name,
|
||||
school.principal_title,
|
||||
school.principal_email,
|
||||
school.principal_phone,
|
||||
school.student_count,
|
||||
school.teacher_count,
|
||||
school.is_public,
|
||||
school.source,
|
||||
school.source_url,
|
||||
)
|
||||
imported += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error importing {school.name}: {str(e)}")
|
||||
if len(errors) > 100:
|
||||
errors.append("... (more errors truncated)")
|
||||
break
|
||||
|
||||
return BulkImportResponse(
|
||||
imported=imported,
|
||||
updated=updated,
|
||||
skipped=skipped,
|
||||
errors=errors[:100],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# School Staff Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/{school_id}/staff", response_model=SchoolStaffListResponse)
|
||||
async def get_school_staff(school_id: str):
|
||||
"""Get staff members for a school."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT
|
||||
ss.id, ss.school_id, ss.first_name, ss.last_name, ss.full_name,
|
||||
ss.title, ss.position, ss.position_type, ss.subjects,
|
||||
ss.email, ss.phone, ss.profile_url, ss.photo_url,
|
||||
ss.is_active, ss.created_at,
|
||||
s.name as school_name
|
||||
FROM school_staff ss
|
||||
JOIN schools s ON ss.school_id = s.id
|
||||
WHERE ss.school_id = $1 AND ss.is_active = TRUE
|
||||
ORDER BY
|
||||
CASE ss.position_type
|
||||
WHEN 'principal' THEN 1
|
||||
WHEN 'vice_principal' THEN 2
|
||||
WHEN 'secretary' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
ss.last_name
|
||||
""", school_id)
|
||||
|
||||
staff = [
|
||||
SchoolStaffResponse(
|
||||
id=str(row["id"]),
|
||||
school_id=str(row["school_id"]),
|
||||
school_name=row["school_name"],
|
||||
first_name=row["first_name"],
|
||||
last_name=row["last_name"],
|
||||
full_name=row["full_name"],
|
||||
title=row["title"],
|
||||
position=row["position"],
|
||||
position_type=row["position_type"],
|
||||
subjects=row["subjects"],
|
||||
email=row["email"],
|
||||
phone=row["phone"],
|
||||
profile_url=row["profile_url"],
|
||||
photo_url=row["photo_url"],
|
||||
is_active=row["is_active"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return SchoolStaffListResponse(
|
||||
staff=staff,
|
||||
total=len(staff),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{school_id}/staff", response_model=SchoolStaffResponse)
|
||||
async def create_school_staff(school_id: str, staff: SchoolStaffBase):
|
||||
"""Add a staff member to a school."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Verify school exists
|
||||
school = await conn.fetchrow("SELECT name FROM schools WHERE id = $1", school_id)
|
||||
if not school:
|
||||
raise HTTPException(status_code=404, detail="School not found")
|
||||
|
||||
# Create full name
|
||||
full_name = staff.full_name
|
||||
if not full_name:
|
||||
parts = []
|
||||
if staff.title:
|
||||
parts.append(staff.title)
|
||||
if staff.first_name:
|
||||
parts.append(staff.first_name)
|
||||
parts.append(staff.last_name)
|
||||
full_name = " ".join(parts)
|
||||
|
||||
row = await conn.fetchrow("""
|
||||
INSERT INTO school_staff (
|
||||
school_id, first_name, last_name, full_name, title,
|
||||
position, position_type, subjects, email, phone
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, created_at
|
||||
""",
|
||||
school_id,
|
||||
staff.first_name,
|
||||
staff.last_name,
|
||||
full_name,
|
||||
staff.title,
|
||||
staff.position,
|
||||
staff.position_type,
|
||||
staff.subjects,
|
||||
staff.email,
|
||||
staff.phone,
|
||||
)
|
||||
|
||||
return SchoolStaffResponse(
|
||||
id=str(row["id"]),
|
||||
school_id=school_id,
|
||||
school_name=school["name"],
|
||||
first_name=staff.first_name,
|
||||
last_name=staff.last_name,
|
||||
full_name=full_name,
|
||||
title=staff.title,
|
||||
position=staff.position,
|
||||
position_type=staff.position_type,
|
||||
subjects=staff.subjects,
|
||||
email=staff.email,
|
||||
phone=staff.phone,
|
||||
is_active=True,
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Search Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/search/staff", response_model=SchoolStaffListResponse)
|
||||
async def search_school_staff(
|
||||
q: Optional[str] = Query(None, description="Search query"),
|
||||
state: Optional[str] = Query(None, description="Filter by state"),
|
||||
position_type: Optional[str] = Query(None, description="Filter by position type"),
|
||||
has_email: Optional[bool] = Query(None, description="Only staff with email"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
"""Search school staff across all schools."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
conditions = ["ss.is_active = TRUE", "s.is_active = TRUE"]
|
||||
params = []
|
||||
param_idx = 1
|
||||
|
||||
if q:
|
||||
conditions.append(f"""
|
||||
(LOWER(ss.full_name) LIKE LOWER(${param_idx})
|
||||
OR LOWER(ss.last_name) LIKE LOWER(${param_idx})
|
||||
OR LOWER(s.name) LIKE LOWER(${param_idx}))
|
||||
""")
|
||||
params.append(f"%{q}%")
|
||||
param_idx += 1
|
||||
|
||||
if state:
|
||||
conditions.append(f"s.state = ${param_idx}")
|
||||
params.append(state.upper())
|
||||
param_idx += 1
|
||||
|
||||
if position_type:
|
||||
conditions.append(f"ss.position_type = ${param_idx}")
|
||||
params.append(position_type)
|
||||
param_idx += 1
|
||||
|
||||
if has_email is not None and has_email:
|
||||
conditions.append("ss.email IS NOT NULL")
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
# Count total
|
||||
total = await conn.fetchval(f"""
|
||||
SELECT COUNT(*) FROM school_staff ss
|
||||
JOIN schools s ON ss.school_id = s.id
|
||||
WHERE {where_clause}
|
||||
""", *params)
|
||||
|
||||
# Fetch staff
|
||||
offset = (page - 1) * page_size
|
||||
rows = await conn.fetch(f"""
|
||||
SELECT
|
||||
ss.id, ss.school_id, ss.first_name, ss.last_name, ss.full_name,
|
||||
ss.title, ss.position, ss.position_type, ss.subjects,
|
||||
ss.email, ss.phone, ss.profile_url, ss.photo_url,
|
||||
ss.is_active, ss.created_at,
|
||||
s.name as school_name
|
||||
FROM school_staff ss
|
||||
JOIN schools s ON ss.school_id = s.id
|
||||
WHERE {where_clause}
|
||||
ORDER BY ss.last_name, ss.first_name
|
||||
LIMIT ${param_idx} OFFSET ${param_idx + 1}
|
||||
""", *params, page_size, offset)
|
||||
|
||||
staff = [
|
||||
SchoolStaffResponse(
|
||||
id=str(row["id"]),
|
||||
school_id=str(row["school_id"]),
|
||||
school_name=row["school_name"],
|
||||
first_name=row["first_name"],
|
||||
last_name=row["last_name"],
|
||||
full_name=row["full_name"],
|
||||
title=row["title"],
|
||||
position=row["position"],
|
||||
position_type=row["position_type"],
|
||||
subjects=row["subjects"],
|
||||
email=row["email"],
|
||||
phone=row["phone"],
|
||||
profile_url=row["profile_url"],
|
||||
photo_url=row["photo_url"],
|
||||
is_active=row["is_active"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return SchoolStaffListResponse(
|
||||
staff=staff,
|
||||
total=total,
|
||||
)
|
||||
router.include_router(_crud_router)
|
||||
router.include_router(_staff_router)
|
||||
|
||||
# Re-export models for any external consumers
|
||||
from .schools_models import ( # noqa: E402, F401
|
||||
SchoolTypeResponse,
|
||||
SchoolBase,
|
||||
SchoolCreate,
|
||||
SchoolUpdate,
|
||||
SchoolResponse,
|
||||
SchoolsListResponse,
|
||||
SchoolStaffBase,
|
||||
SchoolStaffCreate,
|
||||
SchoolStaffResponse,
|
||||
SchoolStaffListResponse,
|
||||
SchoolStatsResponse,
|
||||
BulkImportRequest,
|
||||
BulkImportResponse,
|
||||
)
|
||||
from .schools_db import get_db_pool # noqa: E402, F401
|
||||
|
||||
464
backend-lehrer/llm_gateway/routes/schools_crud.py
Normal file
464
backend-lehrer/llm_gateway/routes/schools_crud.py
Normal file
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
Schools API - School CRUD & Stats Routes.
|
||||
|
||||
List, get, stats, and bulk-import endpoints for schools.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from .schools_db import get_db_pool
|
||||
from .schools_models import (
|
||||
SchoolResponse,
|
||||
SchoolsListResponse,
|
||||
SchoolStatsResponse,
|
||||
SchoolTypeResponse,
|
||||
BulkImportRequest,
|
||||
BulkImportResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["schools"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# School Type Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/types", response_model=list[SchoolTypeResponse])
|
||||
async def list_school_types():
|
||||
"""List all school types."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT id, name, name_short, category, description
|
||||
FROM school_types
|
||||
ORDER BY category, name
|
||||
""")
|
||||
return [
|
||||
SchoolTypeResponse(
|
||||
id=str(row["id"]),
|
||||
name=row["name"],
|
||||
name_short=row["name_short"],
|
||||
category=row["category"],
|
||||
description=row["description"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# School Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=SchoolsListResponse)
|
||||
async def list_schools(
|
||||
state: Optional[str] = Query(None, description="Filter by state code (BW, BY, etc.)"),
|
||||
school_type: Optional[str] = Query(None, description="Filter by school type name"),
|
||||
city: Optional[str] = Query(None, description="Filter by city"),
|
||||
district: Optional[str] = Query(None, description="Filter by district"),
|
||||
postal_code: Optional[str] = Query(None, description="Filter by postal code prefix"),
|
||||
search: Optional[str] = Query(None, description="Search in name, city"),
|
||||
has_email: Optional[bool] = Query(None, description="Filter schools with email"),
|
||||
has_website: Optional[bool] = Query(None, description="Filter schools with website"),
|
||||
is_public: Optional[bool] = Query(None, description="Filter public/private schools"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
"""List schools with optional filtering and pagination."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Build WHERE clause
|
||||
conditions = ["s.is_active = TRUE"]
|
||||
params = []
|
||||
param_idx = 1
|
||||
|
||||
if state:
|
||||
conditions.append(f"s.state = ${param_idx}")
|
||||
params.append(state.upper())
|
||||
param_idx += 1
|
||||
|
||||
if school_type:
|
||||
conditions.append(f"st.name = ${param_idx}")
|
||||
params.append(school_type)
|
||||
param_idx += 1
|
||||
|
||||
if city:
|
||||
conditions.append(f"LOWER(s.city) = LOWER(${param_idx})")
|
||||
params.append(city)
|
||||
param_idx += 1
|
||||
|
||||
if district:
|
||||
conditions.append(f"LOWER(s.district) LIKE LOWER(${param_idx})")
|
||||
params.append(f"%{district}%")
|
||||
param_idx += 1
|
||||
|
||||
if postal_code:
|
||||
conditions.append(f"s.postal_code LIKE ${param_idx}")
|
||||
params.append(f"{postal_code}%")
|
||||
param_idx += 1
|
||||
|
||||
if search:
|
||||
conditions.append(f"""
|
||||
(LOWER(s.name) LIKE LOWER(${param_idx})
|
||||
OR LOWER(s.city) LIKE LOWER(${param_idx})
|
||||
OR LOWER(s.district) LIKE LOWER(${param_idx}))
|
||||
""")
|
||||
params.append(f"%{search}%")
|
||||
param_idx += 1
|
||||
|
||||
if has_email is not None:
|
||||
if has_email:
|
||||
conditions.append("s.email IS NOT NULL")
|
||||
else:
|
||||
conditions.append("s.email IS NULL")
|
||||
|
||||
if has_website is not None:
|
||||
if has_website:
|
||||
conditions.append("s.website IS NOT NULL")
|
||||
else:
|
||||
conditions.append("s.website IS NULL")
|
||||
|
||||
if is_public is not None:
|
||||
conditions.append(f"s.is_public = ${param_idx}")
|
||||
params.append(is_public)
|
||||
param_idx += 1
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
# Count total
|
||||
count_query = f"""
|
||||
SELECT COUNT(*) FROM schools s
|
||||
LEFT JOIN school_types st ON s.school_type_id = st.id
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
total = await conn.fetchval(count_query, *params)
|
||||
|
||||
# Fetch schools
|
||||
offset = (page - 1) * page_size
|
||||
query = f"""
|
||||
SELECT
|
||||
s.id, s.name, s.school_number, s.state, s.district, s.city,
|
||||
s.postal_code, s.street, s.address_full, s.latitude, s.longitude,
|
||||
s.website, s.email, s.phone, s.fax,
|
||||
s.principal_name, s.principal_email,
|
||||
s.student_count, s.teacher_count,
|
||||
s.is_public, s.is_all_day, s.source, s.crawled_at,
|
||||
s.is_active, s.created_at, s.updated_at,
|
||||
st.name as school_type, st.name_short as school_type_short, st.category as school_category,
|
||||
(SELECT COUNT(*) FROM school_staff ss WHERE ss.school_id = s.id AND ss.is_active = TRUE) as staff_count
|
||||
FROM schools s
|
||||
LEFT JOIN school_types st ON s.school_type_id = st.id
|
||||
WHERE {where_clause}
|
||||
ORDER BY s.state, s.city, s.name
|
||||
LIMIT ${param_idx} OFFSET ${param_idx + 1}
|
||||
"""
|
||||
params.extend([page_size, offset])
|
||||
rows = await conn.fetch(query, *params)
|
||||
|
||||
schools = [
|
||||
SchoolResponse(
|
||||
id=str(row["id"]),
|
||||
name=row["name"],
|
||||
school_number=row["school_number"],
|
||||
school_type=row["school_type"],
|
||||
school_type_short=row["school_type_short"],
|
||||
school_category=row["school_category"],
|
||||
state=row["state"],
|
||||
district=row["district"],
|
||||
city=row["city"],
|
||||
postal_code=row["postal_code"],
|
||||
street=row["street"],
|
||||
address_full=row["address_full"],
|
||||
latitude=row["latitude"],
|
||||
longitude=row["longitude"],
|
||||
website=row["website"],
|
||||
email=row["email"],
|
||||
phone=row["phone"],
|
||||
fax=row["fax"],
|
||||
principal_name=row["principal_name"],
|
||||
principal_email=row["principal_email"],
|
||||
student_count=row["student_count"],
|
||||
teacher_count=row["teacher_count"],
|
||||
is_public=row["is_public"],
|
||||
is_all_day=row["is_all_day"],
|
||||
staff_count=row["staff_count"],
|
||||
source=row["source"],
|
||||
crawled_at=row["crawled_at"],
|
||||
is_active=row["is_active"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return SchoolsListResponse(
|
||||
schools=schools,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=SchoolStatsResponse)
|
||||
async def get_school_stats():
|
||||
"""Get school statistics."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Total schools and staff
|
||||
totals = await conn.fetchrow("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM schools WHERE is_active = TRUE) as total_schools,
|
||||
(SELECT COUNT(*) FROM school_staff WHERE is_active = TRUE) as total_staff,
|
||||
(SELECT COUNT(*) FROM schools WHERE is_active = TRUE AND website IS NOT NULL) as with_website,
|
||||
(SELECT COUNT(*) FROM schools WHERE is_active = TRUE AND email IS NOT NULL) as with_email,
|
||||
(SELECT COUNT(*) FROM schools WHERE is_active = TRUE AND principal_name IS NOT NULL) as with_principal,
|
||||
(SELECT COALESCE(SUM(student_count), 0) FROM schools WHERE is_active = TRUE) as total_students,
|
||||
(SELECT COALESCE(SUM(teacher_count), 0) FROM schools WHERE is_active = TRUE) as total_teachers,
|
||||
(SELECT MAX(crawled_at) FROM schools) as last_crawl
|
||||
""")
|
||||
|
||||
# By state
|
||||
state_rows = await conn.fetch("""
|
||||
SELECT state, COUNT(*) as count
|
||||
FROM schools
|
||||
WHERE is_active = TRUE
|
||||
GROUP BY state
|
||||
ORDER BY state
|
||||
""")
|
||||
schools_by_state = {row["state"]: row["count"] for row in state_rows}
|
||||
|
||||
# By type
|
||||
type_rows = await conn.fetch("""
|
||||
SELECT COALESCE(st.name, 'Unbekannt') as type_name, COUNT(*) as count
|
||||
FROM schools s
|
||||
LEFT JOIN school_types st ON s.school_type_id = st.id
|
||||
WHERE s.is_active = TRUE
|
||||
GROUP BY st.name
|
||||
ORDER BY count DESC
|
||||
""")
|
||||
schools_by_type = {row["type_name"]: row["count"] for row in type_rows}
|
||||
|
||||
return SchoolStatsResponse(
|
||||
total_schools=totals["total_schools"],
|
||||
total_staff=totals["total_staff"],
|
||||
schools_by_state=schools_by_state,
|
||||
schools_by_type=schools_by_type,
|
||||
schools_with_website=totals["with_website"],
|
||||
schools_with_email=totals["with_email"],
|
||||
schools_with_principal=totals["with_principal"],
|
||||
total_students=totals["total_students"],
|
||||
total_teachers=totals["total_teachers"],
|
||||
last_crawl_time=totals["last_crawl"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{school_id}", response_model=SchoolResponse)
|
||||
async def get_school(school_id: str):
|
||||
"""Get a single school by ID."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT
|
||||
s.id, s.name, s.school_number, s.state, s.district, s.city,
|
||||
s.postal_code, s.street, s.address_full, s.latitude, s.longitude,
|
||||
s.website, s.email, s.phone, s.fax,
|
||||
s.principal_name, s.principal_email,
|
||||
s.student_count, s.teacher_count,
|
||||
s.is_public, s.is_all_day, s.source, s.crawled_at,
|
||||
s.is_active, s.created_at, s.updated_at,
|
||||
st.name as school_type, st.name_short as school_type_short, st.category as school_category,
|
||||
(SELECT COUNT(*) FROM school_staff ss WHERE ss.school_id = s.id AND ss.is_active = TRUE) as staff_count
|
||||
FROM schools s
|
||||
LEFT JOIN school_types st ON s.school_type_id = st.id
|
||||
WHERE s.id = $1
|
||||
""", school_id)
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="School not found")
|
||||
|
||||
return SchoolResponse(
|
||||
id=str(row["id"]),
|
||||
name=row["name"],
|
||||
school_number=row["school_number"],
|
||||
school_type=row["school_type"],
|
||||
school_type_short=row["school_type_short"],
|
||||
school_category=row["school_category"],
|
||||
state=row["state"],
|
||||
district=row["district"],
|
||||
city=row["city"],
|
||||
postal_code=row["postal_code"],
|
||||
street=row["street"],
|
||||
address_full=row["address_full"],
|
||||
latitude=row["latitude"],
|
||||
longitude=row["longitude"],
|
||||
website=row["website"],
|
||||
email=row["email"],
|
||||
phone=row["phone"],
|
||||
fax=row["fax"],
|
||||
principal_name=row["principal_name"],
|
||||
principal_email=row["principal_email"],
|
||||
student_count=row["student_count"],
|
||||
teacher_count=row["teacher_count"],
|
||||
is_public=row["is_public"],
|
||||
is_all_day=row["is_all_day"],
|
||||
staff_count=row["staff_count"],
|
||||
source=row["source"],
|
||||
crawled_at=row["crawled_at"],
|
||||
is_active=row["is_active"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk-import", response_model=BulkImportResponse)
|
||||
async def bulk_import_schools(request: BulkImportRequest):
|
||||
"""Bulk import schools. Updates existing schools based on school_number + state."""
|
||||
pool = await get_db_pool()
|
||||
imported = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Get school type mapping
|
||||
type_rows = await conn.fetch("SELECT id, name FROM school_types")
|
||||
type_map = {row["name"].lower(): str(row["id"]) for row in type_rows}
|
||||
|
||||
for school in request.schools:
|
||||
try:
|
||||
# Find school type ID
|
||||
school_type_id = None
|
||||
if school.school_type_raw:
|
||||
school_type_id = type_map.get(school.school_type_raw.lower())
|
||||
|
||||
# Check if school exists (by school_number + state, or by name + city + state)
|
||||
existing = None
|
||||
if school.school_number:
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id FROM schools WHERE school_number = $1 AND state = $2",
|
||||
school.school_number, school.state
|
||||
)
|
||||
if not existing and school.city:
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id FROM schools WHERE LOWER(name) = LOWER($1) AND LOWER(city) = LOWER($2) AND state = $3",
|
||||
school.name, school.city, school.state
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update existing school
|
||||
await conn.execute("""
|
||||
UPDATE schools SET
|
||||
name = $2,
|
||||
school_type_id = COALESCE($3, school_type_id),
|
||||
school_type_raw = COALESCE($4, school_type_raw),
|
||||
district = COALESCE($5, district),
|
||||
city = COALESCE($6, city),
|
||||
postal_code = COALESCE($7, postal_code),
|
||||
street = COALESCE($8, street),
|
||||
address_full = COALESCE($9, address_full),
|
||||
latitude = COALESCE($10, latitude),
|
||||
longitude = COALESCE($11, longitude),
|
||||
website = COALESCE($12, website),
|
||||
email = COALESCE($13, email),
|
||||
phone = COALESCE($14, phone),
|
||||
fax = COALESCE($15, fax),
|
||||
principal_name = COALESCE($16, principal_name),
|
||||
principal_title = COALESCE($17, principal_title),
|
||||
principal_email = COALESCE($18, principal_email),
|
||||
principal_phone = COALESCE($19, principal_phone),
|
||||
student_count = COALESCE($20, student_count),
|
||||
teacher_count = COALESCE($21, teacher_count),
|
||||
is_public = $22,
|
||||
source = COALESCE($23, source),
|
||||
source_url = COALESCE($24, source_url),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
""",
|
||||
existing["id"],
|
||||
school.name,
|
||||
school_type_id,
|
||||
school.school_type_raw,
|
||||
school.district,
|
||||
school.city,
|
||||
school.postal_code,
|
||||
school.street,
|
||||
school.address_full,
|
||||
school.latitude,
|
||||
school.longitude,
|
||||
school.website,
|
||||
school.email,
|
||||
school.phone,
|
||||
school.fax,
|
||||
school.principal_name,
|
||||
school.principal_title,
|
||||
school.principal_email,
|
||||
school.principal_phone,
|
||||
school.student_count,
|
||||
school.teacher_count,
|
||||
school.is_public,
|
||||
school.source,
|
||||
school.source_url,
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
# Insert new school
|
||||
await conn.execute("""
|
||||
INSERT INTO schools (
|
||||
name, school_number, school_type_id, school_type_raw,
|
||||
state, district, city, postal_code, street, address_full,
|
||||
latitude, longitude, website, email, phone, fax,
|
||||
principal_name, principal_title, principal_email, principal_phone,
|
||||
student_count, teacher_count, is_public,
|
||||
source, source_url, crawled_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
|
||||
$21, $22, $23, $24, $25, NOW()
|
||||
)
|
||||
""",
|
||||
school.name,
|
||||
school.school_number,
|
||||
school_type_id,
|
||||
school.school_type_raw,
|
||||
school.state,
|
||||
school.district,
|
||||
school.city,
|
||||
school.postal_code,
|
||||
school.street,
|
||||
school.address_full,
|
||||
school.latitude,
|
||||
school.longitude,
|
||||
school.website,
|
||||
school.email,
|
||||
school.phone,
|
||||
school.fax,
|
||||
school.principal_name,
|
||||
school.principal_title,
|
||||
school.principal_email,
|
||||
school.principal_phone,
|
||||
school.student_count,
|
||||
school.teacher_count,
|
||||
school.is_public,
|
||||
school.source,
|
||||
school.source_url,
|
||||
)
|
||||
imported += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error importing {school.name}: {str(e)}")
|
||||
if len(errors) > 100:
|
||||
errors.append("... (more errors truncated)")
|
||||
break
|
||||
|
||||
return BulkImportResponse(
|
||||
imported=imported,
|
||||
updated=updated,
|
||||
skipped=skipped,
|
||||
errors=errors[:100],
|
||||
)
|
||||
25
backend-lehrer/llm_gateway/routes/schools_db.py
Normal file
25
backend-lehrer/llm_gateway/routes/schools_db.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Schools API - Database Connection.
|
||||
|
||||
Shared database pool for school endpoints.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import asyncpg
|
||||
|
||||
# Database connection pool
|
||||
_pool: Optional[asyncpg.Pool] = None
|
||||
|
||||
|
||||
async def get_db_pool() -> asyncpg.Pool:
|
||||
"""Get or create database connection pool."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
database_url = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://breakpilot:breakpilot123@postgres:5432/breakpilot_db"
|
||||
)
|
||||
_pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10)
|
||||
return _pool
|
||||
200
backend-lehrer/llm_gateway/routes/schools_models.py
Normal file
200
backend-lehrer/llm_gateway/routes/schools_models.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Schools API - Pydantic Models.
|
||||
|
||||
Data models for school and school staff endpoints.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# School Type Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SchoolTypeResponse(BaseModel):
|
||||
"""School type response model."""
|
||||
id: str
|
||||
name: str
|
||||
name_short: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# School Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SchoolBase(BaseModel):
|
||||
"""Base school model for creation/update."""
|
||||
name: str = Field(..., max_length=255)
|
||||
school_number: Optional[str] = Field(None, max_length=20)
|
||||
school_type_id: Optional[str] = None
|
||||
school_type_raw: Optional[str] = None
|
||||
state: str = Field(..., max_length=10)
|
||||
district: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
street: Optional[str] = None
|
||||
address_full: Optional[str] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
website: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
fax: Optional[str] = None
|
||||
principal_name: Optional[str] = None
|
||||
principal_title: Optional[str] = None
|
||||
principal_email: Optional[str] = None
|
||||
principal_phone: Optional[str] = None
|
||||
secretary_name: Optional[str] = None
|
||||
secretary_email: Optional[str] = None
|
||||
secretary_phone: Optional[str] = None
|
||||
student_count: Optional[int] = None
|
||||
teacher_count: Optional[int] = None
|
||||
class_count: Optional[int] = None
|
||||
founded_year: Optional[int] = None
|
||||
is_public: bool = True
|
||||
is_all_day: Optional[bool] = None
|
||||
has_inclusion: Optional[bool] = None
|
||||
languages: Optional[List[str]] = None
|
||||
specializations: Optional[List[str]] = None
|
||||
source: Optional[str] = None
|
||||
source_url: Optional[str] = None
|
||||
|
||||
|
||||
class SchoolCreate(SchoolBase):
|
||||
"""School creation model."""
|
||||
pass
|
||||
|
||||
|
||||
class SchoolUpdate(BaseModel):
|
||||
"""School update model (all fields optional)."""
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
school_number: Optional[str] = None
|
||||
school_type_id: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
district: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
street: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
principal_name: Optional[str] = None
|
||||
student_count: Optional[int] = None
|
||||
teacher_count: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class SchoolResponse(BaseModel):
|
||||
"""School response model."""
|
||||
id: str
|
||||
name: str
|
||||
school_number: Optional[str] = None
|
||||
school_type: Optional[str] = None
|
||||
school_type_short: Optional[str] = None
|
||||
school_category: Optional[str] = None
|
||||
state: str
|
||||
district: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
street: Optional[str] = None
|
||||
address_full: Optional[str] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
website: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
fax: Optional[str] = None
|
||||
principal_name: Optional[str] = None
|
||||
principal_email: Optional[str] = None
|
||||
student_count: Optional[int] = None
|
||||
teacher_count: Optional[int] = None
|
||||
is_public: bool = True
|
||||
is_all_day: Optional[bool] = None
|
||||
staff_count: int = 0
|
||||
source: Optional[str] = None
|
||||
crawled_at: Optional[datetime] = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SchoolsListResponse(BaseModel):
|
||||
"""List response with pagination info."""
|
||||
schools: List[SchoolResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class SchoolStatsResponse(BaseModel):
|
||||
"""School statistics response."""
|
||||
total_schools: int
|
||||
total_staff: int
|
||||
schools_by_state: dict
|
||||
schools_by_type: dict
|
||||
schools_with_website: int
|
||||
schools_with_email: int
|
||||
schools_with_principal: int
|
||||
total_students: int
|
||||
total_teachers: int
|
||||
last_crawl_time: Optional[datetime] = None
|
||||
|
||||
|
||||
class BulkImportRequest(BaseModel):
|
||||
"""Bulk import request."""
|
||||
schools: List[SchoolCreate]
|
||||
|
||||
|
||||
class BulkImportResponse(BaseModel):
|
||||
"""Bulk import response."""
|
||||
imported: int
|
||||
updated: int
|
||||
skipped: int
|
||||
errors: List[str]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# School Staff Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SchoolStaffBase(BaseModel):
|
||||
"""Base school staff model."""
|
||||
first_name: Optional[str] = None
|
||||
last_name: str
|
||||
full_name: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
position_type: Optional[str] = None
|
||||
subjects: Optional[List[str]] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
class SchoolStaffCreate(SchoolStaffBase):
|
||||
"""School staff creation model."""
|
||||
school_id: str
|
||||
|
||||
|
||||
class SchoolStaffResponse(SchoolStaffBase):
|
||||
"""School staff response model."""
|
||||
id: str
|
||||
school_id: str
|
||||
school_name: Optional[str] = None
|
||||
profile_url: Optional[str] = None
|
||||
photo_url: Optional[str] = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SchoolStaffListResponse(BaseModel):
|
||||
"""Staff list response."""
|
||||
staff: List[SchoolStaffResponse]
|
||||
total: int
|
||||
233
backend-lehrer/llm_gateway/routes/schools_staff.py
Normal file
233
backend-lehrer/llm_gateway/routes/schools_staff.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Schools API - Staff Routes.
|
||||
|
||||
CRUD and search endpoints for school staff members.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from .schools_db import get_db_pool
|
||||
from .schools_models import (
|
||||
SchoolStaffBase,
|
||||
SchoolStaffResponse,
|
||||
SchoolStaffListResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["schools"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# School Staff Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/{school_id}/staff", response_model=SchoolStaffListResponse)
|
||||
async def get_school_staff(school_id: str):
|
||||
"""Get staff members for a school."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT
|
||||
ss.id, ss.school_id, ss.first_name, ss.last_name, ss.full_name,
|
||||
ss.title, ss.position, ss.position_type, ss.subjects,
|
||||
ss.email, ss.phone, ss.profile_url, ss.photo_url,
|
||||
ss.is_active, ss.created_at,
|
||||
s.name as school_name
|
||||
FROM school_staff ss
|
||||
JOIN schools s ON ss.school_id = s.id
|
||||
WHERE ss.school_id = $1 AND ss.is_active = TRUE
|
||||
ORDER BY
|
||||
CASE ss.position_type
|
||||
WHEN 'principal' THEN 1
|
||||
WHEN 'vice_principal' THEN 2
|
||||
WHEN 'secretary' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
ss.last_name
|
||||
""", school_id)
|
||||
|
||||
staff = [
|
||||
SchoolStaffResponse(
|
||||
id=str(row["id"]),
|
||||
school_id=str(row["school_id"]),
|
||||
school_name=row["school_name"],
|
||||
first_name=row["first_name"],
|
||||
last_name=row["last_name"],
|
||||
full_name=row["full_name"],
|
||||
title=row["title"],
|
||||
position=row["position"],
|
||||
position_type=row["position_type"],
|
||||
subjects=row["subjects"],
|
||||
email=row["email"],
|
||||
phone=row["phone"],
|
||||
profile_url=row["profile_url"],
|
||||
photo_url=row["photo_url"],
|
||||
is_active=row["is_active"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return SchoolStaffListResponse(
|
||||
staff=staff,
|
||||
total=len(staff),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{school_id}/staff", response_model=SchoolStaffResponse)
|
||||
async def create_school_staff(school_id: str, staff: SchoolStaffBase):
|
||||
"""Add a staff member to a school."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Verify school exists
|
||||
school = await conn.fetchrow("SELECT name FROM schools WHERE id = $1", school_id)
|
||||
if not school:
|
||||
raise HTTPException(status_code=404, detail="School not found")
|
||||
|
||||
# Create full name
|
||||
full_name = staff.full_name
|
||||
if not full_name:
|
||||
parts = []
|
||||
if staff.title:
|
||||
parts.append(staff.title)
|
||||
if staff.first_name:
|
||||
parts.append(staff.first_name)
|
||||
parts.append(staff.last_name)
|
||||
full_name = " ".join(parts)
|
||||
|
||||
row = await conn.fetchrow("""
|
||||
INSERT INTO school_staff (
|
||||
school_id, first_name, last_name, full_name, title,
|
||||
position, position_type, subjects, email, phone
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, created_at
|
||||
""",
|
||||
school_id,
|
||||
staff.first_name,
|
||||
staff.last_name,
|
||||
full_name,
|
||||
staff.title,
|
||||
staff.position,
|
||||
staff.position_type,
|
||||
staff.subjects,
|
||||
staff.email,
|
||||
staff.phone,
|
||||
)
|
||||
|
||||
return SchoolStaffResponse(
|
||||
id=str(row["id"]),
|
||||
school_id=school_id,
|
||||
school_name=school["name"],
|
||||
first_name=staff.first_name,
|
||||
last_name=staff.last_name,
|
||||
full_name=full_name,
|
||||
title=staff.title,
|
||||
position=staff.position,
|
||||
position_type=staff.position_type,
|
||||
subjects=staff.subjects,
|
||||
email=staff.email,
|
||||
phone=staff.phone,
|
||||
is_active=True,
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Search Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/search/staff", response_model=SchoolStaffListResponse)
|
||||
async def search_school_staff(
|
||||
q: Optional[str] = Query(None, description="Search query"),
|
||||
state: Optional[str] = Query(None, description="Filter by state"),
|
||||
position_type: Optional[str] = Query(None, description="Filter by position type"),
|
||||
has_email: Optional[bool] = Query(None, description="Only staff with email"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
"""Search school staff across all schools."""
|
||||
pool = await get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
conditions = ["ss.is_active = TRUE", "s.is_active = TRUE"]
|
||||
params = []
|
||||
param_idx = 1
|
||||
|
||||
if q:
|
||||
conditions.append(f"""
|
||||
(LOWER(ss.full_name) LIKE LOWER(${param_idx})
|
||||
OR LOWER(ss.last_name) LIKE LOWER(${param_idx})
|
||||
OR LOWER(s.name) LIKE LOWER(${param_idx}))
|
||||
""")
|
||||
params.append(f"%{q}%")
|
||||
param_idx += 1
|
||||
|
||||
if state:
|
||||
conditions.append(f"s.state = ${param_idx}")
|
||||
params.append(state.upper())
|
||||
param_idx += 1
|
||||
|
||||
if position_type:
|
||||
conditions.append(f"ss.position_type = ${param_idx}")
|
||||
params.append(position_type)
|
||||
param_idx += 1
|
||||
|
||||
if has_email is not None and has_email:
|
||||
conditions.append("ss.email IS NOT NULL")
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
# Count total
|
||||
total = await conn.fetchval(f"""
|
||||
SELECT COUNT(*) FROM school_staff ss
|
||||
JOIN schools s ON ss.school_id = s.id
|
||||
WHERE {where_clause}
|
||||
""", *params)
|
||||
|
||||
# Fetch staff
|
||||
offset = (page - 1) * page_size
|
||||
rows = await conn.fetch(f"""
|
||||
SELECT
|
||||
ss.id, ss.school_id, ss.first_name, ss.last_name, ss.full_name,
|
||||
ss.title, ss.position, ss.position_type, ss.subjects,
|
||||
ss.email, ss.phone, ss.profile_url, ss.photo_url,
|
||||
ss.is_active, ss.created_at,
|
||||
s.name as school_name
|
||||
FROM school_staff ss
|
||||
JOIN schools s ON ss.school_id = s.id
|
||||
WHERE {where_clause}
|
||||
ORDER BY ss.last_name, ss.first_name
|
||||
LIMIT ${param_idx} OFFSET ${param_idx + 1}
|
||||
""", *params, page_size, offset)
|
||||
|
||||
staff = [
|
||||
SchoolStaffResponse(
|
||||
id=str(row["id"]),
|
||||
school_id=str(row["school_id"]),
|
||||
school_name=row["school_name"],
|
||||
first_name=row["first_name"],
|
||||
last_name=row["last_name"],
|
||||
full_name=row["full_name"],
|
||||
title=row["title"],
|
||||
position=row["position"],
|
||||
position_type=row["position_type"],
|
||||
subjects=row["subjects"],
|
||||
email=row["email"],
|
||||
phone=row["phone"],
|
||||
profile_url=row["profile_url"],
|
||||
photo_url=row["photo_url"],
|
||||
is_active=row["is_active"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return SchoolStaffListResponse(
|
||||
staff=staff,
|
||||
total=total,
|
||||
)
|
||||
@@ -1,840 +1,21 @@
|
||||
"""
|
||||
BreakPilot Messenger API
|
||||
BreakPilot Messenger API — Barrel Re-export.
|
||||
|
||||
Stellt Endpoints fuer:
|
||||
- Kontaktverwaltung (CRUD)
|
||||
- Konversationen
|
||||
- Nachrichten
|
||||
- CSV-Import fuer Kontakte
|
||||
- Gruppenmanagement
|
||||
Stellt Endpoints fuer Kontakte, Konversationen, Nachrichten,
|
||||
CSV-Import, Gruppenmanagement und Templates bereit.
|
||||
|
||||
DSGVO-konform: Alle Daten werden lokal gespeichert.
|
||||
Split into:
|
||||
- messenger_models.py: Pydantic models
|
||||
- messenger_helpers.py: JSON file storage & default templates
|
||||
- messenger_contacts.py: Contact CRUD & CSV import/export
|
||||
- messenger_conversations.py: Conversations, messages, groups, templates, stats
|
||||
"""
|
||||
|
||||
import os
|
||||
import csv
|
||||
import uuid
|
||||
import json
|
||||
from io import StringIO
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from messenger_contacts import router as _contacts_router
|
||||
from messenger_conversations import router as _conversations_router
|
||||
|
||||
router = APIRouter(prefix="/api/messenger", tags=["Messenger"])
|
||||
|
||||
# Datenspeicherung (JSON-basiert fuer einfache Persistenz)
|
||||
DATA_DIR = Path(__file__).parent / "data" / "messenger"
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
CONTACTS_FILE = DATA_DIR / "contacts.json"
|
||||
CONVERSATIONS_FILE = DATA_DIR / "conversations.json"
|
||||
MESSAGES_FILE = DATA_DIR / "messages.json"
|
||||
GROUPS_FILE = DATA_DIR / "groups.json"
|
||||
|
||||
|
||||
# ==========================================
|
||||
# PYDANTIC MODELS
|
||||
# ==========================================
|
||||
|
||||
class ContactBase(BaseModel):
|
||||
"""Basis-Modell fuer Kontakte."""
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
role: str = Field(default="parent", description="parent, teacher, staff, student")
|
||||
student_name: Optional[str] = Field(None, description="Name des zugehoerigen Schuelers")
|
||||
class_name: Optional[str] = Field(None, description="Klasse z.B. 10a")
|
||||
notes: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
matrix_id: Optional[str] = Field(None, description="Matrix-ID z.B. @user:matrix.org")
|
||||
preferred_channel: str = Field(default="email", description="email, matrix, pwa")
|
||||
|
||||
|
||||
class ContactCreate(ContactBase):
|
||||
"""Model fuer neuen Kontakt."""
|
||||
pass
|
||||
|
||||
|
||||
class Contact(ContactBase):
|
||||
"""Vollstaendiger Kontakt mit ID."""
|
||||
id: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
online: bool = False
|
||||
last_seen: Optional[str] = None
|
||||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
"""Update-Model fuer Kontakte."""
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
student_name: Optional[str] = None
|
||||
class_name: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
matrix_id: Optional[str] = None
|
||||
preferred_channel: Optional[str] = None
|
||||
|
||||
|
||||
class GroupBase(BaseModel):
|
||||
"""Basis-Modell fuer Gruppen."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
group_type: str = Field(default="class", description="class, department, custom")
|
||||
|
||||
|
||||
class GroupCreate(GroupBase):
|
||||
"""Model fuer neue Gruppe."""
|
||||
member_ids: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Group(GroupBase):
|
||||
"""Vollstaendige Gruppe mit ID."""
|
||||
id: str
|
||||
member_ids: List[str] = []
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class MessageBase(BaseModel):
|
||||
"""Basis-Modell fuer Nachrichten."""
|
||||
content: str = Field(..., min_length=1)
|
||||
content_type: str = Field(default="text", description="text, file, image")
|
||||
file_url: Optional[str] = None
|
||||
send_email: bool = Field(default=False, description="Nachricht auch per Email senden")
|
||||
|
||||
|
||||
class MessageCreate(MessageBase):
|
||||
"""Model fuer neue Nachricht."""
|
||||
conversation_id: str
|
||||
|
||||
|
||||
class Message(MessageBase):
|
||||
"""Vollstaendige Nachricht mit ID."""
|
||||
id: str
|
||||
conversation_id: str
|
||||
sender_id: str # "self" fuer eigene Nachrichten
|
||||
timestamp: str
|
||||
read: bool = False
|
||||
read_at: Optional[str] = None
|
||||
email_sent: bool = False
|
||||
email_sent_at: Optional[str] = None
|
||||
email_error: Optional[str] = None
|
||||
|
||||
|
||||
class ConversationBase(BaseModel):
|
||||
"""Basis-Modell fuer Konversationen."""
|
||||
name: Optional[str] = None
|
||||
is_group: bool = False
|
||||
|
||||
|
||||
class Conversation(ConversationBase):
|
||||
"""Vollstaendige Konversation mit ID."""
|
||||
id: str
|
||||
participant_ids: List[str] = []
|
||||
group_id: Optional[str] = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
last_message: Optional[str] = None
|
||||
last_message_time: Optional[str] = None
|
||||
unread_count: int = 0
|
||||
|
||||
|
||||
class CSVImportResult(BaseModel):
|
||||
"""Ergebnis eines CSV-Imports."""
|
||||
imported: int
|
||||
skipped: int
|
||||
errors: List[str]
|
||||
contacts: List[Contact]
|
||||
|
||||
|
||||
# ==========================================
|
||||
# DATA HELPERS
|
||||
# ==========================================
|
||||
|
||||
def load_json(filepath: Path) -> List[Dict]:
|
||||
"""Laedt JSON-Daten aus Datei."""
|
||||
if not filepath.exists():
|
||||
return []
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def save_json(filepath: Path, data: List[Dict]):
|
||||
"""Speichert Daten in JSON-Datei."""
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def get_contacts() -> List[Dict]:
|
||||
return load_json(CONTACTS_FILE)
|
||||
|
||||
|
||||
def save_contacts(contacts: List[Dict]):
|
||||
save_json(CONTACTS_FILE, contacts)
|
||||
|
||||
|
||||
def get_conversations() -> List[Dict]:
|
||||
return load_json(CONVERSATIONS_FILE)
|
||||
|
||||
|
||||
def save_conversations(conversations: List[Dict]):
|
||||
save_json(CONVERSATIONS_FILE, conversations)
|
||||
|
||||
|
||||
def get_messages() -> List[Dict]:
|
||||
return load_json(MESSAGES_FILE)
|
||||
|
||||
|
||||
def save_messages(messages: List[Dict]):
|
||||
save_json(MESSAGES_FILE, messages)
|
||||
|
||||
|
||||
def get_groups() -> List[Dict]:
|
||||
return load_json(GROUPS_FILE)
|
||||
|
||||
|
||||
def save_groups(groups: List[Dict]):
|
||||
save_json(GROUPS_FILE, groups)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# CONTACTS ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.get("/contacts", response_model=List[Contact])
|
||||
async def list_contacts(
|
||||
role: Optional[str] = Query(None, description="Filter by role"),
|
||||
class_name: Optional[str] = Query(None, description="Filter by class"),
|
||||
search: Optional[str] = Query(None, description="Search in name/email")
|
||||
):
|
||||
"""Listet alle Kontakte auf."""
|
||||
contacts = get_contacts()
|
||||
|
||||
# Filter anwenden
|
||||
if role:
|
||||
contacts = [c for c in contacts if c.get("role") == role]
|
||||
if class_name:
|
||||
contacts = [c for c in contacts if c.get("class_name") == class_name]
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
contacts = [c for c in contacts if
|
||||
search_lower in c.get("name", "").lower() or
|
||||
search_lower in (c.get("email") or "").lower() or
|
||||
search_lower in (c.get("student_name") or "").lower()]
|
||||
|
||||
return contacts
|
||||
|
||||
|
||||
@router.post("/contacts", response_model=Contact)
|
||||
async def create_contact(contact: ContactCreate):
|
||||
"""Erstellt einen neuen Kontakt."""
|
||||
contacts = get_contacts()
|
||||
|
||||
# Pruefen ob Email bereits existiert
|
||||
if contact.email:
|
||||
existing = [c for c in contacts if c.get("email") == contact.email]
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Kontakt mit dieser Email existiert bereits")
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
new_contact = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"online": False,
|
||||
"last_seen": None,
|
||||
**contact.dict()
|
||||
}
|
||||
|
||||
contacts.append(new_contact)
|
||||
save_contacts(contacts)
|
||||
|
||||
return new_contact
|
||||
|
||||
|
||||
@router.get("/contacts/{contact_id}", response_model=Contact)
|
||||
async def get_contact(contact_id: str):
|
||||
"""Ruft einen einzelnen Kontakt ab."""
|
||||
contacts = get_contacts()
|
||||
contact = next((c for c in contacts if c["id"] == contact_id), None)
|
||||
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Kontakt nicht gefunden")
|
||||
|
||||
return contact
|
||||
|
||||
|
||||
@router.put("/contacts/{contact_id}", response_model=Contact)
|
||||
async def update_contact(contact_id: str, update: ContactUpdate):
|
||||
"""Aktualisiert einen Kontakt."""
|
||||
contacts = get_contacts()
|
||||
contact_idx = next((i for i, c in enumerate(contacts) if c["id"] == contact_id), None)
|
||||
|
||||
if contact_idx is None:
|
||||
raise HTTPException(status_code=404, detail="Kontakt nicht gefunden")
|
||||
|
||||
update_data = update.dict(exclude_unset=True)
|
||||
contacts[contact_idx].update(update_data)
|
||||
contacts[contact_idx]["updated_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
save_contacts(contacts)
|
||||
return contacts[contact_idx]
|
||||
|
||||
|
||||
@router.delete("/contacts/{contact_id}")
|
||||
async def delete_contact(contact_id: str):
|
||||
"""Loescht einen Kontakt."""
|
||||
contacts = get_contacts()
|
||||
contacts = [c for c in contacts if c["id"] != contact_id]
|
||||
save_contacts(contacts)
|
||||
|
||||
return {"status": "deleted", "id": contact_id}
|
||||
|
||||
|
||||
@router.post("/contacts/import", response_model=CSVImportResult)
|
||||
async def import_contacts_csv(file: UploadFile = File(...)):
|
||||
"""
|
||||
Importiert Kontakte aus einer CSV-Datei.
|
||||
|
||||
Erwartete Spalten:
|
||||
- name (required)
|
||||
- email
|
||||
- phone
|
||||
- role (parent/teacher/staff/student)
|
||||
- student_name
|
||||
- class_name
|
||||
- notes
|
||||
- tags (komma-separiert)
|
||||
"""
|
||||
if not file.filename.endswith('.csv'):
|
||||
raise HTTPException(status_code=400, detail="Nur CSV-Dateien werden unterstuetzt")
|
||||
|
||||
content = await file.read()
|
||||
try:
|
||||
text = content.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
text = content.decode('latin-1')
|
||||
|
||||
contacts = get_contacts()
|
||||
existing_emails = {c.get("email") for c in contacts if c.get("email")}
|
||||
|
||||
imported = []
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
reader = csv.DictReader(StringIO(text), delimiter=';') # Deutsche CSV meist mit Semikolon
|
||||
if not reader.fieldnames or 'name' not in [f.lower() for f in reader.fieldnames]:
|
||||
# Versuche mit Komma
|
||||
reader = csv.DictReader(StringIO(text), delimiter=',')
|
||||
|
||||
for row_num, row in enumerate(reader, start=2):
|
||||
try:
|
||||
# Normalisiere Spaltennamen
|
||||
row = {k.lower().strip(): v.strip() if v else "" for k, v in row.items()}
|
||||
|
||||
name = row.get('name') or row.get('kontakt') or row.get('elternname')
|
||||
if not name:
|
||||
errors.append(f"Zeile {row_num}: Name fehlt")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
email = row.get('email') or row.get('e-mail') or row.get('mail')
|
||||
if email and email in existing_emails:
|
||||
errors.append(f"Zeile {row_num}: Email {email} existiert bereits")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
tags_str = row.get('tags') or row.get('kategorien') or ""
|
||||
tags = [t.strip() for t in tags_str.split(',') if t.strip()]
|
||||
|
||||
# Matrix-ID und preferred_channel auslesen
|
||||
matrix_id = row.get('matrix_id') or row.get('matrix') or None
|
||||
preferred_channel = row.get('preferred_channel') or row.get('kanal') or "email"
|
||||
if preferred_channel not in ["email", "matrix", "pwa"]:
|
||||
preferred_channel = "email"
|
||||
|
||||
new_contact = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"email": email if email else None,
|
||||
"phone": row.get('phone') or row.get('telefon') or row.get('tel'),
|
||||
"role": row.get('role') or row.get('rolle') or "parent",
|
||||
"student_name": row.get('student_name') or row.get('schueler') or row.get('kind'),
|
||||
"class_name": row.get('class_name') or row.get('klasse'),
|
||||
"notes": row.get('notes') or row.get('notizen') or row.get('bemerkungen'),
|
||||
"tags": tags,
|
||||
"matrix_id": matrix_id if matrix_id else None,
|
||||
"preferred_channel": preferred_channel,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"online": False,
|
||||
"last_seen": None
|
||||
}
|
||||
|
||||
contacts.append(new_contact)
|
||||
imported.append(new_contact)
|
||||
if email:
|
||||
existing_emails.add(email)
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Zeile {row_num}: {str(e)}")
|
||||
skipped += 1
|
||||
|
||||
save_contacts(contacts)
|
||||
|
||||
return CSVImportResult(
|
||||
imported=len(imported),
|
||||
skipped=skipped,
|
||||
errors=errors[:20], # Maximal 20 Fehler zurueckgeben
|
||||
contacts=imported
|
||||
)
|
||||
|
||||
|
||||
@router.get("/contacts/export/csv")
|
||||
async def export_contacts_csv():
|
||||
"""Exportiert alle Kontakte als CSV."""
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
contacts = get_contacts()
|
||||
|
||||
output = StringIO()
|
||||
fieldnames = ['name', 'email', 'phone', 'role', 'student_name', 'class_name', 'notes', 'tags', 'matrix_id', 'preferred_channel']
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=';')
|
||||
writer.writeheader()
|
||||
|
||||
for contact in contacts:
|
||||
writer.writerow({
|
||||
'name': contact.get('name', ''),
|
||||
'email': contact.get('email', ''),
|
||||
'phone': contact.get('phone', ''),
|
||||
'role': contact.get('role', ''),
|
||||
'student_name': contact.get('student_name', ''),
|
||||
'class_name': contact.get('class_name', ''),
|
||||
'notes': contact.get('notes', ''),
|
||||
'tags': ','.join(contact.get('tags', [])),
|
||||
'matrix_id': contact.get('matrix_id', ''),
|
||||
'preferred_channel': contact.get('preferred_channel', 'email')
|
||||
})
|
||||
|
||||
output.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=kontakte.csv"}
|
||||
)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# GROUPS ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.get("/groups", response_model=List[Group])
|
||||
async def list_groups():
|
||||
"""Listet alle Gruppen auf."""
|
||||
return get_groups()
|
||||
|
||||
|
||||
@router.post("/groups", response_model=Group)
|
||||
async def create_group(group: GroupCreate):
|
||||
"""Erstellt eine neue Gruppe."""
|
||||
groups = get_groups()
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
new_group = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
**group.dict()
|
||||
}
|
||||
|
||||
groups.append(new_group)
|
||||
save_groups(groups)
|
||||
|
||||
return new_group
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}/members")
|
||||
async def update_group_members(group_id: str, member_ids: List[str]):
|
||||
"""Aktualisiert die Mitglieder einer Gruppe."""
|
||||
groups = get_groups()
|
||||
group_idx = next((i for i, g in enumerate(groups) if g["id"] == group_id), None)
|
||||
|
||||
if group_idx is None:
|
||||
raise HTTPException(status_code=404, detail="Gruppe nicht gefunden")
|
||||
|
||||
groups[group_idx]["member_ids"] = member_ids
|
||||
groups[group_idx]["updated_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
save_groups(groups)
|
||||
return groups[group_idx]
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
async def delete_group(group_id: str):
|
||||
"""Loescht eine Gruppe."""
|
||||
groups = get_groups()
|
||||
groups = [g for g in groups if g["id"] != group_id]
|
||||
save_groups(groups)
|
||||
|
||||
return {"status": "deleted", "id": group_id}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# CONVERSATIONS ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.get("/conversations", response_model=List[Conversation])
|
||||
async def list_conversations():
|
||||
"""Listet alle Konversationen auf."""
|
||||
conversations = get_conversations()
|
||||
messages = get_messages()
|
||||
|
||||
# Unread count und letzte Nachricht hinzufuegen
|
||||
for conv in conversations:
|
||||
conv_messages = [m for m in messages if m.get("conversation_id") == conv["id"]]
|
||||
conv["unread_count"] = len([m for m in conv_messages if not m.get("read") and m.get("sender_id") != "self"])
|
||||
|
||||
if conv_messages:
|
||||
last_msg = max(conv_messages, key=lambda m: m.get("timestamp", ""))
|
||||
conv["last_message"] = last_msg.get("content", "")[:50]
|
||||
conv["last_message_time"] = last_msg.get("timestamp")
|
||||
|
||||
# Nach letzter Nachricht sortieren
|
||||
conversations.sort(key=lambda c: c.get("last_message_time") or "", reverse=True)
|
||||
|
||||
return conversations
|
||||
|
||||
|
||||
@router.post("/conversations", response_model=Conversation)
|
||||
async def create_conversation(contact_id: Optional[str] = None, group_id: Optional[str] = None):
|
||||
"""
|
||||
Erstellt eine neue Konversation.
|
||||
Entweder mit einem Kontakt (1:1) oder einer Gruppe.
|
||||
"""
|
||||
conversations = get_conversations()
|
||||
|
||||
if not contact_id and not group_id:
|
||||
raise HTTPException(status_code=400, detail="Entweder contact_id oder group_id erforderlich")
|
||||
|
||||
# Pruefen ob Konversation bereits existiert
|
||||
if contact_id:
|
||||
existing = next((c for c in conversations
|
||||
if not c.get("is_group") and contact_id in c.get("participant_ids", [])), None)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
if group_id:
|
||||
groups = get_groups()
|
||||
group = next((g for g in groups if g["id"] == group_id), None)
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Gruppe nicht gefunden")
|
||||
|
||||
new_conv = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": group.get("name"),
|
||||
"is_group": True,
|
||||
"participant_ids": group.get("member_ids", []),
|
||||
"group_id": group_id,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"last_message": None,
|
||||
"last_message_time": None,
|
||||
"unread_count": 0
|
||||
}
|
||||
else:
|
||||
contacts = get_contacts()
|
||||
contact = next((c for c in contacts if c["id"] == contact_id), None)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Kontakt nicht gefunden")
|
||||
|
||||
new_conv = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": contact.get("name"),
|
||||
"is_group": False,
|
||||
"participant_ids": [contact_id],
|
||||
"group_id": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"last_message": None,
|
||||
"last_message_time": None,
|
||||
"unread_count": 0
|
||||
}
|
||||
|
||||
conversations.append(new_conv)
|
||||
save_conversations(conversations)
|
||||
|
||||
return new_conv
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}", response_model=Conversation)
|
||||
async def get_conversation(conversation_id: str):
|
||||
"""Ruft eine Konversation ab."""
|
||||
conversations = get_conversations()
|
||||
conv = next((c for c in conversations if c["id"] == conversation_id), None)
|
||||
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="Konversation nicht gefunden")
|
||||
|
||||
return conv
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}")
|
||||
async def delete_conversation(conversation_id: str):
|
||||
"""Loescht eine Konversation und alle zugehoerigen Nachrichten."""
|
||||
conversations = get_conversations()
|
||||
conversations = [c for c in conversations if c["id"] != conversation_id]
|
||||
save_conversations(conversations)
|
||||
|
||||
messages = get_messages()
|
||||
messages = [m for m in messages if m.get("conversation_id") != conversation_id]
|
||||
save_messages(messages)
|
||||
|
||||
return {"status": "deleted", "id": conversation_id}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# MESSAGES ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.get("/conversations/{conversation_id}/messages", response_model=List[Message])
|
||||
async def list_messages(
|
||||
conversation_id: str,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
before: Optional[str] = Query(None, description="Load messages before this timestamp")
|
||||
):
|
||||
"""Ruft Nachrichten einer Konversation ab."""
|
||||
messages = get_messages()
|
||||
conv_messages = [m for m in messages if m.get("conversation_id") == conversation_id]
|
||||
|
||||
if before:
|
||||
conv_messages = [m for m in conv_messages if m.get("timestamp", "") < before]
|
||||
|
||||
# Nach Zeit sortieren (neueste zuletzt)
|
||||
conv_messages.sort(key=lambda m: m.get("timestamp", ""))
|
||||
|
||||
return conv_messages[-limit:]
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/messages", response_model=Message)
|
||||
async def send_message(conversation_id: str, message: MessageBase):
|
||||
"""
|
||||
Sendet eine Nachricht in einer Konversation.
|
||||
|
||||
Wenn send_email=True und der Kontakt eine Email-Adresse hat,
|
||||
wird die Nachricht auch per Email versendet.
|
||||
"""
|
||||
conversations = get_conversations()
|
||||
conv = next((c for c in conversations if c["id"] == conversation_id), None)
|
||||
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="Konversation nicht gefunden")
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
new_message = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"conversation_id": conversation_id,
|
||||
"sender_id": "self",
|
||||
"timestamp": now,
|
||||
"read": True,
|
||||
"read_at": now,
|
||||
"email_sent": False,
|
||||
"email_sent_at": None,
|
||||
"email_error": None,
|
||||
**message.dict()
|
||||
}
|
||||
|
||||
# Email-Versand wenn gewuenscht
|
||||
if message.send_email and not conv.get("is_group"):
|
||||
# Kontakt laden
|
||||
participant_ids = conv.get("participant_ids", [])
|
||||
if participant_ids:
|
||||
contacts = get_contacts()
|
||||
contact = next((c for c in contacts if c["id"] == participant_ids[0]), None)
|
||||
|
||||
if contact and contact.get("email"):
|
||||
try:
|
||||
from email_service import email_service
|
||||
|
||||
result = email_service.send_messenger_notification(
|
||||
to_email=contact["email"],
|
||||
to_name=contact.get("name", ""),
|
||||
sender_name="BreakPilot Lehrer", # TODO: Aktuellen User-Namen verwenden
|
||||
message_content=message.content
|
||||
)
|
||||
|
||||
if result.success:
|
||||
new_message["email_sent"] = True
|
||||
new_message["email_sent_at"] = result.sent_at
|
||||
else:
|
||||
new_message["email_error"] = result.error
|
||||
|
||||
except Exception as e:
|
||||
new_message["email_error"] = str(e)
|
||||
|
||||
messages = get_messages()
|
||||
messages.append(new_message)
|
||||
save_messages(messages)
|
||||
|
||||
# Konversation aktualisieren
|
||||
conv_idx = next(i for i, c in enumerate(conversations) if c["id"] == conversation_id)
|
||||
conversations[conv_idx]["last_message"] = message.content[:50]
|
||||
conversations[conv_idx]["last_message_time"] = now
|
||||
conversations[conv_idx]["updated_at"] = now
|
||||
save_conversations(conversations)
|
||||
|
||||
return new_message
|
||||
|
||||
|
||||
@router.put("/messages/{message_id}/read")
|
||||
async def mark_message_read(message_id: str):
|
||||
"""Markiert eine Nachricht als gelesen."""
|
||||
messages = get_messages()
|
||||
msg_idx = next((i for i, m in enumerate(messages) if m["id"] == message_id), None)
|
||||
|
||||
if msg_idx is None:
|
||||
raise HTTPException(status_code=404, detail="Nachricht nicht gefunden")
|
||||
|
||||
messages[msg_idx]["read"] = True
|
||||
messages[msg_idx]["read_at"] = datetime.utcnow().isoformat()
|
||||
save_messages(messages)
|
||||
|
||||
return {"status": "read", "id": message_id}
|
||||
|
||||
|
||||
@router.put("/conversations/{conversation_id}/read-all")
|
||||
async def mark_all_messages_read(conversation_id: str):
|
||||
"""Markiert alle Nachrichten einer Konversation als gelesen."""
|
||||
messages = get_messages()
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
for msg in messages:
|
||||
if msg.get("conversation_id") == conversation_id and not msg.get("read"):
|
||||
msg["read"] = True
|
||||
msg["read_at"] = now
|
||||
|
||||
save_messages(messages)
|
||||
|
||||
return {"status": "all_read", "conversation_id": conversation_id}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# TEMPLATES ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
DEFAULT_TEMPLATES = [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Terminbestaetigung",
|
||||
"content": "Vielen Dank fuer Ihre Terminanfrage. Ich bestaetige den Termin am [DATUM] um [UHRZEIT]. Bitte geben Sie mir Bescheid, falls sich etwas aendern sollte.",
|
||||
"category": "termin"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Hausaufgaben-Info",
|
||||
"content": "Zur Information: Die Hausaufgaben fuer diese Woche umfassen [THEMA]. Abgabetermin ist [DATUM]. Bei Fragen stehe ich gerne zur Verfuegung.",
|
||||
"category": "hausaufgaben"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "Entschuldigung bestaetigen",
|
||||
"content": "Ich bestaetige den Erhalt der Entschuldigung fuer [NAME] am [DATUM]. Die Fehlzeiten wurden entsprechend vermerkt.",
|
||||
"category": "entschuldigung"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"name": "Gespraechsanfrage",
|
||||
"content": "Ich wuerde gerne einen Termin fuer ein Gespraech mit Ihnen vereinbaren, um [THEMA] zu besprechen. Waeren Sie am [DATUM] um [UHRZEIT] verfuegbar?",
|
||||
"category": "gespraech"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"name": "Krankmeldung bestaetigen",
|
||||
"content": "Vielen Dank fuer Ihre Krankmeldung fuer [NAME]. Ich wuensche gute Besserung. Bitte reichen Sie eine schriftliche Entschuldigung nach, sobald Ihr Kind wieder gesund ist.",
|
||||
"category": "krankmeldung"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates():
|
||||
"""Listet alle Nachrichtenvorlagen auf."""
|
||||
templates_file = DATA_DIR / "templates.json"
|
||||
if templates_file.exists():
|
||||
templates = load_json(templates_file)
|
||||
else:
|
||||
templates = DEFAULT_TEMPLATES
|
||||
save_json(templates_file, templates)
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
@router.post("/templates")
|
||||
async def create_template(name: str, content: str, category: str = "custom"):
|
||||
"""Erstellt eine neue Vorlage."""
|
||||
templates_file = DATA_DIR / "templates.json"
|
||||
templates = load_json(templates_file) if templates_file.exists() else DEFAULT_TEMPLATES.copy()
|
||||
|
||||
new_template = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"content": content,
|
||||
"category": category
|
||||
}
|
||||
|
||||
templates.append(new_template)
|
||||
save_json(templates_file, templates)
|
||||
|
||||
return new_template
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
async def delete_template(template_id: str):
|
||||
"""Loescht eine Vorlage."""
|
||||
templates_file = DATA_DIR / "templates.json"
|
||||
templates = load_json(templates_file) if templates_file.exists() else DEFAULT_TEMPLATES.copy()
|
||||
|
||||
templates = [t for t in templates if t["id"] != template_id]
|
||||
save_json(templates_file, templates)
|
||||
|
||||
return {"status": "deleted", "id": template_id}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# STATS ENDPOINT
|
||||
# ==========================================
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_messenger_stats():
|
||||
"""Gibt Statistiken zum Messenger zurueck."""
|
||||
contacts = get_contacts()
|
||||
conversations = get_conversations()
|
||||
messages = get_messages()
|
||||
groups = get_groups()
|
||||
|
||||
unread_total = sum(1 for m in messages if not m.get("read") and m.get("sender_id") != "self")
|
||||
|
||||
return {
|
||||
"total_contacts": len(contacts),
|
||||
"total_groups": len(groups),
|
||||
"total_conversations": len(conversations),
|
||||
"total_messages": len(messages),
|
||||
"unread_messages": unread_total,
|
||||
"contacts_by_role": {
|
||||
role: len([c for c in contacts if c.get("role") == role])
|
||||
for role in set(c.get("role", "parent") for c in contacts)
|
||||
}
|
||||
}
|
||||
router.include_router(_contacts_router)
|
||||
router.include_router(_conversations_router)
|
||||
|
||||
251
backend-lehrer/messenger_contacts.py
Normal file
251
backend-lehrer/messenger_contacts.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Messenger API - Contact Routes.
|
||||
|
||||
CRUD, CSV import/export for contacts.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import uuid
|
||||
from io import StringIO
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from messenger_models import (
|
||||
Contact,
|
||||
ContactCreate,
|
||||
ContactUpdate,
|
||||
CSVImportResult,
|
||||
)
|
||||
from messenger_helpers import get_contacts, save_contacts
|
||||
|
||||
router = APIRouter(tags=["Messenger"])
|
||||
|
||||
|
||||
# ==========================================
|
||||
# CONTACTS ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.get("/contacts", response_model=List[Contact])
|
||||
async def list_contacts(
|
||||
role: Optional[str] = Query(None, description="Filter by role"),
|
||||
class_name: Optional[str] = Query(None, description="Filter by class"),
|
||||
search: Optional[str] = Query(None, description="Search in name/email")
|
||||
):
|
||||
"""Listet alle Kontakte auf."""
|
||||
contacts = get_contacts()
|
||||
|
||||
# Filter anwenden
|
||||
if role:
|
||||
contacts = [c for c in contacts if c.get("role") == role]
|
||||
if class_name:
|
||||
contacts = [c for c in contacts if c.get("class_name") == class_name]
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
contacts = [c for c in contacts if
|
||||
search_lower in c.get("name", "").lower() or
|
||||
search_lower in (c.get("email") or "").lower() or
|
||||
search_lower in (c.get("student_name") or "").lower()]
|
||||
|
||||
return contacts
|
||||
|
||||
|
||||
@router.post("/contacts", response_model=Contact)
|
||||
async def create_contact(contact: ContactCreate):
|
||||
"""Erstellt einen neuen Kontakt."""
|
||||
contacts = get_contacts()
|
||||
|
||||
# Pruefen ob Email bereits existiert
|
||||
if contact.email:
|
||||
existing = [c for c in contacts if c.get("email") == contact.email]
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Kontakt mit dieser Email existiert bereits")
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
new_contact = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"online": False,
|
||||
"last_seen": None,
|
||||
**contact.dict()
|
||||
}
|
||||
|
||||
contacts.append(new_contact)
|
||||
save_contacts(contacts)
|
||||
|
||||
return new_contact
|
||||
|
||||
|
||||
@router.get("/contacts/{contact_id}", response_model=Contact)
|
||||
async def get_contact(contact_id: str):
|
||||
"""Ruft einen einzelnen Kontakt ab."""
|
||||
contacts = get_contacts()
|
||||
contact = next((c for c in contacts if c["id"] == contact_id), None)
|
||||
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Kontakt nicht gefunden")
|
||||
|
||||
return contact
|
||||
|
||||
|
||||
@router.put("/contacts/{contact_id}", response_model=Contact)
|
||||
async def update_contact(contact_id: str, update: ContactUpdate):
|
||||
"""Aktualisiert einen Kontakt."""
|
||||
contacts = get_contacts()
|
||||
contact_idx = next((i for i, c in enumerate(contacts) if c["id"] == contact_id), None)
|
||||
|
||||
if contact_idx is None:
|
||||
raise HTTPException(status_code=404, detail="Kontakt nicht gefunden")
|
||||
|
||||
update_data = update.dict(exclude_unset=True)
|
||||
contacts[contact_idx].update(update_data)
|
||||
contacts[contact_idx]["updated_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
save_contacts(contacts)
|
||||
return contacts[contact_idx]
|
||||
|
||||
|
||||
@router.delete("/contacts/{contact_id}")
|
||||
async def delete_contact(contact_id: str):
|
||||
"""Loescht einen Kontakt."""
|
||||
contacts = get_contacts()
|
||||
contacts = [c for c in contacts if c["id"] != contact_id]
|
||||
save_contacts(contacts)
|
||||
|
||||
return {"status": "deleted", "id": contact_id}
|
||||
|
||||
|
||||
@router.post("/contacts/import", response_model=CSVImportResult)
|
||||
async def import_contacts_csv(file: UploadFile = File(...)):
|
||||
"""
|
||||
Importiert Kontakte aus einer CSV-Datei.
|
||||
|
||||
Erwartete Spalten:
|
||||
- name (required)
|
||||
- email
|
||||
- phone
|
||||
- role (parent/teacher/staff/student)
|
||||
- student_name
|
||||
- class_name
|
||||
- notes
|
||||
- tags (komma-separiert)
|
||||
"""
|
||||
if not file.filename.endswith('.csv'):
|
||||
raise HTTPException(status_code=400, detail="Nur CSV-Dateien werden unterstuetzt")
|
||||
|
||||
content = await file.read()
|
||||
try:
|
||||
text = content.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
text = content.decode('latin-1')
|
||||
|
||||
contacts = get_contacts()
|
||||
existing_emails = {c.get("email") for c in contacts if c.get("email")}
|
||||
|
||||
imported = []
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
reader = csv.DictReader(StringIO(text), delimiter=';') # Deutsche CSV meist mit Semikolon
|
||||
if not reader.fieldnames or 'name' not in [f.lower() for f in reader.fieldnames]:
|
||||
# Versuche mit Komma
|
||||
reader = csv.DictReader(StringIO(text), delimiter=',')
|
||||
|
||||
for row_num, row in enumerate(reader, start=2):
|
||||
try:
|
||||
# Normalisiere Spaltennamen
|
||||
row = {k.lower().strip(): v.strip() if v else "" for k, v in row.items()}
|
||||
|
||||
name = row.get('name') or row.get('kontakt') or row.get('elternname')
|
||||
if not name:
|
||||
errors.append(f"Zeile {row_num}: Name fehlt")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
email = row.get('email') or row.get('e-mail') or row.get('mail')
|
||||
if email and email in existing_emails:
|
||||
errors.append(f"Zeile {row_num}: Email {email} existiert bereits")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
tags_str = row.get('tags') or row.get('kategorien') or ""
|
||||
tags = [t.strip() for t in tags_str.split(',') if t.strip()]
|
||||
|
||||
# Matrix-ID und preferred_channel auslesen
|
||||
matrix_id = row.get('matrix_id') or row.get('matrix') or None
|
||||
preferred_channel = row.get('preferred_channel') or row.get('kanal') or "email"
|
||||
if preferred_channel not in ["email", "matrix", "pwa"]:
|
||||
preferred_channel = "email"
|
||||
|
||||
new_contact = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"email": email if email else None,
|
||||
"phone": row.get('phone') or row.get('telefon') or row.get('tel'),
|
||||
"role": row.get('role') or row.get('rolle') or "parent",
|
||||
"student_name": row.get('student_name') or row.get('schueler') or row.get('kind'),
|
||||
"class_name": row.get('class_name') or row.get('klasse'),
|
||||
"notes": row.get('notes') or row.get('notizen') or row.get('bemerkungen'),
|
||||
"tags": tags,
|
||||
"matrix_id": matrix_id if matrix_id else None,
|
||||
"preferred_channel": preferred_channel,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"online": False,
|
||||
"last_seen": None
|
||||
}
|
||||
|
||||
contacts.append(new_contact)
|
||||
imported.append(new_contact)
|
||||
if email:
|
||||
existing_emails.add(email)
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Zeile {row_num}: {str(e)}")
|
||||
skipped += 1
|
||||
|
||||
save_contacts(contacts)
|
||||
|
||||
return CSVImportResult(
|
||||
imported=len(imported),
|
||||
skipped=skipped,
|
||||
errors=errors[:20], # Maximal 20 Fehler zurueckgeben
|
||||
contacts=imported
|
||||
)
|
||||
|
||||
|
||||
@router.get("/contacts/export/csv")
|
||||
async def export_contacts_csv():
|
||||
"""Exportiert alle Kontakte als CSV."""
|
||||
contacts = get_contacts()
|
||||
|
||||
output = StringIO()
|
||||
fieldnames = ['name', 'email', 'phone', 'role', 'student_name', 'class_name', 'notes', 'tags', 'matrix_id', 'preferred_channel']
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=';')
|
||||
writer.writeheader()
|
||||
|
||||
for contact in contacts:
|
||||
writer.writerow({
|
||||
'name': contact.get('name', ''),
|
||||
'email': contact.get('email', ''),
|
||||
'phone': contact.get('phone', ''),
|
||||
'role': contact.get('role', ''),
|
||||
'student_name': contact.get('student_name', ''),
|
||||
'class_name': contact.get('class_name', ''),
|
||||
'notes': contact.get('notes', ''),
|
||||
'tags': ','.join(contact.get('tags', [])),
|
||||
'matrix_id': contact.get('matrix_id', ''),
|
||||
'preferred_channel': contact.get('preferred_channel', 'email')
|
||||
})
|
||||
|
||||
output.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=kontakte.csv"}
|
||||
)
|
||||
405
backend-lehrer/messenger_conversations.py
Normal file
405
backend-lehrer/messenger_conversations.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
Messenger API - Conversation, Message, Group, Template & Stats Routes.
|
||||
|
||||
Conversations CRUD, message send/read, groups, templates, stats.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from messenger_models import (
|
||||
Conversation,
|
||||
Group,
|
||||
GroupCreate,
|
||||
Message,
|
||||
MessageBase,
|
||||
)
|
||||
from messenger_helpers import (
|
||||
DATA_DIR,
|
||||
DEFAULT_TEMPLATES,
|
||||
get_contacts,
|
||||
get_conversations,
|
||||
save_conversations,
|
||||
get_messages,
|
||||
save_messages,
|
||||
get_groups,
|
||||
save_groups,
|
||||
load_json,
|
||||
save_json,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["Messenger"])
|
||||
|
||||
|
||||
# ==========================================
|
||||
# GROUPS ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.get("/groups", response_model=List[Group])
|
||||
async def list_groups():
|
||||
"""Listet alle Gruppen auf."""
|
||||
return get_groups()
|
||||
|
||||
|
||||
@router.post("/groups", response_model=Group)
|
||||
async def create_group(group: GroupCreate):
|
||||
"""Erstellt eine neue Gruppe."""
|
||||
groups = get_groups()
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
new_group = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
**group.dict()
|
||||
}
|
||||
|
||||
groups.append(new_group)
|
||||
save_groups(groups)
|
||||
|
||||
return new_group
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}/members")
|
||||
async def update_group_members(group_id: str, member_ids: List[str]):
|
||||
"""Aktualisiert die Mitglieder einer Gruppe."""
|
||||
groups = get_groups()
|
||||
group_idx = next((i for i, g in enumerate(groups) if g["id"] == group_id), None)
|
||||
|
||||
if group_idx is None:
|
||||
raise HTTPException(status_code=404, detail="Gruppe nicht gefunden")
|
||||
|
||||
groups[group_idx]["member_ids"] = member_ids
|
||||
groups[group_idx]["updated_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
save_groups(groups)
|
||||
return groups[group_idx]
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
async def delete_group(group_id: str):
|
||||
"""Loescht eine Gruppe."""
|
||||
groups = get_groups()
|
||||
groups = [g for g in groups if g["id"] != group_id]
|
||||
save_groups(groups)
|
||||
|
||||
return {"status": "deleted", "id": group_id}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# CONVERSATIONS ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.get("/conversations", response_model=List[Conversation])
|
||||
async def list_conversations():
|
||||
"""Listet alle Konversationen auf."""
|
||||
conversations = get_conversations()
|
||||
messages = get_messages()
|
||||
|
||||
# Unread count und letzte Nachricht hinzufuegen
|
||||
for conv in conversations:
|
||||
conv_messages = [m for m in messages if m.get("conversation_id") == conv["id"]]
|
||||
conv["unread_count"] = len([m for m in conv_messages if not m.get("read") and m.get("sender_id") != "self"])
|
||||
|
||||
if conv_messages:
|
||||
last_msg = max(conv_messages, key=lambda m: m.get("timestamp", ""))
|
||||
conv["last_message"] = last_msg.get("content", "")[:50]
|
||||
conv["last_message_time"] = last_msg.get("timestamp")
|
||||
|
||||
# Nach letzter Nachricht sortieren
|
||||
conversations.sort(key=lambda c: c.get("last_message_time") or "", reverse=True)
|
||||
|
||||
return conversations
|
||||
|
||||
|
||||
@router.post("/conversations", response_model=Conversation)
|
||||
async def create_conversation(contact_id: Optional[str] = None, group_id: Optional[str] = None):
|
||||
"""
|
||||
Erstellt eine neue Konversation.
|
||||
Entweder mit einem Kontakt (1:1) oder einer Gruppe.
|
||||
"""
|
||||
conversations = get_conversations()
|
||||
|
||||
if not contact_id and not group_id:
|
||||
raise HTTPException(status_code=400, detail="Entweder contact_id oder group_id erforderlich")
|
||||
|
||||
# Pruefen ob Konversation bereits existiert
|
||||
if contact_id:
|
||||
existing = next((c for c in conversations
|
||||
if not c.get("is_group") and contact_id in c.get("participant_ids", [])), None)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
if group_id:
|
||||
groups = get_groups()
|
||||
group = next((g for g in groups if g["id"] == group_id), None)
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Gruppe nicht gefunden")
|
||||
|
||||
new_conv = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": group.get("name"),
|
||||
"is_group": True,
|
||||
"participant_ids": group.get("member_ids", []),
|
||||
"group_id": group_id,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"last_message": None,
|
||||
"last_message_time": None,
|
||||
"unread_count": 0
|
||||
}
|
||||
else:
|
||||
contacts = get_contacts()
|
||||
contact = next((c for c in contacts if c["id"] == contact_id), None)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Kontakt nicht gefunden")
|
||||
|
||||
new_conv = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": contact.get("name"),
|
||||
"is_group": False,
|
||||
"participant_ids": [contact_id],
|
||||
"group_id": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"last_message": None,
|
||||
"last_message_time": None,
|
||||
"unread_count": 0
|
||||
}
|
||||
|
||||
conversations.append(new_conv)
|
||||
save_conversations(conversations)
|
||||
|
||||
return new_conv
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}", response_model=Conversation)
|
||||
async def get_conversation(conversation_id: str):
|
||||
"""Ruft eine Konversation ab."""
|
||||
conversations = get_conversations()
|
||||
conv = next((c for c in conversations if c["id"] == conversation_id), None)
|
||||
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="Konversation nicht gefunden")
|
||||
|
||||
return conv
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}")
|
||||
async def delete_conversation(conversation_id: str):
|
||||
"""Loescht eine Konversation und alle zugehoerigen Nachrichten."""
|
||||
conversations = get_conversations()
|
||||
conversations = [c for c in conversations if c["id"] != conversation_id]
|
||||
save_conversations(conversations)
|
||||
|
||||
messages = get_messages()
|
||||
messages = [m for m in messages if m.get("conversation_id") != conversation_id]
|
||||
save_messages(messages)
|
||||
|
||||
return {"status": "deleted", "id": conversation_id}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# MESSAGES ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.get("/conversations/{conversation_id}/messages", response_model=List[Message])
|
||||
async def list_messages(
|
||||
conversation_id: str,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
before: Optional[str] = Query(None, description="Load messages before this timestamp")
|
||||
):
|
||||
"""Ruft Nachrichten einer Konversation ab."""
|
||||
messages = get_messages()
|
||||
conv_messages = [m for m in messages if m.get("conversation_id") == conversation_id]
|
||||
|
||||
if before:
|
||||
conv_messages = [m for m in conv_messages if m.get("timestamp", "") < before]
|
||||
|
||||
# Nach Zeit sortieren (neueste zuletzt)
|
||||
conv_messages.sort(key=lambda m: m.get("timestamp", ""))
|
||||
|
||||
return conv_messages[-limit:]
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/messages", response_model=Message)
|
||||
async def send_message(conversation_id: str, message: MessageBase):
|
||||
"""
|
||||
Sendet eine Nachricht in einer Konversation.
|
||||
|
||||
Wenn send_email=True und der Kontakt eine Email-Adresse hat,
|
||||
wird die Nachricht auch per Email versendet.
|
||||
"""
|
||||
conversations = get_conversations()
|
||||
conv = next((c for c in conversations if c["id"] == conversation_id), None)
|
||||
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="Konversation nicht gefunden")
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
new_message = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"conversation_id": conversation_id,
|
||||
"sender_id": "self",
|
||||
"timestamp": now,
|
||||
"read": True,
|
||||
"read_at": now,
|
||||
"email_sent": False,
|
||||
"email_sent_at": None,
|
||||
"email_error": None,
|
||||
**message.dict()
|
||||
}
|
||||
|
||||
# Email-Versand wenn gewuenscht
|
||||
if message.send_email and not conv.get("is_group"):
|
||||
# Kontakt laden
|
||||
participant_ids = conv.get("participant_ids", [])
|
||||
if participant_ids:
|
||||
contacts = get_contacts()
|
||||
contact = next((c for c in contacts if c["id"] == participant_ids[0]), None)
|
||||
|
||||
if contact and contact.get("email"):
|
||||
try:
|
||||
from email_service import email_service
|
||||
|
||||
result = email_service.send_messenger_notification(
|
||||
to_email=contact["email"],
|
||||
to_name=contact.get("name", ""),
|
||||
sender_name="BreakPilot Lehrer",
|
||||
message_content=message.content
|
||||
)
|
||||
|
||||
if result.success:
|
||||
new_message["email_sent"] = True
|
||||
new_message["email_sent_at"] = result.sent_at
|
||||
else:
|
||||
new_message["email_error"] = result.error
|
||||
|
||||
except Exception as e:
|
||||
new_message["email_error"] = str(e)
|
||||
|
||||
messages = get_messages()
|
||||
messages.append(new_message)
|
||||
save_messages(messages)
|
||||
|
||||
# Konversation aktualisieren
|
||||
conv_idx = next(i for i, c in enumerate(conversations) if c["id"] == conversation_id)
|
||||
conversations[conv_idx]["last_message"] = message.content[:50]
|
||||
conversations[conv_idx]["last_message_time"] = now
|
||||
conversations[conv_idx]["updated_at"] = now
|
||||
save_conversations(conversations)
|
||||
|
||||
return new_message
|
||||
|
||||
|
||||
@router.put("/messages/{message_id}/read")
|
||||
async def mark_message_read(message_id: str):
|
||||
"""Markiert eine Nachricht als gelesen."""
|
||||
messages = get_messages()
|
||||
msg_idx = next((i for i, m in enumerate(messages) if m["id"] == message_id), None)
|
||||
|
||||
if msg_idx is None:
|
||||
raise HTTPException(status_code=404, detail="Nachricht nicht gefunden")
|
||||
|
||||
messages[msg_idx]["read"] = True
|
||||
messages[msg_idx]["read_at"] = datetime.utcnow().isoformat()
|
||||
save_messages(messages)
|
||||
|
||||
return {"status": "read", "id": message_id}
|
||||
|
||||
|
||||
@router.put("/conversations/{conversation_id}/read-all")
|
||||
async def mark_all_messages_read(conversation_id: str):
|
||||
"""Markiert alle Nachrichten einer Konversation als gelesen."""
|
||||
messages = get_messages()
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
for msg in messages:
|
||||
if msg.get("conversation_id") == conversation_id and not msg.get("read"):
|
||||
msg["read"] = True
|
||||
msg["read_at"] = now
|
||||
|
||||
save_messages(messages)
|
||||
|
||||
return {"status": "all_read", "conversation_id": conversation_id}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# TEMPLATES ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates():
|
||||
"""Listet alle Nachrichtenvorlagen auf."""
|
||||
templates_file = DATA_DIR / "templates.json"
|
||||
if templates_file.exists():
|
||||
templates = load_json(templates_file)
|
||||
else:
|
||||
templates = DEFAULT_TEMPLATES
|
||||
save_json(templates_file, templates)
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
@router.post("/templates")
|
||||
async def create_template(name: str, content: str, category: str = "custom"):
|
||||
"""Erstellt eine neue Vorlage."""
|
||||
templates_file = DATA_DIR / "templates.json"
|
||||
templates = load_json(templates_file) if templates_file.exists() else DEFAULT_TEMPLATES.copy()
|
||||
|
||||
new_template = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"content": content,
|
||||
"category": category
|
||||
}
|
||||
|
||||
templates.append(new_template)
|
||||
save_json(templates_file, templates)
|
||||
|
||||
return new_template
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
async def delete_template(template_id: str):
|
||||
"""Loescht eine Vorlage."""
|
||||
templates_file = DATA_DIR / "templates.json"
|
||||
templates = load_json(templates_file) if templates_file.exists() else DEFAULT_TEMPLATES.copy()
|
||||
|
||||
templates = [t for t in templates if t["id"] != template_id]
|
||||
save_json(templates_file, templates)
|
||||
|
||||
return {"status": "deleted", "id": template_id}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# STATS ENDPOINT
|
||||
# ==========================================
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_messenger_stats():
|
||||
"""Gibt Statistiken zum Messenger zurueck."""
|
||||
contacts = get_contacts()
|
||||
conversations = get_conversations()
|
||||
messages = get_messages()
|
||||
groups = get_groups()
|
||||
|
||||
unread_total = sum(1 for m in messages if not m.get("read") and m.get("sender_id") != "self")
|
||||
|
||||
return {
|
||||
"total_contacts": len(contacts),
|
||||
"total_groups": len(groups),
|
||||
"total_conversations": len(conversations),
|
||||
"total_messages": len(messages),
|
||||
"unread_messages": unread_total,
|
||||
"contacts_by_role": {
|
||||
role: len([c for c in contacts if c.get("role") == role])
|
||||
for role in set(c.get("role", "parent") for c in contacts)
|
||||
}
|
||||
}
|
||||
105
backend-lehrer/messenger_helpers.py
Normal file
105
backend-lehrer/messenger_helpers.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Messenger API - Data Helpers.
|
||||
|
||||
JSON-based file storage for contacts, conversations, messages, and groups.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Dict
|
||||
from pathlib import Path
|
||||
|
||||
# Datenspeicherung (JSON-basiert fuer einfache Persistenz)
|
||||
DATA_DIR = Path(__file__).parent / "data" / "messenger"
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
CONTACTS_FILE = DATA_DIR / "contacts.json"
|
||||
CONVERSATIONS_FILE = DATA_DIR / "conversations.json"
|
||||
MESSAGES_FILE = DATA_DIR / "messages.json"
|
||||
GROUPS_FILE = DATA_DIR / "groups.json"
|
||||
|
||||
|
||||
def load_json(filepath: Path) -> List[Dict]:
|
||||
"""Laedt JSON-Daten aus Datei."""
|
||||
if not filepath.exists():
|
||||
return []
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def save_json(filepath: Path, data: List[Dict]):
|
||||
"""Speichert Daten in JSON-Datei."""
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def get_contacts() -> List[Dict]:
|
||||
return load_json(CONTACTS_FILE)
|
||||
|
||||
|
||||
def save_contacts(contacts: List[Dict]):
|
||||
save_json(CONTACTS_FILE, contacts)
|
||||
|
||||
|
||||
def get_conversations() -> List[Dict]:
|
||||
return load_json(CONVERSATIONS_FILE)
|
||||
|
||||
|
||||
def save_conversations(conversations: List[Dict]):
|
||||
save_json(CONVERSATIONS_FILE, conversations)
|
||||
|
||||
|
||||
def get_messages() -> List[Dict]:
|
||||
return load_json(MESSAGES_FILE)
|
||||
|
||||
|
||||
def save_messages(messages: List[Dict]):
|
||||
save_json(MESSAGES_FILE, messages)
|
||||
|
||||
|
||||
def get_groups() -> List[Dict]:
|
||||
return load_json(GROUPS_FILE)
|
||||
|
||||
|
||||
def save_groups(groups: List[Dict]):
|
||||
save_json(GROUPS_FILE, groups)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# DEFAULT TEMPLATES
|
||||
# ==========================================
|
||||
|
||||
DEFAULT_TEMPLATES = [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Terminbestaetigung",
|
||||
"content": "Vielen Dank fuer Ihre Terminanfrage. Ich bestaetige den Termin am [DATUM] um [UHRZEIT]. Bitte geben Sie mir Bescheid, falls sich etwas aendern sollte.",
|
||||
"category": "termin"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Hausaufgaben-Info",
|
||||
"content": "Zur Information: Die Hausaufgaben fuer diese Woche umfassen [THEMA]. Abgabetermin ist [DATUM]. Bei Fragen stehe ich gerne zur Verfuegung.",
|
||||
"category": "hausaufgaben"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "Entschuldigung bestaetigen",
|
||||
"content": "Ich bestaetige den Erhalt der Entschuldigung fuer [NAME] am [DATUM]. Die Fehlzeiten wurden entsprechend vermerkt.",
|
||||
"category": "entschuldigung"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"name": "Gespraechsanfrage",
|
||||
"content": "Ich wuerde gerne einen Termin fuer ein Gespraech mit Ihnen vereinbaren, um [THEMA] zu besprechen. Waeren Sie am [DATUM] um [UHRZEIT] verfuegbar?",
|
||||
"category": "gespraech"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"name": "Krankmeldung bestaetigen",
|
||||
"content": "Vielen Dank fuer Ihre Krankmeldung fuer [NAME]. Ich wuensche gute Besserung. Bitte reichen Sie eine schriftliche Entschuldigung nach, sobald Ihr Kind wieder gesund ist.",
|
||||
"category": "krankmeldung"
|
||||
}
|
||||
]
|
||||
139
backend-lehrer/messenger_models.py
Normal file
139
backend-lehrer/messenger_models.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Messenger API - Pydantic Models.
|
||||
|
||||
Data models for contacts, conversations, messages, and groups.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ==========================================
|
||||
# CONTACT MODELS
|
||||
# ==========================================
|
||||
|
||||
class ContactBase(BaseModel):
|
||||
"""Basis-Modell fuer Kontakte."""
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
role: str = Field(default="parent", description="parent, teacher, staff, student")
|
||||
student_name: Optional[str] = Field(None, description="Name des zugehoerigen Schuelers")
|
||||
class_name: Optional[str] = Field(None, description="Klasse z.B. 10a")
|
||||
notes: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
matrix_id: Optional[str] = Field(None, description="Matrix-ID z.B. @user:matrix.org")
|
||||
preferred_channel: str = Field(default="email", description="email, matrix, pwa")
|
||||
|
||||
|
||||
class ContactCreate(ContactBase):
|
||||
"""Model fuer neuen Kontakt."""
|
||||
pass
|
||||
|
||||
|
||||
class Contact(ContactBase):
|
||||
"""Vollstaendiger Kontakt mit ID."""
|
||||
id: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
online: bool = False
|
||||
last_seen: Optional[str] = None
|
||||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
"""Update-Model fuer Kontakte."""
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
student_name: Optional[str] = None
|
||||
class_name: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
matrix_id: Optional[str] = None
|
||||
preferred_channel: Optional[str] = None
|
||||
|
||||
|
||||
# ==========================================
|
||||
# GROUP MODELS
|
||||
# ==========================================
|
||||
|
||||
class GroupBase(BaseModel):
|
||||
"""Basis-Modell fuer Gruppen."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
group_type: str = Field(default="class", description="class, department, custom")
|
||||
|
||||
|
||||
class GroupCreate(GroupBase):
|
||||
"""Model fuer neue Gruppe."""
|
||||
member_ids: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Group(GroupBase):
|
||||
"""Vollstaendige Gruppe mit ID."""
|
||||
id: str
|
||||
member_ids: List[str] = []
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
# ==========================================
|
||||
# MESSAGE MODELS
|
||||
# ==========================================
|
||||
|
||||
class MessageBase(BaseModel):
|
||||
"""Basis-Modell fuer Nachrichten."""
|
||||
content: str = Field(..., min_length=1)
|
||||
content_type: str = Field(default="text", description="text, file, image")
|
||||
file_url: Optional[str] = None
|
||||
send_email: bool = Field(default=False, description="Nachricht auch per Email senden")
|
||||
|
||||
|
||||
class MessageCreate(MessageBase):
|
||||
"""Model fuer neue Nachricht."""
|
||||
conversation_id: str
|
||||
|
||||
|
||||
class Message(MessageBase):
|
||||
"""Vollstaendige Nachricht mit ID."""
|
||||
id: str
|
||||
conversation_id: str
|
||||
sender_id: str # "self" fuer eigene Nachrichten
|
||||
timestamp: str
|
||||
read: bool = False
|
||||
read_at: Optional[str] = None
|
||||
email_sent: bool = False
|
||||
email_sent_at: Optional[str] = None
|
||||
email_error: Optional[str] = None
|
||||
|
||||
|
||||
# ==========================================
|
||||
# CONVERSATION MODELS
|
||||
# ==========================================
|
||||
|
||||
class ConversationBase(BaseModel):
|
||||
"""Basis-Modell fuer Konversationen."""
|
||||
name: Optional[str] = None
|
||||
is_group: bool = False
|
||||
|
||||
|
||||
class Conversation(ConversationBase):
|
||||
"""Vollstaendige Konversation mit ID."""
|
||||
id: str
|
||||
participant_ids: List[str] = []
|
||||
group_id: Optional[str] = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
last_message: Optional[str] = None
|
||||
last_message_time: Optional[str] = None
|
||||
unread_count: int = 0
|
||||
|
||||
|
||||
class CSVImportResult(BaseModel):
|
||||
"""Ergebnis eines CSV-Imports."""
|
||||
imported: int
|
||||
skipped: int
|
||||
errors: List[str]
|
||||
contacts: List[Contact]
|
||||
@@ -1,848 +1,22 @@
|
||||
"""
|
||||
BreakPilot Recording API
|
||||
BreakPilot Recording API — Barrel Re-export.
|
||||
|
||||
Verwaltet Jibri Meeting-Aufzeichnungen und deren Metadaten.
|
||||
Empfaengt Webhooks von Jibri nach Upload zu MinIO.
|
||||
Split into:
|
||||
- recording_models.py: Pydantic models & config
|
||||
- recording_helpers.py: In-memory storage & utilities
|
||||
- recording_routes.py: Core recording CRUD routes
|
||||
- recording_transcription.py: Transcription routes
|
||||
- recording_minutes.py: Meeting minutes routes
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from recording_routes import router as _routes_router
|
||||
from recording_transcription import router as _transcription_router
|
||||
from recording_minutes import router as _minutes_router
|
||||
|
||||
router = APIRouter(prefix="/api/recordings", tags=["Recordings"])
|
||||
|
||||
# ==========================================
|
||||
# ENVIRONMENT CONFIGURATION
|
||||
# ==========================================
|
||||
|
||||
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
||||
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "breakpilot")
|
||||
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "breakpilot123")
|
||||
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "breakpilot-recordings")
|
||||
MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
|
||||
|
||||
# Default retention period in days (DSGVO compliance)
|
||||
DEFAULT_RETENTION_DAYS = int(os.getenv("RECORDING_RETENTION_DAYS", "365"))
|
||||
|
||||
|
||||
# ==========================================
|
||||
# PYDANTIC MODELS
|
||||
# ==========================================
|
||||
|
||||
class JibriWebhookPayload(BaseModel):
|
||||
"""Webhook payload from Jibri finalize.sh script."""
|
||||
event: str = Field(..., description="Event type: recording_completed")
|
||||
recording_name: str = Field(..., description="Unique recording identifier")
|
||||
storage_path: str = Field(..., description="Path in MinIO bucket")
|
||||
audio_path: Optional[str] = Field(None, description="Extracted audio path")
|
||||
file_size_bytes: int = Field(..., description="Video file size in bytes")
|
||||
timestamp: str = Field(..., description="ISO timestamp of upload")
|
||||
|
||||
|
||||
class RecordingCreate(BaseModel):
|
||||
"""Manual recording creation (for testing)."""
|
||||
meeting_id: str
|
||||
title: Optional[str] = None
|
||||
storage_path: str
|
||||
audio_path: Optional[str] = None
|
||||
duration_seconds: Optional[int] = None
|
||||
participant_count: Optional[int] = 0
|
||||
retention_days: Optional[int] = DEFAULT_RETENTION_DAYS
|
||||
|
||||
|
||||
class RecordingResponse(BaseModel):
|
||||
"""Recording details response."""
|
||||
id: str
|
||||
meeting_id: str
|
||||
title: Optional[str]
|
||||
storage_path: str
|
||||
audio_path: Optional[str]
|
||||
file_size_bytes: Optional[int]
|
||||
duration_seconds: Optional[int]
|
||||
participant_count: int
|
||||
status: str
|
||||
recorded_at: datetime
|
||||
retention_days: int
|
||||
retention_expires_at: datetime
|
||||
transcription_status: Optional[str] = None
|
||||
transcription_id: Optional[str] = None
|
||||
|
||||
|
||||
class RecordingListResponse(BaseModel):
|
||||
"""Paginated list of recordings."""
|
||||
recordings: List[RecordingResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class TranscriptionRequest(BaseModel):
|
||||
"""Request to start transcription."""
|
||||
language: str = Field(default="de", description="Language code: de, en, etc.")
|
||||
model: str = Field(default="large-v3", description="Whisper model to use")
|
||||
priority: int = Field(default=0, description="Queue priority (higher = sooner)")
|
||||
|
||||
|
||||
class TranscriptionStatusResponse(BaseModel):
|
||||
"""Transcription status and progress."""
|
||||
id: str
|
||||
recording_id: str
|
||||
status: str
|
||||
language: str
|
||||
model: str
|
||||
word_count: Optional[int]
|
||||
confidence_score: Optional[float]
|
||||
processing_duration_seconds: Optional[int]
|
||||
error_message: Optional[str]
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime]
|
||||
|
||||
|
||||
# ==========================================
|
||||
# IN-MEMORY STORAGE (Dev Mode)
|
||||
# ==========================================
|
||||
# In production, these would be database queries
|
||||
|
||||
_recordings_store: dict = {}
|
||||
_transcriptions_store: dict = {}
|
||||
_audit_log: list = []
|
||||
|
||||
|
||||
def log_audit(
|
||||
action: str,
|
||||
recording_id: Optional[str] = None,
|
||||
transcription_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
metadata: Optional[dict] = None
|
||||
):
|
||||
"""Log audit event for DSGVO compliance."""
|
||||
_audit_log.append({
|
||||
"id": str(uuid.uuid4()),
|
||||
"recording_id": recording_id,
|
||||
"transcription_id": transcription_id,
|
||||
"user_id": user_id,
|
||||
"action": action,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
|
||||
# ==========================================
|
||||
# WEBHOOK ENDPOINT (Jibri)
|
||||
# ==========================================
|
||||
|
||||
@router.post("/webhook")
|
||||
async def jibri_webhook(payload: JibriWebhookPayload, request: Request):
|
||||
"""
|
||||
Webhook endpoint called by Jibri finalize.sh after upload.
|
||||
|
||||
This creates a new recording entry and optionally triggers transcription.
|
||||
"""
|
||||
if payload.event != "recording_completed":
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": f"Unknown event type: {payload.event}"}
|
||||
)
|
||||
|
||||
# Extract meeting_id from recording_name (format: meetingId_timestamp)
|
||||
parts = payload.recording_name.split("_")
|
||||
meeting_id = parts[0] if parts else payload.recording_name
|
||||
|
||||
# Create recording entry
|
||||
recording_id = str(uuid.uuid4())
|
||||
recorded_at = datetime.utcnow()
|
||||
|
||||
recording = {
|
||||
"id": recording_id,
|
||||
"meeting_id": meeting_id,
|
||||
"jibri_session_id": payload.recording_name,
|
||||
"title": f"Recording {meeting_id}",
|
||||
"storage_path": payload.storage_path,
|
||||
"audio_path": payload.audio_path,
|
||||
"file_size_bytes": payload.file_size_bytes,
|
||||
"duration_seconds": None, # Will be updated after analysis
|
||||
"participant_count": 0,
|
||||
"status": "uploaded",
|
||||
"recorded_at": recorded_at.isoformat(),
|
||||
"retention_days": DEFAULT_RETENTION_DAYS,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
_recordings_store[recording_id] = recording
|
||||
|
||||
# Log the creation
|
||||
log_audit(
|
||||
action="created",
|
||||
recording_id=recording_id,
|
||||
metadata={
|
||||
"source": "jibri_webhook",
|
||||
"storage_path": payload.storage_path,
|
||||
"file_size_bytes": payload.file_size_bytes
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"recording_id": recording_id,
|
||||
"meeting_id": meeting_id,
|
||||
"status": "uploaded",
|
||||
"message": "Recording registered successfully"
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# HEALTH & AUDIT ENDPOINTS (must be before parameterized routes)
|
||||
# ==========================================
|
||||
|
||||
@router.get("/health")
|
||||
async def recordings_health():
|
||||
"""Health check for recording service."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"recordings_count": len(_recordings_store),
|
||||
"transcriptions_count": len(_transcriptions_store),
|
||||
"minio_endpoint": MINIO_ENDPOINT,
|
||||
"bucket": MINIO_BUCKET
|
||||
}
|
||||
|
||||
|
||||
@router.get("/audit/log")
|
||||
async def get_audit_log(
|
||||
recording_id: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=1000)
|
||||
):
|
||||
"""
|
||||
Get audit log entries (DSGVO compliance).
|
||||
|
||||
Admin-only endpoint for reviewing recording access history.
|
||||
"""
|
||||
logs = _audit_log.copy()
|
||||
|
||||
if recording_id:
|
||||
logs = [l for l in logs if l.get("recording_id") == recording_id]
|
||||
if action:
|
||||
logs = [l for l in logs if l.get("action") == action]
|
||||
|
||||
# Sort by created_at descending
|
||||
logs.sort(key=lambda x: x["created_at"], reverse=True)
|
||||
|
||||
return {
|
||||
"entries": logs[:limit],
|
||||
"total": len(logs)
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# RECORDING MANAGEMENT ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.get("", response_model=RecordingListResponse)
|
||||
async def list_recordings(
|
||||
status: Optional[str] = Query(None, description="Filter by status"),
|
||||
meeting_id: Optional[str] = Query(None, description="Filter by meeting ID"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Items per page")
|
||||
):
|
||||
"""
|
||||
List all recordings with optional filtering.
|
||||
|
||||
Supports pagination and filtering by status or meeting ID.
|
||||
"""
|
||||
# Filter recordings
|
||||
recordings = list(_recordings_store.values())
|
||||
|
||||
if status:
|
||||
recordings = [r for r in recordings if r["status"] == status]
|
||||
if meeting_id:
|
||||
recordings = [r for r in recordings if r["meeting_id"] == meeting_id]
|
||||
|
||||
# Sort by recorded_at descending
|
||||
recordings.sort(key=lambda x: x["recorded_at"], reverse=True)
|
||||
|
||||
# Paginate
|
||||
total = len(recordings)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
page_recordings = recordings[start:end]
|
||||
|
||||
# Convert to response format
|
||||
result = []
|
||||
for rec in page_recordings:
|
||||
recorded_at = datetime.fromisoformat(rec["recorded_at"])
|
||||
retention_expires = recorded_at + timedelta(days=rec["retention_days"])
|
||||
|
||||
# Check for transcription
|
||||
trans = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == rec["id"]),
|
||||
None
|
||||
)
|
||||
|
||||
result.append(RecordingResponse(
|
||||
id=rec["id"],
|
||||
meeting_id=rec["meeting_id"],
|
||||
title=rec.get("title"),
|
||||
storage_path=rec["storage_path"],
|
||||
audio_path=rec.get("audio_path"),
|
||||
file_size_bytes=rec.get("file_size_bytes"),
|
||||
duration_seconds=rec.get("duration_seconds"),
|
||||
participant_count=rec.get("participant_count", 0),
|
||||
status=rec["status"],
|
||||
recorded_at=recorded_at,
|
||||
retention_days=rec["retention_days"],
|
||||
retention_expires_at=retention_expires,
|
||||
transcription_status=trans["status"] if trans else None,
|
||||
transcription_id=trans["id"] if trans else None
|
||||
))
|
||||
|
||||
return RecordingListResponse(
|
||||
recordings=result,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{recording_id}", response_model=RecordingResponse)
|
||||
async def get_recording(recording_id: str):
|
||||
"""
|
||||
Get details for a specific recording.
|
||||
"""
|
||||
recording = _recordings_store.get(recording_id)
|
||||
if not recording:
|
||||
raise HTTPException(status_code=404, detail="Recording not found")
|
||||
|
||||
# Log view action
|
||||
log_audit(action="viewed", recording_id=recording_id)
|
||||
|
||||
recorded_at = datetime.fromisoformat(recording["recorded_at"])
|
||||
retention_expires = recorded_at + timedelta(days=recording["retention_days"])
|
||||
|
||||
# Check for transcription
|
||||
trans = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == recording_id),
|
||||
None
|
||||
)
|
||||
|
||||
return RecordingResponse(
|
||||
id=recording["id"],
|
||||
meeting_id=recording["meeting_id"],
|
||||
title=recording.get("title"),
|
||||
storage_path=recording["storage_path"],
|
||||
audio_path=recording.get("audio_path"),
|
||||
file_size_bytes=recording.get("file_size_bytes"),
|
||||
duration_seconds=recording.get("duration_seconds"),
|
||||
participant_count=recording.get("participant_count", 0),
|
||||
status=recording["status"],
|
||||
recorded_at=recorded_at,
|
||||
retention_days=recording["retention_days"],
|
||||
retention_expires_at=retention_expires,
|
||||
transcription_status=trans["status"] if trans else None,
|
||||
transcription_id=trans["id"] if trans else None
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{recording_id}")
|
||||
async def delete_recording(
|
||||
recording_id: str,
|
||||
reason: str = Query(..., description="Reason for deletion (DSGVO audit)")
|
||||
):
|
||||
"""
|
||||
Soft-delete a recording (DSGVO compliance).
|
||||
|
||||
The recording is marked as deleted but retained for audit purposes.
|
||||
Actual file deletion happens after the audit retention period.
|
||||
"""
|
||||
recording = _recordings_store.get(recording_id)
|
||||
if not recording:
|
||||
raise HTTPException(status_code=404, detail="Recording not found")
|
||||
|
||||
# Soft delete
|
||||
recording["status"] = "deleted"
|
||||
recording["deleted_at"] = datetime.utcnow().isoformat()
|
||||
recording["updated_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
# Log deletion with reason
|
||||
log_audit(
|
||||
action="deleted",
|
||||
recording_id=recording_id,
|
||||
metadata={"reason": reason}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"recording_id": recording_id,
|
||||
"status": "deleted",
|
||||
"message": "Recording marked for deletion"
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# TRANSCRIPTION ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.post("/{recording_id}/transcribe", response_model=TranscriptionStatusResponse)
|
||||
async def start_transcription(recording_id: str, request: TranscriptionRequest):
|
||||
"""
|
||||
Start transcription for a recording.
|
||||
|
||||
Queues the recording for processing by the transcription worker.
|
||||
"""
|
||||
recording = _recordings_store.get(recording_id)
|
||||
if not recording:
|
||||
raise HTTPException(status_code=404, detail="Recording not found")
|
||||
|
||||
if recording["status"] == "deleted":
|
||||
raise HTTPException(status_code=400, detail="Cannot transcribe deleted recording")
|
||||
|
||||
# Check if transcription already exists
|
||||
existing = next(
|
||||
(t for t in _transcriptions_store.values()
|
||||
if t["recording_id"] == recording_id and t["status"] != "failed"),
|
||||
None
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Transcription already exists with status: {existing['status']}"
|
||||
)
|
||||
|
||||
# Create transcription entry
|
||||
transcription_id = str(uuid.uuid4())
|
||||
now = datetime.utcnow()
|
||||
|
||||
transcription = {
|
||||
"id": transcription_id,
|
||||
"recording_id": recording_id,
|
||||
"language": request.language,
|
||||
"model": request.model,
|
||||
"status": "pending",
|
||||
"full_text": None,
|
||||
"word_count": None,
|
||||
"confidence_score": None,
|
||||
"vtt_path": None,
|
||||
"srt_path": None,
|
||||
"json_path": None,
|
||||
"error_message": None,
|
||||
"processing_started_at": None,
|
||||
"processing_completed_at": None,
|
||||
"processing_duration_seconds": None,
|
||||
"created_at": now.isoformat(),
|
||||
"updated_at": now.isoformat()
|
||||
}
|
||||
|
||||
_transcriptions_store[transcription_id] = transcription
|
||||
|
||||
# Update recording status
|
||||
recording["status"] = "processing"
|
||||
recording["updated_at"] = now.isoformat()
|
||||
|
||||
# Log transcription start
|
||||
log_audit(
|
||||
action="transcription_started",
|
||||
recording_id=recording_id,
|
||||
transcription_id=transcription_id,
|
||||
metadata={"language": request.language, "model": request.model}
|
||||
)
|
||||
|
||||
# TODO: Queue job to Redis/Valkey for transcription worker
|
||||
# from redis import Redis
|
||||
# from rq import Queue
|
||||
# q = Queue(connection=Redis.from_url(os.getenv("REDIS_URL")))
|
||||
# q.enqueue('transcription_worker.tasks.transcribe', transcription_id, ...)
|
||||
|
||||
return TranscriptionStatusResponse(
|
||||
id=transcription_id,
|
||||
recording_id=recording_id,
|
||||
status="pending",
|
||||
language=request.language,
|
||||
model=request.model,
|
||||
word_count=None,
|
||||
confidence_score=None,
|
||||
processing_duration_seconds=None,
|
||||
error_message=None,
|
||||
created_at=now,
|
||||
completed_at=None
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{recording_id}/transcription", response_model=TranscriptionStatusResponse)
|
||||
async def get_transcription_status(recording_id: str):
|
||||
"""
|
||||
Get transcription status for a recording.
|
||||
"""
|
||||
transcription = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == recording_id),
|
||||
None
|
||||
)
|
||||
if not transcription:
|
||||
raise HTTPException(status_code=404, detail="No transcription found for this recording")
|
||||
|
||||
return TranscriptionStatusResponse(
|
||||
id=transcription["id"],
|
||||
recording_id=transcription["recording_id"],
|
||||
status=transcription["status"],
|
||||
language=transcription["language"],
|
||||
model=transcription["model"],
|
||||
word_count=transcription.get("word_count"),
|
||||
confidence_score=transcription.get("confidence_score"),
|
||||
processing_duration_seconds=transcription.get("processing_duration_seconds"),
|
||||
error_message=transcription.get("error_message"),
|
||||
created_at=datetime.fromisoformat(transcription["created_at"]),
|
||||
completed_at=(
|
||||
datetime.fromisoformat(transcription["processing_completed_at"])
|
||||
if transcription.get("processing_completed_at") else None
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{recording_id}/transcription/text")
|
||||
async def get_transcription_text(recording_id: str):
|
||||
"""
|
||||
Get the full transcription text.
|
||||
"""
|
||||
transcription = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == recording_id),
|
||||
None
|
||||
)
|
||||
if not transcription:
|
||||
raise HTTPException(status_code=404, detail="No transcription found for this recording")
|
||||
|
||||
if transcription["status"] != "completed":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Transcription not ready. Status: {transcription['status']}"
|
||||
)
|
||||
|
||||
return {
|
||||
"transcription_id": transcription["id"],
|
||||
"recording_id": recording_id,
|
||||
"language": transcription["language"],
|
||||
"text": transcription.get("full_text", ""),
|
||||
"word_count": transcription.get("word_count", 0)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{recording_id}/transcription/vtt")
|
||||
async def get_transcription_vtt(recording_id: str):
|
||||
"""
|
||||
Download transcription as WebVTT subtitle file.
|
||||
"""
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
transcription = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == recording_id),
|
||||
None
|
||||
)
|
||||
if not transcription:
|
||||
raise HTTPException(status_code=404, detail="No transcription found for this recording")
|
||||
|
||||
if transcription["status"] != "completed":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Transcription not ready. Status: {transcription['status']}"
|
||||
)
|
||||
|
||||
# Generate VTT content
|
||||
# In production, this would read from the stored VTT file
|
||||
vtt_content = "WEBVTT\n\n"
|
||||
text = transcription.get("full_text", "")
|
||||
|
||||
if text:
|
||||
# Simple VTT generation - split into sentences
|
||||
sentences = text.replace(".", ".\n").split("\n")
|
||||
time_offset = 0
|
||||
for sentence in sentences:
|
||||
sentence = sentence.strip()
|
||||
if sentence:
|
||||
start = format_vtt_time(time_offset)
|
||||
# Estimate ~3 seconds per sentence
|
||||
time_offset += 3000
|
||||
end = format_vtt_time(time_offset)
|
||||
vtt_content += f"{start} --> {end}\n{sentence}\n\n"
|
||||
|
||||
return PlainTextResponse(
|
||||
content=vtt_content,
|
||||
media_type="text/vtt",
|
||||
headers={"Content-Disposition": f"attachment; filename=transcript_{recording_id}.vtt"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{recording_id}/transcription/srt")
|
||||
async def get_transcription_srt(recording_id: str):
|
||||
"""
|
||||
Download transcription as SRT subtitle file.
|
||||
"""
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
transcription = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == recording_id),
|
||||
None
|
||||
)
|
||||
if not transcription:
|
||||
raise HTTPException(status_code=404, detail="No transcription found for this recording")
|
||||
|
||||
if transcription["status"] != "completed":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Transcription not ready. Status: {transcription['status']}"
|
||||
)
|
||||
|
||||
# Generate SRT content
|
||||
srt_content = ""
|
||||
text = transcription.get("full_text", "")
|
||||
|
||||
if text:
|
||||
sentences = text.replace(".", ".\n").split("\n")
|
||||
time_offset = 0
|
||||
index = 1
|
||||
for sentence in sentences:
|
||||
sentence = sentence.strip()
|
||||
if sentence:
|
||||
start = format_srt_time(time_offset)
|
||||
time_offset += 3000
|
||||
end = format_srt_time(time_offset)
|
||||
srt_content += f"{index}\n{start} --> {end}\n{sentence}\n\n"
|
||||
index += 1
|
||||
|
||||
return PlainTextResponse(
|
||||
content=srt_content,
|
||||
media_type="text/plain",
|
||||
headers={"Content-Disposition": f"attachment; filename=transcript_{recording_id}.srt"}
|
||||
)
|
||||
|
||||
|
||||
def format_vtt_time(ms: int) -> str:
|
||||
"""Format milliseconds to VTT timestamp (HH:MM:SS.mmm)."""
|
||||
hours = ms // 3600000
|
||||
minutes = (ms % 3600000) // 60000
|
||||
seconds = (ms % 60000) // 1000
|
||||
millis = ms % 1000
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
|
||||
|
||||
|
||||
def format_srt_time(ms: int) -> str:
|
||||
"""Format milliseconds to SRT timestamp (HH:MM:SS,mmm)."""
|
||||
hours = ms // 3600000
|
||||
minutes = (ms % 3600000) // 60000
|
||||
seconds = (ms % 60000) // 1000
|
||||
millis = ms % 1000
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
|
||||
|
||||
|
||||
@router.get("/{recording_id}/download")
|
||||
async def download_recording(recording_id: str):
|
||||
"""
|
||||
Download the recording file.
|
||||
|
||||
In production, this would generate a presigned URL to MinIO.
|
||||
"""
|
||||
recording = _recordings_store.get(recording_id)
|
||||
if not recording:
|
||||
raise HTTPException(status_code=404, detail="Recording not found")
|
||||
|
||||
if recording["status"] == "deleted":
|
||||
raise HTTPException(status_code=410, detail="Recording has been deleted")
|
||||
|
||||
# Log download action
|
||||
log_audit(action="downloaded", recording_id=recording_id)
|
||||
|
||||
# In production, generate presigned URL to MinIO
|
||||
# For now, return info about where the file is
|
||||
return {
|
||||
"recording_id": recording_id,
|
||||
"storage_path": recording["storage_path"],
|
||||
"file_size_bytes": recording.get("file_size_bytes"),
|
||||
"message": "In production, this would redirect to a presigned MinIO URL"
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# MEETING MINUTES ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
# In-memory store for meeting minutes (dev mode)
|
||||
_minutes_store: dict = {}
|
||||
|
||||
|
||||
@router.post("/{recording_id}/minutes")
|
||||
async def generate_meeting_minutes(
|
||||
recording_id: str,
|
||||
title: Optional[str] = Query(None, description="Meeting-Titel"),
|
||||
model: str = Query("breakpilot-teacher-8b", description="LLM Modell")
|
||||
):
|
||||
"""
|
||||
Generiert KI-basierte Meeting Minutes aus der Transkription.
|
||||
|
||||
Nutzt das LLM Gateway (Ollama/vLLM) fuer lokale Verarbeitung.
|
||||
"""
|
||||
from meeting_minutes_generator import get_minutes_generator, MeetingMinutes
|
||||
|
||||
# Check recording exists
|
||||
recording = _recordings_store.get(recording_id)
|
||||
if not recording:
|
||||
raise HTTPException(status_code=404, detail="Recording not found")
|
||||
|
||||
# Check transcription exists and is completed
|
||||
transcription = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == recording_id),
|
||||
None
|
||||
)
|
||||
if not transcription:
|
||||
raise HTTPException(status_code=400, detail="No transcription found. Please transcribe first.")
|
||||
|
||||
if transcription["status"] != "completed":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Transcription not ready. Status: {transcription['status']}"
|
||||
)
|
||||
|
||||
# Check if minutes already exist
|
||||
existing = _minutes_store.get(recording_id)
|
||||
if existing and existing.get("status") == "completed":
|
||||
# Return existing minutes
|
||||
return existing
|
||||
|
||||
# Get transcript text
|
||||
transcript_text = transcription.get("full_text", "")
|
||||
if not transcript_text:
|
||||
raise HTTPException(status_code=400, detail="Transcription has no text content")
|
||||
|
||||
# Generate meeting minutes
|
||||
generator = get_minutes_generator()
|
||||
|
||||
try:
|
||||
minutes = await generator.generate(
|
||||
transcript=transcript_text,
|
||||
recording_id=recording_id,
|
||||
transcription_id=transcription["id"],
|
||||
title=title,
|
||||
date=recording.get("recorded_at", "")[:10] if recording.get("recorded_at") else None,
|
||||
duration_minutes=recording.get("duration_seconds", 0) // 60 if recording.get("duration_seconds") else None,
|
||||
participant_count=recording.get("participant_count", 0),
|
||||
model=model
|
||||
)
|
||||
|
||||
# Store minutes
|
||||
minutes_dict = minutes.model_dump()
|
||||
minutes_dict["generated_at"] = minutes.generated_at.isoformat()
|
||||
_minutes_store[recording_id] = minutes_dict
|
||||
|
||||
# Log action
|
||||
log_audit(
|
||||
action="minutes_generated",
|
||||
recording_id=recording_id,
|
||||
metadata={"model": model, "generation_time": minutes.generation_time_seconds}
|
||||
)
|
||||
|
||||
return minutes_dict
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Minutes generation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{recording_id}/minutes")
|
||||
async def get_meeting_minutes(recording_id: str):
|
||||
"""
|
||||
Ruft generierte Meeting Minutes ab.
|
||||
"""
|
||||
minutes = _minutes_store.get(recording_id)
|
||||
if not minutes:
|
||||
raise HTTPException(status_code=404, detail="No meeting minutes found. Generate them first with POST.")
|
||||
|
||||
return minutes
|
||||
|
||||
|
||||
@router.get("/{recording_id}/minutes/markdown")
|
||||
async def get_minutes_markdown(recording_id: str):
|
||||
"""
|
||||
Exportiert Meeting Minutes als Markdown.
|
||||
"""
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from meeting_minutes_generator import minutes_to_markdown, MeetingMinutes
|
||||
|
||||
minutes_dict = _minutes_store.get(recording_id)
|
||||
if not minutes_dict:
|
||||
raise HTTPException(status_code=404, detail="No meeting minutes found")
|
||||
|
||||
# Convert dict back to MeetingMinutes
|
||||
minutes_dict_copy = minutes_dict.copy()
|
||||
if isinstance(minutes_dict_copy.get("generated_at"), str):
|
||||
minutes_dict_copy["generated_at"] = datetime.fromisoformat(minutes_dict_copy["generated_at"])
|
||||
|
||||
minutes = MeetingMinutes(**minutes_dict_copy)
|
||||
markdown = minutes_to_markdown(minutes)
|
||||
|
||||
return PlainTextResponse(
|
||||
content=markdown,
|
||||
media_type="text/markdown",
|
||||
headers={"Content-Disposition": f"attachment; filename=protokoll_{recording_id}.md"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{recording_id}/minutes/html")
|
||||
async def get_minutes_html(recording_id: str):
|
||||
"""
|
||||
Exportiert Meeting Minutes als HTML.
|
||||
"""
|
||||
from fastapi.responses import HTMLResponse
|
||||
from meeting_minutes_generator import minutes_to_html, MeetingMinutes
|
||||
|
||||
minutes_dict = _minutes_store.get(recording_id)
|
||||
if not minutes_dict:
|
||||
raise HTTPException(status_code=404, detail="No meeting minutes found")
|
||||
|
||||
# Convert dict back to MeetingMinutes
|
||||
minutes_dict_copy = minutes_dict.copy()
|
||||
if isinstance(minutes_dict_copy.get("generated_at"), str):
|
||||
minutes_dict_copy["generated_at"] = datetime.fromisoformat(minutes_dict_copy["generated_at"])
|
||||
|
||||
minutes = MeetingMinutes(**minutes_dict_copy)
|
||||
html = minutes_to_html(minutes)
|
||||
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
@router.get("/{recording_id}/minutes/pdf")
|
||||
async def get_minutes_pdf(recording_id: str):
|
||||
"""
|
||||
Exportiert Meeting Minutes als PDF.
|
||||
|
||||
Benoetigt WeasyPrint (pip install weasyprint).
|
||||
"""
|
||||
from meeting_minutes_generator import minutes_to_html, MeetingMinutes
|
||||
|
||||
minutes_dict = _minutes_store.get(recording_id)
|
||||
if not minutes_dict:
|
||||
raise HTTPException(status_code=404, detail="No meeting minutes found")
|
||||
|
||||
# Convert dict back to MeetingMinutes
|
||||
minutes_dict_copy = minutes_dict.copy()
|
||||
if isinstance(minutes_dict_copy.get("generated_at"), str):
|
||||
minutes_dict_copy["generated_at"] = datetime.fromisoformat(minutes_dict_copy["generated_at"])
|
||||
|
||||
minutes = MeetingMinutes(**minutes_dict_copy)
|
||||
html = minutes_to_html(minutes)
|
||||
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
from fastapi.responses import Response
|
||||
|
||||
pdf_bytes = HTML(string=html).write_pdf()
|
||||
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename=protokoll_{recording_id}.pdf"}
|
||||
)
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="PDF export not available. Install weasyprint: pip install weasyprint"
|
||||
)
|
||||
router.include_router(_routes_router)
|
||||
router.include_router(_transcription_router)
|
||||
router.include_router(_minutes_router)
|
||||
|
||||
57
backend-lehrer/recording_helpers.py
Normal file
57
backend-lehrer/recording_helpers.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Recording API - In-Memory Storage & Helpers.
|
||||
|
||||
Shared state and utility functions for recording endpoints.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ==========================================
|
||||
# IN-MEMORY STORAGE (Dev Mode)
|
||||
# ==========================================
|
||||
# In production, these would be database queries
|
||||
|
||||
_recordings_store: dict = {}
|
||||
_transcriptions_store: dict = {}
|
||||
_audit_log: list = []
|
||||
_minutes_store: dict = {}
|
||||
|
||||
|
||||
def log_audit(
|
||||
action: str,
|
||||
recording_id: Optional[str] = None,
|
||||
transcription_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
metadata: Optional[dict] = None
|
||||
):
|
||||
"""Log audit event for DSGVO compliance."""
|
||||
_audit_log.append({
|
||||
"id": str(uuid.uuid4()),
|
||||
"recording_id": recording_id,
|
||||
"transcription_id": transcription_id,
|
||||
"user_id": user_id,
|
||||
"action": action,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
|
||||
def format_vtt_time(ms: int) -> str:
|
||||
"""Format milliseconds to VTT timestamp (HH:MM:SS.mmm)."""
|
||||
hours = ms // 3600000
|
||||
minutes = (ms % 3600000) // 60000
|
||||
seconds = (ms % 60000) // 1000
|
||||
millis = ms % 1000
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
|
||||
|
||||
|
||||
def format_srt_time(ms: int) -> str:
|
||||
"""Format milliseconds to SRT timestamp (HH:MM:SS,mmm)."""
|
||||
hours = ms // 3600000
|
||||
minutes = (ms % 3600000) // 60000
|
||||
seconds = (ms % 60000) // 1000
|
||||
millis = ms % 1000
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
|
||||
187
backend-lehrer/recording_minutes.py
Normal file
187
backend-lehrer/recording_minutes.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Recording API - Meeting Minutes Routes.
|
||||
|
||||
Generate, retrieve, and export KI-based meeting minutes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import PlainTextResponse, HTMLResponse
|
||||
|
||||
from recording_helpers import (
|
||||
_recordings_store,
|
||||
_transcriptions_store,
|
||||
_minutes_store,
|
||||
log_audit,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["Recordings"])
|
||||
|
||||
|
||||
# ==========================================
|
||||
# MEETING MINUTES ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.post("/{recording_id}/minutes")
|
||||
async def generate_meeting_minutes(
|
||||
recording_id: str,
|
||||
title: Optional[str] = Query(None, description="Meeting-Titel"),
|
||||
model: str = Query("breakpilot-teacher-8b", description="LLM Modell")
|
||||
):
|
||||
"""
|
||||
Generiert KI-basierte Meeting Minutes aus der Transkription.
|
||||
|
||||
Nutzt das LLM Gateway (Ollama/vLLM) fuer lokale Verarbeitung.
|
||||
"""
|
||||
from meeting_minutes_generator import get_minutes_generator, MeetingMinutes
|
||||
|
||||
# Check recording exists
|
||||
recording = _recordings_store.get(recording_id)
|
||||
if not recording:
|
||||
raise HTTPException(status_code=404, detail="Recording not found")
|
||||
|
||||
# Check transcription exists and is completed
|
||||
transcription = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == recording_id),
|
||||
None
|
||||
)
|
||||
if not transcription:
|
||||
raise HTTPException(status_code=400, detail="No transcription found. Please transcribe first.")
|
||||
|
||||
if transcription["status"] != "completed":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Transcription not ready. Status: {transcription['status']}"
|
||||
)
|
||||
|
||||
# Check if minutes already exist
|
||||
existing = _minutes_store.get(recording_id)
|
||||
if existing and existing.get("status") == "completed":
|
||||
# Return existing minutes
|
||||
return existing
|
||||
|
||||
# Get transcript text
|
||||
transcript_text = transcription.get("full_text", "")
|
||||
if not transcript_text:
|
||||
raise HTTPException(status_code=400, detail="Transcription has no text content")
|
||||
|
||||
# Generate meeting minutes
|
||||
generator = get_minutes_generator()
|
||||
|
||||
try:
|
||||
minutes = await generator.generate(
|
||||
transcript=transcript_text,
|
||||
recording_id=recording_id,
|
||||
transcription_id=transcription["id"],
|
||||
title=title,
|
||||
date=recording.get("recorded_at", "")[:10] if recording.get("recorded_at") else None,
|
||||
duration_minutes=recording.get("duration_seconds", 0) // 60 if recording.get("duration_seconds") else None,
|
||||
participant_count=recording.get("participant_count", 0),
|
||||
model=model
|
||||
)
|
||||
|
||||
# Store minutes
|
||||
minutes_dict = minutes.model_dump()
|
||||
minutes_dict["generated_at"] = minutes.generated_at.isoformat()
|
||||
_minutes_store[recording_id] = minutes_dict
|
||||
|
||||
# Log action
|
||||
log_audit(
|
||||
action="minutes_generated",
|
||||
recording_id=recording_id,
|
||||
metadata={"model": model, "generation_time": minutes.generation_time_seconds}
|
||||
)
|
||||
|
||||
return minutes_dict
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Minutes generation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{recording_id}/minutes")
|
||||
async def get_meeting_minutes(recording_id: str):
|
||||
"""
|
||||
Ruft generierte Meeting Minutes ab.
|
||||
"""
|
||||
minutes = _minutes_store.get(recording_id)
|
||||
if not minutes:
|
||||
raise HTTPException(status_code=404, detail="No meeting minutes found. Generate them first with POST.")
|
||||
|
||||
return minutes
|
||||
|
||||
|
||||
def _load_minutes(recording_id: str):
|
||||
"""Load and convert stored minutes dict back to MeetingMinutes."""
|
||||
from meeting_minutes_generator import MeetingMinutes
|
||||
|
||||
minutes_dict = _minutes_store.get(recording_id)
|
||||
if not minutes_dict:
|
||||
raise HTTPException(status_code=404, detail="No meeting minutes found")
|
||||
|
||||
minutes_dict_copy = minutes_dict.copy()
|
||||
if isinstance(minutes_dict_copy.get("generated_at"), str):
|
||||
minutes_dict_copy["generated_at"] = datetime.fromisoformat(minutes_dict_copy["generated_at"])
|
||||
|
||||
return MeetingMinutes(**minutes_dict_copy)
|
||||
|
||||
|
||||
@router.get("/{recording_id}/minutes/markdown")
|
||||
async def get_minutes_markdown(recording_id: str):
|
||||
"""
|
||||
Exportiert Meeting Minutes als Markdown.
|
||||
"""
|
||||
from meeting_minutes_generator import minutes_to_markdown
|
||||
|
||||
minutes = _load_minutes(recording_id)
|
||||
markdown = minutes_to_markdown(minutes)
|
||||
|
||||
return PlainTextResponse(
|
||||
content=markdown,
|
||||
media_type="text/markdown",
|
||||
headers={"Content-Disposition": f"attachment; filename=protokoll_{recording_id}.md"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{recording_id}/minutes/html")
|
||||
async def get_minutes_html(recording_id: str):
|
||||
"""
|
||||
Exportiert Meeting Minutes als HTML.
|
||||
"""
|
||||
from meeting_minutes_generator import minutes_to_html
|
||||
|
||||
minutes = _load_minutes(recording_id)
|
||||
html = minutes_to_html(minutes)
|
||||
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
@router.get("/{recording_id}/minutes/pdf")
|
||||
async def get_minutes_pdf(recording_id: str):
|
||||
"""
|
||||
Exportiert Meeting Minutes als PDF.
|
||||
|
||||
Benoetigt WeasyPrint (pip install weasyprint).
|
||||
"""
|
||||
from meeting_minutes_generator import minutes_to_html
|
||||
|
||||
minutes = _load_minutes(recording_id)
|
||||
html = minutes_to_html(minutes)
|
||||
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
from fastapi.responses import Response
|
||||
|
||||
pdf_bytes = HTML(string=html).write_pdf()
|
||||
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename=protokoll_{recording_id}.pdf"}
|
||||
)
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="PDF export not available. Install weasyprint: pip install weasyprint"
|
||||
)
|
||||
98
backend-lehrer/recording_models.py
Normal file
98
backend-lehrer/recording_models.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Recording API - Pydantic Models & Configuration.
|
||||
|
||||
Data models for recording, transcription, and webhook endpoints.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ==========================================
|
||||
# ENVIRONMENT CONFIGURATION
|
||||
# ==========================================
|
||||
|
||||
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
||||
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "breakpilot")
|
||||
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "breakpilot123")
|
||||
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "breakpilot-recordings")
|
||||
MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
|
||||
|
||||
# Default retention period in days (DSGVO compliance)
|
||||
DEFAULT_RETENTION_DAYS = int(os.getenv("RECORDING_RETENTION_DAYS", "365"))
|
||||
|
||||
|
||||
# ==========================================
|
||||
# PYDANTIC MODELS
|
||||
# ==========================================
|
||||
|
||||
class JibriWebhookPayload(BaseModel):
|
||||
"""Webhook payload from Jibri finalize.sh script."""
|
||||
event: str = Field(..., description="Event type: recording_completed")
|
||||
recording_name: str = Field(..., description="Unique recording identifier")
|
||||
storage_path: str = Field(..., description="Path in MinIO bucket")
|
||||
audio_path: Optional[str] = Field(None, description="Extracted audio path")
|
||||
file_size_bytes: int = Field(..., description="Video file size in bytes")
|
||||
timestamp: str = Field(..., description="ISO timestamp of upload")
|
||||
|
||||
|
||||
class RecordingCreate(BaseModel):
|
||||
"""Manual recording creation (for testing)."""
|
||||
meeting_id: str
|
||||
title: Optional[str] = None
|
||||
storage_path: str
|
||||
audio_path: Optional[str] = None
|
||||
duration_seconds: Optional[int] = None
|
||||
participant_count: Optional[int] = 0
|
||||
retention_days: Optional[int] = DEFAULT_RETENTION_DAYS
|
||||
|
||||
|
||||
class RecordingResponse(BaseModel):
|
||||
"""Recording details response."""
|
||||
id: str
|
||||
meeting_id: str
|
||||
title: Optional[str]
|
||||
storage_path: str
|
||||
audio_path: Optional[str]
|
||||
file_size_bytes: Optional[int]
|
||||
duration_seconds: Optional[int]
|
||||
participant_count: int
|
||||
status: str
|
||||
recorded_at: datetime
|
||||
retention_days: int
|
||||
retention_expires_at: datetime
|
||||
transcription_status: Optional[str] = None
|
||||
transcription_id: Optional[str] = None
|
||||
|
||||
|
||||
class RecordingListResponse(BaseModel):
|
||||
"""Paginated list of recordings."""
|
||||
recordings: List[RecordingResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class TranscriptionRequest(BaseModel):
|
||||
"""Request to start transcription."""
|
||||
language: str = Field(default="de", description="Language code: de, en, etc.")
|
||||
model: str = Field(default="large-v3", description="Whisper model to use")
|
||||
priority: int = Field(default=0, description="Queue priority (higher = sooner)")
|
||||
|
||||
|
||||
class TranscriptionStatusResponse(BaseModel):
|
||||
"""Transcription status and progress."""
|
||||
id: str
|
||||
recording_id: str
|
||||
status: str
|
||||
language: str
|
||||
model: str
|
||||
word_count: Optional[int]
|
||||
confidence_score: Optional[float]
|
||||
processing_duration_seconds: Optional[int]
|
||||
error_message: Optional[str]
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime]
|
||||
307
backend-lehrer/recording_routes.py
Normal file
307
backend-lehrer/recording_routes.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
Recording API - Core Recording Routes.
|
||||
|
||||
Webhook, CRUD, health, audit, and download endpoints.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from recording_models import (
|
||||
JibriWebhookPayload,
|
||||
RecordingResponse,
|
||||
RecordingListResponse,
|
||||
MINIO_ENDPOINT,
|
||||
MINIO_BUCKET,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
)
|
||||
from recording_helpers import (
|
||||
_recordings_store,
|
||||
_transcriptions_store,
|
||||
_audit_log,
|
||||
log_audit,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["Recordings"])
|
||||
|
||||
|
||||
# ==========================================
|
||||
# WEBHOOK ENDPOINT (Jibri)
|
||||
# ==========================================
|
||||
|
||||
@router.post("/webhook")
|
||||
async def jibri_webhook(payload: JibriWebhookPayload, request: Request):
|
||||
"""
|
||||
Webhook endpoint called by Jibri finalize.sh after upload.
|
||||
|
||||
This creates a new recording entry and optionally triggers transcription.
|
||||
"""
|
||||
if payload.event != "recording_completed":
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": f"Unknown event type: {payload.event}"}
|
||||
)
|
||||
|
||||
# Extract meeting_id from recording_name (format: meetingId_timestamp)
|
||||
parts = payload.recording_name.split("_")
|
||||
meeting_id = parts[0] if parts else payload.recording_name
|
||||
|
||||
# Create recording entry
|
||||
recording_id = str(uuid.uuid4())
|
||||
recorded_at = datetime.utcnow()
|
||||
|
||||
recording = {
|
||||
"id": recording_id,
|
||||
"meeting_id": meeting_id,
|
||||
"jibri_session_id": payload.recording_name,
|
||||
"title": f"Recording {meeting_id}",
|
||||
"storage_path": payload.storage_path,
|
||||
"audio_path": payload.audio_path,
|
||||
"file_size_bytes": payload.file_size_bytes,
|
||||
"duration_seconds": None, # Will be updated after analysis
|
||||
"participant_count": 0,
|
||||
"status": "uploaded",
|
||||
"recorded_at": recorded_at.isoformat(),
|
||||
"retention_days": DEFAULT_RETENTION_DAYS,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
_recordings_store[recording_id] = recording
|
||||
|
||||
# Log the creation
|
||||
log_audit(
|
||||
action="created",
|
||||
recording_id=recording_id,
|
||||
metadata={
|
||||
"source": "jibri_webhook",
|
||||
"storage_path": payload.storage_path,
|
||||
"file_size_bytes": payload.file_size_bytes
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"recording_id": recording_id,
|
||||
"meeting_id": meeting_id,
|
||||
"status": "uploaded",
|
||||
"message": "Recording registered successfully"
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# HEALTH & AUDIT ENDPOINTS (must be before parameterized routes)
|
||||
# ==========================================
|
||||
|
||||
@router.get("/health")
|
||||
async def recordings_health():
|
||||
"""Health check for recording service."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"recordings_count": len(_recordings_store),
|
||||
"transcriptions_count": len(_transcriptions_store),
|
||||
"minio_endpoint": MINIO_ENDPOINT,
|
||||
"bucket": MINIO_BUCKET
|
||||
}
|
||||
|
||||
|
||||
@router.get("/audit/log")
|
||||
async def get_audit_log(
|
||||
recording_id: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=1000)
|
||||
):
|
||||
"""
|
||||
Get audit log entries (DSGVO compliance).
|
||||
|
||||
Admin-only endpoint for reviewing recording access history.
|
||||
"""
|
||||
logs = _audit_log.copy()
|
||||
|
||||
if recording_id:
|
||||
logs = [l for l in logs if l.get("recording_id") == recording_id]
|
||||
if action:
|
||||
logs = [l for l in logs if l.get("action") == action]
|
||||
|
||||
# Sort by created_at descending
|
||||
logs.sort(key=lambda x: x["created_at"], reverse=True)
|
||||
|
||||
return {
|
||||
"entries": logs[:limit],
|
||||
"total": len(logs)
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# RECORDING MANAGEMENT ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.get("", response_model=RecordingListResponse)
|
||||
async def list_recordings(
|
||||
status: Optional[str] = Query(None, description="Filter by status"),
|
||||
meeting_id: Optional[str] = Query(None, description="Filter by meeting ID"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Items per page")
|
||||
):
|
||||
"""
|
||||
List all recordings with optional filtering.
|
||||
|
||||
Supports pagination and filtering by status or meeting ID.
|
||||
"""
|
||||
# Filter recordings
|
||||
recordings = list(_recordings_store.values())
|
||||
|
||||
if status:
|
||||
recordings = [r for r in recordings if r["status"] == status]
|
||||
if meeting_id:
|
||||
recordings = [r for r in recordings if r["meeting_id"] == meeting_id]
|
||||
|
||||
# Sort by recorded_at descending
|
||||
recordings.sort(key=lambda x: x["recorded_at"], reverse=True)
|
||||
|
||||
# Paginate
|
||||
total = len(recordings)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
page_recordings = recordings[start:end]
|
||||
|
||||
# Convert to response format
|
||||
result = []
|
||||
for rec in page_recordings:
|
||||
recorded_at = datetime.fromisoformat(rec["recorded_at"])
|
||||
retention_expires = recorded_at + timedelta(days=rec["retention_days"])
|
||||
|
||||
# Check for transcription
|
||||
trans = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == rec["id"]),
|
||||
None
|
||||
)
|
||||
|
||||
result.append(RecordingResponse(
|
||||
id=rec["id"],
|
||||
meeting_id=rec["meeting_id"],
|
||||
title=rec.get("title"),
|
||||
storage_path=rec["storage_path"],
|
||||
audio_path=rec.get("audio_path"),
|
||||
file_size_bytes=rec.get("file_size_bytes"),
|
||||
duration_seconds=rec.get("duration_seconds"),
|
||||
participant_count=rec.get("participant_count", 0),
|
||||
status=rec["status"],
|
||||
recorded_at=recorded_at,
|
||||
retention_days=rec["retention_days"],
|
||||
retention_expires_at=retention_expires,
|
||||
transcription_status=trans["status"] if trans else None,
|
||||
transcription_id=trans["id"] if trans else None
|
||||
))
|
||||
|
||||
return RecordingListResponse(
|
||||
recordings=result,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{recording_id}", response_model=RecordingResponse)
|
||||
async def get_recording(recording_id: str):
|
||||
"""
|
||||
Get details for a specific recording.
|
||||
"""
|
||||
recording = _recordings_store.get(recording_id)
|
||||
if not recording:
|
||||
raise HTTPException(status_code=404, detail="Recording not found")
|
||||
|
||||
# Log view action
|
||||
log_audit(action="viewed", recording_id=recording_id)
|
||||
|
||||
recorded_at = datetime.fromisoformat(recording["recorded_at"])
|
||||
retention_expires = recorded_at + timedelta(days=recording["retention_days"])
|
||||
|
||||
# Check for transcription
|
||||
trans = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == recording_id),
|
||||
None
|
||||
)
|
||||
|
||||
return RecordingResponse(
|
||||
id=recording["id"],
|
||||
meeting_id=recording["meeting_id"],
|
||||
title=recording.get("title"),
|
||||
storage_path=recording["storage_path"],
|
||||
audio_path=recording.get("audio_path"),
|
||||
file_size_bytes=recording.get("file_size_bytes"),
|
||||
duration_seconds=recording.get("duration_seconds"),
|
||||
participant_count=recording.get("participant_count", 0),
|
||||
status=recording["status"],
|
||||
recorded_at=recorded_at,
|
||||
retention_days=recording["retention_days"],
|
||||
retention_expires_at=retention_expires,
|
||||
transcription_status=trans["status"] if trans else None,
|
||||
transcription_id=trans["id"] if trans else None
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{recording_id}")
|
||||
async def delete_recording(
|
||||
recording_id: str,
|
||||
reason: str = Query(..., description="Reason for deletion (DSGVO audit)")
|
||||
):
|
||||
"""
|
||||
Soft-delete a recording (DSGVO compliance).
|
||||
|
||||
The recording is marked as deleted but retained for audit purposes.
|
||||
Actual file deletion happens after the audit retention period.
|
||||
"""
|
||||
recording = _recordings_store.get(recording_id)
|
||||
if not recording:
|
||||
raise HTTPException(status_code=404, detail="Recording not found")
|
||||
|
||||
# Soft delete
|
||||
recording["status"] = "deleted"
|
||||
recording["deleted_at"] = datetime.utcnow().isoformat()
|
||||
recording["updated_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
# Log deletion with reason
|
||||
log_audit(
|
||||
action="deleted",
|
||||
recording_id=recording_id,
|
||||
metadata={"reason": reason}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"recording_id": recording_id,
|
||||
"status": "deleted",
|
||||
"message": "Recording marked for deletion"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{recording_id}/download")
|
||||
async def download_recording(recording_id: str):
|
||||
"""
|
||||
Download the recording file.
|
||||
|
||||
In production, this would generate a presigned URL to MinIO.
|
||||
"""
|
||||
recording = _recordings_store.get(recording_id)
|
||||
if not recording:
|
||||
raise HTTPException(status_code=404, detail="Recording not found")
|
||||
|
||||
if recording["status"] == "deleted":
|
||||
raise HTTPException(status_code=410, detail="Recording has been deleted")
|
||||
|
||||
# Log download action
|
||||
log_audit(action="downloaded", recording_id=recording_id)
|
||||
|
||||
# In production, generate presigned URL to MinIO
|
||||
# For now, return info about where the file is
|
||||
return {
|
||||
"recording_id": recording_id,
|
||||
"storage_path": recording["storage_path"],
|
||||
"file_size_bytes": recording.get("file_size_bytes"),
|
||||
"message": "In production, this would redirect to a presigned MinIO URL"
|
||||
}
|
||||
250
backend-lehrer/recording_transcription.py
Normal file
250
backend-lehrer/recording_transcription.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
Recording API - Transcription Routes.
|
||||
|
||||
Start transcription, get status, download VTT/SRT subtitle files.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from recording_models import (
|
||||
TranscriptionRequest,
|
||||
TranscriptionStatusResponse,
|
||||
)
|
||||
from recording_helpers import (
|
||||
_recordings_store,
|
||||
_transcriptions_store,
|
||||
log_audit,
|
||||
format_vtt_time,
|
||||
format_srt_time,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["Recordings"])
|
||||
|
||||
|
||||
# ==========================================
|
||||
# TRANSCRIPTION ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.post("/{recording_id}/transcribe", response_model=TranscriptionStatusResponse)
|
||||
async def start_transcription(recording_id: str, request: TranscriptionRequest):
|
||||
"""
|
||||
Start transcription for a recording.
|
||||
|
||||
Queues the recording for processing by the transcription worker.
|
||||
"""
|
||||
recording = _recordings_store.get(recording_id)
|
||||
if not recording:
|
||||
raise HTTPException(status_code=404, detail="Recording not found")
|
||||
|
||||
if recording["status"] == "deleted":
|
||||
raise HTTPException(status_code=400, detail="Cannot transcribe deleted recording")
|
||||
|
||||
# Check if transcription already exists
|
||||
existing = next(
|
||||
(t for t in _transcriptions_store.values()
|
||||
if t["recording_id"] == recording_id and t["status"] != "failed"),
|
||||
None
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Transcription already exists with status: {existing['status']}"
|
||||
)
|
||||
|
||||
# Create transcription entry
|
||||
transcription_id = str(uuid.uuid4())
|
||||
now = datetime.utcnow()
|
||||
|
||||
transcription = {
|
||||
"id": transcription_id,
|
||||
"recording_id": recording_id,
|
||||
"language": request.language,
|
||||
"model": request.model,
|
||||
"status": "pending",
|
||||
"full_text": None,
|
||||
"word_count": None,
|
||||
"confidence_score": None,
|
||||
"vtt_path": None,
|
||||
"srt_path": None,
|
||||
"json_path": None,
|
||||
"error_message": None,
|
||||
"processing_started_at": None,
|
||||
"processing_completed_at": None,
|
||||
"processing_duration_seconds": None,
|
||||
"created_at": now.isoformat(),
|
||||
"updated_at": now.isoformat()
|
||||
}
|
||||
|
||||
_transcriptions_store[transcription_id] = transcription
|
||||
|
||||
# Update recording status
|
||||
recording["status"] = "processing"
|
||||
recording["updated_at"] = now.isoformat()
|
||||
|
||||
# Log transcription start
|
||||
log_audit(
|
||||
action="transcription_started",
|
||||
recording_id=recording_id,
|
||||
transcription_id=transcription_id,
|
||||
metadata={"language": request.language, "model": request.model}
|
||||
)
|
||||
|
||||
# TODO: Queue job to Redis/Valkey for transcription worker
|
||||
|
||||
return TranscriptionStatusResponse(
|
||||
id=transcription_id,
|
||||
recording_id=recording_id,
|
||||
status="pending",
|
||||
language=request.language,
|
||||
model=request.model,
|
||||
word_count=None,
|
||||
confidence_score=None,
|
||||
processing_duration_seconds=None,
|
||||
error_message=None,
|
||||
created_at=now,
|
||||
completed_at=None
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{recording_id}/transcription", response_model=TranscriptionStatusResponse)
|
||||
async def get_transcription_status(recording_id: str):
|
||||
"""
|
||||
Get transcription status for a recording.
|
||||
"""
|
||||
transcription = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == recording_id),
|
||||
None
|
||||
)
|
||||
if not transcription:
|
||||
raise HTTPException(status_code=404, detail="No transcription found for this recording")
|
||||
|
||||
return TranscriptionStatusResponse(
|
||||
id=transcription["id"],
|
||||
recording_id=transcription["recording_id"],
|
||||
status=transcription["status"],
|
||||
language=transcription["language"],
|
||||
model=transcription["model"],
|
||||
word_count=transcription.get("word_count"),
|
||||
confidence_score=transcription.get("confidence_score"),
|
||||
processing_duration_seconds=transcription.get("processing_duration_seconds"),
|
||||
error_message=transcription.get("error_message"),
|
||||
created_at=datetime.fromisoformat(transcription["created_at"]),
|
||||
completed_at=(
|
||||
datetime.fromisoformat(transcription["processing_completed_at"])
|
||||
if transcription.get("processing_completed_at") else None
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{recording_id}/transcription/text")
|
||||
async def get_transcription_text(recording_id: str):
|
||||
"""
|
||||
Get the full transcription text.
|
||||
"""
|
||||
transcription = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == recording_id),
|
||||
None
|
||||
)
|
||||
if not transcription:
|
||||
raise HTTPException(status_code=404, detail="No transcription found for this recording")
|
||||
|
||||
if transcription["status"] != "completed":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Transcription not ready. Status: {transcription['status']}"
|
||||
)
|
||||
|
||||
return {
|
||||
"transcription_id": transcription["id"],
|
||||
"recording_id": recording_id,
|
||||
"language": transcription["language"],
|
||||
"text": transcription.get("full_text", ""),
|
||||
"word_count": transcription.get("word_count", 0)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{recording_id}/transcription/vtt")
|
||||
async def get_transcription_vtt(recording_id: str):
|
||||
"""
|
||||
Download transcription as WebVTT subtitle file.
|
||||
"""
|
||||
transcription = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == recording_id),
|
||||
None
|
||||
)
|
||||
if not transcription:
|
||||
raise HTTPException(status_code=404, detail="No transcription found for this recording")
|
||||
|
||||
if transcription["status"] != "completed":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Transcription not ready. Status: {transcription['status']}"
|
||||
)
|
||||
|
||||
# Generate VTT content
|
||||
vtt_content = "WEBVTT\n\n"
|
||||
text = transcription.get("full_text", "")
|
||||
|
||||
if text:
|
||||
sentences = text.replace(".", ".\n").split("\n")
|
||||
time_offset = 0
|
||||
for sentence in sentences:
|
||||
sentence = sentence.strip()
|
||||
if sentence:
|
||||
start = format_vtt_time(time_offset)
|
||||
time_offset += 3000
|
||||
end = format_vtt_time(time_offset)
|
||||
vtt_content += f"{start} --> {end}\n{sentence}\n\n"
|
||||
|
||||
return PlainTextResponse(
|
||||
content=vtt_content,
|
||||
media_type="text/vtt",
|
||||
headers={"Content-Disposition": f"attachment; filename=transcript_{recording_id}.vtt"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{recording_id}/transcription/srt")
|
||||
async def get_transcription_srt(recording_id: str):
|
||||
"""
|
||||
Download transcription as SRT subtitle file.
|
||||
"""
|
||||
transcription = next(
|
||||
(t for t in _transcriptions_store.values() if t["recording_id"] == recording_id),
|
||||
None
|
||||
)
|
||||
if not transcription:
|
||||
raise HTTPException(status_code=404, detail="No transcription found for this recording")
|
||||
|
||||
if transcription["status"] != "completed":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Transcription not ready. Status: {transcription['status']}"
|
||||
)
|
||||
|
||||
# Generate SRT content
|
||||
srt_content = ""
|
||||
text = transcription.get("full_text", "")
|
||||
|
||||
if text:
|
||||
sentences = text.replace(".", ".\n").split("\n")
|
||||
time_offset = 0
|
||||
index = 1
|
||||
for sentence in sentences:
|
||||
sentence = sentence.strip()
|
||||
if sentence:
|
||||
start = format_srt_time(time_offset)
|
||||
time_offset += 3000
|
||||
end = format_srt_time(time_offset)
|
||||
srt_content += f"{index}\n{start} --> {end}\n{sentence}\n\n"
|
||||
index += 1
|
||||
|
||||
return PlainTextResponse(
|
||||
content=srt_content,
|
||||
media_type="text/plain",
|
||||
headers={"Content-Disposition": f"attachment; filename=transcript_{recording_id}.srt"}
|
||||
)
|
||||
@@ -1,751 +1,25 @@
|
||||
# ==============================================
|
||||
# Breakpilot Drive - Unit Analytics API
|
||||
# ==============================================
|
||||
# Erweiterte Analytics fuer Lernfortschritt:
|
||||
# - Pre/Post Gain Visualisierung
|
||||
# - Misconception-Tracking
|
||||
# - Stop-Level Analytics
|
||||
# - Aggregierte Klassen-Statistiken
|
||||
# - Export-Funktionen
|
||||
"""
|
||||
Breakpilot Drive - Unit Analytics API — Barrel Re-export.
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
import os
|
||||
import logging
|
||||
import statistics
|
||||
Erweiterte Analytics fuer Lernfortschritt:
|
||||
- Pre/Post Gain Visualisierung
|
||||
- Misconception-Tracking
|
||||
- Stop-Level Analytics
|
||||
- Aggregierte Klassen-Statistiken
|
||||
- Export-Funktionen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
Split into:
|
||||
- unit_analytics_models.py: Pydantic models & enums
|
||||
- unit_analytics_helpers.py: Database access & computation helpers
|
||||
- unit_analytics_routes.py: Core analytics endpoint handlers
|
||||
- unit_analytics_export.py: Export & dashboard endpoints
|
||||
"""
|
||||
|
||||
# Feature flags
|
||||
USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true"
|
||||
from fastapi import APIRouter
|
||||
|
||||
from unit_analytics_routes import router as _routes_router
|
||||
from unit_analytics_export import router as _export_router
|
||||
|
||||
router = APIRouter(prefix="/api/analytics", tags=["Unit Analytics"])
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Pydantic Models
|
||||
# ==============================================
|
||||
|
||||
class TimeRange(str, Enum):
|
||||
"""Time range for analytics queries"""
|
||||
WEEK = "week"
|
||||
MONTH = "month"
|
||||
QUARTER = "quarter"
|
||||
ALL = "all"
|
||||
|
||||
|
||||
class LearningGainData(BaseModel):
|
||||
"""Pre/Post learning gain data point"""
|
||||
student_id: str
|
||||
student_name: str
|
||||
unit_id: str
|
||||
precheck_score: float
|
||||
postcheck_score: float
|
||||
learning_gain: float
|
||||
percentile: Optional[float] = None
|
||||
|
||||
|
||||
class LearningGainSummary(BaseModel):
|
||||
"""Aggregated learning gain statistics"""
|
||||
unit_id: str
|
||||
unit_title: str
|
||||
total_students: int
|
||||
avg_precheck: float
|
||||
avg_postcheck: float
|
||||
avg_gain: float
|
||||
median_gain: float
|
||||
std_deviation: float
|
||||
positive_gain_count: int
|
||||
negative_gain_count: int
|
||||
no_change_count: int
|
||||
gain_distribution: Dict[str, int] # "-20+", "-10-0", "0-10", "10-20", "20+"
|
||||
individual_gains: List[LearningGainData]
|
||||
|
||||
|
||||
class StopPerformance(BaseModel):
|
||||
"""Performance data for a single stop"""
|
||||
stop_id: str
|
||||
stop_label: str
|
||||
attempts_total: int
|
||||
success_rate: float
|
||||
avg_time_seconds: float
|
||||
avg_attempts_before_success: float
|
||||
common_errors: List[str]
|
||||
difficulty_rating: float # 1-5 based on performance
|
||||
|
||||
|
||||
class UnitPerformanceDetail(BaseModel):
|
||||
"""Detailed unit performance breakdown"""
|
||||
unit_id: str
|
||||
unit_title: str
|
||||
template: str
|
||||
total_sessions: int
|
||||
completed_sessions: int
|
||||
completion_rate: float
|
||||
avg_duration_minutes: float
|
||||
stops: List[StopPerformance]
|
||||
bottleneck_stops: List[str] # Stops where students struggle most
|
||||
|
||||
|
||||
class MisconceptionEntry(BaseModel):
|
||||
"""Individual misconception tracking"""
|
||||
concept_id: str
|
||||
concept_label: str
|
||||
misconception_text: str
|
||||
frequency: int
|
||||
affected_student_ids: List[str]
|
||||
unit_id: str
|
||||
stop_id: str
|
||||
detected_via: str # "precheck", "postcheck", "interaction"
|
||||
first_detected: datetime
|
||||
last_detected: datetime
|
||||
|
||||
|
||||
class MisconceptionReport(BaseModel):
|
||||
"""Comprehensive misconception report"""
|
||||
class_id: Optional[str]
|
||||
time_range: str
|
||||
total_misconceptions: int
|
||||
unique_concepts: int
|
||||
most_common: List[MisconceptionEntry]
|
||||
by_unit: Dict[str, List[MisconceptionEntry]]
|
||||
trending_up: List[MisconceptionEntry] # Getting more frequent
|
||||
resolved: List[MisconceptionEntry] # No longer appearing
|
||||
|
||||
|
||||
class StudentProgressTimeline(BaseModel):
|
||||
"""Timeline of student progress"""
|
||||
student_id: str
|
||||
student_name: str
|
||||
units_completed: int
|
||||
total_time_minutes: int
|
||||
avg_score: float
|
||||
trend: str # "improving", "stable", "declining"
|
||||
timeline: List[Dict[str, Any]] # List of session events
|
||||
|
||||
|
||||
class ClassComparisonData(BaseModel):
|
||||
"""Data for comparing class performance"""
|
||||
class_id: str
|
||||
class_name: str
|
||||
student_count: int
|
||||
units_assigned: int
|
||||
avg_completion_rate: float
|
||||
avg_learning_gain: float
|
||||
avg_time_per_unit: float
|
||||
|
||||
|
||||
class ExportFormat(str, Enum):
|
||||
"""Export format options"""
|
||||
JSON = "json"
|
||||
CSV = "csv"
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Database Integration
|
||||
# ==============================================
|
||||
|
||||
_analytics_db = None
|
||||
|
||||
async def get_analytics_database():
|
||||
"""Get analytics database instance."""
|
||||
global _analytics_db
|
||||
if not USE_DATABASE:
|
||||
return None
|
||||
if _analytics_db is None:
|
||||
try:
|
||||
from unit.database import get_analytics_db
|
||||
_analytics_db = await get_analytics_db()
|
||||
logger.info("Analytics database initialized")
|
||||
except ImportError:
|
||||
logger.warning("Analytics database module not available")
|
||||
except Exception as e:
|
||||
logger.warning(f"Analytics database not available: {e}")
|
||||
return _analytics_db
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Helper Functions
|
||||
# ==============================================
|
||||
|
||||
def calculate_gain_distribution(gains: List[float]) -> Dict[str, int]:
|
||||
"""Calculate distribution of learning gains into buckets."""
|
||||
distribution = {
|
||||
"< -20%": 0,
|
||||
"-20% to -10%": 0,
|
||||
"-10% to 0%": 0,
|
||||
"0% to 10%": 0,
|
||||
"10% to 20%": 0,
|
||||
"> 20%": 0,
|
||||
}
|
||||
|
||||
for gain in gains:
|
||||
gain_percent = gain * 100
|
||||
if gain_percent < -20:
|
||||
distribution["< -20%"] += 1
|
||||
elif gain_percent < -10:
|
||||
distribution["-20% to -10%"] += 1
|
||||
elif gain_percent < 0:
|
||||
distribution["-10% to 0%"] += 1
|
||||
elif gain_percent < 10:
|
||||
distribution["0% to 10%"] += 1
|
||||
elif gain_percent < 20:
|
||||
distribution["10% to 20%"] += 1
|
||||
else:
|
||||
distribution["> 20%"] += 1
|
||||
|
||||
return distribution
|
||||
|
||||
|
||||
def calculate_trend(scores: List[float]) -> str:
|
||||
"""Calculate trend from a series of scores."""
|
||||
if len(scores) < 3:
|
||||
return "insufficient_data"
|
||||
|
||||
# Simple linear regression
|
||||
n = len(scores)
|
||||
x_mean = (n - 1) / 2
|
||||
y_mean = sum(scores) / n
|
||||
|
||||
numerator = sum((i - x_mean) * (scores[i] - y_mean) for i in range(n))
|
||||
denominator = sum((i - x_mean) ** 2 for i in range(n))
|
||||
|
||||
if denominator == 0:
|
||||
return "stable"
|
||||
|
||||
slope = numerator / denominator
|
||||
|
||||
if slope > 0.05:
|
||||
return "improving"
|
||||
elif slope < -0.05:
|
||||
return "declining"
|
||||
else:
|
||||
return "stable"
|
||||
|
||||
|
||||
def calculate_difficulty_rating(success_rate: float, avg_attempts: float) -> float:
|
||||
"""Calculate difficulty rating 1-5 based on success metrics."""
|
||||
# Lower success rate and higher attempts = higher difficulty
|
||||
base_difficulty = (1 - success_rate) * 3 + 1 # 1-4 range
|
||||
attempt_modifier = min(avg_attempts - 1, 1) # 0-1 range
|
||||
return min(5.0, base_difficulty + attempt_modifier)
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Learning Gain
|
||||
# ==============================================
|
||||
|
||||
# NOTE: Static routes must come BEFORE dynamic routes like /{unit_id}
|
||||
@router.get("/learning-gain/compare")
|
||||
async def compare_learning_gains(
|
||||
unit_ids: str = Query(..., description="Comma-separated unit IDs"),
|
||||
class_id: Optional[str] = Query(None),
|
||||
time_range: TimeRange = Query(TimeRange.MONTH),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Compare learning gains across multiple units.
|
||||
"""
|
||||
unit_list = [u.strip() for u in unit_ids.split(",")]
|
||||
comparisons = []
|
||||
|
||||
for unit_id in unit_list:
|
||||
try:
|
||||
summary = await get_learning_gain_analysis(unit_id, class_id, time_range)
|
||||
comparisons.append({
|
||||
"unit_id": unit_id,
|
||||
"avg_gain": summary.avg_gain,
|
||||
"median_gain": summary.median_gain,
|
||||
"total_students": summary.total_students,
|
||||
"positive_rate": summary.positive_gain_count / max(summary.total_students, 1),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get comparison for {unit_id}: {e}")
|
||||
|
||||
return {
|
||||
"time_range": time_range.value,
|
||||
"class_id": class_id,
|
||||
"comparisons": sorted(comparisons, key=lambda x: x["avg_gain"], reverse=True),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/learning-gain/{unit_id}", response_model=LearningGainSummary)
|
||||
async def get_learning_gain_analysis(
|
||||
unit_id: str,
|
||||
class_id: Optional[str] = Query(None, description="Filter by class"),
|
||||
time_range: TimeRange = Query(TimeRange.MONTH, description="Time range for analysis"),
|
||||
) -> LearningGainSummary:
|
||||
"""
|
||||
Get detailed pre/post learning gain analysis for a unit.
|
||||
|
||||
Shows individual gains, aggregated statistics, and distribution.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
individual_gains = []
|
||||
|
||||
if db:
|
||||
try:
|
||||
# Get all sessions with pre/post scores for this unit
|
||||
sessions = await db.get_unit_sessions_with_scores(
|
||||
unit_id=unit_id,
|
||||
class_id=class_id,
|
||||
time_range=time_range.value
|
||||
)
|
||||
|
||||
for session in sessions:
|
||||
if session.get("precheck_score") is not None and session.get("postcheck_score") is not None:
|
||||
gain = session["postcheck_score"] - session["precheck_score"]
|
||||
individual_gains.append(LearningGainData(
|
||||
student_id=session["student_id"],
|
||||
student_name=session.get("student_name", session["student_id"][:8]),
|
||||
unit_id=unit_id,
|
||||
precheck_score=session["precheck_score"],
|
||||
postcheck_score=session["postcheck_score"],
|
||||
learning_gain=gain,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get learning gain data: {e}")
|
||||
|
||||
# Calculate statistics
|
||||
if not individual_gains:
|
||||
# Return empty summary
|
||||
return LearningGainSummary(
|
||||
unit_id=unit_id,
|
||||
unit_title=f"Unit {unit_id}",
|
||||
total_students=0,
|
||||
avg_precheck=0.0,
|
||||
avg_postcheck=0.0,
|
||||
avg_gain=0.0,
|
||||
median_gain=0.0,
|
||||
std_deviation=0.0,
|
||||
positive_gain_count=0,
|
||||
negative_gain_count=0,
|
||||
no_change_count=0,
|
||||
gain_distribution={},
|
||||
individual_gains=[],
|
||||
)
|
||||
|
||||
gains = [g.learning_gain for g in individual_gains]
|
||||
prechecks = [g.precheck_score for g in individual_gains]
|
||||
postchecks = [g.postcheck_score for g in individual_gains]
|
||||
|
||||
avg_gain = statistics.mean(gains)
|
||||
median_gain = statistics.median(gains)
|
||||
std_dev = statistics.stdev(gains) if len(gains) > 1 else 0.0
|
||||
|
||||
# Calculate percentiles
|
||||
sorted_gains = sorted(gains)
|
||||
for data in individual_gains:
|
||||
rank = sorted_gains.index(data.learning_gain) + 1
|
||||
data.percentile = rank / len(sorted_gains) * 100
|
||||
|
||||
return LearningGainSummary(
|
||||
unit_id=unit_id,
|
||||
unit_title=f"Unit {unit_id}",
|
||||
total_students=len(individual_gains),
|
||||
avg_precheck=statistics.mean(prechecks),
|
||||
avg_postcheck=statistics.mean(postchecks),
|
||||
avg_gain=avg_gain,
|
||||
median_gain=median_gain,
|
||||
std_deviation=std_dev,
|
||||
positive_gain_count=sum(1 for g in gains if g > 0.01),
|
||||
negative_gain_count=sum(1 for g in gains if g < -0.01),
|
||||
no_change_count=sum(1 for g in gains if -0.01 <= g <= 0.01),
|
||||
gain_distribution=calculate_gain_distribution(gains),
|
||||
individual_gains=sorted(individual_gains, key=lambda x: x.learning_gain, reverse=True),
|
||||
)
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Stop-Level Analytics
|
||||
# ==============================================
|
||||
|
||||
@router.get("/unit/{unit_id}/stops", response_model=UnitPerformanceDetail)
|
||||
async def get_unit_stop_analytics(
|
||||
unit_id: str,
|
||||
class_id: Optional[str] = Query(None),
|
||||
time_range: TimeRange = Query(TimeRange.MONTH),
|
||||
) -> UnitPerformanceDetail:
|
||||
"""
|
||||
Get detailed stop-level performance analytics.
|
||||
|
||||
Identifies bottleneck stops where students struggle most.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
stops_data = []
|
||||
|
||||
if db:
|
||||
try:
|
||||
# Get stop-level telemetry
|
||||
stop_stats = await db.get_stop_performance(
|
||||
unit_id=unit_id,
|
||||
class_id=class_id,
|
||||
time_range=time_range.value
|
||||
)
|
||||
|
||||
for stop in stop_stats:
|
||||
difficulty = calculate_difficulty_rating(
|
||||
stop.get("success_rate", 0.5),
|
||||
stop.get("avg_attempts", 1.0)
|
||||
)
|
||||
stops_data.append(StopPerformance(
|
||||
stop_id=stop["stop_id"],
|
||||
stop_label=stop.get("stop_label", stop["stop_id"]),
|
||||
attempts_total=stop.get("total_attempts", 0),
|
||||
success_rate=stop.get("success_rate", 0.0),
|
||||
avg_time_seconds=stop.get("avg_time_seconds", 0.0),
|
||||
avg_attempts_before_success=stop.get("avg_attempts", 1.0),
|
||||
common_errors=stop.get("common_errors", []),
|
||||
difficulty_rating=difficulty,
|
||||
))
|
||||
|
||||
# Get overall unit stats
|
||||
unit_stats = await db.get_unit_overall_stats(unit_id, class_id, time_range.value)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get stop analytics: {e}")
|
||||
unit_stats = {}
|
||||
else:
|
||||
unit_stats = {}
|
||||
|
||||
# Identify bottleneck stops (difficulty > 3.5 or success rate < 0.6)
|
||||
bottlenecks = [
|
||||
s.stop_id for s in stops_data
|
||||
if s.difficulty_rating > 3.5 or s.success_rate < 0.6
|
||||
]
|
||||
|
||||
return UnitPerformanceDetail(
|
||||
unit_id=unit_id,
|
||||
unit_title=f"Unit {unit_id}",
|
||||
template=unit_stats.get("template", "unknown"),
|
||||
total_sessions=unit_stats.get("total_sessions", 0),
|
||||
completed_sessions=unit_stats.get("completed_sessions", 0),
|
||||
completion_rate=unit_stats.get("completion_rate", 0.0),
|
||||
avg_duration_minutes=unit_stats.get("avg_duration_minutes", 0.0),
|
||||
stops=stops_data,
|
||||
bottleneck_stops=bottlenecks,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Misconception Tracking
|
||||
# ==============================================
|
||||
|
||||
@router.get("/misconceptions", response_model=MisconceptionReport)
|
||||
async def get_misconception_report(
|
||||
class_id: Optional[str] = Query(None),
|
||||
unit_id: Optional[str] = Query(None),
|
||||
time_range: TimeRange = Query(TimeRange.MONTH),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> MisconceptionReport:
|
||||
"""
|
||||
Get comprehensive misconception report.
|
||||
|
||||
Shows most common misconceptions and their frequency.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
misconceptions = []
|
||||
|
||||
if db:
|
||||
try:
|
||||
raw_misconceptions = await db.get_misconceptions(
|
||||
class_id=class_id,
|
||||
unit_id=unit_id,
|
||||
time_range=time_range.value,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
for m in raw_misconceptions:
|
||||
misconceptions.append(MisconceptionEntry(
|
||||
concept_id=m["concept_id"],
|
||||
concept_label=m["concept_label"],
|
||||
misconception_text=m["misconception_text"],
|
||||
frequency=m["frequency"],
|
||||
affected_student_ids=m.get("student_ids", []),
|
||||
unit_id=m["unit_id"],
|
||||
stop_id=m["stop_id"],
|
||||
detected_via=m.get("detected_via", "unknown"),
|
||||
first_detected=m.get("first_detected", datetime.utcnow()),
|
||||
last_detected=m.get("last_detected", datetime.utcnow()),
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get misconceptions: {e}")
|
||||
|
||||
# Group by unit
|
||||
by_unit = {}
|
||||
for m in misconceptions:
|
||||
if m.unit_id not in by_unit:
|
||||
by_unit[m.unit_id] = []
|
||||
by_unit[m.unit_id].append(m)
|
||||
|
||||
# Identify trending misconceptions (would need historical comparison in production)
|
||||
trending_up = misconceptions[:3] if misconceptions else []
|
||||
resolved = [] # Would identify from historical data
|
||||
|
||||
return MisconceptionReport(
|
||||
class_id=class_id,
|
||||
time_range=time_range.value,
|
||||
total_misconceptions=sum(m.frequency for m in misconceptions),
|
||||
unique_concepts=len(set(m.concept_id for m in misconceptions)),
|
||||
most_common=sorted(misconceptions, key=lambda x: x.frequency, reverse=True)[:10],
|
||||
by_unit=by_unit,
|
||||
trending_up=trending_up,
|
||||
resolved=resolved,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/misconceptions/student/{student_id}")
|
||||
async def get_student_misconceptions(
|
||||
student_id: str,
|
||||
time_range: TimeRange = Query(TimeRange.ALL),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get misconceptions for a specific student.
|
||||
|
||||
Useful for personalized remediation.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
|
||||
if db:
|
||||
try:
|
||||
misconceptions = await db.get_student_misconceptions(
|
||||
student_id=student_id,
|
||||
time_range=time_range.value
|
||||
)
|
||||
return {
|
||||
"student_id": student_id,
|
||||
"misconceptions": misconceptions,
|
||||
"recommended_remediation": [
|
||||
{"concept": m["concept_label"], "activity": f"Review {m['unit_id']}/{m['stop_id']}"}
|
||||
for m in misconceptions[:5]
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get student misconceptions: {e}")
|
||||
|
||||
return {
|
||||
"student_id": student_id,
|
||||
"misconceptions": [],
|
||||
"recommended_remediation": [],
|
||||
}
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Student Progress Timeline
|
||||
# ==============================================
|
||||
|
||||
@router.get("/student/{student_id}/timeline", response_model=StudentProgressTimeline)
|
||||
async def get_student_timeline(
|
||||
student_id: str,
|
||||
time_range: TimeRange = Query(TimeRange.ALL),
|
||||
) -> StudentProgressTimeline:
|
||||
"""
|
||||
Get detailed progress timeline for a student.
|
||||
|
||||
Shows all unit sessions and performance trend.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
timeline = []
|
||||
scores = []
|
||||
|
||||
if db:
|
||||
try:
|
||||
sessions = await db.get_student_sessions(
|
||||
student_id=student_id,
|
||||
time_range=time_range.value
|
||||
)
|
||||
|
||||
for session in sessions:
|
||||
timeline.append({
|
||||
"date": session.get("started_at"),
|
||||
"unit_id": session.get("unit_id"),
|
||||
"completed": session.get("completed_at") is not None,
|
||||
"precheck": session.get("precheck_score"),
|
||||
"postcheck": session.get("postcheck_score"),
|
||||
"duration_minutes": session.get("duration_seconds", 0) // 60,
|
||||
})
|
||||
if session.get("postcheck_score") is not None:
|
||||
scores.append(session["postcheck_score"])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get student timeline: {e}")
|
||||
|
||||
trend = calculate_trend(scores) if scores else "insufficient_data"
|
||||
|
||||
return StudentProgressTimeline(
|
||||
student_id=student_id,
|
||||
student_name=f"Student {student_id[:8]}", # Would load actual name
|
||||
units_completed=sum(1 for t in timeline if t["completed"]),
|
||||
total_time_minutes=sum(t["duration_minutes"] for t in timeline),
|
||||
avg_score=statistics.mean(scores) if scores else 0.0,
|
||||
trend=trend,
|
||||
timeline=timeline,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Class Comparison
|
||||
# ==============================================
|
||||
|
||||
@router.get("/compare/classes", response_model=List[ClassComparisonData])
|
||||
async def compare_classes(
|
||||
class_ids: str = Query(..., description="Comma-separated class IDs"),
|
||||
time_range: TimeRange = Query(TimeRange.MONTH),
|
||||
) -> List[ClassComparisonData]:
|
||||
"""
|
||||
Compare performance across multiple classes.
|
||||
"""
|
||||
class_list = [c.strip() for c in class_ids.split(",")]
|
||||
comparisons = []
|
||||
|
||||
db = await get_analytics_database()
|
||||
if db:
|
||||
for class_id in class_list:
|
||||
try:
|
||||
stats = await db.get_class_aggregate_stats(class_id, time_range.value)
|
||||
comparisons.append(ClassComparisonData(
|
||||
class_id=class_id,
|
||||
class_name=stats.get("class_name", f"Klasse {class_id[:8]}"),
|
||||
student_count=stats.get("student_count", 0),
|
||||
units_assigned=stats.get("units_assigned", 0),
|
||||
avg_completion_rate=stats.get("avg_completion_rate", 0.0),
|
||||
avg_learning_gain=stats.get("avg_learning_gain", 0.0),
|
||||
avg_time_per_unit=stats.get("avg_time_per_unit", 0.0),
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get stats for class {class_id}: {e}")
|
||||
|
||||
return sorted(comparisons, key=lambda x: x.avg_learning_gain, reverse=True)
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Export
|
||||
# ==============================================
|
||||
|
||||
@router.get("/export/learning-gains")
|
||||
async def export_learning_gains(
|
||||
unit_id: Optional[str] = Query(None),
|
||||
class_id: Optional[str] = Query(None),
|
||||
time_range: TimeRange = Query(TimeRange.ALL),
|
||||
format: ExportFormat = Query(ExportFormat.JSON),
|
||||
) -> Any:
|
||||
"""
|
||||
Export learning gain data.
|
||||
"""
|
||||
from fastapi.responses import Response
|
||||
|
||||
db = await get_analytics_database()
|
||||
data = []
|
||||
|
||||
if db:
|
||||
try:
|
||||
data = await db.export_learning_gains(
|
||||
unit_id=unit_id,
|
||||
class_id=class_id,
|
||||
time_range=time_range.value
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export data: {e}")
|
||||
|
||||
if format == ExportFormat.CSV:
|
||||
# Convert to CSV
|
||||
if not data:
|
||||
csv_content = "student_id,unit_id,precheck,postcheck,gain\n"
|
||||
else:
|
||||
csv_content = "student_id,unit_id,precheck,postcheck,gain\n"
|
||||
for row in data:
|
||||
csv_content += f"{row['student_id']},{row['unit_id']},{row.get('precheck', '')},{row.get('postcheck', '')},{row.get('gain', '')}\n"
|
||||
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=learning_gains.csv"}
|
||||
)
|
||||
|
||||
return {
|
||||
"export_date": datetime.utcnow().isoformat(),
|
||||
"filters": {
|
||||
"unit_id": unit_id,
|
||||
"class_id": class_id,
|
||||
"time_range": time_range.value,
|
||||
},
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/export/misconceptions")
|
||||
async def export_misconceptions(
|
||||
class_id: Optional[str] = Query(None),
|
||||
format: ExportFormat = Query(ExportFormat.JSON),
|
||||
) -> Any:
|
||||
"""
|
||||
Export misconception data for further analysis.
|
||||
"""
|
||||
report = await get_misconception_report(
|
||||
class_id=class_id,
|
||||
unit_id=None,
|
||||
time_range=TimeRange.MONTH,
|
||||
limit=100
|
||||
)
|
||||
|
||||
if format == ExportFormat.CSV:
|
||||
from fastapi.responses import Response
|
||||
csv_content = "concept_id,concept_label,misconception,frequency,unit_id,stop_id\n"
|
||||
for m in report.most_common:
|
||||
csv_content += f'"{m.concept_id}","{m.concept_label}","{m.misconception_text}",{m.frequency},"{m.unit_id}","{m.stop_id}"\n'
|
||||
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=misconceptions.csv"}
|
||||
)
|
||||
|
||||
return {
|
||||
"export_date": datetime.utcnow().isoformat(),
|
||||
"class_id": class_id,
|
||||
"total_entries": len(report.most_common),
|
||||
"data": [m.model_dump() for m in report.most_common],
|
||||
}
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Dashboard Aggregates
|
||||
# ==============================================
|
||||
|
||||
@router.get("/dashboard/overview")
|
||||
async def get_analytics_overview(
|
||||
time_range: TimeRange = Query(TimeRange.MONTH),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get high-level analytics overview for dashboard.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
|
||||
if db:
|
||||
try:
|
||||
overview = await db.get_analytics_overview(time_range.value)
|
||||
return overview
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get analytics overview: {e}")
|
||||
|
||||
return {
|
||||
"time_range": time_range.value,
|
||||
"total_sessions": 0,
|
||||
"unique_students": 0,
|
||||
"avg_completion_rate": 0.0,
|
||||
"avg_learning_gain": 0.0,
|
||||
"most_played_units": [],
|
||||
"struggling_concepts": [],
|
||||
"active_classes": 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> Dict[str, Any]:
|
||||
"""Health check for analytics API."""
|
||||
db = await get_analytics_database()
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "unit-analytics",
|
||||
"database": "connected" if db else "disconnected",
|
||||
}
|
||||
router.include_router(_routes_router)
|
||||
router.include_router(_export_router)
|
||||
|
||||
145
backend-lehrer/unit_analytics_export.py
Normal file
145
backend-lehrer/unit_analytics_export.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Unit Analytics API - Export & Dashboard Routes.
|
||||
|
||||
Export endpoints for learning gains and misconceptions, plus dashboard overview.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from unit_analytics_models import TimeRange, ExportFormat
|
||||
from unit_analytics_helpers import get_analytics_database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Unit Analytics"])
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Export
|
||||
# ==============================================
|
||||
|
||||
@router.get("/export/learning-gains")
|
||||
async def export_learning_gains(
|
||||
unit_id: Optional[str] = Query(None),
|
||||
class_id: Optional[str] = Query(None),
|
||||
time_range: TimeRange = Query(TimeRange.ALL),
|
||||
format: ExportFormat = Query(ExportFormat.JSON),
|
||||
) -> Any:
|
||||
"""
|
||||
Export learning gain data.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
data = []
|
||||
|
||||
if db:
|
||||
try:
|
||||
data = await db.export_learning_gains(
|
||||
unit_id=unit_id, class_id=class_id, time_range=time_range.value
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export data: {e}")
|
||||
|
||||
if format == ExportFormat.CSV:
|
||||
if not data:
|
||||
csv_content = "student_id,unit_id,precheck,postcheck,gain\n"
|
||||
else:
|
||||
csv_content = "student_id,unit_id,precheck,postcheck,gain\n"
|
||||
for row in data:
|
||||
csv_content += f"{row['student_id']},{row['unit_id']},{row.get('precheck', '')},{row.get('postcheck', '')},{row.get('gain', '')}\n"
|
||||
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=learning_gains.csv"}
|
||||
)
|
||||
|
||||
return {
|
||||
"export_date": datetime.utcnow().isoformat(),
|
||||
"filters": {
|
||||
"unit_id": unit_id, "class_id": class_id, "time_range": time_range.value,
|
||||
},
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/export/misconceptions")
|
||||
async def export_misconceptions(
|
||||
class_id: Optional[str] = Query(None),
|
||||
format: ExportFormat = Query(ExportFormat.JSON),
|
||||
) -> Any:
|
||||
"""
|
||||
Export misconception data for further analysis.
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from unit_analytics_routes import get_misconception_report
|
||||
|
||||
report = await get_misconception_report(
|
||||
class_id=class_id, unit_id=None,
|
||||
time_range=TimeRange.MONTH, limit=100
|
||||
)
|
||||
|
||||
if format == ExportFormat.CSV:
|
||||
csv_content = "concept_id,concept_label,misconception,frequency,unit_id,stop_id\n"
|
||||
for m in report.most_common:
|
||||
csv_content += f'"{m.concept_id}","{m.concept_label}","{m.misconception_text}",{m.frequency},"{m.unit_id}","{m.stop_id}"\n'
|
||||
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=misconceptions.csv"}
|
||||
)
|
||||
|
||||
return {
|
||||
"export_date": datetime.utcnow().isoformat(),
|
||||
"class_id": class_id,
|
||||
"total_entries": len(report.most_common),
|
||||
"data": [m.model_dump() for m in report.most_common],
|
||||
}
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Dashboard Aggregates
|
||||
# ==============================================
|
||||
|
||||
@router.get("/dashboard/overview")
|
||||
async def get_analytics_overview(
|
||||
time_range: TimeRange = Query(TimeRange.MONTH),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get high-level analytics overview for dashboard.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
|
||||
if db:
|
||||
try:
|
||||
overview = await db.get_analytics_overview(time_range.value)
|
||||
return overview
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get analytics overview: {e}")
|
||||
|
||||
return {
|
||||
"time_range": time_range.value,
|
||||
"total_sessions": 0,
|
||||
"unique_students": 0,
|
||||
"avg_completion_rate": 0.0,
|
||||
"avg_learning_gain": 0.0,
|
||||
"most_played_units": [],
|
||||
"struggling_concepts": [],
|
||||
"active_classes": 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> Dict[str, Any]:
|
||||
"""Health check for analytics API."""
|
||||
db = await get_analytics_database()
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "unit-analytics",
|
||||
"database": "connected" if db else "disconnected",
|
||||
}
|
||||
97
backend-lehrer/unit_analytics_helpers.py
Normal file
97
backend-lehrer/unit_analytics_helpers.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Unit Analytics API - Helpers.
|
||||
|
||||
Database access, statistical computation, and utility functions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feature flags
|
||||
USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true"
|
||||
|
||||
# Database singleton
|
||||
_analytics_db = None
|
||||
|
||||
|
||||
async def get_analytics_database():
|
||||
"""Get analytics database instance."""
|
||||
global _analytics_db
|
||||
if not USE_DATABASE:
|
||||
return None
|
||||
if _analytics_db is None:
|
||||
try:
|
||||
from unit.database import get_analytics_db
|
||||
_analytics_db = await get_analytics_db()
|
||||
logger.info("Analytics database initialized")
|
||||
except ImportError:
|
||||
logger.warning("Analytics database module not available")
|
||||
except Exception as e:
|
||||
logger.warning(f"Analytics database not available: {e}")
|
||||
return _analytics_db
|
||||
|
||||
|
||||
def calculate_gain_distribution(gains: List[float]) -> Dict[str, int]:
|
||||
"""Calculate distribution of learning gains into buckets."""
|
||||
distribution = {
|
||||
"< -20%": 0,
|
||||
"-20% to -10%": 0,
|
||||
"-10% to 0%": 0,
|
||||
"0% to 10%": 0,
|
||||
"10% to 20%": 0,
|
||||
"> 20%": 0,
|
||||
}
|
||||
|
||||
for gain in gains:
|
||||
gain_percent = gain * 100
|
||||
if gain_percent < -20:
|
||||
distribution["< -20%"] += 1
|
||||
elif gain_percent < -10:
|
||||
distribution["-20% to -10%"] += 1
|
||||
elif gain_percent < 0:
|
||||
distribution["-10% to 0%"] += 1
|
||||
elif gain_percent < 10:
|
||||
distribution["0% to 10%"] += 1
|
||||
elif gain_percent < 20:
|
||||
distribution["10% to 20%"] += 1
|
||||
else:
|
||||
distribution["> 20%"] += 1
|
||||
|
||||
return distribution
|
||||
|
||||
|
||||
def calculate_trend(scores: List[float]) -> str:
|
||||
"""Calculate trend from a series of scores."""
|
||||
if len(scores) < 3:
|
||||
return "insufficient_data"
|
||||
|
||||
# Simple linear regression
|
||||
n = len(scores)
|
||||
x_mean = (n - 1) / 2
|
||||
y_mean = sum(scores) / n
|
||||
|
||||
numerator = sum((i - x_mean) * (scores[i] - y_mean) for i in range(n))
|
||||
denominator = sum((i - x_mean) ** 2 for i in range(n))
|
||||
|
||||
if denominator == 0:
|
||||
return "stable"
|
||||
|
||||
slope = numerator / denominator
|
||||
|
||||
if slope > 0.05:
|
||||
return "improving"
|
||||
elif slope < -0.05:
|
||||
return "declining"
|
||||
else:
|
||||
return "stable"
|
||||
|
||||
|
||||
def calculate_difficulty_rating(success_rate: float, avg_attempts: float) -> float:
|
||||
"""Calculate difficulty rating 1-5 based on success metrics."""
|
||||
# Lower success rate and higher attempts = higher difficulty
|
||||
base_difficulty = (1 - success_rate) * 3 + 1 # 1-4 range
|
||||
attempt_modifier = min(avg_attempts - 1, 1) # 0-1 range
|
||||
return min(5.0, base_difficulty + attempt_modifier)
|
||||
127
backend-lehrer/unit_analytics_models.py
Normal file
127
backend-lehrer/unit_analytics_models.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Unit Analytics API - Pydantic Models.
|
||||
|
||||
Data models for learning gains, stop performance, misconceptions,
|
||||
student progress, class comparison, and export.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TimeRange(str, Enum):
|
||||
"""Time range for analytics queries"""
|
||||
WEEK = "week"
|
||||
MONTH = "month"
|
||||
QUARTER = "quarter"
|
||||
ALL = "all"
|
||||
|
||||
|
||||
class LearningGainData(BaseModel):
|
||||
"""Pre/Post learning gain data point"""
|
||||
student_id: str
|
||||
student_name: str
|
||||
unit_id: str
|
||||
precheck_score: float
|
||||
postcheck_score: float
|
||||
learning_gain: float
|
||||
percentile: Optional[float] = None
|
||||
|
||||
|
||||
class LearningGainSummary(BaseModel):
|
||||
"""Aggregated learning gain statistics"""
|
||||
unit_id: str
|
||||
unit_title: str
|
||||
total_students: int
|
||||
avg_precheck: float
|
||||
avg_postcheck: float
|
||||
avg_gain: float
|
||||
median_gain: float
|
||||
std_deviation: float
|
||||
positive_gain_count: int
|
||||
negative_gain_count: int
|
||||
no_change_count: int
|
||||
gain_distribution: Dict[str, int]
|
||||
individual_gains: List[LearningGainData]
|
||||
|
||||
|
||||
class StopPerformance(BaseModel):
|
||||
"""Performance data for a single stop"""
|
||||
stop_id: str
|
||||
stop_label: str
|
||||
attempts_total: int
|
||||
success_rate: float
|
||||
avg_time_seconds: float
|
||||
avg_attempts_before_success: float
|
||||
common_errors: List[str]
|
||||
difficulty_rating: float # 1-5 based on performance
|
||||
|
||||
|
||||
class UnitPerformanceDetail(BaseModel):
|
||||
"""Detailed unit performance breakdown"""
|
||||
unit_id: str
|
||||
unit_title: str
|
||||
template: str
|
||||
total_sessions: int
|
||||
completed_sessions: int
|
||||
completion_rate: float
|
||||
avg_duration_minutes: float
|
||||
stops: List[StopPerformance]
|
||||
bottleneck_stops: List[str] # Stops where students struggle most
|
||||
|
||||
|
||||
class MisconceptionEntry(BaseModel):
|
||||
"""Individual misconception tracking"""
|
||||
concept_id: str
|
||||
concept_label: str
|
||||
misconception_text: str
|
||||
frequency: int
|
||||
affected_student_ids: List[str]
|
||||
unit_id: str
|
||||
stop_id: str
|
||||
detected_via: str # "precheck", "postcheck", "interaction"
|
||||
first_detected: datetime
|
||||
last_detected: datetime
|
||||
|
||||
|
||||
class MisconceptionReport(BaseModel):
|
||||
"""Comprehensive misconception report"""
|
||||
class_id: Optional[str]
|
||||
time_range: str
|
||||
total_misconceptions: int
|
||||
unique_concepts: int
|
||||
most_common: List[MisconceptionEntry]
|
||||
by_unit: Dict[str, List[MisconceptionEntry]]
|
||||
trending_up: List[MisconceptionEntry] # Getting more frequent
|
||||
resolved: List[MisconceptionEntry] # No longer appearing
|
||||
|
||||
|
||||
class StudentProgressTimeline(BaseModel):
|
||||
"""Timeline of student progress"""
|
||||
student_id: str
|
||||
student_name: str
|
||||
units_completed: int
|
||||
total_time_minutes: int
|
||||
avg_score: float
|
||||
trend: str # "improving", "stable", "declining"
|
||||
timeline: List[Dict[str, Any]] # List of session events
|
||||
|
||||
|
||||
class ClassComparisonData(BaseModel):
|
||||
"""Data for comparing class performance"""
|
||||
class_id: str
|
||||
class_name: str
|
||||
student_count: int
|
||||
units_assigned: int
|
||||
avg_completion_rate: float
|
||||
avg_learning_gain: float
|
||||
avg_time_per_unit: float
|
||||
|
||||
|
||||
class ExportFormat(str, Enum):
|
||||
"""Export format options"""
|
||||
JSON = "json"
|
||||
CSV = "csv"
|
||||
394
backend-lehrer/unit_analytics_routes.py
Normal file
394
backend-lehrer/unit_analytics_routes.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
Unit Analytics API - Routes.
|
||||
|
||||
All API endpoints for learning gain, stop-level, misconception,
|
||||
student timeline, class comparison, export, and dashboard analytics.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import statistics
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from unit_analytics_models import (
|
||||
TimeRange,
|
||||
LearningGainData,
|
||||
LearningGainSummary,
|
||||
StopPerformance,
|
||||
UnitPerformanceDetail,
|
||||
MisconceptionEntry,
|
||||
MisconceptionReport,
|
||||
StudentProgressTimeline,
|
||||
ClassComparisonData,
|
||||
)
|
||||
from unit_analytics_helpers import (
|
||||
get_analytics_database,
|
||||
calculate_gain_distribution,
|
||||
calculate_trend,
|
||||
calculate_difficulty_rating,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Unit Analytics"])
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Learning Gain
|
||||
# ==============================================
|
||||
|
||||
# NOTE: Static routes must come BEFORE dynamic routes like /{unit_id}
|
||||
@router.get("/learning-gain/compare")
|
||||
async def compare_learning_gains(
|
||||
unit_ids: str = Query(..., description="Comma-separated unit IDs"),
|
||||
class_id: Optional[str] = Query(None),
|
||||
time_range: TimeRange = Query(TimeRange.MONTH),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Compare learning gains across multiple units.
|
||||
"""
|
||||
unit_list = [u.strip() for u in unit_ids.split(",")]
|
||||
comparisons = []
|
||||
|
||||
for unit_id in unit_list:
|
||||
try:
|
||||
summary = await get_learning_gain_analysis(unit_id, class_id, time_range)
|
||||
comparisons.append({
|
||||
"unit_id": unit_id,
|
||||
"avg_gain": summary.avg_gain,
|
||||
"median_gain": summary.median_gain,
|
||||
"total_students": summary.total_students,
|
||||
"positive_rate": summary.positive_gain_count / max(summary.total_students, 1),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get comparison for {unit_id}: {e}")
|
||||
|
||||
return {
|
||||
"time_range": time_range.value,
|
||||
"class_id": class_id,
|
||||
"comparisons": sorted(comparisons, key=lambda x: x["avg_gain"], reverse=True),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/learning-gain/{unit_id}", response_model=LearningGainSummary)
|
||||
async def get_learning_gain_analysis(
|
||||
unit_id: str,
|
||||
class_id: Optional[str] = Query(None, description="Filter by class"),
|
||||
time_range: TimeRange = Query(TimeRange.MONTH, description="Time range for analysis"),
|
||||
) -> LearningGainSummary:
|
||||
"""
|
||||
Get detailed pre/post learning gain analysis for a unit.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
individual_gains = []
|
||||
|
||||
if db:
|
||||
try:
|
||||
sessions = await db.get_unit_sessions_with_scores(
|
||||
unit_id=unit_id,
|
||||
class_id=class_id,
|
||||
time_range=time_range.value
|
||||
)
|
||||
|
||||
for session in sessions:
|
||||
if session.get("precheck_score") is not None and session.get("postcheck_score") is not None:
|
||||
gain = session["postcheck_score"] - session["precheck_score"]
|
||||
individual_gains.append(LearningGainData(
|
||||
student_id=session["student_id"],
|
||||
student_name=session.get("student_name", session["student_id"][:8]),
|
||||
unit_id=unit_id,
|
||||
precheck_score=session["precheck_score"],
|
||||
postcheck_score=session["postcheck_score"],
|
||||
learning_gain=gain,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get learning gain data: {e}")
|
||||
|
||||
# Calculate statistics
|
||||
if not individual_gains:
|
||||
return LearningGainSummary(
|
||||
unit_id=unit_id,
|
||||
unit_title=f"Unit {unit_id}",
|
||||
total_students=0,
|
||||
avg_precheck=0.0, avg_postcheck=0.0,
|
||||
avg_gain=0.0, median_gain=0.0, std_deviation=0.0,
|
||||
positive_gain_count=0, negative_gain_count=0, no_change_count=0,
|
||||
gain_distribution={}, individual_gains=[],
|
||||
)
|
||||
|
||||
gains = [g.learning_gain for g in individual_gains]
|
||||
prechecks = [g.precheck_score for g in individual_gains]
|
||||
postchecks = [g.postcheck_score for g in individual_gains]
|
||||
|
||||
avg_gain = statistics.mean(gains)
|
||||
median_gain = statistics.median(gains)
|
||||
std_dev = statistics.stdev(gains) if len(gains) > 1 else 0.0
|
||||
|
||||
# Calculate percentiles
|
||||
sorted_gains = sorted(gains)
|
||||
for data in individual_gains:
|
||||
rank = sorted_gains.index(data.learning_gain) + 1
|
||||
data.percentile = rank / len(sorted_gains) * 100
|
||||
|
||||
return LearningGainSummary(
|
||||
unit_id=unit_id,
|
||||
unit_title=f"Unit {unit_id}",
|
||||
total_students=len(individual_gains),
|
||||
avg_precheck=statistics.mean(prechecks),
|
||||
avg_postcheck=statistics.mean(postchecks),
|
||||
avg_gain=avg_gain,
|
||||
median_gain=median_gain,
|
||||
std_deviation=std_dev,
|
||||
positive_gain_count=sum(1 for g in gains if g > 0.01),
|
||||
negative_gain_count=sum(1 for g in gains if g < -0.01),
|
||||
no_change_count=sum(1 for g in gains if -0.01 <= g <= 0.01),
|
||||
gain_distribution=calculate_gain_distribution(gains),
|
||||
individual_gains=sorted(individual_gains, key=lambda x: x.learning_gain, reverse=True),
|
||||
)
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Stop-Level Analytics
|
||||
# ==============================================
|
||||
|
||||
@router.get("/unit/{unit_id}/stops", response_model=UnitPerformanceDetail)
|
||||
async def get_unit_stop_analytics(
|
||||
unit_id: str,
|
||||
class_id: Optional[str] = Query(None),
|
||||
time_range: TimeRange = Query(TimeRange.MONTH),
|
||||
) -> UnitPerformanceDetail:
|
||||
"""
|
||||
Get detailed stop-level performance analytics.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
stops_data = []
|
||||
|
||||
if db:
|
||||
try:
|
||||
stop_stats = await db.get_stop_performance(
|
||||
unit_id=unit_id, class_id=class_id, time_range=time_range.value
|
||||
)
|
||||
|
||||
for stop in stop_stats:
|
||||
difficulty = calculate_difficulty_rating(
|
||||
stop.get("success_rate", 0.5),
|
||||
stop.get("avg_attempts", 1.0)
|
||||
)
|
||||
stops_data.append(StopPerformance(
|
||||
stop_id=stop["stop_id"],
|
||||
stop_label=stop.get("stop_label", stop["stop_id"]),
|
||||
attempts_total=stop.get("total_attempts", 0),
|
||||
success_rate=stop.get("success_rate", 0.0),
|
||||
avg_time_seconds=stop.get("avg_time_seconds", 0.0),
|
||||
avg_attempts_before_success=stop.get("avg_attempts", 1.0),
|
||||
common_errors=stop.get("common_errors", []),
|
||||
difficulty_rating=difficulty,
|
||||
))
|
||||
|
||||
unit_stats = await db.get_unit_overall_stats(unit_id, class_id, time_range.value)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get stop analytics: {e}")
|
||||
unit_stats = {}
|
||||
else:
|
||||
unit_stats = {}
|
||||
|
||||
# Identify bottleneck stops
|
||||
bottlenecks = [
|
||||
s.stop_id for s in stops_data
|
||||
if s.difficulty_rating > 3.5 or s.success_rate < 0.6
|
||||
]
|
||||
|
||||
return UnitPerformanceDetail(
|
||||
unit_id=unit_id,
|
||||
unit_title=f"Unit {unit_id}",
|
||||
template=unit_stats.get("template", "unknown"),
|
||||
total_sessions=unit_stats.get("total_sessions", 0),
|
||||
completed_sessions=unit_stats.get("completed_sessions", 0),
|
||||
completion_rate=unit_stats.get("completion_rate", 0.0),
|
||||
avg_duration_minutes=unit_stats.get("avg_duration_minutes", 0.0),
|
||||
stops=stops_data,
|
||||
bottleneck_stops=bottlenecks,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Misconception Tracking
|
||||
# ==============================================
|
||||
|
||||
@router.get("/misconceptions", response_model=MisconceptionReport)
|
||||
async def get_misconception_report(
|
||||
class_id: Optional[str] = Query(None),
|
||||
unit_id: Optional[str] = Query(None),
|
||||
time_range: TimeRange = Query(TimeRange.MONTH),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> MisconceptionReport:
|
||||
"""
|
||||
Get comprehensive misconception report.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
misconceptions = []
|
||||
|
||||
if db:
|
||||
try:
|
||||
raw_misconceptions = await db.get_misconceptions(
|
||||
class_id=class_id, unit_id=unit_id,
|
||||
time_range=time_range.value, limit=limit
|
||||
)
|
||||
|
||||
for m in raw_misconceptions:
|
||||
misconceptions.append(MisconceptionEntry(
|
||||
concept_id=m["concept_id"],
|
||||
concept_label=m["concept_label"],
|
||||
misconception_text=m["misconception_text"],
|
||||
frequency=m["frequency"],
|
||||
affected_student_ids=m.get("student_ids", []),
|
||||
unit_id=m["unit_id"],
|
||||
stop_id=m["stop_id"],
|
||||
detected_via=m.get("detected_via", "unknown"),
|
||||
first_detected=m.get("first_detected", datetime.utcnow()),
|
||||
last_detected=m.get("last_detected", datetime.utcnow()),
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get misconceptions: {e}")
|
||||
|
||||
# Group by unit
|
||||
by_unit = {}
|
||||
for m in misconceptions:
|
||||
if m.unit_id not in by_unit:
|
||||
by_unit[m.unit_id] = []
|
||||
by_unit[m.unit_id].append(m)
|
||||
|
||||
trending_up = misconceptions[:3] if misconceptions else []
|
||||
resolved = []
|
||||
|
||||
return MisconceptionReport(
|
||||
class_id=class_id,
|
||||
time_range=time_range.value,
|
||||
total_misconceptions=sum(m.frequency for m in misconceptions),
|
||||
unique_concepts=len(set(m.concept_id for m in misconceptions)),
|
||||
most_common=sorted(misconceptions, key=lambda x: x.frequency, reverse=True)[:10],
|
||||
by_unit=by_unit,
|
||||
trending_up=trending_up,
|
||||
resolved=resolved,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/misconceptions/student/{student_id}")
|
||||
async def get_student_misconceptions(
|
||||
student_id: str,
|
||||
time_range: TimeRange = Query(TimeRange.ALL),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get misconceptions for a specific student.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
|
||||
if db:
|
||||
try:
|
||||
misconceptions = await db.get_student_misconceptions(
|
||||
student_id=student_id, time_range=time_range.value
|
||||
)
|
||||
return {
|
||||
"student_id": student_id,
|
||||
"misconceptions": misconceptions,
|
||||
"recommended_remediation": [
|
||||
{"concept": m["concept_label"], "activity": f"Review {m['unit_id']}/{m['stop_id']}"}
|
||||
for m in misconceptions[:5]
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get student misconceptions: {e}")
|
||||
|
||||
return {
|
||||
"student_id": student_id,
|
||||
"misconceptions": [],
|
||||
"recommended_remediation": [],
|
||||
}
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Student Progress Timeline
|
||||
# ==============================================
|
||||
|
||||
@router.get("/student/{student_id}/timeline", response_model=StudentProgressTimeline)
|
||||
async def get_student_timeline(
|
||||
student_id: str,
|
||||
time_range: TimeRange = Query(TimeRange.ALL),
|
||||
) -> StudentProgressTimeline:
|
||||
"""
|
||||
Get detailed progress timeline for a student.
|
||||
"""
|
||||
db = await get_analytics_database()
|
||||
timeline = []
|
||||
scores = []
|
||||
|
||||
if db:
|
||||
try:
|
||||
sessions = await db.get_student_sessions(
|
||||
student_id=student_id, time_range=time_range.value
|
||||
)
|
||||
|
||||
for session in sessions:
|
||||
timeline.append({
|
||||
"date": session.get("started_at"),
|
||||
"unit_id": session.get("unit_id"),
|
||||
"completed": session.get("completed_at") is not None,
|
||||
"precheck": session.get("precheck_score"),
|
||||
"postcheck": session.get("postcheck_score"),
|
||||
"duration_minutes": session.get("duration_seconds", 0) // 60,
|
||||
})
|
||||
if session.get("postcheck_score") is not None:
|
||||
scores.append(session["postcheck_score"])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get student timeline: {e}")
|
||||
|
||||
trend = calculate_trend(scores) if scores else "insufficient_data"
|
||||
|
||||
return StudentProgressTimeline(
|
||||
student_id=student_id,
|
||||
student_name=f"Student {student_id[:8]}",
|
||||
units_completed=sum(1 for t in timeline if t["completed"]),
|
||||
total_time_minutes=sum(t["duration_minutes"] for t in timeline),
|
||||
avg_score=statistics.mean(scores) if scores else 0.0,
|
||||
trend=trend,
|
||||
timeline=timeline,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Class Comparison
|
||||
# ==============================================
|
||||
|
||||
@router.get("/compare/classes", response_model=List[ClassComparisonData])
|
||||
async def compare_classes(
|
||||
class_ids: str = Query(..., description="Comma-separated class IDs"),
|
||||
time_range: TimeRange = Query(TimeRange.MONTH),
|
||||
) -> List[ClassComparisonData]:
|
||||
"""
|
||||
Compare performance across multiple classes.
|
||||
"""
|
||||
class_list = [c.strip() for c in class_ids.split(",")]
|
||||
comparisons = []
|
||||
|
||||
db = await get_analytics_database()
|
||||
if db:
|
||||
for class_id in class_list:
|
||||
try:
|
||||
stats = await db.get_class_aggregate_stats(class_id, time_range.value)
|
||||
comparisons.append(ClassComparisonData(
|
||||
class_id=class_id,
|
||||
class_name=stats.get("class_name", f"Klasse {class_id[:8]}"),
|
||||
student_count=stats.get("student_count", 0),
|
||||
units_assigned=stats.get("units_assigned", 0),
|
||||
avg_completion_rate=stats.get("avg_completion_rate", 0.0),
|
||||
avg_learning_gain=stats.get("avg_learning_gain", 0.0),
|
||||
avg_time_per_unit=stats.get("avg_time_per_unit", 0.0),
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get stats for class {class_id}: {e}")
|
||||
|
||||
return sorted(comparisons, key=lambda x: x.avg_learning_gain, reverse=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user