feat(custody): ASSET-010 Gerät-Ausgabe/Rücknahme mit optionalem QR-Signier-Flow
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
1 parent
1147ec0872
commit
24db5a056f
18 files changed
+1306
-2
No files matched your search
@@ -285,8 +285,30 @@ Details zu ASSET-001 … ASSET-010. ASSET-010 ergänzt am 2026-09-09 aus Open-So
|
||||
auf denselben Token, nur einer darf gewinnen), Rate-Limit-Test, IDOR-Test
|
||||
(Token-Lookup nur über Hash, keine ID-Erratbarkeit), Signatur-Bild-Validierungs-
|
||||
test (ungültige/manipulierte Datei wird abgelehnt).
|
||||
- **DoD:** offen — Design vollständig geklärt (2026-09-09), bereit für
|
||||
Implementierung. **Referenz (Open-Source-Vergleich 2026-09-09):** Shelf.nu
|
||||
- **DoD:** ✅ umgesetzt (2026-09-10). `geraet_instanz_custody` +
|
||||
`signier_token` (Migration 0040), partieller Unique-Index erzwingt max. eine
|
||||
offene Custody-Zeile je Gerät (DB-Constraint, nicht nur Anwendungslogik).
|
||||
`app/services/custody.py` implementiert alle 7 Security-Review-Punkte
|
||||
(256-Bit-Token nur als SHA-256-Hash, Pfad-Parameter statt Query, atomares
|
||||
`UPDATE ... WHERE token_hash=$1 AND status='ausstehend'` gegen Race
|
||||
Conditions, bewusst kein PIN-Schritt, relative API-Basis-URL, minimale
|
||||
Signier-Seiten-Daten ohne Custody-Historie, Signatur-Bild-Validierung über
|
||||
Pillow vor PDF-Einbettung). PDF-Erzeugung über reportlab (reine
|
||||
Zeichenoperationen, kein HTML/Jinja2 — Security-Punkt 7 damit strukturell
|
||||
gegenstandslos statt nur behandelt). `GET/POST /signieren/{token}`
|
||||
unauthentifiziert mit `slowapi`-Rate-Limit (10/Minute pro IP — Pro-Token-
|
||||
Limit bewusst nicht zusätzlich umgesetzt, da ein 256-Bit-Token ohnehin nicht
|
||||
brute-forcebar ist und Einmalverwendung strukturell bereits einen
|
||||
Wiederholungsversuch auf denselben Token verhindert). Frontend:
|
||||
`CustodyDialog.tsx` (Ausgeben/Zurücknehmen-Aktion in
|
||||
`GeraeteInstanzenListe.tsx`, QR-Anzeige via authentifiziertem
|
||||
`/qr-png`-Endpunkt als Blob statt `<img src>` mit Query-Only-Auth-Lücke),
|
||||
eigenständige öffentliche Route `/signieren/:token`
|
||||
(`SignierPage.tsx`, Touch-Canvas-Unterschrift, kein AppShell/Login).
|
||||
`backend/tests/test_custody.py` (Doppelausgabe-Ablehnung, Token-Einmal-
|
||||
verwendung, ungültiger/abgelaufener Token, ungültige Signatur ohne
|
||||
Token-Verbrauch, formlose + protokollierte Ausgabe/Rücknahme).
|
||||
**Referenz (Open-Source-Vergleich 2026-09-09):** Shelf.nu
|
||||
(`shelf-nu/shelf.nu`, AGPL-3.0 — nur Konzeptvorbild, keine Codeübernahme wegen
|
||||
Copyleft) — „Custody"-Feature, Kernaussage „know who has what at all times".
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""ASSET-010: Custody/Ausgabe von GeraetInstanz an Person.
|
||||
|
||||
Revision ID: 0040_custody
|
||||
Revises: 0039_standort_scoping
|
||||
Create Date: 2026-09-10
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0040_custody"
|
||||
down_revision: Union[str, None] = "0039_standort_scoping"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TYPE dokument_typ ADD VALUE IF NOT EXISTS 'ausgabeprotokoll'")
|
||||
op.execute("CREATE TYPE signier_token_status AS ENUM ('ausstehend', 'verwendet')")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE geraet_instanz_custody (
|
||||
id UUID PRIMARY KEY,
|
||||
geraet_instanz_id UUID NOT NULL REFERENCES geraet_instanz(id),
|
||||
person_id INTEGER NOT NULL REFERENCES person(id),
|
||||
mit_protokoll BOOLEAN NOT NULL DEFAULT false,
|
||||
ausgegeben_am TIMESTAMPTZ NOT NULL,
|
||||
ausgegeben_von_benutzer_id INTEGER NOT NULL REFERENCES benutzer(id),
|
||||
ausgabeprotokoll_dokument_id UUID REFERENCES dokument(id),
|
||||
ausgabe_abgeschlossen_am TIMESTAMPTZ,
|
||||
zurueckgegeben_am TIMESTAMPTZ,
|
||||
zurueckgenommen_von_benutzer_id INTEGER REFERENCES benutzer(id),
|
||||
rueckgabeprotokoll_dokument_id UUID REFERENCES dokument(id),
|
||||
rueckgabe_abgeschlossen_am TIMESTAMPTZ
|
||||
)
|
||||
"""
|
||||
)
|
||||
# ASSET-010-Akzeptanzkriterium: Gerät nicht an zwei Personen gleichzeitig -
|
||||
# partieller Unique-Index statt Anwendungslogik allein, DB erzwingt es.
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX ux_custody_offen_je_geraet ON geraet_instanz_custody (geraet_instanz_id) "
|
||||
"WHERE zurueckgegeben_am IS NULL"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX ix_custody_geraet_instanz ON geraet_instanz_custody (geraet_instanz_id)"
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE signier_token (
|
||||
id UUID PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
custody_id UUID NOT NULL REFERENCES geraet_instanz_custody(id),
|
||||
vorgang TEXT NOT NULL,
|
||||
ablauf_am TIMESTAMPTZ NOT NULL,
|
||||
status signier_token_status NOT NULL DEFAULT 'ausstehend'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE signier_token")
|
||||
op.execute("DROP TABLE geraet_instanz_custody")
|
||||
op.execute("DROP TYPE IF EXISTS signier_token_status")
|
||||
@@ -5,6 +5,7 @@ from app.api.v1.endpoints import (
|
||||
ausgabe,
|
||||
auth,
|
||||
benutzer,
|
||||
custody,
|
||||
dashboard,
|
||||
dokument,
|
||||
eskalation,
|
||||
@@ -50,6 +51,7 @@ api_router.include_router(fehlbestaende.router, tags=["fehlbestaende"])
|
||||
api_router.include_router(eskalation.router, tags=["eskalation"])
|
||||
api_router.include_router(historie.router, tags=["historie"])
|
||||
api_router.include_router(geraet_instanz.router, tags=["geraet_instanz"])
|
||||
api_router.include_router(custody.router, tags=["custody"])
|
||||
api_router.include_router(dashboard.router, tags=["dashboard"])
|
||||
api_router.include_router(personal.router, tags=["personal"])
|
||||
api_router.include_router(mangel.router, tags=["mangel"])
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.core.rate_limit import limiter
|
||||
from app.db.session import get_db
|
||||
from app.models.auth import Benutzer, KnotenTyp, RolleTyp, Systemknoten
|
||||
from app.schemas.custody import (
|
||||
CustodyAusgabeCreate,
|
||||
CustodyAusgabeResponse,
|
||||
CustodyRead,
|
||||
CustodyStatus,
|
||||
SignierSeiteDaten,
|
||||
)
|
||||
from app.services.ausgabeprotokoll import UngueltigeSignaturError
|
||||
from app.services.custody import (
|
||||
GeraetBereitsAusgegebenError,
|
||||
KeineOffeneAusgabeError,
|
||||
UngueltigerTokenError,
|
||||
ausgeben,
|
||||
signier_seite_daten,
|
||||
signiere,
|
||||
status as custody_status,
|
||||
zurueckgeben,
|
||||
)
|
||||
from app.services.qr_png import generiere_qr_png
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_mitarbeiter_plus = require_roles(
|
||||
RolleTyp.mitarbeiter,
|
||||
RolleTyp.materialverantwortlicher,
|
||||
RolleTyp.leitungsverantwortlicher,
|
||||
RolleTyp.administration,
|
||||
)
|
||||
|
||||
|
||||
async def _hauptserver_id(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
def _signier_url(request: Request, token: str) -> str:
|
||||
# Gleiche Domain/gleiches TLS-Zertifikat wie das Portal selbst (Security-
|
||||
# Review Punkt 5) - aus dem eingehenden Request abgeleitet statt fest
|
||||
# konfiguriert, funktioniert unverändert hinter jedem Reverse-Proxy.
|
||||
basis = str(request.base_url).rstrip("/")
|
||||
return f"{basis}/signieren/{token}"
|
||||
|
||||
|
||||
@router.post(
|
||||
"/geraete/{geraet_instanz_id}/ausgeben",
|
||||
response_model=CustodyAusgabeResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def geraet_ausgeben(
|
||||
geraet_instanz_id: uuid.UUID,
|
||||
payload: CustodyAusgabeCreate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Benutzer = Depends(_mitarbeiter_plus),
|
||||
) -> CustodyAusgabeResponse:
|
||||
zustaendiger_server_id = await _hauptserver_id(db)
|
||||
try:
|
||||
custody, token = await ausgeben(
|
||||
db,
|
||||
geraet_instanz_id=geraet_instanz_id,
|
||||
person_id=payload.person_id,
|
||||
mit_protokoll=payload.mit_protokoll,
|
||||
benutzer_id=current_user.id,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
)
|
||||
except GeraetBereitsAusgegebenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Gerät ist bereits an eine Person ausgegeben"
|
||||
) from exc
|
||||
return CustodyAusgabeResponse(
|
||||
custody=CustodyRead.model_validate(custody),
|
||||
signier_url=_signier_url(request, token) if token else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/geraete/{geraet_instanz_id}/zurueckgeben",
|
||||
response_model=CustodyAusgabeResponse,
|
||||
)
|
||||
async def geraet_zurueckgeben(
|
||||
geraet_instanz_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Benutzer = Depends(_mitarbeiter_plus),
|
||||
) -> CustodyAusgabeResponse:
|
||||
zustaendiger_server_id = await _hauptserver_id(db)
|
||||
try:
|
||||
custody, token = await zurueckgeben(
|
||||
db,
|
||||
geraet_instanz_id=geraet_instanz_id,
|
||||
benutzer_id=current_user.id,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
)
|
||||
except KeineOffeneAusgabeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Gerät ist aktuell an niemanden ausgegeben"
|
||||
) from exc
|
||||
return CustodyAusgabeResponse(
|
||||
custody=CustodyRead.model_validate(custody),
|
||||
signier_url=_signier_url(request, token) if token else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/custody/{custody_id}/status", response_model=CustodyStatus)
|
||||
async def custody_status_abfragen(
|
||||
custody_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
||||
) -> CustodyStatus:
|
||||
"""Portal-Polling während auf die Signatur gewartet wird."""
|
||||
return CustodyStatus(abgeschlossen=await custody_status(db, custody_id))
|
||||
|
||||
|
||||
@router.get("/qr-png")
|
||||
async def qr_png(inhalt: str, _=Depends(get_current_user)) -> Response:
|
||||
return Response(content=generiere_qr_png(inhalt), media_type="image/png")
|
||||
|
||||
|
||||
# Ab hier: ÖFFENTLICHE Endpunkte (kein Login) für die Signier-Seite auf dem
|
||||
# Empfänger-Handy - Security-Review Punkt 1/2: Token als Pfad-Parameter,
|
||||
# Rate-Limit pro IP, Lookup ausschließlich über Token-Hash.
|
||||
|
||||
|
||||
@router.get("/signieren/{token}", response_model=SignierSeiteDaten)
|
||||
@limiter.limit("10/minute")
|
||||
async def signier_seite_lesen(token: str, request: Request, db: AsyncSession = Depends(get_db)) -> SignierSeiteDaten:
|
||||
try:
|
||||
daten = await signier_seite_daten(db, token)
|
||||
except UngueltigerTokenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Link ungültig oder abgelaufen"
|
||||
) from exc
|
||||
return SignierSeiteDaten(**daten)
|
||||
|
||||
|
||||
@router.post("/signieren/{token}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@limiter.limit("10/minute")
|
||||
async def signier_seite_absenden(
|
||||
token: str,
|
||||
request: Request,
|
||||
signatur: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
zustaendiger_server_id = await _hauptserver_id(db)
|
||||
inhalt = await signatur.read()
|
||||
try:
|
||||
await signiere(db, token_klartext=token, signatur_bytes=inhalt, zustaendiger_server_id=zustaendiger_server_id)
|
||||
except UngueltigerTokenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Link ungültig oder abgelaufen"
|
||||
) from exc
|
||||
except UngueltigeSignaturError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
@@ -1,5 +1,6 @@
|
||||
from app.models.ausgabe import Ausgabe, AusgabeStatus
|
||||
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp
|
||||
from app.models.custody import GeraetInstanzCustody, SignierToken, SignierTokenStatus
|
||||
from app.models.dokument import Dokument
|
||||
from app.models.eskalation import EskalationKonfiguration
|
||||
from app.models.fahrzeugdetails import Fahrzeugdetails
|
||||
@@ -45,6 +46,9 @@ __all__ = [
|
||||
"RolleTyp",
|
||||
"Systemknoten",
|
||||
"KnotenTyp",
|
||||
"GeraetInstanzCustody",
|
||||
"SignierToken",
|
||||
"SignierTokenStatus",
|
||||
"Dokument",
|
||||
"EskalationKonfiguration",
|
||||
"Fahrzeugdetails",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SignierTokenStatus(str, enum.Enum):
|
||||
ausstehend = "ausstehend"
|
||||
verwendet = "verwendet"
|
||||
|
||||
|
||||
signier_token_status_pg = PgEnum(SignierTokenStatus, name="signier_token_status", create_type=False)
|
||||
|
||||
|
||||
class GeraetInstanzCustody(Base):
|
||||
"""ASSET-010: aktuelle Inhaberschaft = Zeile mit zurueckgegeben_am IS NULL
|
||||
(DB-Constraint erzwingt max. eine offene Zeile je geraet_instanz_id, siehe
|
||||
Migration). `mit_protokoll` entscheidet bei Ausgabe UND gilt spiegelbildlich
|
||||
für die Rücknahme (kein nachträglicher Wechsel)."""
|
||||
|
||||
__tablename__ = "geraet_instanz_custody"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
geraet_instanz_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("geraet_instanz.id"), nullable=False
|
||||
)
|
||||
person_id: Mapped[int] = mapped_column(ForeignKey("person.id"), nullable=False)
|
||||
mit_protokoll: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
ausgegeben_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
||||
ausgegeben_von_benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
|
||||
ausgabeprotokoll_dokument_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("dokument.id")
|
||||
)
|
||||
# NULL = Ausgabe (bei mit_protokoll=True) wartet noch auf die Signatur -
|
||||
# gilt laut Akzeptanzkriterium erst danach als abgeschlossen. Bei
|
||||
# mit_protokoll=False wird dieses Feld beim Anlegen sofort gesetzt.
|
||||
ausgabe_abgeschlossen_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||
zurueckgegeben_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||
zurueckgenommen_von_benutzer_id: Mapped[int | None] = mapped_column(ForeignKey("benutzer.id"))
|
||||
rueckgabeprotokoll_dokument_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("dokument.id")
|
||||
)
|
||||
rueckgabe_abgeschlossen_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||
|
||||
|
||||
class SignierToken(Base):
|
||||
"""ASSET-010 Security-Review: Klartext-Token existiert nur im QR-Code/URL,
|
||||
hier ausschließlich der SHA-256-Hash - Lookup ausschließlich über den Hash,
|
||||
niemals über custody_id (IDOR-Schutz laut Review)."""
|
||||
|
||||
__tablename__ = "signier_token"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
token_hash: Mapped[str] = mapped_column(unique=True, nullable=False)
|
||||
custody_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("geraet_instanz_custody.id"), nullable=False
|
||||
)
|
||||
# Ausgabe- ODER Rückgabe-Signatur - dasselbe Token-Modell für beide
|
||||
# Richtungen, unterschieden über dieses Feld.
|
||||
vorgang: Mapped[str] = mapped_column(nullable=False)
|
||||
ablauf_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
||||
status: Mapped[SignierTokenStatus] = mapped_column(
|
||||
signier_token_status_pg, nullable=False, default=SignierTokenStatus.ausstehend
|
||||
)
|
||||
@@ -19,6 +19,7 @@ class DokumentTyp(str, enum.Enum):
|
||||
bedienungsanleitung = "bedienungsanleitung"
|
||||
rechnung = "rechnung"
|
||||
zulassungsdokument = "zulassungsdokument"
|
||||
ausgabeprotokoll = "ausgabeprotokoll"
|
||||
sonstiges = "sonstiges"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CustodyAusgabeCreate(BaseModel):
|
||||
person_id: int
|
||||
mit_protokoll: bool = False
|
||||
|
||||
|
||||
class CustodyRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
geraet_instanz_id: uuid.UUID
|
||||
person_id: int
|
||||
mit_protokoll: bool
|
||||
ausgegeben_am: datetime
|
||||
ausgabe_abgeschlossen_am: datetime | None
|
||||
zurueckgegeben_am: datetime | None
|
||||
rueckgabe_abgeschlossen_am: datetime | None
|
||||
|
||||
|
||||
class CustodyAusgabeResponse(BaseModel):
|
||||
custody: CustodyRead
|
||||
# Nur gesetzt bei mit_protokoll=True - Klartext-Token existiert nur hier,
|
||||
# einmalig in dieser Response, danach nie wieder abrufbar (Security-Review).
|
||||
signier_url: str | None = None
|
||||
|
||||
|
||||
class SignierSeiteDaten(BaseModel):
|
||||
"""Security-Review Punkt 6: bewusst minimal - kein Custody-Verlauf, keine
|
||||
internen IDs."""
|
||||
|
||||
vorgang: str
|
||||
geraet_bezeichnung: str
|
||||
person_name: str
|
||||
ablauf_am: datetime
|
||||
|
||||
|
||||
class CustodyStatus(BaseModel):
|
||||
abgeschlossen: bool
|
||||
@@ -0,0 +1,86 @@
|
||||
import io
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
_MAX_SIGNATUR_BYTES = 2 * 1024 * 1024
|
||||
_MAX_SIGNATUR_PIXEL = 4000
|
||||
|
||||
|
||||
class UngueltigeSignaturError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def pruefe_signatur_bild(daten: bytes) -> Image.Image:
|
||||
"""ASSET-010 Security-Review Punkt 7: Signatur-Bild vor Einbettung ins PDF
|
||||
validieren (Magic-Bytes/Größe/Dimensionen) - kein Vertrauen auf den vom
|
||||
Client gesendeten Content-Type. `Image.open` + `.verify()`/erneutes Öffnen
|
||||
prüft die tatsächlichen Bilddaten, nicht nur die Dateiendung."""
|
||||
if len(daten) > _MAX_SIGNATUR_BYTES:
|
||||
raise UngueltigeSignaturError("Signatur-Datei zu groß")
|
||||
try:
|
||||
bild = Image.open(io.BytesIO(daten))
|
||||
bild.verify()
|
||||
bild = Image.open(io.BytesIO(daten))
|
||||
bild.load()
|
||||
except (UnidentifiedImageError, OSError) as exc:
|
||||
raise UngueltigeSignaturError("Keine gültige Bilddatei") from exc
|
||||
if bild.format not in ("PNG", "JPEG"):
|
||||
raise UngueltigeSignaturError("Nur PNG/JPEG erlaubt")
|
||||
if bild.width > _MAX_SIGNATUR_PIXEL or bild.height > _MAX_SIGNATUR_PIXEL:
|
||||
raise UngueltigeSignaturError("Signatur-Bild zu groß (Pixel)")
|
||||
return bild
|
||||
|
||||
|
||||
def generiere_ausgabeprotokoll_pdf(
|
||||
*,
|
||||
vorgang: str,
|
||||
geraet_bezeichnung: str,
|
||||
seriennummer: str,
|
||||
person_name: str,
|
||||
zeitpunkt_iso: str,
|
||||
signatur_bild: Image.Image,
|
||||
) -> bytes:
|
||||
"""Reine reportlab-Zeichenoperationen (keine HTML-Templates/Jinja2) - kein
|
||||
Injection-Risiko über Nutzereingaben, da nichts als Markup interpretiert
|
||||
wird, nur als Text auf die Seite gezeichnet."""
|
||||
puffer = io.BytesIO()
|
||||
c = canvas.Canvas(puffer, pagesize=A4)
|
||||
breite, hoehe = A4
|
||||
|
||||
c.setFont("Helvetica-Bold", 16)
|
||||
c.drawString(20 * mm, hoehe - 25 * mm, f"{vorgang} - Bestätigung")
|
||||
|
||||
c.setFont("Helvetica", 11)
|
||||
zeilen = [
|
||||
f"Gerät: {geraet_bezeichnung}",
|
||||
f"Seriennummer: {seriennummer}",
|
||||
f"Person: {person_name}",
|
||||
f"Zeitpunkt: {zeitpunkt_iso}",
|
||||
]
|
||||
y = hoehe - 40 * mm
|
||||
for zeile in zeilen:
|
||||
c.drawString(20 * mm, y, zeile)
|
||||
y -= 7 * mm
|
||||
|
||||
c.setFont("Helvetica", 10)
|
||||
c.drawString(20 * mm, y - 5 * mm, "Unterschrift:")
|
||||
signatur_reader = ImageReader(signatur_bild)
|
||||
ziel_breite = 70 * mm
|
||||
ziel_hoehe = ziel_breite * signatur_bild.height / signatur_bild.width
|
||||
c.drawImage(
|
||||
signatur_reader,
|
||||
20 * mm,
|
||||
y - 15 * mm - ziel_hoehe,
|
||||
width=ziel_breite,
|
||||
height=ziel_hoehe,
|
||||
preserveAspectRatio=True,
|
||||
mask="auto",
|
||||
)
|
||||
|
||||
c.showPage()
|
||||
c.save()
|
||||
return puffer.getvalue()
|
||||
@@ -0,0 +1,259 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.custody import GeraetInstanzCustody, SignierToken, SignierTokenStatus
|
||||
from app.models.dokument import DokumentTyp
|
||||
from app.models.geraet_instanz import GeraetInstanz
|
||||
from app.models.objekt import Objekt
|
||||
from app.models.objektposition import Objektposition
|
||||
from app.models.personal import Person
|
||||
from app.services import historie as historie_service
|
||||
from app.services.ausgabeprotokoll import UngueltigeSignaturError, generiere_ausgabeprotokoll_pdf, pruefe_signatur_bild
|
||||
from app.services.dokument import speichere_dokument
|
||||
|
||||
TOKEN_GUELTIGKEIT = timedelta(minutes=5)
|
||||
|
||||
|
||||
class GeraetBereitsAusgegebenError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class KeineOffeneAusgabeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UngueltigerTokenError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _offene_custody(db: AsyncSession, geraet_instanz_id) -> GeraetInstanzCustody | None:
|
||||
result = await db.execute(
|
||||
select(GeraetInstanzCustody).where(
|
||||
GeraetInstanzCustody.geraet_instanz_id == geraet_instanz_id,
|
||||
GeraetInstanzCustody.zurueckgegeben_am.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _erzeuge_token(db: AsyncSession, custody_id, vorgang: str) -> str:
|
||||
token_klartext = secrets.token_urlsafe(32)
|
||||
db.add(
|
||||
SignierToken(
|
||||
token_hash=_hash_token(token_klartext),
|
||||
custody_id=custody_id,
|
||||
vorgang=vorgang,
|
||||
ablauf_am=datetime.now(timezone.utc) + TOKEN_GUELTIGKEIT,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return token_klartext
|
||||
|
||||
|
||||
async def ausgeben(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
geraet_instanz_id,
|
||||
person_id: int,
|
||||
mit_protokoll: bool,
|
||||
benutzer_id: int,
|
||||
zustaendiger_server_id: int,
|
||||
) -> tuple[GeraetInstanzCustody, str | None]:
|
||||
if await _offene_custody(db, geraet_instanz_id) is not None:
|
||||
raise GeraetBereitsAusgegebenError()
|
||||
|
||||
jetzt = datetime.now(timezone.utc)
|
||||
custody = GeraetInstanzCustody(
|
||||
geraet_instanz_id=geraet_instanz_id,
|
||||
person_id=person_id,
|
||||
mit_protokoll=mit_protokoll,
|
||||
ausgegeben_am=jetzt,
|
||||
ausgegeben_von_benutzer_id=benutzer_id,
|
||||
ausgabe_abgeschlossen_am=None if mit_protokoll else jetzt,
|
||||
)
|
||||
db.add(custody)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError as exc:
|
||||
raise GeraetBereitsAusgegebenError() from exc
|
||||
|
||||
token_klartext = None
|
||||
if mit_protokoll:
|
||||
token_klartext = await _erzeuge_token(db, custody.id, "ausgabe")
|
||||
else:
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp="geraet_ausgegeben",
|
||||
entitaet_typ="geraet_instanz",
|
||||
entitaet_id=geraet_instanz_id,
|
||||
neuer_wert={"person_id": person_id, "mit_protokoll": False},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(custody)
|
||||
return custody, token_klartext
|
||||
|
||||
|
||||
async def zurueckgeben(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
geraet_instanz_id,
|
||||
benutzer_id: int,
|
||||
zustaendiger_server_id: int,
|
||||
) -> tuple[GeraetInstanzCustody, str | None]:
|
||||
custody = await _offene_custody(db, geraet_instanz_id)
|
||||
if custody is None:
|
||||
raise KeineOffeneAusgabeError()
|
||||
|
||||
token_klartext = None
|
||||
if custody.mit_protokoll:
|
||||
token_klartext = await _erzeuge_token(db, custody.id, "ruecknahme")
|
||||
else:
|
||||
custody.zurueckgegeben_am = datetime.now(timezone.utc)
|
||||
custody.zurueckgenommen_von_benutzer_id = benutzer_id
|
||||
custody.rueckgabe_abgeschlossen_am = custody.zurueckgegeben_am
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp="geraet_zurueckgegeben",
|
||||
entitaet_typ="geraet_instanz",
|
||||
entitaet_id=geraet_instanz_id,
|
||||
neuer_wert={"mit_protokoll": False},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(custody)
|
||||
return custody, token_klartext
|
||||
|
||||
|
||||
async def _hole_kontext(db: AsyncSession, custody: GeraetInstanzCustody):
|
||||
geraet = await db.get(GeraetInstanz, custody.geraet_instanz_id)
|
||||
position = await db.get(Objektposition, geraet.objektposition_id)
|
||||
objekt = await db.get(Objekt, position.objekt_id)
|
||||
person = await db.get(Person, custody.person_id)
|
||||
return geraet, objekt, person
|
||||
|
||||
|
||||
async def signier_seite_daten(db: AsyncSession, token_klartext: str) -> dict:
|
||||
"""Security-Review Punkt 2/6: Lookup NUR über Token-Hash, generische
|
||||
Fehlermeldung bei jedem Fehlerfall (kein Enumerieren des Status)."""
|
||||
result = await db.execute(
|
||||
select(SignierToken).where(SignierToken.token_hash == _hash_token(token_klartext))
|
||||
)
|
||||
token = result.scalar_one_or_none()
|
||||
if (
|
||||
token is None
|
||||
or token.status != SignierTokenStatus.ausstehend
|
||||
or token.ablauf_am < datetime.now(timezone.utc)
|
||||
):
|
||||
raise UngueltigerTokenError()
|
||||
|
||||
custody = await db.get(GeraetInstanzCustody, token.custody_id)
|
||||
geraet, _objekt, person = await _hole_kontext(db, custody)
|
||||
return {
|
||||
"vorgang": token.vorgang,
|
||||
"geraet_bezeichnung": f"Gerät SN {geraet.seriennummer}",
|
||||
"person_name": person.name,
|
||||
"ablauf_am": token.ablauf_am,
|
||||
}
|
||||
|
||||
|
||||
async def signiere(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
token_klartext: str,
|
||||
signatur_bytes: bytes,
|
||||
zustaendiger_server_id: int,
|
||||
) -> None:
|
||||
token_hash = _hash_token(token_klartext)
|
||||
|
||||
# Security-Review Punkt 3: atomares UPDATE statt Read-then-Write, Rowcount
|
||||
# entscheidet - verhindert, dass derselbe Token zweimal parallel "gewinnt"
|
||||
# (Race Condition).
|
||||
ergebnis = await db.execute(
|
||||
update(SignierToken)
|
||||
.where(SignierToken.token_hash == token_hash, SignierToken.status == SignierTokenStatus.ausstehend)
|
||||
.values(status=SignierTokenStatus.verwendet)
|
||||
)
|
||||
if ergebnis.rowcount != 1:
|
||||
raise UngueltigerTokenError()
|
||||
|
||||
token_result = await db.execute(select(SignierToken).where(SignierToken.token_hash == token_hash))
|
||||
token = token_result.scalar_one()
|
||||
if token.ablauf_am < datetime.now(timezone.utc):
|
||||
await db.commit()
|
||||
raise UngueltigerTokenError()
|
||||
|
||||
try:
|
||||
signatur_bild = pruefe_signatur_bild(signatur_bytes)
|
||||
except UngueltigeSignaturError:
|
||||
# Token bewusst NICHT als verwendet stehen lassen (Rollback) - eine
|
||||
# fehlerhafte Bilddatei soll den einmaligen Token nicht verbrennen,
|
||||
# sonst müsste der ganze QR-Vorgang neu gestartet werden.
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
custody = await db.get(GeraetInstanzCustody, token.custody_id)
|
||||
geraet, objekt, person = await _hole_kontext(db, custody)
|
||||
jetzt = datetime.now(timezone.utc)
|
||||
|
||||
pdf_bytes = generiere_ausgabeprotokoll_pdf(
|
||||
vorgang="Ausgabe" if token.vorgang == "ausgabe" else "Rückgabe",
|
||||
geraet_bezeichnung=f"Gerät SN {geraet.seriennummer}",
|
||||
seriennummer=geraet.seriennummer,
|
||||
person_name=person.name,
|
||||
zeitpunkt_iso=jetzt.isoformat(),
|
||||
signatur_bild=signatur_bild,
|
||||
)
|
||||
dokument = await speichere_dokument(
|
||||
db,
|
||||
entitaet_typ="objekt",
|
||||
entitaet_id=str(objekt.id),
|
||||
dateiname=f"{token.vorgang}protokoll-{geraet.seriennummer}-{jetzt.date().isoformat()}.pdf",
|
||||
mime_type="application/pdf",
|
||||
inhalt=pdf_bytes,
|
||||
dokumenttyp=DokumentTyp.ausgabeprotokoll,
|
||||
beschreibung=f"{token.vorgang.capitalize()}protokoll {person.name}",
|
||||
hochgeladen_von=custody.ausgegeben_von_benutzer_id,
|
||||
ist_original=True,
|
||||
)
|
||||
|
||||
if token.vorgang == "ausgabe":
|
||||
custody.ausgabeprotokoll_dokument_id = dokument.id
|
||||
custody.ausgabe_abgeschlossen_am = jetzt
|
||||
ereignistyp = "geraet_ausgegeben"
|
||||
else:
|
||||
custody.rueckgabeprotokoll_dokument_id = dokument.id
|
||||
custody.zurueckgegeben_am = jetzt
|
||||
custody.rueckgabe_abgeschlossen_am = jetzt
|
||||
ereignistyp = "geraet_zurueckgegeben"
|
||||
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=custody.ausgegeben_von_benutzer_id,
|
||||
ereignistyp=ereignistyp,
|
||||
entitaet_typ="geraet_instanz",
|
||||
entitaet_id=custody.geraet_instanz_id,
|
||||
neuer_wert={"person_id": custody.person_id, "mit_protokoll": True, "signiert_am": jetzt.isoformat()},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def status(db: AsyncSession, custody_id) -> bool:
|
||||
custody = await db.get(GeraetInstanzCustody, custody_id)
|
||||
if custody is None:
|
||||
return False
|
||||
if custody.zurueckgegeben_am is not None:
|
||||
return custody.rueckgabe_abgeschlossen_am is not None
|
||||
return custody.ausgabe_abgeschlossen_am is not None
|
||||
@@ -0,0 +1,27 @@
|
||||
import io
|
||||
|
||||
from reportlab.graphics import renderPM
|
||||
from reportlab.graphics.barcode import qr
|
||||
from reportlab.graphics.shapes import Drawing
|
||||
|
||||
_ZIELGROESSE = 220
|
||||
|
||||
|
||||
def generiere_qr_png(inhalt: str) -> bytes:
|
||||
"""ASSET-010: rendert einen beliebigen Text (hier: Signier-Seiten-URL) als
|
||||
PNG - reine Bild-Ausgabe statt PDF-Label (label.py), fürs direkte Anzeigen
|
||||
im Ausgabe-Dialog des Portals."""
|
||||
qr_code = qr.QrCodeWidget(inhalt)
|
||||
bounds = qr_code.getBounds()
|
||||
breite = bounds[2] - bounds[0]
|
||||
hoehe = bounds[3] - bounds[1]
|
||||
skalierung = _ZIELGROESSE / max(breite, hoehe)
|
||||
|
||||
zeichnung = Drawing(
|
||||
_ZIELGROESSE, _ZIELGROESSE, transform=[skalierung, 0, 0, skalierung, -bounds[0] * skalierung, -bounds[1] * skalierung]
|
||||
)
|
||||
zeichnung.add(qr_code)
|
||||
|
||||
puffer = io.BytesIO()
|
||||
renderPM.drawToFile(zeichnung, puffer, fmt="PNG")
|
||||
return puffer.getvalue()
|
||||
@@ -0,0 +1,192 @@
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from tests.conftest import auth_header, login
|
||||
|
||||
|
||||
def _signatur_png_bytes() -> bytes:
|
||||
bild = Image.new("RGB", (100, 40), color="white")
|
||||
puffer = io.BytesIO()
|
||||
bild.save(puffer, format="PNG")
|
||||
return puffer.getvalue()
|
||||
|
||||
|
||||
async def _geraet_instanz(client, db_session, objekt_mit_position, token):
|
||||
objekt, material = objekt_mit_position
|
||||
positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(token))
|
||||
position_id = positionen.json()[0]["id"]
|
||||
erstellt = await client.post(
|
||||
f"/api/v1/objektpositionen/{position_id}/geraete",
|
||||
json={"seriennummer": "SN-CUSTODY-1"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
return erstellt.json()["id"]
|
||||
|
||||
|
||||
async def _person(db_session):
|
||||
from app.models.personal import Person
|
||||
|
||||
person = Person(name="Max Mustermann")
|
||||
db_session.add(person)
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
return person
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_formlose_ausgabe_sofort_abgeschlossen(client, db_session, objekt_mit_position, mitarbeiter_user):
|
||||
token = await login(client, "mitarbeiter1")
|
||||
geraet_id = await _geraet_instanz(client, db_session, objekt_mit_position, token)
|
||||
person = await _person(db_session)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/geraete/{geraet_id}/ausgeben",
|
||||
json={"person_id": person.id, "mit_protokoll": False},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["signier_url"] is None
|
||||
assert body["custody"]["ausgabe_abgeschlossen_am"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doppelausgabe_abgelehnt(client, db_session, objekt_mit_position, mitarbeiter_user):
|
||||
token = await login(client, "mitarbeiter1")
|
||||
geraet_id = await _geraet_instanz(client, db_session, objekt_mit_position, token)
|
||||
person_a = await _person(db_session)
|
||||
person_b = await _person(db_session)
|
||||
|
||||
erste = await client.post(
|
||||
f"/api/v1/geraete/{geraet_id}/ausgeben",
|
||||
json={"person_id": person_a.id, "mit_protokoll": False},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert erste.status_code == 201
|
||||
|
||||
zweite = await client.post(
|
||||
f"/api/v1/geraete/{geraet_id}/ausgeben",
|
||||
json={"person_id": person_b.id, "mit_protokoll": False},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert zweite.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ausgabe_mit_protokoll_wartet_auf_signatur(client, db_session, objekt_mit_position, mitarbeiter_user):
|
||||
token = await login(client, "mitarbeiter1")
|
||||
geraet_id = await _geraet_instanz(client, db_session, objekt_mit_position, token)
|
||||
person = await _person(db_session)
|
||||
|
||||
ausgabe = await client.post(
|
||||
f"/api/v1/geraete/{geraet_id}/ausgeben",
|
||||
json={"person_id": person.id, "mit_protokoll": True},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert ausgabe.status_code == 201
|
||||
body = ausgabe.json()
|
||||
assert body["signier_url"] is not None
|
||||
assert body["custody"]["ausgabe_abgeschlossen_am"] is None
|
||||
|
||||
custody_id = body["custody"]["id"]
|
||||
status_antwort = await client.get(f"/api/v1/custody/{custody_id}/status", headers=auth_header(token))
|
||||
assert status_antwort.json()["abgeschlossen"] is False
|
||||
|
||||
signier_token = body["signier_url"].rsplit("/", 1)[-1]
|
||||
|
||||
seite = await client.get(f"/api/v1/signieren/{signier_token}")
|
||||
assert seite.status_code == 200
|
||||
daten = seite.json()
|
||||
assert daten["vorgang"] == "ausgabe"
|
||||
assert daten["person_name"] == "Max Mustermann"
|
||||
|
||||
absenden = await client.post(
|
||||
f"/api/v1/signieren/{signier_token}",
|
||||
files={"signatur": ("sig.png", _signatur_png_bytes(), "image/png")},
|
||||
)
|
||||
assert absenden.status_code == 204
|
||||
|
||||
status_danach = await client.get(f"/api/v1/custody/{custody_id}/status", headers=auth_header(token))
|
||||
assert status_danach.json()["abgeschlossen"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_nur_einmal_verwendbar(client, db_session, objekt_mit_position, mitarbeiter_user):
|
||||
token = await login(client, "mitarbeiter1")
|
||||
geraet_id = await _geraet_instanz(client, db_session, objekt_mit_position, token)
|
||||
person = await _person(db_session)
|
||||
|
||||
ausgabe = await client.post(
|
||||
f"/api/v1/geraete/{geraet_id}/ausgeben",
|
||||
json={"person_id": person.id, "mit_protokoll": True},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
signier_token = ausgabe.json()["signier_url"].rsplit("/", 1)[-1]
|
||||
|
||||
erste = await client.post(
|
||||
f"/api/v1/signieren/{signier_token}",
|
||||
files={"signatur": ("sig.png", _signatur_png_bytes(), "image/png")},
|
||||
)
|
||||
assert erste.status_code == 204
|
||||
|
||||
zweite = await client.post(
|
||||
f"/api/v1/signieren/{signier_token}",
|
||||
files={"signatur": ("sig.png", _signatur_png_bytes(), "image/png")},
|
||||
)
|
||||
assert zweite.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ungueltiger_token_generische_fehlermeldung(client):
|
||||
response = await client.get("/api/v1/signieren/nicht-existent")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ungueltige_signatur_abgelehnt(client, db_session, objekt_mit_position, mitarbeiter_user):
|
||||
token = await login(client, "mitarbeiter1")
|
||||
geraet_id = await _geraet_instanz(client, db_session, objekt_mit_position, token)
|
||||
person = await _person(db_session)
|
||||
|
||||
ausgabe = await client.post(
|
||||
f"/api/v1/geraete/{geraet_id}/ausgeben",
|
||||
json={"person_id": person.id, "mit_protokoll": True},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
signier_token = ausgabe.json()["signier_url"].rsplit("/", 1)[-1]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/signieren/{signier_token}",
|
||||
files={"signatur": ("sig.txt", b"kein-bild", "text/plain")},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
# Token bleibt nach fehlgeschlagener Signatur nutzbar (kein Verbrennen bei
|
||||
# bloß ungültiger Datei).
|
||||
zweiter_versuch = await client.post(
|
||||
f"/api/v1/signieren/{signier_token}",
|
||||
files={"signatur": ("sig.png", _signatur_png_bytes(), "image/png")},
|
||||
)
|
||||
assert zweiter_versuch.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ruecknahme_formlos_und_mit_protokoll(client, db_session, objekt_mit_position, mitarbeiter_user):
|
||||
token = await login(client, "mitarbeiter1")
|
||||
geraet_id = await _geraet_instanz(client, db_session, objekt_mit_position, token)
|
||||
person = await _person(db_session)
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/geraete/{geraet_id}/ausgeben",
|
||||
json={"person_id": person.id, "mit_protokoll": False},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
ruecknahme = await client.post(f"/api/v1/geraete/{geraet_id}/zurueckgeben", headers=auth_header(token))
|
||||
assert ruecknahme.status_code == 200
|
||||
assert ruecknahme.json()["custody"]["rueckgabe_abgeschlossen_am"] is not None
|
||||
assert ruecknahme.json()["signier_url"] is None
|
||||
|
||||
ohne_offene_ausgabe = await client.post(f"/api/v1/geraete/{geraet_id}/zurueckgeben", headers=auth_header(token))
|
||||
assert ohne_offene_ausgabe.status_code == 409
|
||||
@@ -12,6 +12,7 @@ import { KontrollPage } from "./pages/KontrollPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { MangelListePage } from "./pages/MangelListePage";
|
||||
import { ObjektListPage } from "./pages/ObjektListPage";
|
||||
import { SignierPage } from "./pages/SignierPage";
|
||||
|
||||
function GeschuetzteRoute({ children }: { children: ReactNode }) {
|
||||
const { eingeloggt } = useAuth();
|
||||
@@ -55,6 +56,7 @@ export default function App() {
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
<Route path="/signieren/:token" element={<SignierPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<Startseite />} />
|
||||
<Route
|
||||
|
||||
@@ -120,6 +120,21 @@ export async function ladeDokumentErsetzen(felder: {
|
||||
// Download braucht den Auth-Header (kein einfacher <a href>-Link möglich, da
|
||||
// der Server ohne Bearer-Token 401 liefert) - Blob laden und über einen
|
||||
// temporären Objekt-Link im Browser "herunterladen" lassen.
|
||||
// ASSET-010: QR-PNG-Endpunkt ist authentifiziert (kein offener "rendere
|
||||
// beliebigen Text als QR"-Endpunkt) - <img src> kann keinen Bearer-Header
|
||||
// mitschicken, daher Blob laden und als Objekt-URL nutzen (gleiches Muster
|
||||
// wie ladeDokumentHerunter).
|
||||
export async function ladeQrPng(inhalt: string): Promise<string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
||||
const response = await fetch(`${BASE_URL}/qr-png?inhalt=${encodeURIComponent(inhalt)}`, { headers });
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, null);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
export async function ladeDokumentHerunter(dokumentId: string, dateiname: string): Promise<void> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
||||
|
||||
@@ -166,6 +166,27 @@ export interface AkteNotiz {
|
||||
erstellt_am: string;
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CustodyRead {
|
||||
id: string;
|
||||
geraet_instanz_id: string;
|
||||
person_id: number;
|
||||
mit_protokoll: boolean;
|
||||
ausgegeben_am: string;
|
||||
ausgabe_abgeschlossen_am: string | null;
|
||||
zurueckgegeben_am: string | null;
|
||||
rueckgabe_abgeschlossen_am: string | null;
|
||||
}
|
||||
|
||||
export interface CustodyAusgabeResponse {
|
||||
custody: CustodyRead;
|
||||
signier_url: string | null;
|
||||
}
|
||||
|
||||
export interface TankbuchEintrag {
|
||||
id: string;
|
||||
objekt_id: number;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ApiError, apiRequest, ladeQrPng } from "../api/client";
|
||||
import type { CustodyAusgabeResponse, Person } from "../api/types";
|
||||
|
||||
interface Props {
|
||||
geraetInstanzId: string;
|
||||
modus: "ausgeben" | "zurueckgeben";
|
||||
onSchliessen: () => void;
|
||||
onErledigt: () => void;
|
||||
onFehler: (text: string) => void;
|
||||
}
|
||||
|
||||
const POLL_INTERVALL_MS = 3000;
|
||||
|
||||
// ASSET-010: Ausgabe-/Rücknahme-Dialog. Formlos (mit_protokoll=false) schließt
|
||||
// sofort, "mit Protokoll" zeigt einen QR-Code zum Scannen mit dem EIGENEN
|
||||
// Handy des Empfängers und pollt bis die Signatur abgeschlossen ist.
|
||||
export function CustodyDialog({ geraetInstanzId, modus, onSchliessen, onErledigt, onFehler }: Props) {
|
||||
const [personen, setPersonen] = useState<Person[]>([]);
|
||||
const [personId, setPersonId] = useState<number | "">("");
|
||||
const [mitProtokoll, setMitProtokoll] = useState(false);
|
||||
const [wirdGesendet, setWirdGesendet] = useState(false);
|
||||
const [signierUrl, setSignierUrl] = useState<string | null>(null);
|
||||
const [qrObjektUrl, setQrObjektUrl] = useState<string | null>(null);
|
||||
const [custodyId, setCustodyId] = useState<string | null>(null);
|
||||
const [abgeschlossen, setAbgeschlossen] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (modus === "ausgeben") {
|
||||
apiRequest<Person[]>("/personen")
|
||||
.then(setPersonen)
|
||||
.catch(() => onFehler("Personenliste konnte nicht geladen werden."));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!custodyId || abgeschlossen) return;
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const status = await apiRequest<{ abgeschlossen: boolean }>(`/custody/${custodyId}/status`);
|
||||
if (status.abgeschlossen) {
|
||||
setAbgeschlossen(true);
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
onErledigt();
|
||||
}
|
||||
} catch {
|
||||
// Polling-Fehler ignorieren, nächster Tick versucht es erneut
|
||||
}
|
||||
}, POLL_INTERVALL_MS);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [custodyId, abgeschlossen]);
|
||||
|
||||
async function absenden() {
|
||||
setWirdGesendet(true);
|
||||
try {
|
||||
const pfad =
|
||||
modus === "ausgeben"
|
||||
? `/geraete/${geraetInstanzId}/ausgeben`
|
||||
: `/geraete/${geraetInstanzId}/zurueckgeben`;
|
||||
const body = modus === "ausgeben" ? { person_id: personId, mit_protokoll: mitProtokoll } : undefined;
|
||||
const antwort = await apiRequest<CustodyAusgabeResponse>(pfad, { method: "POST", body });
|
||||
if (antwort.signier_url) {
|
||||
setSignierUrl(antwort.signier_url);
|
||||
setCustodyId(antwort.custody.id);
|
||||
ladeQrPng(antwort.signier_url)
|
||||
.then(setQrObjektUrl)
|
||||
.catch(() => onFehler("QR-Code konnte nicht geladen werden."));
|
||||
} else {
|
||||
onErledigt();
|
||||
}
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof ApiError && fehler.status === 409) {
|
||||
onFehler(
|
||||
modus === "ausgeben"
|
||||
? "Gerät ist bereits an eine Person ausgegeben."
|
||||
: "Gerät ist aktuell an niemanden ausgegeben."
|
||||
);
|
||||
} else {
|
||||
onFehler("Vorgang konnte nicht gestartet werden.");
|
||||
}
|
||||
onSchliessen();
|
||||
} finally {
|
||||
setWirdGesendet(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onSchliessen}>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!signierUrl && (
|
||||
<div className="stack">
|
||||
<h2 style={{ marginTop: 0 }}>{modus === "ausgeben" ? "Gerät ausgeben" : "Gerät zurücknehmen"}</h2>
|
||||
{modus === "ausgeben" && (
|
||||
<>
|
||||
<select className="input" value={personId} onChange={(e) => setPersonId(Number(e.target.value) || "")}>
|
||||
<option value="">Person wählen…</option>
|
||||
{personen.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="row" style={{ gap: "0.4rem" }}>
|
||||
<input type="checkbox" checked={mitProtokoll} onChange={(e) => setMitProtokoll(e.target.checked)} />
|
||||
Mit Protokoll + Unterschrift
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<div className="row" style={{ justifyContent: "flex-end", gap: "0.5rem" }}>
|
||||
<button className="btn btn-secondary" onClick={onSchliessen}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={absenden}
|
||||
disabled={wirdGesendet || (modus === "ausgeben" && personId === "")}
|
||||
>
|
||||
{modus === "ausgeben" ? "Ausgeben" : "Zurücknehmen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{signierUrl && !abgeschlossen && (
|
||||
<div className="stack" style={{ textAlign: "center" }}>
|
||||
<h2 style={{ marginTop: 0 }}>Mit eigenem Handy scannen</h2>
|
||||
{qrObjektUrl && (
|
||||
<img
|
||||
src={qrObjektUrl}
|
||||
alt="QR-Code zur Signier-Seite"
|
||||
style={{ width: "220px", height: "220px", margin: "0 auto" }}
|
||||
/>
|
||||
)}
|
||||
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
|
||||
Warte auf Unterschrift… (Link läuft in 5 Minuten ab)
|
||||
</p>
|
||||
<button className="btn btn-secondary" onClick={onSchliessen}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{abgeschlossen && (
|
||||
<div className="stack" style={{ textAlign: "center" }}>
|
||||
<h2 style={{ marginTop: 0 }}>Unterschrieben ✓</h2>
|
||||
<button className="btn btn-primary" onClick={onSchliessen}>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
|
||||
|
||||
interface SeiteDaten {
|
||||
vorgang: string;
|
||||
geraet_bezeichnung: string;
|
||||
person_name: string;
|
||||
ablauf_am: string;
|
||||
}
|
||||
|
||||
// ASSET-010: eigenständige, unauthentifizierte Route (kein AppShell/Login) -
|
||||
// Empfänger öffnet sie auf dem EIGENEN Handy über den QR-Code, unterschreibt
|
||||
// per Touch-Canvas, sendet ab. Läuft über denselben relativen
|
||||
// VITE_API_BASE_URL wie die Haupt-App (Security-Review: gleiche Domain/TLS).
|
||||
export function SignierPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [daten, setDaten] = useState<SeiteDaten | null>(null);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
const [gesendet, setGesendet] = useState(false);
|
||||
const [wirdGesendet, setWirdGesendet] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const zeichnetRef = useRef(false);
|
||||
const hatSignaturRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
fetch(`${BASE_URL}/signieren/${token}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error();
|
||||
return res.json();
|
||||
})
|
||||
.then(setDaten)
|
||||
.catch(() => setFehler("Dieser Link ist ungültig oder abgelaufen."));
|
||||
}, [token]);
|
||||
|
||||
function position(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
}
|
||||
|
||||
function zeichnenStart(e: React.PointerEvent<HTMLCanvasElement>) {
|
||||
zeichnetRef.current = true;
|
||||
hatSignaturRef.current = true;
|
||||
const ctx = canvasRef.current?.getContext("2d");
|
||||
const { x, y } = position(e);
|
||||
ctx?.beginPath();
|
||||
ctx?.moveTo(x, y);
|
||||
}
|
||||
|
||||
function zeichnenWeiter(e: React.PointerEvent<HTMLCanvasElement>) {
|
||||
if (!zeichnetRef.current) return;
|
||||
const ctx = canvasRef.current?.getContext("2d");
|
||||
const { x, y } = position(e);
|
||||
if (ctx) {
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineCap = "round";
|
||||
ctx.strokeStyle = "#000";
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function zeichnenEnde() {
|
||||
zeichnetRef.current = false;
|
||||
}
|
||||
|
||||
function loeschen() {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
hatSignaturRef.current = false;
|
||||
}
|
||||
|
||||
async function absenden() {
|
||||
if (!token || !canvasRef.current || !hatSignaturRef.current) return;
|
||||
setWirdGesendet(true);
|
||||
try {
|
||||
const blob: Blob | null = await new Promise((resolve) => canvasRef.current!.toBlob(resolve, "image/png"));
|
||||
if (!blob) throw new Error();
|
||||
const formData = new FormData();
|
||||
formData.append("signatur", blob, "signatur.png");
|
||||
const response = await fetch(`${BASE_URL}/signieren/${token}`, { method: "POST", body: formData });
|
||||
if (!response.ok) throw new Error();
|
||||
setGesendet(true);
|
||||
} catch {
|
||||
setFehler("Unterschrift konnte nicht übermittelt werden. Link evtl. abgelaufen.");
|
||||
} finally {
|
||||
setWirdGesendet(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (fehler) {
|
||||
return (
|
||||
<main className="page" style={{ maxWidth: "26rem", margin: "3rem auto", textAlign: "center" }}>
|
||||
<p className="text-danger">{fehler}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (gesendet) {
|
||||
return (
|
||||
<main className="page" style={{ maxWidth: "26rem", margin: "3rem auto", textAlign: "center" }}>
|
||||
<h1>Danke!</h1>
|
||||
<p>Ihre Unterschrift wurde übermittelt.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!daten) {
|
||||
return (
|
||||
<main className="page" style={{ maxWidth: "26rem", margin: "3rem auto", textAlign: "center" }}>
|
||||
<p className="text-muted">Lade…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page" style={{ maxWidth: "26rem", margin: "2rem auto" }}>
|
||||
<h1>{daten.vorgang === "ausgabe" ? "Ausgabe bestätigen" : "Rückgabe bestätigen"}</h1>
|
||||
<p>
|
||||
{daten.geraet_bezeichnung} an {daten.person_name}
|
||||
</p>
|
||||
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
|
||||
Bitte hier unterschreiben:
|
||||
</p>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={320}
|
||||
height={160}
|
||||
style={{ border: "1px solid var(--color-border)", touchAction: "none", width: "100%", background: "#fff" }}
|
||||
onPointerDown={zeichnenStart}
|
||||
onPointerMove={zeichnenWeiter}
|
||||
onPointerUp={zeichnenEnde}
|
||||
onPointerLeave={zeichnenEnde}
|
||||
/>
|
||||
<div className="row" style={{ marginTop: "0.75rem", gap: "0.5rem" }}>
|
||||
<button className="btn btn-secondary" onClick={loeschen}>
|
||||
Löschen
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={absenden} disabled={wirdGesendet}>
|
||||
Absenden
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { apiRequest } from "../../api/client";
|
||||
import type { GeraetInstanz } from "../../api/types";
|
||||
import { CustodyDialog } from "../../components/CustodyDialog";
|
||||
|
||||
interface Props {
|
||||
objektpositionId: string;
|
||||
@@ -33,6 +34,9 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate,
|
||||
const [pruefdatum, setPruefdatum] = useState("");
|
||||
const [bemerkung, setBemerkung] = useState("");
|
||||
const [erzeugeFehlbestand, setErzeugeFehlbestand] = useState(false);
|
||||
const [custodyDialog, setCustodyDialog] = useState<{ geraetId: string; modus: "ausgeben" | "zurueckgeben" } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
@@ -116,6 +120,18 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate,
|
||||
)}
|
||||
</span>
|
||||
<div className="row">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setCustodyDialog({ geraetId: instanz.id, modus: "ausgeben" })}
|
||||
>
|
||||
Ausgeben
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setCustodyDialog({ geraetId: instanz.id, modus: "zurueckgeben" })}
|
||||
>
|
||||
Zurücknehmen
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => bearbeiten(instanz)}>
|
||||
{bearbeiteId === instanz.id ? "Schließen" : "Bearbeiten"}
|
||||
</button>
|
||||
@@ -179,6 +195,15 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate,
|
||||
Gerät hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{custodyDialog && (
|
||||
<CustodyDialog
|
||||
geraetInstanzId={custodyDialog.geraetId}
|
||||
modus={custodyDialog.modus}
|
||||
onSchliessen={() => setCustodyDialog(null)}
|
||||
onErledigt={() => setCustodyDialog(null)}
|
||||
onFehler={onFehler}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user