v4.1.0: DMS email documents, category-specific Nachweis linking, version system
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled

- Save cover email body as DMS document with new 'email' context type
- Show email body separately from attachments in email detail view
- Add per-category DMS document assignment in quarterly confirmation
  (Studiennachweis, Einkommenssituation, Vermögenssituation)
- Add VERSION file and context processor for automatic version display
- Add MCP server, agent system, import/export, and new migrations
- Update compose files and production environment template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-15 18:48:52 +00:00
parent faeb7c1073
commit e0b377014c
49 changed files with 5913 additions and 55 deletions

232
app/stiftung/agent/views.py Normal file
View File

@@ -0,0 +1,232 @@
"""
Views für den AI Agent.
Endpunkte:
POST /agent/chat/ Neue Nachricht senden (startet neue oder bestehende Session)
GET /agent/chat/stream/ SSE-Stream für laufende Anfrage
GET /agent/sessions/ Liste der Chat-Sitzungen (JSON)
DELETE /agent/sessions/<id>/ Sitzung löschen
"""
from __future__ import annotations
import json
import logging
from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.http import (
HttpResponse,
JsonResponse,
StreamingHttpResponse,
)
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .models import AgentConfig, ChatSession, ChatMessage
from .orchestrator import run_agent_stream
logger = logging.getLogger(__name__)
RATE_LIMIT_PER_MINUTE = 20
def _check_rate_limit(user_id: int) -> bool:
"""Einfaches Rate-Limiting via Django-Cache (Redis). True = erlaubt."""
key = f"agent_rl_{user_id}"
count = cache.get(key, 0)
if count >= RATE_LIMIT_PER_MINUTE:
return False
cache.set(key, count + 1, timeout=60)
return True
def _require_agent_permission(user) -> bool:
"""Prüft ob der Benutzer den Agent nutzen darf."""
return (
user.is_superuser
or user.has_perm("stiftung.can_use_agent")
)
@login_required
@require_http_methods(["GET"])
def agent_index(request):
"""Einstiegsseite für den Chat (wird als Modal geöffnet, nicht direkt navigiert)."""
config = AgentConfig.get_config()
return JsonResponse({
"provider": config.provider,
"model": config.model_name,
"allow_write": config.allow_write,
})
@login_required
@require_http_methods(["POST"])
def agent_chat(request):
"""
Startet oder setzt einen Chat fort.
Body (JSON):
{
"message": "Wie viele aktive Destinatäre gibt es?",
"session_id": "optional-uuid",
"page_context": "optional aktueller Seiteninhalt als Text"
}
Antwort:
{
"session_id": "...",
"stream_url": "/agent/chat/stream/?session_id=..."
}
"""
if not _require_agent_permission(request.user):
return JsonResponse({"error": "Keine Berechtigung für den AI-Assistenten."}, status=403)
if not _check_rate_limit(request.user.id):
return JsonResponse(
{"error": "Rate-Limit erreicht. Bitte warten Sie eine Minute."},
status=429,
)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({"error": "Ungültiger JSON-Body."}, status=400)
message = (body.get("message") or "").strip()
if not message:
return JsonResponse({"error": "Nachricht darf nicht leer sein."}, status=400)
page_context = (body.get("page_context") or "")[:2000]
session_id = body.get("session_id")
# Session ermitteln oder neu erstellen
session = None
if session_id:
try:
session = ChatSession.objects.get(id=session_id, user=request.user)
except ChatSession.DoesNotExist:
pass
if session is None:
session = ChatSession.objects.create(user=request.user)
# Nachricht + Page-Context in Cache für Stream-Endpunkt speichern
cache_key = f"agent_pending_{session.id}"
cache.set(
cache_key,
{"message": message, "page_context": page_context},
timeout=300, # 5 Minuten
)
return JsonResponse({
"session_id": str(session.id),
"stream_url": f"/agent/chat/stream/?session_id={session.id}",
})
@login_required
@require_http_methods(["GET"])
def agent_chat_stream(request):
"""
SSE-Endpunkt: streamt die Antwort des Agenten.
Query-Params:
session_id: UUID der Chat-Sitzung
"""
if not _require_agent_permission(request.user):
return HttpResponse("Keine Berechtigung.", status=403)
session_id = request.GET.get("session_id")
if not session_id:
return HttpResponse("session_id fehlt.", status=400)
session = get_object_or_404(ChatSession, id=session_id, user=request.user)
cache_key = f"agent_pending_{session.id}"
pending = cache.get(cache_key)
if not pending:
return HttpResponse("Keine ausstehende Nachricht gefunden.", status=400)
cache.delete(cache_key)
message = pending["message"]
page_context = pending.get("page_context", "")
def event_stream():
try:
yield from run_agent_stream(
session=session,
user_message=message,
page_context=page_context,
user=request.user,
)
except Exception as e:
logger.error("Agent-Stream-Fehler: %s", e, exc_info=True)
import json
yield f"data: {json.dumps({'type': 'error', 'message': 'Interner Fehler.'})}\n\n"
response = StreamingHttpResponse(
event_stream(),
content_type="text/event-stream",
)
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response
@login_required
@require_http_methods(["GET"])
def agent_sessions(request):
"""Gibt die Chat-Sitzungen des Benutzers zurück (letzte 20)."""
if not _require_agent_permission(request.user):
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
sessions = ChatSession.objects.filter(user=request.user).order_by("-updated_at")[:20]
data = []
for s in sessions:
data.append({
"id": str(s.id),
"title": s.title or "Neue Unterhaltung",
"created_at": s.created_at.isoformat(),
"updated_at": s.updated_at.isoformat(),
"message_count": s.messages.count(),
})
return JsonResponse({"sessions": data})
@login_required
@require_http_methods(["GET"])
def agent_session_messages(request, session_id):
"""Gibt alle Nachrichten einer Sitzung zurück."""
if not _require_agent_permission(request.user):
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
session = get_object_or_404(ChatSession, id=session_id, user=request.user)
messages = session.messages.exclude(role="tool").order_by("created_at")
data = []
for m in messages:
data.append({
"id": str(m.id),
"role": m.role,
"content": m.content,
"created_at": m.created_at.isoformat(),
})
return JsonResponse({
"session_id": str(session.id),
"title": session.title,
"messages": data,
})
@login_required
@require_http_methods(["POST"])
def agent_session_delete(request, session_id):
"""Löscht eine Chat-Sitzung."""
if not _require_agent_permission(request.user):
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
session = get_object_or_404(ChatSession, id=session_id, user=request.user)
session.delete()
return JsonResponse({"ok": True})