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

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