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 ( +
+
{title}
+

{description}

+

{impact}

+
+ + +
+
+ ); +} diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx index 87536de..8557d12 100644 --- a/web/src/pages/Categories.jsx +++ b/web/src/pages/Categories.jsx @@ -73,7 +73,7 @@ export default function Categories() {

Kategorien

- Ordnen die Artikelliste für den Überblick – „zeig mir alle Süßwaren". + Ordnen die Artikelliste für den Überblick – „zeig mir alle Süßwaren“. Kategorien haben bewusst keinen Bestand und keine EAN-Codes; das übernehmen die Gruppen.
diff --git a/web/src/pages/Groups.jsx b/web/src/pages/Groups.jsx index dfaad8f..9e31eab 100644 --- a/web/src/pages/Groups.jsx +++ b/web/src/pages/Groups.jsx @@ -70,7 +70,7 @@ export default function Groups() {

Gruppen

- Bestände mehrerer Marken zusammenzählen – z.B. Mehl von Rewe, Aldi und Migros als „5 kg Mehl". Mit gemeinsamem Mindestbestand; die EAN-Codes der zugeordneten Artikel erscheinen automatisch hier. + Bestände mehrerer Marken zusammenzählen – z.B. Mehl von Rewe, Aldi und Migros als „5 kg Mehl“. Mit gemeinsamem Mindestbestand; die EAN-Codes der zugeordneten Artikel erscheinen automatisch hier.
diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 1f2c12b..88b49d6 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -97,8 +97,8 @@ export default function ProductForm() { setInfo( "Daten von Open Food Facts übernommen." + (s.package_size != null ? "" : " (Füllmenge nicht hinterlegt – bitte Packungsgröße prüfen.)") + - (res.category_name ? ` Kategorie „${res.category_name}" vorgeschlagen.` : "") + - (res.group_name ? ` Gruppe „${res.group_name}" über den EAN-Code erkannt.` : "") + (res.category_name ? ` Kategorie „${res.category_name}“ vorgeschlagen.` : "") + + (res.group_name ? ` Gruppe „${res.group_name}“ über den EAN-Code erkannt.` : "") ); } diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx index f9ea50d..54b77ad 100644 --- a/web/src/pages/Settings.jsx +++ b/web/src/pages/Settings.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import Icon from "../components/Icon"; import BrandingUpload from "../components/BrandingUpload"; +import DangerAction from "../components/DangerAction"; import { DATE_FORMAT_KEY, useSettings } from "../settings"; import { DATE_FORMATS, formatDate } from "../units"; import { applyFavicon } from "../branding"; @@ -101,6 +102,7 @@ export default function Settings() { + ); } @@ -210,3 +212,88 @@ rest: ); } + + +/** + * Gefahrenbereich: Daten in großem Stil löschen. + * + * Drei Stufen, damit man nicht mehr wegwirft als nötig. Benutzer, API-Tokens, + * Einstellungen und das eigene Logo bleiben in allen Stufen erhalten. + */ +function DangerZoneCard() { + const [zahlen, setZahlen] = useState(null); + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + + async function load() { + try { + setZahlen(await api.maintenanceSummary()); + } catch (err) { + setError(err.message); + } + } + + useEffect(() => { load(); }, []); + + function run(aktion, meldung) { + return async () => { + setError(null); + setInfo(null); + try { + setZahlen(await aktion()); + setInfo(meldung); + } catch (err) { + setError(err.message); + } + }; + } + + if (!zahlen) return null; + + const n = (wert, eins, mehr) => `${wert} ${wert === 1 ? eins : mehr}`; + + return ( +
+
+

Gefahrenbereich

+
+

+ 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 &&
{error}
} + {info &&
{info}
} + + + + + + +
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css index 16e8d07..941fa60 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -305,6 +305,12 @@ td select { width: auto; min-width: 132px; max-width: 100%; } .precision-pick { display: flex; align-items: center; gap: var(--sp-2); margin: 0; font-weight: 400; } .precision-pick select { margin: 0; width: auto; padding-top: 3px; padding-bottom: 3px; font-size: 0.8rem; } /* Hinweis beim Einlagern: welche Kategorie automatisch zugeordnet wird */ + /* Gefahrenbereich: deutlich abgesetzt, damit man nicht versehentlich hineinruscht */ +.danger-zone { border-color: var(--danger); } +.danger-zone .card-head .icon { color: var(--danger); } +.danger-action { padding: var(--sp-4) 0; border-top: 1px solid var(--border); } +.danger-action:first-of-type { border-top: none; } + .assign-box { margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--border); } .assign-note { display: flex; align-items: center; gap: 6px; flex-wrap: wrap;