"""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." ) @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) 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 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()