neue Archivfunktion und Optimierung an Artikelbearbeitung
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user