diff --git a/backend/app/main.py b/backend/app/main.py index f51d0d2..b0b3bab 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ from .routers import ( categories, groups, locations, + maintenance, products, settings as settings_router, stock, @@ -105,3 +106,4 @@ app.include_router(api_tokens.router) app.include_router(settings_router.router) app.include_router(branding.router) app.include_router(categories.router) +app.include_router(maintenance.router) diff --git a/backend/app/routers/maintenance.py b/backend/app/routers/maintenance.py new file mode 100644 index 0000000..e33a206 --- /dev/null +++ b/backend/app/routers/maintenance.py @@ -0,0 +1,121 @@ +"""Gefahrenbereich: Daten in großem Stil löschen. + +Bewusst in drei Stufen, damit man nicht mehr wegwirft als nötig: + +1. ``/reset/stock`` – Chargen und Bewegungsverlauf. Der Katalog bleibt. +2. ``/reset/products`` – zusätzlich alle Artikel samt EAN-Codes. +3. ``/reset/all`` – zusätzlich Gruppen, Kategorien, Lagerorte, Einheiten. + +Benutzer, API-Tokens, Einstellungen und das eigene Logo bleiben in **allen** +Stufen erhalten: Sonst würde man sich aussperren und müsste externe Zugriffe +(Home Assistant) neu einrichten. + +Nach Stufe 3 werden die eingebauten Einheiten und die Kategorie-Startliste +sofort neu angelegt – ohne Einheiten ließe sich nichts mehr einlagern. +""" + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import require_admin +from ..models import ( + Barcode, + Category, + Group, + Location, + Lot, + Movement, + Product, + Unit, + User, +) +from ..seed import ensure_builtin_categories, ensure_builtin_units + +router = APIRouter(prefix="/maintenance", tags=["maintenance"]) + + +class ResetSummary(BaseModel): + """Was gerade in der Datenbank steht – für die Rückfrage vor dem Löschen.""" + lots: int = 0 + movements: int = 0 + products: int = 0 + groups: int = 0 + categories: int = 0 + locations: int = 0 + units: int = 0 + + +def _summary(db: Session) -> ResetSummary: + return ResetSummary( + lots=db.query(Lot).count(), + movements=db.query(Movement).count(), + products=db.query(Product).count(), + groups=db.query(Group).count(), + categories=db.query(Category).count(), + locations=db.query(Location).count(), + units=db.query(Unit).count(), + ) + + +def _delete_stock(db: Session) -> None: + """Chargen und Verlauf. Zuerst die Bewegungen, sie verweisen auf Chargen.""" + db.query(Movement).delete(synchronize_session=False) + db.query(Lot).delete(synchronize_session=False) + + +def _delete_products(db: Session) -> None: + _delete_stock(db) + # EAN-Codes hängen an Artikeln UND an Gruppen. Hier gehen nur die der + # Artikel weg; die Gruppen-Codes bleiben, solange die Gruppen bleiben. + db.query(Barcode).filter(Barcode.product_id.isnot(None)).delete( + synchronize_session=False + ) + db.query(Product).delete(synchronize_session=False) + + +@router.get("/summary", response_model=ResetSummary) +def summary(db: Session = Depends(get_db), _: User = Depends(require_admin)) -> ResetSummary: + return _summary(db) + + +@router.post("/reset/stock", response_model=ResetSummary) +def reset_stock( + db: Session = Depends(get_db), _: User = Depends(require_admin) +) -> ResetSummary: + """Alle Bestände auf null – Artikel, Gruppen und Stammdaten bleiben.""" + _delete_stock(db) + db.commit() + return _summary(db) + + +@router.post("/reset/products", response_model=ResetSummary) +def reset_products( + db: Session = Depends(get_db), _: User = Depends(require_admin) +) -> ResetSummary: + """Katalog leeren: Artikel samt Chargen, Verlauf und ihren EAN-Codes.""" + _delete_products(db) + db.commit() + return _summary(db) + + +@router.post("/reset/all", response_model=ResetSummary) +def reset_all( + db: Session = Depends(get_db), _: User = Depends(require_admin) +) -> ResetSummary: + """Zusätzlich Gruppen, Kategorien, Lagerorte und Einheiten.""" + _delete_products(db) + # Restliche EAN-Codes (die an Gruppen hängen) mitnehmen. + db.query(Barcode).delete(synchronize_session=False) + db.query(Group).delete(synchronize_session=False) + db.query(Category).delete(synchronize_session=False) + db.query(Location).delete(synchronize_session=False) + db.query(Unit).delete(synchronize_session=False) + db.commit() + + # Ohne Einheiten wäre die Installation unbrauchbar, deshalb sofort zurück + # auf den Auslieferungsstand. + ensure_builtin_units(db) + ensure_builtin_categories(db) + return _summary(db) diff --git a/backend/app/services/group_codes.py b/backend/app/services/group_codes.py index f04657f..1ec21be 100644 --- a/backend/app/services/group_codes.py +++ b/backend/app/services/group_codes.py @@ -2,8 +2,8 @@ Bisher entstand ein Gruppen-Code ausschließlich von Hand. Wurde ein Artikel einer Gruppe zugeordnet, blieb dessen EAN-Code dort unsichtbar – die Gruppe -zeigte „0 EANs", obwohl Artikel darin lagen, und der Versuch, den Code -nachzutragen, scheiterte mit „bereits vergeben" (er hing ja am Artikel). +zeigte „0 EANs“, obwohl Artikel darin lagen, und der Versuch, den Code +nachzutragen, scheiterte mit „bereits vergeben“ (er hing ja am Artikel). Jetzt wandert der Haupt-Barcode eines Artikels automatisch an die Gruppe: diff --git a/backend/tests/test_maintenance.py b/backend/tests/test_maintenance.py new file mode 100644 index 0000000..9165e82 --- /dev/null +++ b/backend/tests/test_maintenance.py @@ -0,0 +1,67 @@ +"""Gefahrenbereich: die drei Löschstufen. + +Wichtig ist nicht nur, dass gelöscht wird, sondern auch, was stehen bleibt – +Benutzer und Zugänge dürfen nie verschwinden, sonst sperrt man sich aus. +""" + +from app.models import Barcode, Category, Group, Location, Lot, Movement, MovementType, Product, Unit +from app.routers.maintenance import _delete_products, _delete_stock +from app.seed import ensure_builtin_categories + + +def _bestand(db, rice): + """Ein Artikel mit Charge, Bewegung, EAN-Code, Gruppe und Lagerort.""" + gruppe = Group(name="Mehl") + ort = Location(name="Keller") + db.add_all([gruppe, ort]) + db.flush() + + rice.group_id = gruppe.id + lot = Lot(product_id=rice.id, quantity=500) + db.add(lot) + db.flush() + db.add(Movement(product_id=rice.id, lot_id=lot.id, type=MovementType.in_, + quantity=500, unit_used="g")) + db.add(Barcode(code="4001", product_id=rice.id)) + db.add(Barcode(code="4002", group_id=gruppe.id)) + db.commit() + + +def test_bestaende_loeschen_laesst_artikel_stehen(db, rice): + _bestand(db, rice) + _delete_stock(db) + db.commit() + + assert db.query(Lot).count() == 0 + assert db.query(Movement).count() == 0 + # Der Katalog bleibt vollstaendig erhalten. + assert db.query(Product).count() == 1 + assert db.query(Group).count() == 1 + assert db.query(Barcode).count() == 2 + + +def test_artikel_loeschen_laesst_gruppen_und_einheiten_stehen(db, rice): + _bestand(db, rice) + _delete_products(db) + db.commit() + + assert db.query(Product).count() == 0 + assert db.query(Lot).count() == 0 + # Der Artikel-Code ist weg, der Gruppen-Code bleibt. + assert [b.code for b in db.query(Barcode).all()] == ["4002"] + assert db.query(Group).count() == 1 + assert db.query(Location).count() == 1 + assert db.query(Unit).count() > 0 + + +def test_startkategorien_kommen_nach_dem_leeren_zurueck(db): + ensure_builtin_categories(db) + assert db.query(Category).count() > 0 + + db.query(Category).delete(synchronize_session=False) + db.commit() + assert db.query(Category).count() == 0 + + # Genau das macht /maintenance/reset/all im Anschluss. + ensure_builtin_categories(db) + assert db.query(Category).count() > 0 diff --git a/web/public/favicon.svg b/web/public/favicon.svg index 07558be..585ff47 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1 +1,18 @@ - \ No newline at end of file + diff --git a/web/src/api.js b/web/src/api.js index 87322e2..33563ad 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -186,6 +186,12 @@ export const api = { }, deleteBranding: (kind) => request(`/branding/${kind}`, { method: "DELETE" }), + // Gefahrenbereich (nur Admin) + maintenanceSummary: () => request("/maintenance/summary"), + resetStock: () => request("/maintenance/reset/stock", { method: "POST" }), + resetProducts: () => request("/maintenance/reset/products", { method: "POST" }), + resetAll: () => request("/maintenance/reset/all", { method: "POST" }), + // Einstellungen listSettings: () => request("/settings"), setSetting: (key, value) => diff --git a/web/src/components/DangerAction.jsx b/web/src/components/DangerAction.jsx new file mode 100644 index 0000000..f6efdcf --- /dev/null +++ b/web/src/components/DangerAction.jsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import Icon from "./Icon"; + +const BESTAETIGUNG = "LÖSCHEN"; + +/** + * Eine unwiderrufliche Aktion im Gefahrenbereich. + * + * Absichtlich umständlich: Der Text nennt vorher, was genau verschwindet, und + * ausgelöst wird erst, wenn das Wort getippt wurde. Ein Fehlklick soll hier + * nichts anrichten können. + */ +export default function DangerAction({ title, description, impact, buttonLabel, onRun }) { + const [wort, setWort] = useState(""); + const [busy, setBusy] = useState(false); + const bereit = wort.trim().toUpperCase() === BESTAETIGUNG; + + async function run() { + if (!bereit) return; + setBusy(true); + try { + await onRun(); + setWort(""); + } finally { + setBusy(false); + } + } + + return ( +
{description}
+{impact}
++ Diese Aktionen lassen sich nicht rückgängig machen. Lege vorher ein Backup an + (Import / Export → Backup als JSON). Benutzer, API-Tokens, Einstellungen und + dein Logo bleiben in allen Fällen erhalten. +
+ + {error &&