Files
breakpilot-lehrer/klausur-service/backend/upload_api_mobile.py
Benjamin Admin bd4b956e3c [split-required] Split final 43 files (500-668 LOC) to complete refactoring
klausur-service (11 files):
- cv_gutter_repair, ocr_pipeline_regression, upload_api
- ocr_pipeline_sessions, smart_spell, nru_worksheet_generator
- ocr_pipeline_overlays, mail/aggregator, zeugnis_api
- cv_syllable_detect, self_rag

backend-lehrer (17 files):
- classroom_engine/suggestions, generators/quiz_generator
- worksheets_api, llm_gateway/comparison, state_engine_api
- classroom/models (→ 4 submodules), services/file_processor
- alerts_agent/api/wizard+digests+routes, content_generators/pdf
- classroom/routes/sessions, llm_gateway/inference
- classroom_engine/analytics, auth/keycloak_auth
- alerts_agent/processing/rule_engine, ai_processor/print_versions

agent-core (5 files):
- brain/memory_store, brain/knowledge_graph, brain/context_manager
- orchestrator/supervisor, sessions/session_manager

admin-lehrer (5 components):
- GridOverlay, StepGridReview, DevOpsPipelineSidebar
- DataFlowDiagram, sbom/wizard/page

website (2 files):
- DependencyMap, lehrer/abitur-archiv

Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:41:42 +02:00

293 lines
13 KiB
Python

