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>
252 lines
8.2 KiB
Python
252 lines
8.2 KiB
Python
"""
|
|
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"}
|
|
)
|