diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py index e270523..16d0b7a 100644 --- a/backend/app/routers/locations.py +++ b/backend/app/routers/locations.py @@ -10,6 +10,20 @@ from ..schemas import LocationCreate, LocationOut, LocationUpdate router = APIRouter(prefix="/locations", tags=["locations"]) +def _descendant_ids(db: Session, location_id: int) -> set[int]: + """Alle Unterorte (rekursiv) – als Ziel beim Umhängen ausgeschlossen, sonst + entstünde ein Ring.""" + result: set[int] = set() + stack = [location_id] + while stack: + cur = stack.pop() + for kid in db.query(Location).filter(Location.parent_id == cur).all(): + if kid.id not in result: + result.add(kid.id) + stack.append(kid.id) + return result + + @router.get("", response_model=list[LocationOut]) def list_locations( db: Session = Depends(get_db), _: User = Depends(get_current_user) @@ -37,21 +51,41 @@ def update_location( db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> Location: - """Umbenennen. Chargen haengen an der ID, behalten ihren Lagerort also.""" + """Umbenennen und/oder umhängen. Chargen haengen an der ID, behalten ihren + Lagerort also.""" loc = db.get(Location, location_id) if loc is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden") - name = payload.name.strip() - doppelt = ( - db.query(Location) - .filter(func.lower(Location.name) == name.lower(), Location.id != location_id) - .first() - ) - if doppelt is not None: - raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Lagerort gibt es bereits") + data = payload.model_dump(exclude_unset=True) + + if "name" in data and data["name"]: + name = data["name"].strip() + doppelt = ( + db.query(Location) + .filter(func.lower(Location.name) == name.lower(), Location.id != location_id) + .first() + ) + if doppelt is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Lagerort gibt es bereits") + loc.name = name + + # parent_id nur anfassen, wenn ausdrücklich mitgeschickt (None = oberste Ebene). + if "parent_id" in data: + neu = data["parent_id"] + if neu is not None: + if db.get(Location, neu) is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, "Übergeordneter Lagerort nicht gefunden" + ) + if neu == location_id or neu in _descendant_ids(db, location_id): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Ein Lagerort kann nicht sich selbst oder einem seiner Unterorte " + "untergeordnet werden.", + ) + loc.parent_id = neu - loc.name = name db.commit() db.refresh(loc) return loc diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 4b8885b..9e5de7e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -235,7 +235,8 @@ class LocationCreate(BaseModel): class LocationUpdate(BaseModel): - name: str = Field(min_length=1, max_length=120) + name: str | None = Field(default=None, min_length=1, max_length=120) + parent_id: int | None = None # ---- Gebinde (Packung, Glas, …) ---- diff --git a/backend/tests/test_locations.py b/backend/tests/test_locations.py new file mode 100644 index 0000000..65f0bde --- /dev/null +++ b/backend/tests/test_locations.py @@ -0,0 +1,71 @@ +"""Lagerorte: Umbenennen und Umhängen (mit Schutz vor Ringen).""" + +import pytest +from fastapi import HTTPException + +from app.models import Location, Role, User +from app.routers.locations import update_location +from app.schemas import LocationUpdate + + +@pytest.fixture() +def admin(db): + person = User(username="admin", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +def _orte(db): + """Keller → Regal → Fach.""" + keller = Location(name="Keller") + db.add(keller) + db.flush() + regal = Location(name="Regal", parent_id=keller.id) + db.add(regal) + db.flush() + fach = Location(name="Fach", parent_id=regal.id) + db.add(fach) + db.commit() + return keller, regal, fach + + +def test_umbenennen_ohne_parent_bleibt_hierarchie(db, admin): + keller, regal, _ = _orte(db) + out = update_location(regal.id, LocationUpdate(name="Regal links"), db=db, _=admin) + assert out.name == "Regal links" + assert out.parent_id == keller.id # Umhängen war nicht gemeint + + +def test_umhaengen_setzt_neuen_parent(db, admin): + keller, _, fach = _orte(db) + out = update_location(fach.id, LocationUpdate(parent_id=keller.id), db=db, _=admin) + assert out.parent_id == keller.id + + +def test_umhaengen_auf_oberste_ebene(db, admin): + _, regal, _ = _orte(db) + out = update_location(regal.id, LocationUpdate(parent_id=None), db=db, _=admin) + assert out.parent_id is None + + +def test_umhaengen_auf_sich_selbst_wird_abgelehnt(db, admin): + keller, _, _ = _orte(db) + with pytest.raises(HTTPException) as ex: + update_location(keller.id, LocationUpdate(parent_id=keller.id), db=db, _=admin) + assert ex.value.status_code == 400 + + +def test_umhaengen_in_eigenen_unterort_wird_abgelehnt(db, admin): + keller, _, fach = _orte(db) + with pytest.raises(HTTPException) as ex: + update_location(keller.id, LocationUpdate(parent_id=fach.id), db=db, _=admin) + assert ex.value.status_code == 400 + + +def test_umhaengen_auf_unbekannten_ort_ist_404(db, admin): + _, _, fach = _orte(db) + with pytest.raises(HTTPException) as ex: + update_location(fach.id, LocationUpdate(parent_id=99999), db=db, _=admin) + assert ex.value.status_code == 404 diff --git a/web/src/api.js b/web/src/api.js index ca8b716..0722401 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -204,6 +204,7 @@ export const api = { // Stammdaten listLocations: () => request("/locations"), createLocation: (body) => request("/locations", { method: "POST", body }), + updateLocation: (id, body) => request(`/locations/${id}`, { method: "PATCH", body }), deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }), // Kategorien: Ordnungshilfe + Verwaltungsart (food/object), verschachtelbar diff --git a/web/src/pages/Locations.jsx b/web/src/pages/Locations.jsx index 8ec189b..6b8244d 100644 --- a/web/src/pages/Locations.jsx +++ b/web/src/pages/Locations.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import { useConfirm } from "../confirm"; import Icon from "../components/Icon"; +import CategorySelect from "../components/CategorySelect"; import { QrImg, printQrLabels } from "../qr"; export default function Locations() { @@ -10,6 +11,10 @@ export default function Locations() { const [name, setName] = useState(""); const [parentId, setParentId] = useState(""); const [error, setError] = useState(null); + // Inline-Bearbeiten (Umbenennen + Umhängen) eines vorhandenen Orts. + const [editId, setEditId] = useState(null); + const [editName, setEditName] = useState(""); + const [editParent, setEditParent] = useState(null); async function load() { try { @@ -34,6 +39,44 @@ export default function Locations() { } } + function startEdit(l) { + setEditId(l.id); + setEditName(l.name); + setEditParent(l.parent_id ?? null); + setError(null); + } + + function cancelEdit() { + setEditId(null); + } + + async function saveEdit() { + setError(null); + try { + await api.updateLocation(editId, { + name: editName.trim(), + parent_id: editParent == null ? null : Number(editParent), + }); + setEditId(null); + load(); + } catch (err) { + setError(err.message); + } + } + + // Eigene Unterorte (rekursiv) – als Ziel beim Umhängen ausgeschlossen. + function descendantIds(id) { + const result = new Set(); + const stack = [id]; + while (stack.length) { + const cur = stack.pop(); + for (const c of locations.filter((l) => l.parent_id === cur)) { + if (!result.has(c.id)) { result.add(c.id); stack.push(c.id); } + } + } + return result; + } + async function remove(id) { const ok = await confirm({ title: "Lagerort löschen?", @@ -65,6 +108,11 @@ export default function Locations() { } for (const r of locations.filter(isRoot)) walk(r, 0); + // Mögliche neue Elternorte beim Bearbeiten: alle außer dem Ort selbst und + // seinen Unterorten (sonst entstünde ein Ring). + const editDesc = editId != null ? descendantIds(editId) : new Set(); + const editParentNodes = ordered.filter((o) => o.id !== editId && !editDesc.has(o.id)); + return (
@@ -106,22 +154,51 @@ export default function Locations() {