[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>
This commit is contained in:
292
klausur-service/backend/upload_api_mobile.py
Normal file
292
klausur-service/backend/upload_api_mobile.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
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">☁</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">✓ Erfolgreich hochgeladen</div>';
|
||||
} else if (f.status === 'error') {
|
||||
statusHtml = '<div class="status-error">⚠ ' + (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 + '\\')">×</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)
|
||||
Reference in New Issue
Block a user