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

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