Add browser-based web MVP with mobile upload flow

This commit is contained in:
2026-03-02 20:17:44 +01:00
parent e7c62c2628
commit b14cc39455
8 changed files with 352 additions and 0 deletions

0
web/__init__.py Normal file
View File

111
web/app.py Normal file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Web-MVP für PDF zu ICS Konverter
"""
import re
import sys
import tempfile
from pathlib import Path
import os
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from starlette.background import BackgroundTask
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from pdf_to_ics import create_ics_from_dienstplan, extract_dienstplan_data
app = FastAPI(title="PDF zu ICS Web")
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"error": None,
},
)
@app.post("/convert")
async def convert_pdf(
request: Request,
file: UploadFile = File(...),
exclude_rest: bool = Form(False),
exclude_vacation: bool = Form(False),
):
filename = file.filename or "dienstplan.pdf"
if not filename.lower().endswith(".pdf"):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"error": "Bitte eine PDF-Datei hochladen.",
},
status_code=400,
)
data = await file.read()
if not data:
return templates.TemplateResponse(
"index.html",
{
"request": request,
"error": "Die Datei ist leer.",
},
status_code=400,
)
with tempfile.TemporaryDirectory(prefix="pdf_to_ics_web_") as temp_dir:
temp_dir_path = Path(temp_dir)
pdf_path = temp_dir_path / "upload.pdf"
pdf_path.write_bytes(data)
dienstplan = extract_dienstplan_data(str(pdf_path))
if not dienstplan.get("events"):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"error": "Keine Dienstplan-Events in der PDF gefunden.",
},
status_code=422,
)
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", Path(filename).stem)
ics_filename = f"{safe_name}.ics"
ics_path = temp_dir_path / ics_filename
create_ics_from_dienstplan(
dienstplan,
str(ics_path),
exclude_rest=exclude_rest,
exclude_vacation=exclude_vacation,
)
content = ics_path.read_bytes()
response_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".ics", prefix="pdf_to_ics_out_")
response_tmp.write(content)
response_tmp.close()
return FileResponse(
path=response_tmp.name,
media_type="text/calendar",
filename=ics_filename,
headers={"Cache-Control": "no-store"},
background=BackgroundTask(lambda path: os.remove(path) if os.path.exists(path) else None, response_tmp.name),
)
@app.get("/health")
def health():
return {"status": "ok"}

9
web/requirements-web.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi>=0.115.0
uvicorn>=0.30.0
jinja2>=3.1.0
python-multipart>=0.0.9
pdfplumber
icalendar
pytz
pypdf2
packaging

120
web/templates/index.html Normal file
View File

@@ -0,0 +1,120 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PDF zu ICS (Web)</title>
<style>
:root { color-scheme: light; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
background: #f5f7fb;
color: #1f2937;
}
.wrap {
max-width: 520px;
margin: 0 auto;
padding: 20px 14px 28px;
}
.card {
background: #ffffff;
border-radius: 14px;
padding: 18px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
h1 {
margin: 0 0 10px;
font-size: 1.3rem;
}
p {
margin: 0 0 16px;
color: #4b5563;
line-height: 1.4;
}
label {
display: block;
font-weight: 600;
margin: 12px 0 8px;
}
input[type=file] {
width: 100%;
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 10px;
background: #fff;
box-sizing: border-box;
}
.options {
margin: 12px 0;
display: grid;
gap: 8px;
}
.option {
display: flex;
gap: 10px;
align-items: center;
font-weight: 500;
color: #111827;
}
button {
width: 100%;
border: 0;
border-radius: 10px;
padding: 12px;
font-size: 1rem;
font-weight: 700;
background: #2563eb;
color: #fff;
margin-top: 6px;
cursor: pointer;
}
button:active { transform: translateY(1px); }
.error {
margin: 0 0 12px;
padding: 10px;
border-radius: 10px;
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.hint {
margin-top: 12px;
font-size: 0.9rem;
color: #6b7280;
}
</style>
</head>
<body>
<main class="wrap">
<section class="card">
<h1>PDF zu ICS Konverter</h1>
<p>PDF hochladen, konvertieren und die ICS-Datei direkt herunterladen.</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form action="/convert" method="post" enctype="multipart/form-data">
<label for="file">Dienstplan-PDF</label>
<input id="file" name="file" type="file" accept="application/pdf,.pdf" required />
<div class="options">
<label class="option">
<input type="checkbox" name="exclude_rest" value="true" />
Ruhetage ausschließen
</label>
<label class="option">
<input type="checkbox" name="exclude_vacation" value="true" />
Urlaub ausschließen
</label>
</div>
<button type="submit">ICS erstellen</button>
</form>
<div class="hint">Hinweis: Die Datei wird nur temporär für die Konvertierung verarbeitet.</div>
</section>
</main>
</body>
</html>