neue Archivfunktion und Optimierung an Artikelbearbeitung

This commit is contained in:
2026-08-09 18:24:04 +02:00
parent dd51532533
commit b80e2d5a6c
23 changed files with 1185 additions and 77 deletions

114
README.md
View File

@@ -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`).

View File

@@ -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")

137
backend/app/article_ops.py Normal file
View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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],

View File

@@ -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"]

View File

@@ -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):

View File

@@ -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

View File

@@ -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;

View File

@@ -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

119
tools/check-editable.py Normal file
View File

@@ -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())

View File

@@ -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; } }

View File

@@ -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];

View File

@@ -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()

View File

@@ -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"))
);
}

View File

@@ -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
);
}

View File

@@ -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 = [