"""
Mobile Upload HTML Page — serves the mobile upload UI directly from klausur-service.
Extracted from upload_api.py for modularity.
DSGVO-konform: Data stays local in WLAN, no external transmission.
"""
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
router = APIRouter(prefix="/api/v1/upload", tags=["Mobile Upload"])
@router.get("/mobile", response_class=HTMLResponse)
async def mobile_upload_page():
"""
Serve the mobile upload page directly from the klausur-service.
This allows mobile devices to upload without needing the Next.js website.
"""
html_content = '''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>BreakPilot Upload</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
color: white;
min-height: 100vh;
padding: 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #334155;
margin-bottom: 24px;
}
.header h1 { font-size: 20px; color: #60a5fa; }
.badge { font-size: 10px; background: #1e293b; padding: 4px 8px; border-radius: 4px; color: #94a3b8; }
.destination-selector {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.dest-btn {
flex: 1;
padding: 14px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.dest-btn.active-klausur { background: #2563eb; color: white; box-shadow: 0 4px 15px rgba(37, 99, 235, 0.3); }
.dest-btn.active-rag { background: #7c3aed; color: white; box-shadow: 0 4px 15px rgba(124, 58, 237, 0.3); }
.dest-btn:not(.active-klausur):not(.active-rag) { background: #1e293b; color: #94a3b8; }
.upload-zone {
border: 2px dashed #475569;
border-radius: 16px;
padding: 40px 20px;
text-align: center;
margin-bottom: 24px;
transition: all 0.2s;
position: relative;
}
.upload-zone.dragover { border-color: #60a5fa; background: rgba(96, 165, 250, 0.1); transform: scale(1.02); }
.upload-zone input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.upload-icon {
width: 64px;
height: 64px;
background: #334155;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
font-size: 28px;
}
.upload-title { font-size: 18px; margin-bottom: 8px; }
.upload-subtitle { font-size: 14px; color: #94a3b8; margin-bottom: 16px; }
.upload-hint { font-size: 12px; color: #64748b; }
.file-list { margin-bottom: 24px; }
.file-item {
background: #1e293b;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
.file-item.error { border: 2px solid rgba(239, 68, 68, 0.5); }
.file-item.complete { border: 2px solid rgba(34, 197, 94, 0.3); }
.file-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; }
.file-name { font-weight: 500; word-break: break-all; }
.file-size { font-size: 14px; color: #94a3b8; }
.remove-btn { background: none; border: none; color: #94a3b8; font-size: 20px; cursor: pointer; padding: 4px; }
.progress-bar { height: 6px; background: #334155; border-radius: 3px; overflow: hidden; margin-top: 12px; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #60a5fa); transition: width 0.3s; }
.progress-text { font-size: 12px; color: #94a3b8; margin-top: 4px; }
.status-complete { display: flex; align-items: center; gap: 8px; color: #22c55e; font-size: 14px; margin-top: 12px; }
.status-error { display: flex; align-items: center; gap: 8px; color: #ef4444; font-size: 14px; margin-top: 12px; }
.info-box {
background: rgba(30, 41, 59, 0.5);
border-radius: 12px;
padding: 16px;
font-size: 14px;
color: #94a3b8;
}
.info-box h3 { color: #cbd5e1; margin-bottom: 8px; font-size: 14px; }
.info-box ul { padding-left: 20px; }
.info-box li { margin-bottom: 4px; }
.server-info { text-align: center; font-size: 12px; color: #64748b; margin-top: 16px; }
.stats { display: flex; justify-content: space-between; font-size: 14px; color: #94a3b8; padding: 0 8px; margin-bottom: 12px; }
</style>
</head>
<body>
<header class="header">
<h1>BreakPilot Upload</h1>
<span class="badge">DSGVO-konform</span>
</header>
<div class="destination-selector">
<button class="dest-btn active-klausur" id="btn-klausur" onclick="setDestination('klausur')">Klausuren</button>
<button class="dest-btn" id="btn-rag" onclick="setDestination('rag')">Erwartungshorizonte</button>
</div>
<div class="upload-zone" id="upload-zone">
<input type="file" accept=".pdf" multiple onchange="handleFiles(this.files)">
<div class="upload-icon">&#x2601;</div>
<div class="upload-title">PDF-Dateien hochladen</div>
<div class="upload-subtitle">Tippen zum Auswaehlen oder hierher ziehen</div>
<div class="upload-hint">Grosse Dateien bis 200 MB werden automatisch in Teilen hochgeladen</div>
</div>
<div class="stats" id="stats" style="display: none;">
<span id="completed-count">0 von 0 fertig</span>
<span id="total-size">0 B gesamt</span>
</div>
<div class="file-list" id="file-list"></div>
<div class="info-box">
<h3>Hinweise:</h3>
<ul>
<li>Die Dateien werden lokal im WLAN uebertragen</li>
<li>Keine Daten werden ins Internet gesendet</li>
<li>Unterstuetzte Formate: PDF</li>
</ul>
</div>
<div class="server-info" id="server-info">Server: wird ermittelt...</div>
<script>
const CHUNK_SIZE = 5 * 1024 * 1024;
let destination = 'klausur';
let files = [];
const serverUrl = window.location.origin;
document.getElementById('server-info').textContent = 'Server: ' + serverUrl;
function setDestination(dest) {
destination = dest;
document.querySelectorAll('.dest-btn').forEach(btn => {
btn.classList.remove('active-klausur', 'active-rag');
});
if (dest === 'klausur') {
document.getElementById('btn-klausur').classList.add('active-klausur');
} else {
document.getElementById('btn-rag').classList.add('active-rag');
}
}
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function updateStats() {
const completed = files.filter(f => f.status === 'complete').length;
const total = files.reduce((sum, f) => sum + f.size, 0);
document.getElementById('completed-count').textContent = completed + ' von ' + files.length + ' fertig';
document.getElementById('total-size').textContent = formatSize(total) + ' gesamt';
document.getElementById('stats').style.display = files.length > 0 ? 'flex' : 'none';
}
function renderFiles() {
const list = document.getElementById('file-list');
list.innerHTML = files.map(f => {
let statusHtml = '';
if (f.status === 'uploading' || f.status === 'pending') {
statusHtml = '<div class="progress-bar"><div class="progress-fill" style="width: ' + f.progress + '%"></div></div><div class="progress-text">' + f.progress + '% hochgeladen</div>';
} else if (f.status === 'complete') {
statusHtml = '<div class="status-complete">&#x2713; Erfolgreich hochgeladen</div>';
} else if (f.status === 'error') {
statusHtml = '<div class="status-error">&#x26A0; ' + (f.error || 'Fehler beim Hochladen') + '</div>';
}
return '<div class="file-item ' + f.status + '"><div class="file-header"><div><div class="file-name">' + f.name + '</div><div class="file-size">' + formatSize(f.size) + '</div></div><button class="remove-btn" onclick="removeFile(\\'' + f.id + '\\')">&times;</button></div>' + statusHtml + '</div>';
}).join('');
updateStats();
}
function removeFile(id) {
files = files.filter(f => f.id !== id);
renderFiles();
}
async function uploadFile(file, fileId) {
const updateProgress = (progress) => {
const f = files.find(f => f.id === fileId);
if (f) { f.progress = progress; renderFiles(); }
};
const setStatus = (status, error) => {
const f = files.find(f => f.id === fileId);
if (f) { f.status = status; if (error) f.error = error; renderFiles(); }
};
try {
setStatus('uploading');
if (file.size > 10 * 1024 * 1024) {
// Chunked upload
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const initRes = await fetch(serverUrl + '/api/v1/upload/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, filesize: file.size, chunks: totalChunks, destination: destination })
});
if (!initRes.ok) throw new Error('Konnte Upload nicht starten');
const { upload_id } = await initRes.json();
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('upload_id', upload_id);
formData.append('chunk_index', i.toString());
const chunkRes = await fetch(serverUrl + '/api/v1/upload/chunk', { method: 'POST', body: formData });
if (!chunkRes.ok) throw new Error('Fehler bei Teil ' + (i + 1));
updateProgress(Math.round(((i + 1) / totalChunks) * 100));
}
const finalizeForm = new FormData();
finalizeForm.append('upload_id', upload_id);
const finalRes = await fetch(serverUrl + '/api/v1/upload/finalize', { method: 'POST', body: finalizeForm });
if (!finalRes.ok) throw new Error('Fehler beim Abschliessen');
} else {
// Simple upload
const formData = new FormData();
formData.append('file', file);
formData.append('destination', destination);
const res = await fetch(serverUrl + '/api/v1/upload/simple', { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload fehlgeschlagen');
updateProgress(100);
}
setStatus('complete');
} catch (e) {
setStatus('error', e.message);
}
}
function handleFiles(fileList) {
const newFiles = Array.from(fileList).filter(f => f.type === 'application/pdf');
newFiles.forEach(file => {
const id = Math.random().toString(36).substr(2, 9);
files.push({ id, name: file.name, size: file.size, progress: 0, status: 'pending', file });
renderFiles();
uploadFile(file, id);
});
}
// Drag & Drop
const zone = document.getElementById('upload-zone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', e => { e.preventDefault(); zone.classList.remove('dragover'); });
zone.addEventListener('drop', e => { e.preventDefault(); zone.classList.remove('dragover'); handleFiles(e.dataTransfer.files); });
</script>
</body>
</html>'''
return HTMLResponse(content=html_content)