From b80e2d5a6c30b84b57b83ee3ae30c39b748f808a Mon Sep 17 00:00:00 2001 From: Marco Morath Date: Sun, 9 Aug 2026 18:24:04 +0200 Subject: [PATCH] neue Archivfunktion und Optimierung an Artikelbearbeitung --- README.md | 114 ++++++++++++++- backend/alembic/versions/0013_archive.py | 39 ++++++ backend/app/article_ops.py | 137 ++++++++++++++++++ backend/app/mail.py | 27 ++++ backend/app/maintenance.py | 131 +++++++++++++++++- backend/app/models.py | 14 ++ backend/app/permissions.py | 13 ++ backend/app/routers/admin.py | 12 ++ backend/app/routers/catalog.py | 84 ++++++++++- backend/app/routers/items.py | 10 +- backend/app/routers/lists.py | 96 ++++++++++++- backend/app/routers/sync.py | 4 + backend/app/schemas_admin.py | 5 + backend/app/schemas_shopping.py | 32 +++++ db/schema.sql | 14 +- tools/check-all.sh | 1 + tools/check-editable.py | 119 ++++++++++++++++ web/html/app.css | 80 +++++++++++ web/html/js/sync.js | 4 + web/html/js/views/admin.js | 65 +++++++-- web/html/js/views/list-detail.js | 169 +++++++++++++++++++---- web/html/js/views/lists.js | 90 +++++++++--- web/html/sw.js | 2 +- 23 files changed, 1185 insertions(+), 77 deletions(-) create mode 100644 backend/alembic/versions/0013_archive.py create mode 100644 backend/app/article_ops.py create mode 100644 tools/check-editable.py diff --git a/README.md b/README.md index 361d4c4..129c2dd 100644 --- a/README.md +++ b/README.md @@ -551,6 +551,7 @@ Einzeln: bash tools/check-env.sh .env # doppelte oder fehlerhafte .env-Einträge python3 tools/check-routes.py # Endpunkte ohne Berechtigungsprüfung python3 tools/check-schema.py # db/schema.sql gegen die Modelle +python3 tools/check-editable.py # Felder eines Eintrags überall verdrahtet? python3 tools/check-nginx.py web # Kopfzeilen, doppelte Direktiven node tools/check-js.mjs # undefinierte Bezeichner in den JS-Modulen node tools/test-barcode.mjs # Strichcode-Decoder @@ -575,6 +576,16 @@ der weder eine Prüfung noch einen Eintrag in der Ausnahmeliste hat. FastAPI mahnt fehlende Absicherung nicht an – beim Hinzufügen einer Route ist sie schnell vergessen. +### `check-editable.py` + +Prüft, dass jedes änderbare Feld eines Listeneintrags auch tatsächlich überall +eingetragen ist: Modell, `ItemUpdateIn`, `ItemOut`, `items.py`, `sync.py` und +die Oberfläche. + +Ein Feld hinzuzufügen bedeutet sechs Stellen. Fehlt eine, merkt es niemand: Die +Anwendung startet, die Oberfläche zeigt ein Eingabefeld, der Server verwirft den +Wert stillschweigend. Genau so ist der Artikelname zunächst untergegangen. + ### `check-schema.py` Vergleicht `db/schema.sql` mit den SQLAlchemy-Modellen: fehlende oder @@ -1119,12 +1130,14 @@ das geht bewusst nur direkt in der Datenbank. ### Automatische Bereinigung -Zwei Fristen, in der Verwaltung einstellbar: +Vier Fristen, in der Verwaltung einstellbar: | Einstellung | Voreinstellung | Wirkung | |---|---|---| -| Deaktivieren nach … Monaten ohne Anmeldung | 12 | Konto wird gesperrt, Person bekommt eine Nachricht | -| Löschen nach … Monaten Deaktivierung | 12 | Konto wird endgültig entfernt | +| Konto deaktivieren nach … Monaten ohne Anmeldung | 12 | Konto wird gesperrt, Person bekommt eine Nachricht | +| Konto löschen nach … Monaten Deaktivierung | 12 | Konto wird endgültig entfernt | +| Liste archivieren nach … Monaten ohne Öffnen | 12 | **Je Person** – die Liste verschwindet nur aus deren Übersicht | +| Liste löschen nach … Monaten im Archiv | 12 | Nur nach dem Archivdatum des **Eigentümers** | **0 bedeutet abgeschaltet, nicht „sofort".** Ein Tippfehler soll nicht dazu führen, dass beim nächsten Durchlauf alle Konten verschwinden. @@ -1137,6 +1150,101 @@ Grundlage ist `user.last_seen_at`. Der Wert wird bei der Anmeldung gesetzt und danach höchstens stündlich nachgeführt, damit auch jemand mitzählt, der sich monatelang nicht neu anmeldet, weil die Sitzung hält. +## Archiv + +Jede Liste kann archiviert werden – **je Person getrennt.** Wer eine gemeinsame +Liste nicht mehr braucht, nimmt sie aus seiner Übersicht, ohne sie den anderen +wegzunehmen. + +In der Listenübersicht erscheint unterhalb der aktiven Listen ein aufklappbarer +Abschnitt „Archiv". Archivieren und Zurückholen stehen im „⋯"-Menü jeder Zeile +und stehen allen Mitgliedern offen, nicht nur dem Eigentümer. + +| Methode | Pfad | +|---|---| +| GET | `/api/lists?archived=false\|true` – ohne Parameter alle | +| POST | `/api/lists/{id}/archive`, `/unarchive` | + +### Automatisch archivieren + +Wer eine Liste zwölf Monate nicht geöffnet hat, findet sie danach im Archiv. +Grundlage ist `list_member.last_opened_at`, gesetzt bei jedem Zugriff und +höchstens stündlich nachgeführt. + +Das wirkt sich **individuell** aus: Öffnet ein Mitglied die Liste regelmäßig +und ein anderes nie, bleibt sie beim einen aktiv und wandert beim anderen ins +Archiv. + +### Automatisch löschen + +Maßgeblich ist **allein das Archivdatum des Eigentümers.** Dass ein +eingeladenes Mitglied die Liste weggeräumt hat, sagt nichts darüber, ob sie +noch gebraucht wird – sonst könnte jemand, der einmal eingeladen wurde und +nie wieder hineinschaut, eine Löschung auslösen, die den Eigentümer trifft. + +Zwei Sicherungen gegen Datenverlust: + +**Vorwarnungen per E-Mail** an den Eigentümer, 7 Tage und 1 Tag vor der +Löschung. Jede Stufe genau einmal – eine tägliche Erinnerung wäre Belästigung +statt Warnung. Ein Klick auf „Aus dem Archiv holen" setzt die Frist zurück und +löscht auch die Warnvermerke, damit beim nächsten Mal wieder gewarnt wird. + +**Wer die Liste zwischenzeitlich geöffnet hat, verliert sie nicht** – auch wenn +das Archivdatum alt ist. Ohne diese Prüfung könnte eine manuell archivierte, +aber weiterhin benutzte Liste verschwinden. + +Gelöscht wird per Soft Delete: Offline gebliebene Geräte erfahren noch davon, +endgültig entfernt der Aufräumlauf nach `CLEANUP_DELETED_DAYS`. + +## Einträge bearbeiten + +Im „⋯"-Menü eines Eintrags lässt sich alles ändern: **Artikel**, Anzahl, +Gebinde, Einheit, Eigenschaft, Notiz, Markt, Warengruppe, Preis und Status. + +### Artikel wechseln oder umbenennen + +Der Name gehört nicht dem Eintrag, sondern dem Artikel dahinter – und der kann +von mehreren Einträgen benutzt werden, trägt einen Strichcode, Vorgaben für +Markt und Warengruppe und hängt an der Preishistorie. Ein Namenswechsel am +Eintrag ist deshalb mehrdeutig: + +``` +"Mlich" → "Milch" ein Tippfehler soll verschwinden +"Milch" → "Butter" dieser Eintrag soll etwas anderes sein +``` + +Die Regel: **Benutzt außer diesem Eintrag niemand den Artikel, wird er +umbenannt** – der Tippfehler verschwindet dann restlos. Sonst hängt der Eintrag +auf einen anderen Artikel um, und die übrigen Einträge bleiben unberührt. + +Bleibt der bisherige Artikel dabei ohne Einträge zurück und hängt nichts +Gepflegtes daran (kein Strichcode, keine Eigenschaften, keine Vorgaben, keine +beobachteten Preise), verschwindet er mit. Wo etwas dranhängt, bleibt er +stehen – dort stört er nicht und lässt sich von Hand löschen. + +Wer einen Artikel ausdrücklich **überall** umbenennen will, tut das unter +„Artikel"; dort ist der Name ein Feld wie jedes andere. + +## Vorschläge beim Eintippen + +Ab drei Zeichen im Feld „Artikel hinzufügen" erscheint eine Liste dessen, was +auf dieser Liste schon eingetragen wurde – von **allen** Mitgliedern, aus den +letzten 24 Monaten. + +Quelle sind die Listeneinträge, **auch die bereits gelöschten**: Gerade die +abgehakten und aufgeräumten sind die interessanten. Was jemand vor drei Jahren +einmal gekauft hat, hilft dagegen nicht mehr und würde die Liste nur +verlängern. + +Ein Antippen füllt Name, Anzahl, Gebinde, Eigenschaft sowie Markt und +Warengruppe mit den zuletzt verwendeten Werten – **trägt den Artikel aber noch +nicht ein.** Der Fokus springt ins Gebinde-Feld, damit noch Änderungen möglich +sind. Erst „Hinzufügen" oder Enter übernimmt. + +Sortiert nach Häufigkeit, dann nach Aktualität; höchstens acht Vorschläge. + +`GET /api/lists/{id}/articles/suggest?q=…` + ## Datenbankschema `db/schema.sql` enthält das vollständige Schema (Revision `0011`). diff --git a/backend/alembic/versions/0013_archive.py b/backend/alembic/versions/0013_archive.py new file mode 100644 index 0000000..077491e --- /dev/null +++ b/backend/alembic/versions/0013_archive.py @@ -0,0 +1,39 @@ +"""Phase 11: Archivierung von Listen + +Revision ID: 0013 +Revises: 0012 +Create Date: 2026-08-08 +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0013" +down_revision: str | None = "0012" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("list_member", sa.Column("archived_at", sa.DateTime(), nullable=True)) + op.add_column( + "list_member", sa.Column("last_opened_at", sa.DateTime(), nullable=True) + ) + # Bestandsmitgliedschaften nicht sofort der Automatik aussetzen: + # Ohne Startwert waere last_opened_at NULL und damit scheinbar "nie + # geoeffnet". Der Beitrittszeitpunkt ist die konservativere Annahme. + op.execute( + "UPDATE list_member SET last_opened_at = joined_at " + "WHERE last_opened_at IS NULL" + ) + + op.add_column("shopping_list", sa.Column("warn_7d_at", sa.DateTime(), nullable=True)) + op.add_column("shopping_list", sa.Column("warn_1d_at", sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("shopping_list", "warn_1d_at") + op.drop_column("shopping_list", "warn_7d_at") + op.drop_column("list_member", "last_opened_at") + op.drop_column("list_member", "archived_at") diff --git a/backend/app/article_ops.py b/backend/app/article_ops.py new file mode 100644 index 0000000..e073720 --- /dev/null +++ b/backend/app/article_ops.py @@ -0,0 +1,137 @@ +"""Artikelwechsel an einem Listeneintrag. + +Der Name eines Eintrags gehört nicht dem Eintrag, sondern dem Artikel +dahinter - und der kann von mehreren Einträgen benutzt werden, trägt +einen Strichcode, Vorgaben für Markt und Warengruppe und hängt an der +Preishistorie. Ein Namenswechsel am Eintrag ist deshalb mehrdeutig: + + "Mlich" -> "Milch" ein Tippfehler soll verschwinden + "Milch" -> "Butter" dieser Eintrag soll etwas anderes sein + +Die Unterscheidung trifft diese Datei nach einer einfachen Regel: +Benutzt außer diesem Eintrag niemand den Artikel, wird er umbenannt - +der Tippfehler verschwindet dann restlos. Sonst wird der Eintrag auf +einen anderen Artikel umgehängt, und die übrigen Einträge bleiben +unberührt. + +Wer einen Artikel ausdrücklich überall umbenennen will, tut das unter +"Artikel" - dort ist der Name ein Feld wie jedes andere. +""" + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models import ( + Article, + ArticleAttribute, + ListItem, + PricePoint, + ShoppingList, +) +from app.security import utcnow + + +def _other_item_count(db: Session, article_id: str, except_item_id: str) -> int: + return db.scalar( + select(func.count()) + .select_from(ListItem) + .where( + ListItem.article_id == article_id, + ListItem.id != except_item_id, + ListItem.deleted_at.is_(None), + ) + ) or 0 + + +def _is_disposable(db: Session, article: Article) -> bool: + """Darf der Artikel verschwinden, wenn ihn niemand mehr benutzt? + + Nur wenn nichts daran hängt, was jemand bewusst gepflegt hat: + kein Strichcode, keine Eigenschaften, keine Vorgaben, keine + beobachteten Preise. Sonst bleibt er im Bestand - dort stört er + nicht und lässt sich von Hand löschen. + """ + if article.barcode or article.note: + return False + if article.default_market_id or article.default_category_id: + return False + + has_attributes = db.scalar( + select(func.count()) + .select_from(ArticleAttribute) + .where(ArticleAttribute.article_id == article.id) + ) + if has_attributes: + return False + + has_prices = db.scalar( + select(func.count()) + .select_from(PricePoint) + .where(PricePoint.article_id == article.id) + ) + return not has_prices + + +def change_item_article( + db: Session, lst: ShoppingList, item: ListItem, new_name: str, rev: int +) -> str: + """Setzt den Artikel eines Eintrags anhand eines Namens. + + @returns die ID des jetzt zugeordneten Artikels + @raises ValueError bei leerem Namen + """ + name = (new_name or "").strip()[:200] + if not name: + raise ValueError("Artikelname fehlt") + + current = db.get(Article, item.article_id) + if current is not None and current.name == name: + return current.id + + # Vergleich unabhängig von Groß- und Kleinschreibung: Die Spalte + # steht auf utf8mb4_unicode_ci, die Datenbank erledigt das. + existing = db.scalar( + select(Article).where( + Article.list_id == lst.id, + Article.name == name, + Article.deleted_at.is_(None), + ) + ) + + if existing is not None and existing.id == item.article_id: + return existing.id + + if existing is None and current is not None and _other_item_count( + db, current.id, item.id + ) == 0: + # Niemand sonst benutzt ihn: umbenennen. Damit verschwindet ein + # Tippfehler restlos, statt einen unbrauchbaren Artikel im + # Bestand zurückzulassen. + current.name = name + current.row_rev = rev + db.flush() + return current.id + + if existing is not None: + target = existing + else: + target = Article(list_id=lst.id, name=name, row_rev=rev) + db.add(target) + db.flush() + + item.article_id = target.id + item.row_rev = rev + db.flush() + + # Bleibt der bisherige Artikel ohne Einträge zurück und hängt nichts + # Gepflegtes daran, verschwindet er mit. + if ( + current is not None + and current.id != target.id + and _other_item_count(db, current.id, item.id) == 0 + and _is_disposable(db, current) + ): + current.deleted_at = utcnow() + current.row_rev = rev + + return target.id diff --git a/backend/app/mail.py b/backend/app/mail.py index 0ff783a..9f45f63 100644 --- a/backend/app/mail.py +++ b/backend/app/mail.py @@ -257,6 +257,33 @@ def send_deactivation_notice(to: str, months: int) -> None: ) +def send_list_deletion_warning( + to: str, list_name: str, days: int, archived_since: str, item_count: int +) -> None: + """Vorwarnung an den Eigentümer, bevor eine archivierte Liste + automatisch gelöscht wird.""" + url = settings.public_base_url.rstrip("/") + frist = "in 7 Tagen" if days == 7 else "morgen" + send_mail( + to, + f"{settings.app_name}: Liste „{list_name}“ wird {frist} gelöscht", + "Hallo,\n\n" + f"deine Liste „{list_name}“ liegt seit {archived_since} in deinem " + "Archiv und wird\n" + f"{frist} automatisch gelöscht. Sie enthält derzeit {item_count} " + "Eintrag/Einträge.\n\n" + "Wenn du sie behalten möchtest, hol sie einfach aus dem Archiv " + "zurück:\n\n" + f"{url}\n\n" + "Dort findest du das Archiv in der Listenübersicht. Ein einziger " + "Klick auf\n„Aus dem Archiv holen“ genügt - die Frist beginnt " + "dann von vorn.\n\n" + "Wenn du nichts unternimmst, wird die Liste mit allen Einträgen, " + "Märkten und\nWarengruppen entfernt. Die erfassten Preise bleiben " + "davon unberührt.\n", + ) + + def send_test_mail(to: str) -> None: send_mail( to, diff --git a/backend/app/maintenance.py b/backend/app/maintenance.py index 0f19cf3..2a06add 100644 --- a/backend/app/maintenance.py +++ b/backend/app/maintenance.py @@ -14,11 +14,11 @@ damit auch keine weitere Stelle, an der etwas kaputtgehen kann. import logging from datetime import timedelta -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from app.config import settings -from app.mail import send_deactivation_notice +from app.mail import send_deactivation_notice, send_list_deletion_warning from app.models import ( AppliedOp, Article, @@ -29,6 +29,7 @@ from app.models import ( EmailToken, ListInvite, ListItem, + ListMember, Market, ProductCache, PublicShare, @@ -142,6 +143,132 @@ def run_cleanup(db: Session) -> dict[str, int]: counts.update(_retire_users(db)) db.commit() + + counts.update(_archive_lists(db)) + db.commit() + return counts + + +def _archive_lists(db: Session) -> dict[str, int]: + """Archiviert lange nicht geöffnete Listen und löscht lange + archivierte. + + Zwei Besonderheiten, die diesen Ablauf von der Kontenbereinigung + unterscheiden: + + * Archiviert wird JE MITGLIED. Dass eine Person eine gemeinsame + Liste seit einem Jahr nicht geöffnet hat, sagt nichts über die + anderen - sie verschwindet nur aus ihrer eigenen Übersicht. + + * Gelöscht wird nur nach dem Archivdatum des EIGENTÜMERS. Sonst + könnte ein eingeladenes Mitglied, das die Liste weggeräumt hat, + eine Löschung auslösen, die den Eigentümer trifft. + """ + now = utcnow() + counts: dict[str, int] = {} + + archive_months = months_setting(db, "auto_archive_months", 12) + delete_months = months_setting(db, "archive_delete_months", 12) + + # --- Automatisch archivieren, je Mitgliedschaft --- + if archive_months: + cutoff = now - timedelta(days=archive_months * 30) + stale = db.scalars( + select(ListMember) + .join(ShoppingList, ShoppingList.id == ListMember.list_id) + .where( + ListMember.archived_at.is_(None), + ListMember.last_opened_at.is_not(None), + ListMember.last_opened_at < cutoff, + ShoppingList.deleted_at.is_(None), + ) + ).all() + for member in stale: + member.archived_at = now + if stale: + counts["archivierte Listen"] = len(stale) + + if not delete_months: + return counts + + # --- Vorwarnen und löschen, nach dem Archivdatum des Eigentümers --- + delete_after = timedelta(days=delete_months * 30) + + owners = db.scalars( + select(ListMember) + .join(ShoppingList, ShoppingList.id == ListMember.list_id) + .where( + ListMember.archived_at.is_not(None), + ShoppingList.deleted_at.is_(None), + ShoppingList.owner_id == ListMember.user_id, + ) + ).all() + + warned = 0 + deleted = 0 + + for member in owners: + lst = db.get(ShoppingList, member.list_id) + if lst is None or lst.deleted_at is not None: + continue + + due = member.archived_at + delete_after + remaining = due - now + + # Wer die Liste zwischenzeitlich wieder geöffnet hat, soll sie + # nicht verlieren - auch wenn das Archivdatum alt ist. + if member.last_opened_at and member.last_opened_at > now - delete_after: + continue + + if remaining.total_seconds() <= 0: + log.info( + "Lösche Liste %s (%s) - seit %s im Archiv des Eigentümers", + lst.id, lst.name, member.archived_at.date(), + ) + # Soft Delete: Offline gebliebene Geräte sollen noch + # erfahren, dass die Liste weg ist. Endgültig entfernt sie + # der Durchlauf nach CLEANUP_DELETED_DAYS. + lst.deleted_at = now + lst.rev += 1 + deleted += 1 + continue + + stage = None + if remaining <= timedelta(days=1) and lst.warn_1d_at is None: + stage = 1 + elif remaining <= timedelta(days=7) and lst.warn_7d_at is None: + stage = 7 + + if stage is None: + continue + + owner = db.get(User, lst.owner_id) + if owner is None or not owner.is_active: + continue + + item_count = db.scalar( + select(func.count()) + .select_from(ListItem) + .where(ListItem.list_id == lst.id, ListItem.deleted_at.is_(None)) + ) or 0 + + send_list_deletion_warning( + owner.email, lst.name, stage, + member.archived_at.strftime("%d.%m.%Y"), item_count, + ) + if stage == 1: + lst.warn_1d_at = now + # Falls die Sieben-Tage-Stufe übersprungen wurde, etwa weil + # die Frist verkürzt wurde: als erledigt vermerken. + lst.warn_7d_at = lst.warn_7d_at or now + else: + lst.warn_7d_at = now + warned += 1 + + if warned: + counts["Vorwarnungen zu Listen"] = warned + if deleted: + counts["gelöschte Listen"] = deleted return counts diff --git a/backend/app/models.py b/backend/app/models.py index 9e28cbd..0ec2c89 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -148,6 +148,12 @@ class ShoppingList(Base): ) deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) + # Vorwarnungen vor der automatischen Loeschung. Getrennt vermerkt, + # damit jede Stufe genau einmal verschickt wird - eine taegliche + # Erinnerung waere Belaestigung statt Warnung. + warn_7d_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) + warn_1d_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) + members: Mapped[list["ListMember"]] = relationship( back_populates="shopping_list", cascade="all, delete-orphan" ) @@ -168,6 +174,14 @@ class ListMember(Base): # und widerrufen. Der Eigentuemer hat es immer, alle anderen nur, # wenn er es ausdruecklich erteilt. may_share_public: Mapped[bool] = mapped_column(Boolean, default=False) + + # Archivierung ist eine Eigenschaft der MITGLIEDSCHAFT, nicht der + # Liste: Wer eine gemeinsame Liste nicht mehr braucht, soll sie aus + # seiner Uebersicht nehmen koennen, ohne sie den anderen wegzunehmen. + archived_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) + # Grundlage der automatischen Archivierung - je Person getrennt. + last_opened_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) + joined_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) shopping_list: Mapped[ShoppingList] = relationship(back_populates="members") diff --git a/backend/app/permissions.py b/backend/app/permissions.py index 7d12af3..9f05f11 100644 --- a/backend/app/permissions.py +++ b/backend/app/permissions.py @@ -12,6 +12,7 @@ verhindert, dass eine Liste durch Löschen des letzten Mitglieds verwaist, die Mitgliedschaft macht Abfragen einheitlich. """ +from datetime import timedelta from typing import Annotated from fastapi import Depends, HTTPException, status @@ -20,6 +21,7 @@ from sqlalchemy.orm import Session from app.deps import DbSession, VerifiedUser from app.models import ListMember, ShoppingList +from app.security import utcnow ROLE_RANK = {"viewer": 1, "editor": 2, "owner": 3} @@ -60,6 +62,17 @@ def _access(db: Session, list_id: str, user_id: str, minimum: str) -> ShoppingLi status.HTTP_403_FORBIDDEN, f"Für diese Aktion ist mindestens die Rolle '{minimum}' erforderlich.", ) + + # Grundlage der automatischen Archivierung. Höchstens stündlich + # schreiben - jeder Zugriff wäre unnötige Last, und für eine Frist + # von Monaten genügt diese Auflösung bei Weitem. + now = utcnow() + if membership.last_opened_at is None or now - membership.last_opened_at > timedelta( + hours=1 + ): + membership.last_opened_at = now + db.commit() + return lst diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index c9daa52..2468b12 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -54,6 +54,8 @@ router = APIRouter(prefix="/api/admin", tags=["admin"]) DEFAULT_DEACTIVATE_MONTHS = 12 DEFAULT_DELETE_MONTHS = 12 +DEFAULT_ARCHIVE_MONTHS = 12 +DEFAULT_ARCHIVE_DELETE_MONTHS = 12 # ========================================================================== @@ -70,6 +72,12 @@ def _settings_out(db) -> AdminSettingsOut: auto_delete_months=months_setting( db, "auto_delete_months", DEFAULT_DELETE_MONTHS ), + auto_archive_months=months_setting( + db, "auto_archive_months", DEFAULT_ARCHIVE_MONTHS + ), + archive_delete_months=months_setting( + db, "archive_delete_months", DEFAULT_ARCHIVE_DELETE_MONTHS + ), ) @@ -98,6 +106,10 @@ def write_settings(payload: AdminSettingsIn, db: DbSession, admin: AdminUser): set_setting(db, "auto_deactivate_months", str(payload.auto_deactivate_months)) if payload.auto_delete_months is not None: set_setting(db, "auto_delete_months", str(payload.auto_delete_months)) + if payload.auto_archive_months is not None: + set_setting(db, "auto_archive_months", str(payload.auto_archive_months)) + if payload.archive_delete_months is not None: + set_setting(db, "archive_delete_months", str(payload.archive_delete_months)) db.commit() return _settings_out(db) diff --git a/backend/app/routers/catalog.py b/backend/app/routers/catalog.py index 3a7e6d8..a790695 100644 --- a/backend/app/routers/catalog.py +++ b/backend/app/routers/catalog.py @@ -4,7 +4,9 @@ Alle Schreibzugriffe erhöhen den Revisionszähler der Liste und schreiben den neuen Wert in `row_rev` der geänderten Zeile. """ -from fastapi import APIRouter, HTTPException, status +from datetime import timedelta + +from fastapi import APIRouter, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -23,6 +25,7 @@ from app.schemas import MessageOut from app.product_lookup import lookup from app.schemas_shopping import ( ArticleIn, + ArticleSuggestion, ArticleOut, ArticleUpdateIn, AttributeOut, @@ -263,6 +266,85 @@ def get_articles( return [_article_out(a) for a in rows] +SUGGEST_MONTHS = 24 +SUGGEST_LIMIT = 8 +SUGGEST_MIN_CHARS = 3 + + +@router.get("/articles/suggest", response_model=list[ArticleSuggestion]) +def suggest_articles( + lst: ReadableList, + db: DbSession, + q: str = Query(min_length=SUGGEST_MIN_CHARS, max_length=200), +): + """Vorschlaege aus dem, was auf dieser Liste schon eingetragen wurde. + + Quelle sind die Listeneintraege der letzten 24 Monate - auch die + bereits geloeschten, denn gerade die abgehakten und aufgeraeumten + sind die interessanten. Was jemand vor drei Jahren einmal gekauft + hat, hilft dagegen nicht mehr und wuerde die Liste nur verlaengern. + + Zurueckgegeben werden die zuletzt verwendeten Angaben: Menge, + Gebinde, Eigenschaft, Markt und Warengruppe. Wer den Vorschlag + antippt, hat die Felder gefuellt und kann sie noch aendern. + """ + needle = q.strip() + if len(needle) < SUGGEST_MIN_CHARS: + return [] + + # LIKE-Sonderzeichen maskieren, sonst faende die Eingabe "50%" alles. + escaped = ( + needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + ) + cutoff = utcnow() - timedelta(days=SUGGEST_MONTHS * 30) + + rows = db.execute( + select(ListItem, Article) + .join(Article, Article.id == ListItem.article_id) + .where( + ListItem.list_id == lst.id, + ListItem.created_at >= cutoff, + Article.deleted_at.is_(None), + Article.name.like(f"%{escaped}%", escape="\\"), + ) + .order_by(ListItem.created_at.desc()) + .limit(600) + ).all() + + # Je Artikel den juengsten Eintrag behalten und mitzaehlen, wie oft + # er vorkam - haeufig Verwendetes soll oben stehen. + best: dict[str, dict] = {} + for item, article in rows: + entry = best.get(article.id) + if entry is None: + best[article.id] = {"item": item, "article": article, "count": 1} + else: + entry["count"] += 1 + + def rank(entry: dict) -> tuple[int, float]: + # Erst Haeufigkeit, dann Aktualitaet. + return (-entry["count"], -entry["item"].created_at.timestamp()) + + out = [] + for entry in sorted(best.values(), key=rank)[:SUGGEST_LIMIT]: + item, article = entry["item"], entry["article"] + out.append( + ArticleSuggestion( + article_id=article.id, + name=article.name, + count=item.count, + pack_size=item.pack_size, + pack_unit=item.pack_unit, + variant=item.variant, + market_id=item.market_id or article.default_market_id, + category_id=item.category_id or article.default_category_id, + times_used=entry["count"], + last_used_at=item.created_at, + ) + ) + return out + + @router.get("/barcode/{barcode}", response_model=ProductLookupOut) def resolve_barcode(barcode: str, lst: ReadableList, db: DbSession): """Strichcode auflösen - erst im eigenen Bestand, dann außerhalb. diff --git a/backend/app/routers/items.py b/backend/app/routers/items.py index 2bf80d3..e06174f 100644 --- a/backend/app/routers/items.py +++ b/backend/app/routers/items.py @@ -12,6 +12,7 @@ from sqlalchemy.exc import IntegrityError from app.deps import DbSession, VerifiedUser from app.models import Article, Category, ListItem, Market +from app.article_ops import change_item_article from app.list_view import build_view, item_out from app.print_view import render_print from app.permissions import EditableList, ReadableList, bump_rev @@ -132,6 +133,13 @@ def update_item( raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden") data = payload.model_dump(exclude_unset=True) + rev = bump_rev(db, lst.id) + + if data.get("article_name"): + try: + change_item_article(db, lst, item, data["article_name"], rev) + except ValueError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from None if payload.clear_market: item.market_id = None @@ -150,7 +158,7 @@ def update_item( if field in data: setattr(item, field, data[field]) - item.row_rev = bump_rev(db, lst.id) + item.row_rev = rev # Nach dem Setzen der Felder: Der Preis wird nur festgehalten, wenn # auch ein Markt zugeordnet ist - ohne den ergibt er keinen # Vergleichswert. diff --git a/backend/app/routers/lists.py b/backend/app/routers/lists.py index 202388f..277aa8b 100644 --- a/backend/app/routers/lists.py +++ b/backend/app/routers/lists.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, HTTPException, status +from datetime import datetime, timedelta + +from fastapi import APIRouter, HTTPException, Query, status from sqlalchemy import func, select from app.deps import DbSession, VerifiedUser @@ -17,13 +19,38 @@ from app.schemas_shopping import ( MemberRoleIn, ) from app.security import utcnow +from app.users import months_setting router = APIRouter(prefix="/api/lists", tags=["lists"]) MAX_LISTS_PER_USER = 100 -def _to_out(db, lst: ShoppingList, role: str, may_share: bool = False) -> ListOut: +ARCHIVE_DELETE_MONTHS_DEFAULT = 12 + + +def _delete_due(db, lst: ShoppingList) -> datetime | None: + """Wann würde die Liste automatisch gelöscht? + + Maßgeblich ist ausschließlich das Archivdatum des EIGENTÜMERS. Dass + ein eingeladenes Mitglied die Liste weggeräumt hat, sagt nichts + darüber, ob sie noch gebraucht wird. + """ + owner = db.get(ListMember, (lst.id, lst.owner_id)) + if owner is None or owner.archived_at is None: + return None + months = months_setting( + db, "archive_delete_months", ARCHIVE_DELETE_MONTHS_DEFAULT + ) + if not months: + return None + return owner.archived_at + timedelta(days=months * 30) + + +def _to_out( + db, lst: ShoppingList, role: str, may_share: bool = False, + archived_at: datetime | None = None, +) -> ListOut: count = db.scalar( select(func.count()).select_from(ListMember).where(ListMember.list_id == lst.id) ) @@ -31,18 +58,40 @@ def _to_out(db, lst: ShoppingList, role: str, may_share: bool = False) -> ListOu id=lst.id, name=lst.name, owner_id=lst.owner_id, rev=lst.rev, role=role, may_share_public=(role == "owner") or may_share, member_count=count or 0, created_at=lst.created_at, + archived=archived_at is not None, + archived_at=archived_at, + delete_due_at=_delete_due(db, lst) if role == "owner" else None, ) @router.get("", response_model=list[ListOut]) -def my_lists(db: DbSession, user: VerifiedUser): +def my_lists( + db: DbSession, + user: VerifiedUser, + archived: bool | None = Query( + default=False, + description="False = nur aktive, True = nur archivierte, " + "nicht gesetzt = alle", + ), +): rows = db.execute( - select(ShoppingList, ListMember.role, ListMember.may_share_public) + select( + ShoppingList, + ListMember.role, + ListMember.may_share_public, + ListMember.archived_at, + ) .join(ListMember, ListMember.list_id == ShoppingList.id) .where(ListMember.user_id == user.id, ShoppingList.deleted_at.is_(None)) .order_by(ShoppingList.created_at) ).all() - return [_to_out(db, lst, role, may) for lst, role, may in rows] + + if archived is True: + rows = [r for r in rows if r[3] is not None] + elif archived is False: + rows = [r for r in rows if r[3] is None] + + return [_to_out(db, lst, role, may, at) for lst, role, may, at in rows] @router.post("", response_model=ListOut, status_code=status.HTTP_201_CREATED) @@ -71,7 +120,40 @@ def create_list(payload: ListCreateIn, db: DbSession, user: VerifiedUser): @router.get("/{list_id}", response_model=ListOut) def read_list(lst: ReadableList, db: DbSession, user: VerifiedUser): member = db.get(ListMember, (lst.id, user.id)) - return _to_out(db, lst, member.role, member.may_share_public) + return _to_out(db, lst, member.role, member.may_share_public, member.archived_at) + + +@router.post("/{list_id}/archive", response_model=ListOut) +def archive_list(lst: ReadableList, db: DbSession, user: VerifiedUser): + """Nimmt die Liste aus der eigenen Übersicht. + + Wirkt nur für die aufrufende Person. Eine gemeinsame Liste + wegzuräumen, weil einer sie nicht mehr braucht, wäre ein Übergriff + auf die anderen. + """ + member = db.get(ListMember, (lst.id, user.id)) + if member.archived_at is None: + member.archived_at = utcnow() + db.commit() + return _to_out(db, lst, member.role, member.may_share_public, member.archived_at) + + +@router.post("/{list_id}/unarchive", response_model=ListOut) +def unarchive_list(lst: ReadableList, db: DbSession, user: VerifiedUser): + member = db.get(ListMember, (lst.id, user.id)) + member.archived_at = None + # Zähler zurücksetzen, sonst wandert die Liste beim nächsten + # Durchlauf sofort wieder ins Archiv. + member.last_opened_at = utcnow() + + # Beim Eigentümer zusätzlich die Vorwarnungen zurücknehmen: Die Frist + # beginnt von vorn, also soll auch wieder gewarnt werden. + if lst.owner_id == user.id: + lst.warn_7d_at = None + lst.warn_1d_at = None + + db.commit() + return _to_out(db, lst, member.role, member.may_share_public, None) @router.get("/{list_id}/snapshot", response_model=ListSnapshot) @@ -91,7 +173,7 @@ def snapshot(lst: ReadableList, db: DbSession, user: VerifiedUser): ).all() return ListSnapshot( - list=_to_out(db, lst, member.role, member.may_share_public), + list=_to_out(db, lst, member.role, member.may_share_public, member.archived_at), view=build_view(db, lst), markets=[MarketOut.model_validate(m) for m in markets], categories=[CategoryOut.model_validate(c) for c in categories], diff --git a/backend/app/routers/sync.py b/backend/app/routers/sync.py index cbb8d22..04d8cfc 100644 --- a/backend/app/routers/sync.py +++ b/backend/app/routers/sync.py @@ -30,6 +30,7 @@ from sqlalchemy.orm import Session from app.db import SessionLocal from app.deps import DbSession, VerifiedUser, current_user +from app.article_ops import change_item_article from app.list_view import build_view from app.models import AppliedOp, Article, Category, ListItem, ListMember, Market, ShoppingList from app.permissions import EditableList, ReadableList, bump_rev @@ -157,6 +158,9 @@ def _apply(db: Session, lst: ShoppingList, user_id: str, op, rev: int) -> str | category = _ref(db, Category, data["category_id"], lst.id, "Warengruppe") item.category_id = category.id if category else None + if data.get("article_name"): + change_item_article(db, lst, item, data["article_name"], rev) + # Alte Feldnamen aus einer Outbox von vor der Umstellung. if "quantity" in data and "pack_size" not in data: data["pack_size"] = data["quantity"] diff --git a/backend/app/schemas_admin.py b/backend/app/schemas_admin.py index 595853d..fcf9d66 100644 --- a/backend/app/schemas_admin.py +++ b/backend/app/schemas_admin.py @@ -64,12 +64,17 @@ class AdminSettingsOut(BaseModel): # 0 = abgeschaltet auto_deactivate_months: int auto_delete_months: int + # Listen: archivieren nach Nichtbenutzung, loeschen nach Archivdauer + auto_archive_months: int + archive_delete_months: int class AdminSettingsIn(BaseModel): allow_self_registration: bool | None = None auto_deactivate_months: int | None = Field(default=None, ge=0, le=600) auto_delete_months: int | None = Field(default=None, ge=0, le=600) + auto_archive_months: int | None = Field(default=None, ge=0, le=600) + archive_delete_months: int | None = Field(default=None, ge=0, le=600) class WelcomeCompleteIn(BaseModel): diff --git a/backend/app/schemas_shopping.py b/backend/app/schemas_shopping.py index 333be3d..76159e8 100644 --- a/backend/app/schemas_shopping.py +++ b/backend/app/schemas_shopping.py @@ -31,6 +31,13 @@ class ListOut(BaseModel): may_share_public: bool member_count: int created_at: datetime + # Archivierung gilt je Person - dieselbe Liste kann bei einem + # Mitglied im Archiv liegen und beim anderen aktiv sein. + archived: bool = False + archived_at: datetime | None = None + # Nur beim Eigentümer gefüllt: Wann würde die Liste automatisch + # gelöscht? Die Frist läuft ausschließlich über sein Archivdatum. + delete_due_at: datetime | None = None class MemberOut(BaseModel): @@ -192,6 +199,10 @@ class ItemCreateIn(BaseModel): class ItemUpdateIn(BaseModel): + # Artikel wechseln oder umbenennen. Welches von beidem geschieht, + # entscheidet der Server: Benutzt niemand sonst den bisherigen + # Artikel, wird er umbenannt - sonst haengt der Eintrag um. + article_name: str | None = Field(default=None, min_length=1, max_length=200) market_id: str | None = Field(default=None, max_length=36) category_id: str | None = Field(default=None, max_length=36) count: int | None = Field(default=None, ge=1, le=999) @@ -405,3 +416,24 @@ class PriceHint(BaseModel): best_cents: int spread_cents: int prices: dict[str, int] + + +class ArticleSuggestion(BaseModel): + """Ein Vorschlag beim Eintippen eines Artikelnamens. + + Enthaelt die zuletzt verwendeten Angaben, damit ein Antippen die + Eingabefelder fuellt - uebernommen wird der Eintrag dadurch noch + nicht, es bleibt Zeit fuer Aenderungen. + """ + + article_id: str + name: str + count: int | None = None + pack_size: Decimal | None = None + pack_unit: str | None = None + variant: str | None = None + market_id: str | None = None + category_id: str | None = None + # Wie oft in der beruecksichtigten Zeitspanne verwendet + times_used: int + last_used_at: datetime diff --git a/db/schema.sql b/db/schema.sql index fd7045b..d7c12b1 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,6 @@ -- ========================================================================== -- Einkaufsliste – vollständiges Datenbankschema --- Entspricht dem Stand nach Alembic-Revision 0012. +-- Entspricht dem Stand nach Alembic-Revision 0013. -- ========================================================================== -- -- Normalerweise wird diese Datei NICHT gebraucht: Der api-Container @@ -163,6 +163,10 @@ CREATE TABLE `shopping_list` ( ON UPDATE CURRENT_TIMESTAMP, -- Soft Delete: Offline gebliebene Geräte sollen vom Löschen erfahren. `deleted_at` DATETIME NULL, + -- Vorwarnungen vor der automatischen Löschung, getrennt vermerkt, + -- damit jede Stufe genau einmal verschickt wird + `warn_7d_at` DATETIME NULL, + `warn_1d_at` DATETIME NULL, PRIMARY KEY (`id`), KEY `fk_shopping_list_owner` (`owner_id`), -- RESTRICT: Ein Konto lässt sich nicht löschen, solange ihm Listen @@ -178,6 +182,12 @@ CREATE TABLE `list_member` ( `role` VARCHAR(16) NOT NULL DEFAULT 'editor', -- Zusatzrecht, unabhängig von der Rolle `may_share_public` TINYINT(1) NOT NULL DEFAULT 0, + -- Archivierung ist eine Eigenschaft der MITGLIEDSCHAFT, nicht der + -- Liste: Dieselbe Liste kann bei einem Mitglied im Archiv liegen und + -- beim anderen aktiv sein. + `archived_at` DATETIME NULL, + -- Grundlage der automatischen Archivierung, je Person getrennt + `last_opened_at` DATETIME NULL, `joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`list_id`, `user_id`), KEY `ix_list_member_user` (`user_id`), @@ -504,6 +514,6 @@ CREATE TABLE `alembic_version` ( PRIMARY KEY (`version_num`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -INSERT INTO `alembic_version` (`version_num`) VALUES ('0012'); +INSERT INTO `alembic_version` (`version_num`) VALUES ('0013'); SET FOREIGN_KEY_CHECKS = 1; diff --git a/tools/check-all.sh b/tools/check-all.sh index d581458..78c3a66 100644 --- a/tools/check-all.sh +++ b/tools/check-all.sh @@ -29,6 +29,7 @@ run() { run "Konfigurationsvorlage" bash tools/check-env.sh .env.example run "Berechtigungen" python3 tools/check-routes.py run "Datenbankschema" python3 tools/check-schema.py +run "Bearbeitbare Felder" python3 tools/check-editable.py run "nginx-Konfiguration" python3 tools/check-nginx.py web run "JavaScript-Module" node tools/check-js.mjs run "Strichcode-Decoder" node tools/test-barcode.mjs diff --git a/tools/check-editable.py b/tools/check-editable.py new file mode 100644 index 0000000..f7c4e19 --- /dev/null +++ b/tools/check-editable.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Prüft, dass jedes Feld eines Listeneintrags auch bearbeitbar ist. + + python3 tools/check-editable.py + +Hintergrund: Ein Feld hinzuzufügen bedeutet, es an fünf Stellen +einzutragen - Modell, Eingabeschema, Ausgabeschema, Router und +Oberfläche. Fehlt eine davon, merkt es niemand: Die Anwendung startet, +die Oberfläche zeigt ein Feld, der Server verwirft es stillschweigend. + +Genau das ist beim Artikelnamen passiert - die Ergänzung im Schema fand +ihr Suchmuster nicht und lief kommentarlos durch. + +Rein textlich, ohne SQLAlchemy zu laden. +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MODELS = ROOT / "backend" / "app" / "models.py" +SCHEMAS = ROOT / "backend" / "app" / "schemas_shopping.py" +SYNC = ROOT / "backend" / "app" / "routers" / "sync.py" +ITEMS = ROOT / "backend" / "app" / "routers" / "items.py" +DETAIL = ROOT / "web" / "html" / "js" / "views" / "list-detail.js" + +# Spalten von list_item, die der Nutzer NICHT ändern können soll. +INTERNAL = { + "id", "list_id", "created_by", "row_rev", + "created_at", "updated_at", "deleted_at", +} + +# Felder, die im Eingabeschema anders heißen als in der Tabelle. +# article_id ist bewusst NICHT intern: Der zugeordnete Artikel lässt +# sich ändern - über seinen Namen, nicht über die ID. +ALIASES = {"article_id": "article_name"} + + +def model_columns(table: str) -> set[str]: + source = MODELS.read_text(encoding="utf-8") + block = source[source.index(f'__tablename__ = "{table}"'):] + end = block.find("\nclass ") + if end > 0: + block = block[:end] + columns = set() + for line in block.splitlines(): + # Beziehungen sind keine Spalten - sie stehen zwar ebenfalls als + # "name: Mapped[...]" da, tragen aber relationship() statt + # mapped_column(). + if "relationship(" in line: + continue + match = re.match(r"^ (\w+):\s*Mapped\[", line) + if match: + columns.add(match.group(1)) + return columns + + +def schema_fields(name: str) -> set[str]: + source = SCHEMAS.read_text(encoding="utf-8") + start = source.index(f"class {name}(BaseModel):") + rest = source[start:] + end = rest.find("\nclass ") + block = rest[:end] if end > 0 else rest + return set(re.findall(r"^ (\w+):", block, re.M)) + + +def main() -> int: + problems: list[str] = [] + + columns = model_columns("list_item") + editable = {ALIASES.get(c, c) for c in columns - INTERNAL} + + update_in = schema_fields("ItemUpdateIn") + item_out = schema_fields("ItemOut") + + detail = DETAIL.read_text(encoding="utf-8") + menu_start = detail.find("function itemMenu") + menu_end = detail.find("function marketSection") + menu = detail[menu_start:menu_end] if menu_start > 0 else "" + row_start = detail.find("function itemRow") + row = detail[row_start:menu_start] if row_start > 0 else "" + + sync_source = SYNC.read_text(encoding="utf-8") + items_source = ITEMS.read_text(encoding="utf-8") + + for field in sorted(editable): + if field not in update_in: + problems.append(f"{field}: fehlt in ItemUpdateIn") + if field not in sync_source: + problems.append(f"{field}: wird in sync.py nicht verarbeitet") + if field not in items_source: + problems.append(f"{field}: wird in items.py nicht verarbeitet") + if field not in menu and field not in row: + problems.append(f"{field}: in der Oberfläche nicht bearbeitbar") + + # Was der Nutzer ändern kann, muss er auch sehen können. + for field in sorted(editable): + if field == "article_name": + continue # heißt in der Ausgabe article_name - passt + if field not in item_out: + problems.append(f"{field}: fehlt in ItemOut (nicht sichtbar)") + + if problems: + print(f"{len(problems)} Lücke(n):\n") + for entry in problems: + print(f" - {entry}") + return 1 + + print( + f"{len(editable)} bearbeitbare Felder je Eintrag " + f"({', '.join(sorted(editable))}) – " + "überall eingetragen" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/html/app.css b/web/html/app.css index 8548534..b4ece33 100644 --- a/web/html/app.css +++ b/web/html/app.css @@ -1243,3 +1243,83 @@ input.months { } @media print { .stats, ul.admin-users { display: none !important; } } + +/* =================================================================== + Vorschlagsliste beim Eintippen + =================================================================== */ + +.name-wrap { position: relative; } + +ul.suggestions { + position: absolute; + z-index: 20; + left: 0; + right: 0; + top: calc(100% + 2px); + list-style: none; + margin: 0; + padding: 0; + max-height: 16rem; + overflow-y: auto; + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: 0 6px 18px rgba(0, 0, 0, .16); +} + +ul.suggestions > li { border-bottom: 1px solid var(--line); } +ul.suggestions > li:last-child { border-bottom: none; } + +ul.suggestions button { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + padding: .55rem .8rem; + font: inherit; + color: inherit; + cursor: pointer; +} + +ul.suggestions button:hover, +ul.suggestions button:focus-visible { + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +ul.suggestions .name { display: block; overflow-wrap: anywhere; } +ul.suggestions .sub { display: block; font-size: .78rem; color: var(--muted); } + +/* =================================================================== + Archiv in der Listenübersicht + =================================================================== */ + +.card.archive { padding-top: .75rem; } + +button.archive-head { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + background: none; + border: none; + padding: .4rem 0; + font: inherit; + font-weight: 600; + color: var(--fg); + cursor: pointer; +} + +button.archive-head .chev { color: var(--muted); } + +.card.archive ul.lists { margin-top: .5rem; } + +.reg-toggle { + padding: 0 0 .9rem; + border-bottom: 1px solid var(--line); + margin-bottom: .3rem; +} + +.reg-toggle .checkline { margin-top: 0; } + +@media print { ul.suggestions, .card.archive { display: none !important; } } diff --git a/web/html/js/sync.js b/web/html/js/sync.js index 846225a..45020a9 100644 --- a/web/html/js/sync.js +++ b/web/html/js/sync.js @@ -131,6 +131,10 @@ export function applyOp(view, op, markets, categories) { else if ("market_id" in p) { item.market_id = p.market_id; needsRegroup = true; } if (p.clear_category) { item.category_id = null; needsRegroup = true; } else if ("category_id" in p) { item.category_id = p.category_id; needsRegroup = true; } + // Der Server entscheidet, ob umbenannt oder umgehängt wird - für + // die Anzeige ist beides derselbe neue Name. + if (p.article_name) item.article_name = p.article_name; + for (const field of ["count", "pack_size", "pack_unit", "variant", "note", "status", "price_cents"]) { if (field in p) item[field] = p[field]; diff --git a/web/html/js/views/admin.js b/web/html/js/views/admin.js index 3e0b710..0a6fe5d 100644 --- a/web/html/js/views/admin.js +++ b/web/html/js/views/admin.js @@ -91,6 +91,35 @@ export async function adminView(root, { back }) { ); } + /** Steht bei den Konten, nicht bei der Bereinigung: Es geht darum, + * WER hinzukommen darf - das gehört zur Kontenverwaltung. */ + function registrationToggle() { + const box = el("input", { + type: "checkbox", + checked: config.allow_self_registration, + disabled: config.locked_by_env, + onchange: (ev) => guarded(async () => { + await put("/api/admin/settings", { + allow_self_registration: ev.target.checked, + }); + say(ev.target.checked + ? "Selbstregistrierung eingeschaltet." + : "Selbstregistrierung abgeschaltet – Zugang nur noch per Einladung."); + }), + }); + + return el("div.reg-toggle", {}, + el("label.checkline", {}, box, + el("span", {}, "Selbstregistrierung erlauben")), + config.locked_by_env + ? el("p.sub", {}, + "Über die Umgebungsvariable ALLOW_SELF_REGISTRATION festgelegt " + + "und hier nicht änderbar.") + : el("p.sub", {}, + "Ist sie aus, kommen neue Personen nur über „Konto anlegen“ " + + "oder eine Listeneinladung hinein.")); + } + // ------------------------------------------------------------------- // Neues Konto // ------------------------------------------------------------------- @@ -275,12 +304,14 @@ export async function adminView(root, { back }) { type: "number", min: "0", max: "600", value: String(config.auto_delete_months), }); - const selfReg = el("input", { - type: "checkbox", - checked: config.allow_self_registration, - disabled: config.locked_by_env, + const archive = el("input.months", { + type: "number", min: "0", max: "600", + value: String(config.auto_archive_months), + }); + const archiveDelete = el("input.months", { + type: "number", min: "0", max: "600", + value: String(config.archive_delete_months), }); - return el("section.card", {}, el("h2", {}, "Automatische Bereinigung"), el("p.lead", {}, @@ -293,13 +324,17 @@ export async function adminView(root, { back }) { el("label", {}, "Löschen nach … Monaten Deaktivierung"), remove, - el("label.checkline", {}, selfReg, - el("span", {}, "Selbstregistrierung erlauben")), - config.locked_by_env - ? el("p.sub", {}, - "Über die Umgebungsvariable ALLOW_SELF_REGISTRATION festgelegt " + - "und hier nicht änderbar.") - : null, + el("h3.device-heading", {}, "Listen"), + el("label", {}, "Archivieren nach … Monaten ohne Öffnen"), + archive, + el("p.sub", {}, + "Gilt je Person: Die Liste verschwindet nur aus der Übersicht " + + "derjenigen, die sie nicht geöffnet hat."), + el("label", {}, "Löschen nach … Monaten im Archiv"), + archiveDelete, + el("p.sub", {}, + "Maßgeblich ist allein das Archivdatum des Eigentümers. Er wird " + + "7 Tage und 1 Tag vorher per E-Mail gewarnt."), el("button.primary", { type: "button", @@ -307,9 +342,8 @@ export async function adminView(root, { back }) { await put("/api/admin/settings", { auto_deactivate_months: Number(deactivate.value) || 0, auto_delete_months: Number(remove.value) || 0, - ...(config.locked_by_env - ? {} - : { allow_self_registration: selfReg.checked }), + auto_archive_months: Number(archive.value) || 0, + archive_delete_months: Number(archiveDelete.value) || 0, }); say("Einstellungen gespeichert."); }), @@ -335,6 +369,7 @@ export async function adminView(root, { back }) { statsCard(), el("section.card", {}, el("h2", {}, "Konten"), + registrationToggle(), el("ul.admin-users", {}, users.map(userRow))), createCard(), automationCard() diff --git a/web/html/js/views/list-detail.js b/web/html/js/views/list-detail.js index 04fc537..b3ee5d2 100644 --- a/web/html/js/views/list-detail.js +++ b/web/html/js/views/list-detail.js @@ -220,6 +220,22 @@ export async function listDetailView( const deferred = item.status === "deferred"; + const nameEdit = el("input", { + type: "text", + value: item.article_name, + maxLength: 200, + onchange: (ev) => { + const value = ev.target.value.trim(); + if (!value) { + // Leeres Feld ist kein Löschbefehl - der alte Name bleibt. + ev.target.value = item.article_name; + return; + } + if (value === item.article_name) return; + change("item.update", { item_id: item.id, article_name: value }); + }, + }); + const variantField = el("input", { type: "text", value: item.variant || "", @@ -293,6 +309,12 @@ export async function listDetailView( return el("div.item-menu", {}, hintBlock, + el("label", {}, "Artikel"), + nameEdit, + el("p.sub", {}, + "Steht der Artikel nur auf diesem Eintrag, wird er umbenannt – " + + "ein Tippfehler verschwindet damit ganz. Wird er auch anderswo " + + "verwendet, bekommt nur dieser Eintrag den neuen Artikel."), el("label", {}, "Anzahl und Gebinde", el("span.hint", {}, " (Preis gilt je Gebinde)")), el("div.add-fields", {}, countEdit, packSizeEdit, packUnitEdit), @@ -342,8 +364,16 @@ export async function listDetailView( maxLength: 200, placeholder: "Artikel hinzufügen", autocomplete: "off", - onkeydown: (ev) => { if (ev.key === "Enter") addItem(); }, + // Der eingebaute Vervollständiger würde die eigene Vorschlagsliste + // überdecken. + autocapitalize: "sentences", + onkeydown: (ev) => { + if (ev.key === "Enter") { closeSuggestions(); addItem(); } + if (ev.key === "Escape") closeSuggestions(); + }, + oninput: () => scheduleSuggestions(), }); + // Anzahl und Gebinde getrennt: Der Preis gilt je Gebinde, die // Summe ist Preis mal Anzahl. Steckte beides in einem Feld, ergaben // 500 ml zu 2,99 EUR eine Summe von 1495 EUR. @@ -362,33 +392,106 @@ export async function listDetailView( title: "z. B. ml, g, Stk", onkeydown: (ev) => { if (ev.key === "Enter") addItem(); }, }); - - // Vorschläge aus dem, was auf dieser Liste schon vorkommt - so muss - // "bunt, ganz" nur einmal getippt werden. - const suggestions = new Set(); - for (const market of view?.markets || []) { - for (const cat of market.categories) { - for (const item of cat.items) { - if (item.variant) suggestions.add(item.variant); - } - } - } - const variantList = el("datalist", { id: "variant-suggestions" }, - [...suggestions].sort((a, b) => a.localeCompare(b, "de")) - .map((v) => el("option", { value: v }))); - const variantField = el("input.variant", { type: "text", maxLength: 200, placeholder: "Eigenschaft", title: "Nähere Bestimmung, z. B. „bunt, ganz“ oder „laktosefrei“", autocomplete: "off", - list: "variant-suggestions", onkeydown: (ev) => { if (ev.key === "Enter") addItem(); }, }); - // el() setzt "list" nicht als Eigenschaft - das Attribut muss direkt - // gesetzt werden, sonst findet der Browser die Vorschlagsliste nicht. - variantField.setAttribute("list", "variant-suggestions"); + + // Vorgeschlagener Markt und Warengruppe: werden mit übernommen, + // sind im Formular aber nicht sichtbar - sie stehen im Menü des + // Eintrags und würden die Eingabezeile überladen. + let chosenMarket = null; + let chosenCategory = null; + + const suggestionBox = el("ul.suggestions", { hidden: true }); + + function closeSuggestions() { + suggestionBox.hidden = true; + suggestionBox.replaceChildren(); + } + + /** Übernimmt einen Vorschlag in die Felder - aber trägt ihn NICHT + * ein. So bleibt Gelegenheit, Menge oder Eigenschaft zu ändern, + * bevor der Artikel auf der Liste landet. */ + function applySuggestion(suggestion) { + nameField.value = suggestion.name; + countField.value = suggestion.count && suggestion.count > 1 + ? String(suggestion.count) : ""; + qtyField.value = suggestion.pack_size + ? formatQuantity(suggestion.pack_size) : ""; + unitField.value = suggestion.pack_unit || ""; + variantField.value = suggestion.variant || ""; + chosenMarket = suggestion.market_id || null; + chosenCategory = suggestion.category_id || null; + closeSuggestions(); + // Zum Ändern einladen, nicht zum Abschicken drängen. + qtyField.focus(); + } + + let suggestTimer = null; + let suggestToken = 0; + + function scheduleSuggestions() { + // Getippte Zeichen sammeln, statt bei jedem Anschlag zu fragen. + clearTimeout(suggestTimer); + const value = nameField.value.trim(); + + // Eine frei getippte Eingabe hebt eine zuvor übernommene + // Zuordnung auf - sonst landete der neue Artikel im Markt des + // alten Vorschlags. + chosenMarket = null; + chosenCategory = null; + + if (value.length < 3 || !state.online) { + closeSuggestions(); + return; + } + suggestTimer = setTimeout(() => loadSuggestions(value), 220); + } + + async function loadSuggestions(value) { + const token = ++suggestToken; + let rows; + try { + rows = await get( + `/api/lists/${listId}/articles/suggest?q=${encodeURIComponent(value)}`); + } catch { + // Vorschläge sind Beiwerk: Wenn sie nicht kommen, tippt man + // eben weiter. + return; + } + // Antwort einer älteren Anfrage verwerfen. + if (token !== suggestToken) return; + if (!rows.length) { + closeSuggestions(); + return; + } + + suggestionBox.replaceChildren(...rows.map((row) => { + const details = [ + row.count && row.count > 1 ? `${row.count}×` : null, + formatPack(row.pack_size, row.pack_unit) || null, + row.variant, + markets.find((m) => m.id === row.market_id)?.name, + ].filter(Boolean); + + return el("li", {}, + el("button", { + type: "button", + // mousedown statt click: Ein Klick käme erst nach dem + // blur des Eingabefelds - und das schließt die Liste. + onmousedown: (ev) => { ev.preventDefault(); applySuggestion(row); }, + onclick: () => applySuggestion(row), + }, + el("span.name", {}, row.name), + details.length ? el("span.sub", {}, details.join(" · ")) : null)); + })); + suggestionBox.hidden = false; + } async function addItem() { const name = nameField.value.trim(); @@ -400,11 +503,18 @@ export async function listDetailView( pack_unit: unitField.value.trim() || null, variant: variantField.value.trim() || null, }; + if (chosenMarket) payload.market_id = chosenMarket; + if (chosenCategory) payload.category_id = chosenCategory; + nameField.value = ""; countField.value = ""; qtyField.value = ""; unitField.value = ""; variantField.value = ""; + chosenMarket = null; + chosenCategory = null; + closeSuggestions(); + await change("item.create", payload); document.getElementById("add-name")?.focus(); } @@ -434,15 +544,17 @@ export async function listDetailView( return; } + const extras = { + count: parseCount(countField.value), + pack_size: parseQuantity(qtyField.value), + pack_unit: unitField.value.trim() || null, + variant: variantField.value.trim() || null, + }; + // Bereits im eigenen Bestand: ohne Rückfrage auf die Liste. if (lookup.source === "catalog") { await change("item.create", { - article_id: lookup.article_id, - barcode: code, - count: parseCount(countField.value), - pack_size: parseQuantity(qtyField.value), - pack_unit: unitField.value.trim() || null, - variant: variantField.value.trim() || null, + article_id: lookup.article_id, barcode: code, ...extras, }); countField.value = ""; qtyField.value = ""; @@ -473,7 +585,7 @@ export async function listDetailView( // Schaltflächen in einer Zeile - auf schmalen Geräten lief der // letzte Knopf aus der Box. return el("div.add-row", {}, - nameField, + el("div.name-wrap", {}, nameField, suggestionBox), el("div.add-fields", {}, countField, qtyField, unitField, variantField), el("div.add-actions", {}, cameraAvailable() @@ -483,8 +595,7 @@ export async function listDetailView( onclick: scanAndAdd, }, "Scannen") : null, - el("button.primary", { type: "button", onclick: addItem }, "Hinzufügen")), - variantList + el("button.primary", { type: "button", onclick: addItem }, "Hinzufügen")) ); } diff --git a/web/html/js/views/lists.js b/web/html/js/views/lists.js index 4203317..7237304 100644 --- a/web/html/js/views/lists.js +++ b/web/html/js/views/lists.js @@ -10,14 +10,32 @@ import { set, state } from "../store.js"; let openMenuId = null; // Welche Zeile wird gerade umbenannt let renamingId = null; +// Ist das Archiv aufgeklappt? +let archiveOpen = false; const ROLE_LABEL = { owner: "Eigentümer", editor: "Bearbeiter", viewer: "Nur lesen" }; +function formatDate(iso) { + if (!iso) return ""; + return new Date(iso).toLocaleDateString("de-DE", { + day: "2-digit", month: "2-digit", year: "numeric", + }); +} + export async function listsView(root, { openList, onLogout, settings }) { let banner = null; + let archived = []; + async function reload() { - set({ lists: await get("/api/lists") }); + // Zwei Abfragen statt einer mit Filterung im Browser: Das Archiv + // wird selten geöffnet und kann mit der Zeit groß werden. + const [active, old] = await Promise.all([ + get("/api/lists?archived=false"), + get("/api/lists?archived=true"), + ]); + archived = old; + set({ lists: active }); } function say(message, kind = "error") { @@ -102,25 +120,43 @@ export async function listsView(root, { openList, onLogout, settings }) { openMenuId === entry.id ? el("div.list-menu", {}, - isOwner - ? el("div.menu-actions", {}, - el("button", { + // Archivieren wirkt nur für die eigene Übersicht - deshalb + // steht es allen Mitgliedern offen, nicht nur dem Eigentümer. + el("p.sub", {}, + entry.archived + ? `Seit ${formatDate(entry.archived_at)} in deinem Archiv.` + : "Archivieren nimmt die Liste aus deiner Übersicht – " + + "für die anderen Mitglieder bleibt sie sichtbar."), + entry.archived && entry.delete_due_at + ? el("p.lead.warn", {}, + `Wird am ${formatDate(entry.delete_due_at)} automatisch ` + + "gelöscht, wenn du sie nicht zurückholst.") + : null, + el("div.menu-actions", {}, + el("button", { + type: "button", + onclick: () => guarded(() => + post(`/api/lists/${entry.id}/${entry.archived ? "un" : ""}archive`)), + }, entry.archived ? "Aus dem Archiv holen" : "Archivieren"), + isOwner + ? el("button", { type: "button", onclick: () => { openMenuId = null; renamingId = entry.id; render(); }, - }, "Umbenennen"), - el("button.danger", { - type: "button", - onclick: () => remove(entry), - }, "Löschen")) - : el("div.menu-actions", {}, - el("button.danger", { - type: "button", - onclick: () => leave(entry), - }, "Liste verlassen"))) + }, "Umbenennen") + : null), + isOwner + ? el("button.danger.wide", { + type: "button", + onclick: () => remove(entry), + }, "Löschen") + : el("button.danger.wide", { + type: "button", + onclick: () => leave(entry), + }, "Liste verlassen")) : null ); } @@ -220,9 +256,31 @@ export async function listsView(root, { openList, onLogout, settings }) { el("h1", {}, "Meine Listen"), state.lists.length ? el("ul.lists", {}, state.lists.map(row)) - : el("p.empty", {}, "Noch keine Liste vorhanden."), + : el("p.empty", {}, + archived.length + ? "Alle Listen liegen im Archiv." + : "Noch keine Liste vorhanden."), el("label", { htmlFor: "new-list" }, "Neue Liste"), - createRow()) + createRow()), + + archived.length + ? el("section.card.archive", {}, + el("button.archive-head", { + type: "button", + "aria-expanded": archiveOpen ? "true" : "false", + onclick: () => { archiveOpen = !archiveOpen; render(); }, + }, + el("span", {}, `Archiv (${archived.length})`), + el("span.chev", {}, archiveOpen ? "▾" : "▸")), + archiveOpen + ? el("div", {}, + el("p.lead", {}, + "Listen, die du weggeräumt hast oder die du länger " + + "nicht geöffnet hast. Sie sind nicht gelöscht – ein " + + "Klick holt sie zurück."), + el("ul.lists", {}, archived.map(row))) + : null) + : null ); } diff --git a/web/html/sw.js b/web/html/sw.js index c17b5de..8028763 100644 --- a/web/html/sw.js +++ b/web/html/sw.js @@ -5,7 +5,7 @@ // Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet, // wann alte Zwischenspeicher verworfen werden. -const VERSION = "v22"; +const VERSION = "v24"; const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`; const SHELL = [