From 48cc2646e85c4c2343fe39479571f55eeaf68069 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 22 Jul 2026 21:21:52 +0200 Subject: [PATCH] Favicon aus dem Seitenleisten-Zeichen und Gefahrenbereich in den Einstellungen Favicon: Bisher diente der Barcode-Glyph des App-Icons als Standard. Jetzt zeigt der Browser-Tab dasselbe Zeichen wie die Seitenleiste - das "box"-Icon aus components/Icon.jsx auf der Akzentflaeche. Die Farbe folgt der Oberflaeche: im dunklen Modus der hellere Akzent, damit das Symbol auf beiden Untergruenden sitzt. Ein eigenes Favicon laesst sich weiterhin hochladen und hat Vorrang. Gefahrenbereich (Einstellungen, nur Admin) mit drei Stufen, damit man nicht mehr wegwirft als noetig: 1. Alle Bestaende loeschen - Chargen und Bewegungsverlauf. Der Katalog bleibt vollstaendig erhalten, der Bestand steht ueberall auf null. 2. Alle Artikel loeschen - zusaetzlich der Artikelkatalog samt seiner EAN-Codes. Gruppen, Kategorien, Lagerorte und Einheiten bleiben. Die Codes, die an Gruppen haengen, bleiben ebenfalls. 3. Alles zuruecksetzen - zusaetzlich Gruppen, Kategorien, Lagerorte, Einheiten. Benutzer, API-Tokens, Einstellungen und das eigene Logo bleiben in allen drei Stufen erhalten. Anders liesse man sich aussperren und muesste externe Zugriffe wie Home Assistant neu einrichten. Nach Stufe 3 werden die eingebauten Einheiten und die Kategorie-Startliste sofort neu angelegt - ohne Einheiten liesse sich nichts mehr einlagern, die Installation waere unbrauchbar. Absicherung: Jede Aktion nennt vorher mit Zahlen, was genau verschwindet ("Betrifft 43 Chargen und 12 Bewegungen"), und laeuft erst, wenn das Wort LOESCHEN eingetippt wurde. Ein Fehlklick kann nichts anrichten. Der Abschnitt ist rot abgesetzt und weist auf das JSON-Backup hin. Getestet: 57 pytest-Tests gruen, 3 davon neu (Bestaende loeschen laesst den Katalog stehen, Artikel loeschen laesst Gruppen, Lagerorte und Einheiten sowie den Gruppen-Code stehen, Startkategorien kommen zurueck). Gegen die laufende API alle drei Stufen nacheinander durchgespielt und die Zahlen nach jedem Schritt geprueft; Benutzer bleiben, Einheiten sind danach wieder da, ohne Adminrechte antwortet der Endpunkt mit 401. Das Favicon wurde gerendert und mit dem Zeichen der Seitenleiste verglichen. Web-Build laeuft durch. Die Oberflaeche habe ich nicht selbst bedient - insbesondere habe ich keine der Loeschaktionen ueber die Web-Oberflaeche ausgeloest. Nebenbei die deutschen Anfuehrungszeichen im ganzen Projekt vereinheitlicht (schliessendes Zeichen typografisch statt gerade). Co-Authored-By: Claude Opus 4.8 --- backend/app/main.py | 2 + backend/app/routers/maintenance.py | 121 ++++++++++++++++++++++++++++ backend/app/services/group_codes.py | 4 +- backend/tests/test_maintenance.py | 67 +++++++++++++++ web/public/favicon.svg | 19 ++++- web/src/api.js | 6 ++ web/src/components/DangerAction.jsx | 45 +++++++++++ web/src/pages/Categories.jsx | 2 +- web/src/pages/Groups.jsx | 2 +- web/src/pages/ProductForm.jsx | 4 +- web/src/pages/Settings.jsx | 87 ++++++++++++++++++++ web/src/styles.css | 6 ++ 12 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 backend/app/routers/maintenance.py create mode 100644 backend/tests/test_maintenance.py create mode 100644 web/src/components/DangerAction.jsx 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;