Einzelstuecke hatten bisher keinen Zustand - der Lagerort sagt, wohin ein
Stueck gehoert, aber nicht, woran es gerade ist. Neue Tabelle item_usages
nach dem Vorbild von item_loans: ein Zeitraum je Zustand, ended_at NULL
heisst "laeuft noch", und ein Teilindex laesst nur einen laufenden zu.
"Verfuegbar" wird bewusst nicht gespeichert - keine offene Zeile IST
verfuegbar, wie bei lots.opened_on und beim Verleih. Damit kostet der
Ruhezustand nichts und die Tabelle fuellt sich nicht mit Rauschen.
Abgeschlossene Zeilen bleiben stehen, sie sind der Verlauf.
Eigener Endpunkt (PUT /items/{id}/verwendung) und ausdruecklich nicht
PATCH /items/{id}: Das ist Admins vorbehalten, und die App schickt dort
immer alle Felder mitsamt null - jedes Speichern wuerde den Status
stillschweigend zuruecksetzen. Umstellen darf deshalb jeder angemeldete
Benutzer, wie Verleihen.
Der Zustand aendert den Bestand nicht und schreibt keine Bewegung: ein
getragenes Hemd ist weiterhin im Haushalt, anders als ein verliehenes.
Sichtbar nur bei Kategorien mit usage_tracking. Ein Haken an einer
Oberkategorie wandert in den Unterbaum, weil die Garderobe als
Kategoriebaum ja schon existiert. Die Markierung fahrt in beiden
Sicherungswegen mit; der Verlauf nicht, weil Einzelstuecke dort
ueberhaupt nicht vorkommen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 lines
3.9 KiB
Python
105 lines
3.9 KiB
Python
"""Verwendungsstatus von Einzelstücken: umstellen und Verlauf.
|
||
|
||
Woran ist das Stück gerade? Getragen, in der Wäsche, in Reparatur – oder
|
||
einfach verfügbar. Gedacht für eine Garderobe, aber bewusst allgemein: ein Auto
|
||
in der Waschanlage ist genauso „in Reinigung".
|
||
|
||
Umstellen ist eine **Buchung**, keine Stammdatenänderung – deshalb darf jeder
|
||
angemeldete Benutzer es tun (wie Verleihen, Ein- und Auslagern), und es wird mit
|
||
dem Konto protokolliert. Ausdrücklich **nicht** über ``PATCH /items/{id}``:
|
||
Diese Route ist Admins vorbehalten, und die App schickt dort immer alle Felder
|
||
mitsamt ``null`` (siehe ``ItemUpdateRequest.encode`` in ios/Sources/Models.swift)
|
||
– jedes beliebige Speichern würde den Status stillschweigend zurücksetzen.
|
||
Verleih und Zubehör haben aus demselben Grund eigene Endpunkte.
|
||
|
||
Bewusst **keine** Bewegung (``Movement``), aus demselben Grund wie beim Verleih
|
||
(siehe ``routers/loans.py``). Und bewusst **keine** Auswirkung auf den Bestand:
|
||
Ein getragenes Hemd ist weiterhin im Haushalt – anders als ein verliehenes.
|
||
"""
|
||
|
||
from datetime import UTC, datetime
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy.orm import Session, joinedload
|
||
|
||
from ..database import get_db
|
||
from ..deps import get_current_user
|
||
from ..models import Item, ItemUsage, User
|
||
from ..schemas import ItemOut, VerwendungOut, VerwendungSetzen
|
||
from ..services.items import item_to_out, verwendung_to_out
|
||
|
||
router = APIRouter(tags=["verwendung"])
|
||
|
||
|
||
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 _laufend(db: Session, item_id: int) -> ItemUsage | None:
|
||
"""Der laufende Zeitraum dieses Stücks, falls einer offen ist."""
|
||
return (
|
||
db.query(ItemUsage)
|
||
.filter(ItemUsage.item_id == item_id, ItemUsage.ended_at.is_(None))
|
||
.first()
|
||
)
|
||
|
||
|
||
@router.put("/items/{item_id}/verwendung", response_model=ItemOut)
|
||
def setze_verwendung(
|
||
item_id: int,
|
||
payload: VerwendungSetzen,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> ItemOut:
|
||
"""Verwendungszustand setzen. Leerer Status = verfügbar.
|
||
|
||
Eine Route für alle Zustände, auch für den Ruhezustand: „verfügbar" ist
|
||
nichts anderes als „kein laufender Zeitraum", also beendet der leere Status
|
||
nur den aktuellen. Damit ist der Aufruf idempotent.
|
||
|
||
Antwortet mit dem ganzen Stück, damit die Oberflächen nach der Buchung nicht
|
||
noch einmal laden müssen.
|
||
"""
|
||
item = _item_or_404(db, item_id)
|
||
neu = payload.status.value if payload.status is not None else None
|
||
laufend = _laufend(db, item.id)
|
||
|
||
if laufend is not None and laufend.status == neu:
|
||
# Zweimal dasselbe angetippt – sonst entstünde ein Zeitraum der Länge 0.
|
||
return item_to_out(item)
|
||
|
||
if laufend is not None:
|
||
laufend.ended_at = datetime.now(UTC)
|
||
# Vor dem Einfügen schreiben: Sonst darf SQLAlchemy das INSERT vor das
|
||
# UPDATE stellen, und dann liegen kurzzeitig zwei offene Zeiträume an –
|
||
# der Teilindex uq_item_usage_offen schlägt zu.
|
||
db.flush()
|
||
|
||
if neu is not None:
|
||
db.add(ItemUsage(item_id=item.id, status=neu, user_id=user.id))
|
||
|
||
db.commit()
|
||
db.refresh(item)
|
||
return item_to_out(item)
|
||
|
||
|
||
@router.get("/items/{item_id}/verwendungen", response_model=list[VerwendungOut])
|
||
def list_verwendungen(
|
||
item_id: int,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(get_current_user),
|
||
) -> list[VerwendungOut]:
|
||
"""Der ganze Verlauf dieses Stücks, neueste zuerst."""
|
||
_item_or_404(db, item_id)
|
||
rows = (
|
||
db.query(ItemUsage)
|
||
.options(joinedload(ItemUsage.user))
|
||
.filter(ItemUsage.item_id == item_id)
|
||
.order_by(ItemUsage.started_at.desc(), ItemUsage.id.desc())
|
||
.all()
|
||
)
|
||
return [verwendung_to_out(u) for u in rows]
|