[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:
Benjamin Admin
2026-04-25 08:01:18 +02:00
parent b6983ab1dc
commit 34da9f4cda
106 changed files with 16500 additions and 16947 deletions

View 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'>&nbsp;</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

View File

@@ -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'>&nbsp;</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'>&nbsp;</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",
]

View 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)

View 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

View 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'>&nbsp;</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>")

View File

@@ -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"]

View 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}")

View 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}")

View 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,
}

View 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"],
)

View 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]

View File

@@ -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",
]

View 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
)

View File

@@ -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

View 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],
)

View 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

View 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

View 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,
)

View File

@@ -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)

View 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"}
)

View 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)
}
}

View 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"
}
]

View 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]

View File

@@ -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)

View 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}"

View 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"
)

View 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]

View 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"
}

View 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"}
)

View File

@@ -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)

View 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",
}

View 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)

View 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"

View 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)