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