Files
Vorrania/backend/app/routers/loans.py
T
ScarriffleandClaude Opus 5 a3fd9819c4 Rueckgabe sichtbar machen, Zubehoerteile am Einzelstueck
RUECKGABE
Der Knopf war da, nur nicht zu finden: ein 16-px-Symbol in einer Spalte ohne
Ueberschrift, 52 px breit, ganz rechts in einer horizontal scrollenden Tabelle.
Beschriftet war er allein ueber title= - ein Tooltip, den auf einem Touchgeraet
nie jemand sieht.

Jetzt traegt er Text ("Zuruecknehmen"), die Spalte heisst "Verleih" und ist
breit genug, und beide Knoepfe haben ein aria-label. Dazu eine neue Seite
"Verliehen" in der Seitenleiste: alles Draussene an einem Ort, Rueckgabe direkt
aus der Liste. Bisher gab es diese Uebersicht nur als zwei Dashboard-Karten, und
die stehen nicht im Standard-Layout - wer sie nicht selbst hinzufuegte, hatte
gar keine.

In der App fuehrte der einzige Weg ueber das Bearbeiten-Formular des Stuecks;
jetzt geht es per Wischgeste und Kontextmenue direkt aus der Liste. Der
Verleih-Filter dort sass hinter einem nackten Symbol und ist beschriftet.

Zurueckgenommen wird nur noch nach Rueckfrage, in Web und App.

ZUBEHOER
Ein Netzteil gehoert zu einem Laptop: items.parent_item_id, genau ein Geraet je
Teil, beliebig viele Teile je Geraet. Eine Spalte genuegt dafuer, es braucht
keine Verknuepfungstabelle. ON DELETE SET NULL, damit das Netzteil den Laptop
ueberlebt statt mitgeloescht zu werden - und weil SQLite Fremdschluessel nicht
erzwingt, loest der Router die Zuordnung beim Loeschen zusaetzlich selbst.

Ringe (A traegt B, B traegt A) weist die Zuordnung mit 409 ab; die Elternkette
wird dafuer hochgelaufen, wie beim Kategoriebaum.

Die Beziehung laeuft ueber eigene Endpunkte und bewusst NICHT ueber ItemUpdate:
dessen Schema sendet immer alle Felder mit, auch leere, damit sich Felder
loeschen lassen. Genau so gingen schon einmal Kaufpreis und Waehrung verloren,
als ein Aufrufer ein Feld vergass.

Beim Verleihen fragt die Oberflaeche, welche Teile mitgehen (vorausgewaehlt:
alles, was im Haus ist), beim Zuruecknehmen kommen sie zusammen zurueck. Ohne
das Spiegelbild kaeme das Geraet zurueck und sein Netzteil bliebe ewig
"verliehen", weil niemand mehr danach sucht. Ungefragt nimmt der Server nie
etwas mit.

In der App sind zubehoer und gehoert_zu optional dekodiert: Die App spricht mit
mehreren Serverprofilen, und ein aelterer Server schickt die Felder nicht - ein
Vorgabewert reicht dafuer nicht, Swift nimmt ihn beim Dekodieren nicht her.

12 neue Tests (320 gesamt). Der Umlauf ist ausserdem gegen ein lokales Backend
im Browser durchgespielt: verleihen mit Zubehoer, Rueckfrage, beide zusammen
zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:08:11 +02:00

