Verwendungsstatus: Backend fuer "getragen / in Reinigung / in Reparatur"

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>
This commit is contained in:
Scarriffle
2026-10-20 21:47:02 +02:00
co-authored by Claude Opus 5
parent 8c752e3d1f
commit 98e2d18142
10 changed files with 691 additions and 6 deletions
+22 -1
View File
@@ -85,9 +85,17 @@ def create_category(
tracking = parent.tracking
else:
tracking = CategoryTracking.object.value
# Verwendungsstatus genauso: gewaehlt > vom Elternteil geerbt > aus.
# Eine Unterkategorie von "Kleidung" soll ihn ohne Zutun fuehren.
if payload.usage_tracking is not None:
usage_tracking = payload.usage_tracking
elif parent is not None:
usage_tracking = parent.usage_tracking
else:
usage_tracking = False
category = Category(
name=payload.name, parent_id=payload.parent_id, tracking=tracking,
open_days=payload.open_days,
open_days=payload.open_days, usage_tracking=usage_tracking,
)
db.add(category)
db.commit()
@@ -125,6 +133,19 @@ def update_category(
data["tracking"] = CategoryTracking(data["tracking"]).value
for field, value in data.items():
setattr(category, field, value)
# Der Verwendungsstatus wandert in den ganzen Unterbaum. Ohne das erreichte
# ein Haken an einer bestehenden Kategorie ("Kleidung") deren vorhandene
# Unterkategorien nicht - und genau die gibt es ja schon. Bewusst sichtbar:
# Die Haken stehen danach auch dort und lassen sich einzeln wieder abwaehlen.
if data.get("usage_tracking") is not None:
kinder = descendant_ids(db, category_id)
if kinder:
(
db.query(Category)
.filter(Category.id.in_(kinder))
.update({"usage_tracking": data["usage_tracking"]},
synchronize_session=False)
)
db.commit()
db.refresh(category)
return _to_out(db, category)
+6 -1
View File
@@ -9,7 +9,7 @@ import re
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm import Session, joinedload, selectinload
from ..database import get_db
from ..deps import get_current_user, require_admin
@@ -17,6 +17,7 @@ from ..models import (
Item,
ItemDocument,
ItemLoan,
ItemUsage,
Location,
Movement,
MovementType,
@@ -166,6 +167,10 @@ def list_all_items(
# Verleihe samt Konto mitladen: Sonst fragt item_to_out je
# verliehenem Stueck einzeln nach (N+1).
joinedload(Item.loans).joinedload(ItemLoan.user),
# selectin und nicht joined: Hier haengen schon drei Sammlungen
# dran, eine vierte vervielfachte die Zeilen ueber Kreuz. So kostet
# es eine zusaetzliche Abfrage statt eines Kreuzprodukts.
selectinload(Item.usages).joinedload(ItemUsage.user),
)
.order_by(Item.id)
.all()
+8 -1
View File
@@ -200,7 +200,11 @@ def export_backup_json(
# Kategorien mit Verwaltungsart, damit Lebensmittel/Gegenstände beim
# Wiederherstellen erhalten bleiben (auch leere Kategorien).
"categories": [
{"path": _category_path(db, c), "tracking": c.tracking}
{
"path": _category_path(db, c),
"tracking": c.tracking,
"usage_tracking": c.usage_tracking,
}
for c in db.query(Category).order_by(Category.id).all()
],
# Bezugsquellen (nur für Gegenstände).
@@ -963,6 +967,9 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
)
if cat is not None and entry.get("tracking"):
cat.tracking = entry["tracking"]
# Fehlt der Schluessel (aelteres Backup), bleibt die Markierung aus.
if cat is not None:
cat.usage_tracking = bool(entry.get("usage_tracking", False))
for entry in data.get("shops", []):
_get_or_create_shop(db, entry.get("name"), entry.get("website"))
db.flush()
+104
View File
@@ -0,0 +1,104 @@
"""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]