feat(flutter): öffentliche Download-Seite für Scan-App-Feldtest
/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:
1 parent
973bf35514
commit
777bf5eaa5
8 files changed
+187
-19
No files matched your search
@@ -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,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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user