neue Archivfunktion und Optimierung an Artikelbearbeitung
This commit is contained in:
114
README.md
114
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`).
|
||||
|
||||
39
backend/alembic/versions/0013_archive.py
Normal file
39
backend/alembic/versions/0013_archive.py
Normal 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
137
backend/app/article_ops.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
119
tools/check-editable.py
Normal 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())
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
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, ...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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", {},
|
||||
// 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", {
|
||||
}, "Umbenennen")
|
||||
: null),
|
||||
isOwner
|
||||
? el("button.danger.wide", {
|
||||
type: "button",
|
||||
onclick: () => remove(entry),
|
||||
}, "Löschen"))
|
||||
: el("div.menu-actions", {},
|
||||
el("button.danger", {
|
||||
}, "Löschen")
|
||||
: el("button.danger.wide", {
|
||||
type: "button",
|
||||
onclick: () => leave(entry),
|
||||
}, "Liste verlassen")))
|
||||
}, "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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user