feat(flutter): öffentliche Download-Seite für Scan-App-Feldtest
CI / backend-tests (push) Failing after 3m17s
CI / frontend-build (push) Successful in 21s

/app zeigt QR-Code + Download-Button + Installationsanleitung ohne
Login (Henne-Ei: die App ist der Login-Client). Backend liefert
GET /app-download/info und /app-download/qr.png (bewusst ohne Auth),
liest version.json vom lokalen downloads_dir, QR über bestehenden
services/qr_png.py. Admin-Hinweis verlinkt jetzt auf /app statt
Version/URL zu duplizieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 20:51:52 +02:00
1 parent 973bf35514
commit 777bf5eaa5
8 files changed
+187 -19

No files matched your search

+12
View File
@@ -90,6 +90,18 @@ Build-Host, kein Dauerbetrieb der App dort (App läuft auf Endgeräten).
— ein normaler Frontend-Deploy mit `rsync --delete` hätte den Ordner sonst
gelöscht, real passiert und behoben). Download-Link im Portal nur für
Administrator sichtbar (`AdminPage.tsx`).
- **Feldtest-Vorbereitung 2026-09-10:** öffentliche Download-Seite
`/app` (KEIN Login nötig — Henne-Ei, die App IST der Login-Client)
zeigt QR-Code + Download-Button + Installationsanleitung für Helfer.
Backend: `GET /app-download/info` (JSON, volle APK-URL) und
`GET /app-download/qr.png` (QR-Bild direkt auf die APK-URL, via
bestehendem `services/qr_png.py` wie bei ASSET-010), beide bewusst
OHNE Auth, lesen `version.json` vom lokalen `downloads_dir`
(Backend/Downloads liegen auf demselben Host, `192.168.1.238`) und
setzen die volle URL aus `app_settings.portal_url` zusammen. Admin-
Hinweis in `AdminPage.tsx` verlinkt jetzt auf `/app` statt Version/URL
zu duplizieren. Tests: `test_app_download.py` (404 ohne version.json,
volle URL korrekt zusammengesetzt, PNG-Response, kein Auth-Zwang).
- **Bugfix 2026-09-10:** Portal-Link war fest auf `v0.5.apk` verlinkt,
dadurch nach dem v0.6.0-Build stale (zeigte auf eine ältere Version, ohne
dass ein Frontend-Deploy das automatisch mitbekommen hätte).
+2
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter
from app.api.v1.endpoints import (
akte,
app_download,
ausgabe,
auth,
benutzer,
@@ -42,6 +43,7 @@ from app.api.v1.endpoints import (
api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
api_router.include_router(app_download.router, tags=["app_download"])
api_router.include_router(auth.router, tags=["auth"])
api_router.include_router(stammdaten.router, tags=["stammdaten"])
api_router.include_router(benutzer.router, tags=["benutzer"])
@@ -0,0 +1,39 @@
import json
from pathlib import Path
from fastapi import APIRouter, HTTPException, Response, status
from app.core.app_settings import settings
from app.services.qr_png import generiere_qr_png
router = APIRouter()
def _lade_version_info() -> dict:
pfad = Path(settings.downloads_dir) / "version.json"
if not pfad.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine App-Version veröffentlicht")
return json.loads(pfad.read_text())
@router.get("/app-download/info")
async def app_download_info() -> dict:
"""FLUT-001: bewusst OHNE Auth - Helfer im Feld sollen die Scan-App auch
ohne MABEA-Login installieren können (Henne-Ei: die App IST der
Login-Client). Liefert dieselbe version.json wie der In-App-Update-Check,
plus die volle URL fürs Frontend/den QR-Code."""
daten = _lade_version_info()
return {
"version": daten["version"],
"apk_url": f"{settings.portal_url}{daten['apk_url']}",
}
@router.get("/app-download/qr.png")
async def app_download_qr() -> Response:
"""QR-Code direkt auf die aktuelle APK - zum Ausdrucken (Poster/Aushang)
oder Scannen von einem zweiten Gerät aus. Bewusst OHNE Auth, siehe
app_download_info()."""
daten = _lade_version_info()
apk_url = f"{settings.portal_url}{daten['apk_url']}"
return Response(content=generiere_qr_png(apk_url), media_type="image/png")
+9
View File
@@ -41,5 +41,14 @@ class Settings(BaseSettings):
# Fehler-Tracking, No-Op - kein Zwang, GlitchTip zu betreiben.
sentry_dsn: str = ""
# FLUT-001: Scan-App-APK + version.json liegen als statische Dateien
# außerhalb des SPA-dist/-Verzeichnisses auf demselben Host wie das
# Backend (siehe deploy, nginx location /downloads/). Leerer
# portal_url-Default bewusst falsch/auffällig, damit ein vergessenes
# .env-Setzen sofort als kaputter Link auffällt statt still auf
# localhost zu zeigen.
downloads_dir: str = "/var/www/mabea-downloads"
portal_url: str = "https://mabea.perlbach-edv.de"
settings = Settings()
+53
View File
@@ -0,0 +1,53 @@
import json
import pytest
from app.core.app_settings import settings
@pytest.mark.asyncio
async def test_app_download_info_ohne_version_json_404(client, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "downloads_dir", str(tmp_path))
response = await client.get("/api/v1/app-download/info")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_app_download_info_liefert_volle_url(client, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "downloads_dir", str(tmp_path))
monkeypatch.setattr(settings, "portal_url", "https://example.test")
(tmp_path / "version.json").write_text(
json.dumps({"version": "0.6.0", "apk_url": "/downloads/mabea-scan-prototyp-v0.6.apk"})
)
response = await client.get("/api/v1/app-download/info")
assert response.status_code == 200
daten = response.json()
assert daten["version"] == "0.6.0"
assert daten["apk_url"] == "https://example.test/downloads/mabea-scan-prototyp-v0.6.apk"
@pytest.mark.asyncio
async def test_app_download_qr_liefert_png(client, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "downloads_dir", str(tmp_path))
monkeypatch.setattr(settings, "portal_url", "https://example.test")
(tmp_path / "version.json").write_text(
json.dumps({"version": "0.6.0", "apk_url": "/downloads/mabea-scan-prototyp-v0.6.apk"})
)
response = await client.get("/api/v1/app-download/qr.png")
assert response.status_code == 200
assert response.headers["content-type"] == "image/png"
assert response.content[:8] == b"\x89PNG\r\n\x1a\n"
@pytest.mark.asyncio
async def test_app_download_ohne_auth_erreichbar(client, tmp_path, monkeypatch):
"""Kernanforderung: Helfer brauchen KEIN MABEA-Login, um die Scan-App zu
installieren (Henne-Ei - die App IST der Login-Client)."""
monkeypatch.setattr(settings, "downloads_dir", str(tmp_path))
(tmp_path / "version.json").write_text(json.dumps({"version": "0.6.0", "apk_url": "/x.apk"}))
response = await client.get("/api/v1/app-download/info")
assert response.status_code != 401
assert response.status_code != 403
+2
View File
@@ -13,6 +13,7 @@ import { LoginPage } from "./pages/LoginPage";
import { MangelListePage } from "./pages/MangelListePage";
import { ObjektListPage } from "./pages/ObjektListPage";
import { SignierPage } from "./pages/SignierPage";
import { AppDownloadPage } from "./pages/AppDownloadPage";
function GeschuetzteRoute({ children }: { children: ReactNode }) {
const { eingeloggt } = useAuth();
@@ -57,6 +58,7 @@ export default function App() {
<ToastProvider>
<Routes>
<Route path="/signieren/:token" element={<SignierPage />} />
<Route path="/app" element={<AppDownloadPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<Startseite />} />
<Route
+7 -19
View File
@@ -365,28 +365,16 @@ export function AdminPage() {
);
}
// FLUT-001: Version/APK-URL werden zur Laufzeit aus version.json gelesen
// (statt fest verlinkt) - sonst veraltet der Link bei jedem neuen APK-Build
// im Portal, ohne dass ein Frontend-Deploy das mitbekommt (genau das ist
// vorher passiert: Link zeigte noch auf v0.5, obwohl v0.6.0 längst live war).
// FLUT-001: verlinkt auf die öffentliche /app-Seite (eigener Download-Link +
// QR-Code fürs Feld, kein Login nötig - siehe AppDownloadPage.tsx), statt
// hier selbst Version/APK-URL zu duplizieren.
function ScanAppDownloadHinweis() {
const [info, setInfo] = useState<{ version: string; apk_url: string } | null>(null);
useEffect(() => {
fetch("/downloads/version.json")
.then((r) => (r.ok ? r.json() : null))
.then((daten) => setInfo(daten))
.catch(() => setInfo(null));
}, []);
return (
<p className="text-muted" style={{ marginTop: "2rem", fontSize: "0.85rem" }}>
{info ? (
<a href={info.apk_url}>MABEA Scan-App (Android, Prototyp) v{info.version}</a>
) : (
"MABEA Scan-App (Android, Prototyp)"
)}{" "}
herunterladen — FLUT-001-Sondierung, kein Produktivstand.
<a href="/app" target="_blank" rel="noreferrer">
MABEA Scan-App (Android, Prototyp) — Download-Seite mit QR-Code
</a>{" "}
— FLUT-001-Sondierung, kein Produktivstand.
</p>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
interface AppInfo {
version: string;
apk_url: string;
}
// FLUT-001: öffentliche Seite (kein Login nötig - Henne-Ei, die App IST der
// Login-Client), damit Helfer im Feld die Scan-App per Link oder QR-Code
// installieren können, ohne vorher im Portal angemeldet zu sein.
export function AppDownloadPage() {
const [info, setInfo] = useState<AppInfo | null>(null);
const [fehler, setFehler] = useState(false);
useEffect(() => {
fetch(`${API_BASE}/app-download/info`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then(setInfo)
.catch(() => setFehler(true));
}, []);
return (
<main style={{ maxWidth: "28rem", margin: "3rem auto", padding: "0 1rem", textAlign: "center" }}>
<h1>MABEA Scan-App</h1>
<p className="text-muted">Android-Prototyp (FLUT-001) zum QR-Scan von Objekten.</p>
{fehler && <p className="alert alert-danger">Aktuell keine App-Version verfügbar.</p>}
{info && (
<>
<img
src={`${API_BASE}/app-download/qr.png`}
alt="QR-Code zum App-Download"
style={{ width: "220px", height: "220px", margin: "1.5rem auto", display: "block" }}
/>
<p>
<a className="btn btn-primary" href={info.apk_url}>
APK herunterladen (v{info.version})
</a>
</p>
<div className="card" style={{ marginTop: "2rem", textAlign: "left" }}>
<h3 style={{ marginTop: 0 }}>Installation</h3>
<ol>
<li>QR-Code scannen oder Link auf dem Smartphone öffnen.</li>
<li>Download antippen.</li>
<li>
Beim ersten Mal fragt Android nach Erlaubnis für „Unbekannte Quellen" — für den
Browser/Dateimanager zustimmen.
</li>
<li>Installieren antippen, App öffnen, mit MABEA-Zugangsdaten anmelden.</li>
</ol>
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
Sondierungs-Prototyp, kein Produktivstand — Updates werden in der App selbst
angezeigt und lassen sich direkt installieren.
</p>
</div>
</>
)}
</main>
);
}