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:
co-authored by
Claude Opus 5
parent
8c752e3d1f
commit
98e2d18142
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user