258 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Verleih von Einzelstücken: herausgeben, zurücknehmen, Übersicht, Historie.
Verleihen ist eine **Buchung**, keine Stammdatenänderung – deshalb darf jeder
angemeldete Benutzer es tun (wie Ein- und Auslagern), und es wird mit dem Konto
protokolliert. Nur das Löschen eines Vorgangs bleibt Admins vorbehalten, weil es
Historie vernichtet; korrigieren (PATCH) darf jeder.
Bewusst **keine** Bewegung (``Movement``) je Verleih: Der Verlauf im Dashboard
rechnet den Bestand rückwärts aus vorzeichenbehafteten Bewegungen. Ein
Verleih-Abgang ohne Gegenbuchung würde diese Kurve verfälschen. Die Tabelle
``item_loans`` ist die Historie.
"""
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Item, ItemLoan, User
from ..schemas import LoanCreate, LoanOut, LoanReturn, LoanUpdate
from ..services.loans import loan_to_out, offener_verleih
router = APIRouter(tags=["loans"])
def _item_or_404(db: Session, item_id: int) -> Item:
item = db.get(Item, item_id)
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einzelstück nicht gefunden")
return item
def _loan_or_404(db: Session, loan_id: int) -> ItemLoan:
loan = db.get(ItemLoan, loan_id)
if loan is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Verleih nicht gefunden")
return loan
def _pruefe_zeitfolge(lent_on: date, due_on: date | None, returned_on: date | None) -> None:
"""Rückgabe und Frist dürfen nicht vor der Herausgabe liegen."""
if due_on is not None and due_on < lent_on:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Rückgabedatum liegt vor dem Verleihdatum."
)
if returned_on is not None and returned_on < lent_on:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Rückgabe liegt vor dem Verleihdatum."
)
def _zubehoer_pruefen(db: Session, geraet: Item, ids: list[int]) -> list[Item]:
"""Die benannten Teile holen und pruefen, dass sie zu diesem Geraet gehoeren.
Ohne die Pruefung koennte ein Aufrufer ueber diesen Weg ein beliebiges
fremdes Stueck mitverleihen.
"""
teile: list[Item] = []
for teil_id in dict.fromkeys(ids):
teil = db.get(Item, teil_id)
if teil is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Zubehörteil nicht gefunden"
)
if teil.parent_item_id != geraet.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"„{teil.uid}“ ist kein Zubehör von „{geraet.uid}“",
)
teile.append(teil)
return teile
@router.post(
"/items/{item_id}/loan", response_model=LoanOut, status_code=status.HTTP_201_CREATED
)
def lend_item(
item_id: int,
payload: LoanCreate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> LoanOut:
"""Einzelstück verleihen. Der Lagerort bleibt stehen – dorthin gehört es zurück."""
item = _item_or_404(db, item_id)
if offener_verleih(db, item.id) is not None:
raise HTTPException(
status.HTTP_409_CONFLICT, "Dieses Einzelstück ist bereits verliehen."
)
name = payload.borrower.strip()
if not name:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Bitte einen Namen angeben.")
lent_on = payload.lent_on or date.today()
_pruefe_zeitfolge(lent_on, payload.due_on, None)
loan = ItemLoan(
item_id=item.id,
borrower=name,
reason=(payload.reason or "").strip() or None,
lent_on=lent_on,
due_on=payload.due_on,
user_id=user.id,
)
db.add(loan)
# Zubehoer geht nur mit, wenn es ausdruecklich benannt wurde - die
# Oberflaeche fragt danach. Ein Netzteil, das im Schrank bleibt, darf nicht
# als verliehen gefuehrt werden, nur weil es am Geraet haengt.
for teil in _zubehoer_pruefen(db, item, payload.mit_zubehoer):
if offener_verleih(db, teil.id) is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"„{teil.uid}“ ist bereits verliehen.",
)
db.add(
ItemLoan(
item_id=teil.id,
borrower=name,
reason=(payload.reason or "").strip() or None,
lent_on=lent_on,
due_on=payload.due_on,
user_id=user.id,
)
)
db.commit()
db.refresh(loan)
return loan_to_out(loan)
@router.post("/items/{item_id}/return", response_model=LoanOut)
def return_item(
item_id: int,
payload: LoanReturn,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> LoanOut:
"""Rückgabe buchen. Das Stück zählt danach wieder an seinem Lagerort."""
item = _item_or_404(db, item_id)
loan = offener_verleih(db, item.id)
if loan is None:
raise HTTPException(
status.HTTP_409_CONFLICT, "Dieses Einzelstück ist nicht verliehen."
)
returned_on = payload.returned_on or date.today()
_pruefe_zeitfolge(loan.lent_on, loan.due_on, returned_on)
loan.returned_on = returned_on
# Spiegelbild zum Verleihen: Ohne das kaeme das Geraet zurueck und sein
# Netzteil bliebe ewig „verliehen“, weil niemand mehr danach sucht.
for teil in _zubehoer_pruefen(db, item, payload.mit_zubehoer):
offen = offener_verleih(db, teil.id)
if offen is None:
continue # war gar nicht mit draussen – kein Fehler, nur nichts zu tun
_pruefe_zeitfolge(offen.lent_on, offen.due_on, returned_on)
offen.returned_on = returned_on
db.commit()
db.refresh(loan)
return loan_to_out(loan)
@router.get("/items/{item_id}/loans", response_model=list[LoanOut])
def item_loans(
item_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[LoanOut]:
"""Verleih-Historie eines Stücks, neueste zuerst."""
item = _item_or_404(db, item_id)
return [loan_to_out(loan) for loan in item.loans]
@router.get("/loans", response_model=list[LoanOut])
def list_loans(
open_only: bool = True,
due_in: int | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[LoanOut]:
"""Verleihe – standardmäßig nur die offenen, fälligste zuerst.
``due_in`` grenzt auf die ein, deren Rückgabe in so vielen Tagen ansteht;
schon überfällige sind dabei immer enthalten (wie bei ``/expiring``). Ohne
Rückgabedatum fällt ein Verleih damit aus der Liste – gewollt: Er ist nie
fällig. Diese Route ist die Datenquelle für die Übersicht und für die
Rückgabe-Erinnerung der App.
"""
query = (
db.query(ItemLoan)
.options(
joinedload(ItemLoan.user),
joinedload(ItemLoan.item).joinedload(Item.product),
joinedload(ItemLoan.item).joinedload(Item.location),
)
)
if open_only:
query = query.filter(ItemLoan.returned_on.is_(None))
if due_in is not None:
grenze = date.today() + timedelta(days=max(0, due_in))
query = query.filter(ItemLoan.due_on.isnot(None), ItemLoan.due_on <= grenze)
# Fälligste zuerst; Verleihe ohne Frist hinten nach Verleihdatum.
rows = query.order_by(
ItemLoan.due_on.is_(None), ItemLoan.due_on.asc(), ItemLoan.lent_on.asc()
).all()
heute = date.today()
return [loan_to_out(loan, heute) for loan in rows]
@router.patch("/loans/{loan_id}", response_model=LoanOut)
def update_loan(
loan_id: int,
payload: LoanUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> LoanOut:
"""Verleih korrigieren – Name, Grund, Daten. Auch nachträglich zurückbuchen."""
loan = _loan_or_404(db, loan_id)
data = payload.model_dump(exclude_unset=True)
if "borrower" in data:
name = (data["borrower"] or "").strip()
if not name:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Bitte einen Namen angeben.")
data["borrower"] = name
if "reason" in data:
data["reason"] = (data["reason"] or "").strip() or None
if data.get("lent_on") is None:
data.pop("lent_on", None) # Verleihdatum kann nicht geleert werden
_pruefe_zeitfolge(
data.get("lent_on", loan.lent_on),
data.get("due_on", loan.due_on),
data.get("returned_on", loan.returned_on),
)
# Wieder-Öffnen nur, solange kein anderer Verleih des Stücks offen ist.
if "returned_on" in data and data["returned_on"] is None:
offen = offener_verleih(db, loan.item_id)
if offen is not None and offen.id != loan.id:
raise HTTPException(
status.HTTP_409_CONFLICT, "Dieses Einzelstück ist bereits verliehen."
)
for field, value in data.items():
setattr(loan, field, value)
db.commit()
db.refresh(loan)
return loan_to_out(loan)
@router.delete("/loans/{loan_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_loan(
loan_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Verleih ganz entfernen (Fehleingabe). Löscht Historie – daher nur Admin."""
db.delete(_loan_or_404(db, loan_id))
db.commit()