mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Library files now move to a configurable-retention trash bin on delete
instead of being hard-deleted from disk (default 30 days). Admins get a
"Purge old" bulk action on the File Manager with a live preview, plus an
optional auto-purge setting in Settings → File Manager that runs the same
operation once per 24h when enabled (default off). Regular users see and
manage their own trashed files; admins see everyone's. External (linked)
files bypass trash since their bytes aren't under Bambuddy's control.
- New `library:purge` permission (admin-only by default)
- Nullable indexed `deleted_at` column on library_files; dialect-aware
ALTER TABLE so the column actually gets added on PostgreSQL (raw
DATETIME is SQLite-only syntax)
- New `LibraryFile.active()` classmethod; every query site routed through
it so trashed rows don't leak into listings, print dispatch, MakerWorld
dedupe, or stats
- Trash page: select-all + bulk restore/delete, per-row checkboxes, wider
layout so datetime columns don't clip
- Auto-purge: 24h throttle via `library_auto_purge_last_run` setting so
the 15-minute sweeper cadence still runs the purge at most once per day
- Save toast wired into every trash/auto-purge setting change
- 17 new backend integration tests (service + routes + auto-purge throttle),
8 new frontend tests, localised across all 8 UI languages
- Wiki + website feature entries updated
282 lines
9.8 KiB
Python
282 lines
9.8 KiB
Python
"""Library trash bin + admin purge endpoints (#1008).
|
|
|
|
Permission model:
|
|
|
|
* **Admin purge** (``/library/purge/*``) and **retention settings**
|
|
(``/library/trash/settings``) require :attr:`Permission.LIBRARY_PURGE` —
|
|
admin-only.
|
|
* **Per-user trash** (list / restore / hard-delete / empty own trash) is
|
|
gated by the existing :attr:`Permission.LIBRARY_DELETE_ALL` /
|
|
:attr:`Permission.LIBRARY_DELETE_OWN` ownership pair, so a regular user
|
|
sees their own trashed files and an admin sees everyone's.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import timedelta
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
|
|
from backend.app.core.database import get_db
|
|
from backend.app.core.permissions import Permission
|
|
from backend.app.models.library import LibraryFile, LibraryFolder
|
|
from backend.app.models.user import User
|
|
from backend.app.schemas.library_trash import (
|
|
EmptyTrashResponse,
|
|
PurgePreviewResponse,
|
|
PurgeRequest,
|
|
PurgeResponse,
|
|
TrashFile,
|
|
TrashListResponse,
|
|
TrashSettings,
|
|
)
|
|
from backend.app.services.library_trash import (
|
|
MAX_RETENTION_DAYS,
|
|
MIN_RETENTION_DAYS,
|
|
library_trash_service,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/library", tags=["library-trash"])
|
|
|
|
|
|
# ===================== Admin purge =====================
|
|
|
|
|
|
@router.get("/purge/preview", response_model=PurgePreviewResponse)
|
|
async def preview_purge(
|
|
older_than_days: int = Query(ge=1, le=3650),
|
|
include_never_printed: bool = True,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
|
|
):
|
|
"""Preview how many files would move to trash for the given age threshold.
|
|
|
|
Read-only — safe to call repeatedly as the admin adjusts the slider.
|
|
"""
|
|
result = await library_trash_service.preview_purge(
|
|
db,
|
|
older_than_days=older_than_days,
|
|
include_never_printed=include_never_printed,
|
|
)
|
|
return PurgePreviewResponse(**result)
|
|
|
|
|
|
@router.post("/purge", response_model=PurgeResponse)
|
|
async def execute_purge(
|
|
body: PurgeRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
|
|
):
|
|
"""Move matching files to trash. Idempotent — already-trashed rows skip."""
|
|
moved = await library_trash_service.purge_older_than(
|
|
db,
|
|
older_than_days=body.older_than_days,
|
|
include_never_printed=body.include_never_printed,
|
|
)
|
|
return PurgeResponse(moved_to_trash=moved)
|
|
|
|
|
|
# ===================== Trash list + per-item ops =====================
|
|
|
|
|
|
@router.get("/trash", response_model=TrashListResponse)
|
|
async def list_trash(
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
auth_result: tuple[User | None, bool] = Depends(
|
|
require_ownership_permission(
|
|
Permission.LIBRARY_DELETE_ALL,
|
|
Permission.LIBRARY_DELETE_OWN,
|
|
)
|
|
),
|
|
):
|
|
"""List trashed files.
|
|
|
|
Admins (``LIBRARY_DELETE_ALL``) see every user's trash; regular users
|
|
(``LIBRARY_DELETE_OWN``) see only rows they created.
|
|
"""
|
|
user, can_modify_all = auth_result
|
|
retention_days = await library_trash_service.get_retention_days(db)
|
|
|
|
# Base query: trashed files + their folder name (for the UI) + creator.
|
|
base_conditions = [LibraryFile.deleted_at.isnot(None)]
|
|
if not can_modify_all:
|
|
if user is None:
|
|
# Defensive: ownership checker only returns user=None when auth is off,
|
|
# in which case can_modify_all=True. If we somehow land here, err safe.
|
|
raise HTTPException(status_code=403, detail="Authentication required")
|
|
base_conditions.append(LibraryFile.created_by_id == user.id)
|
|
|
|
total_result = await db.execute(select(func.count(LibraryFile.id)).where(*base_conditions))
|
|
total = int(total_result.scalar() or 0)
|
|
|
|
rows_result = await db.execute(
|
|
select(LibraryFile, LibraryFolder.name, User.username)
|
|
.outerjoin(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
|
|
.outerjoin(User, LibraryFile.created_by_id == User.id)
|
|
.where(*base_conditions)
|
|
.order_by(LibraryFile.deleted_at.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
)
|
|
|
|
items: list[TrashFile] = []
|
|
for file, folder_name, username in rows_result.all():
|
|
# deleted_at is not-null by construction above; narrow for the typechecker.
|
|
assert file.deleted_at is not None
|
|
auto_purge_at = file.deleted_at + timedelta(days=retention_days)
|
|
items.append(
|
|
TrashFile(
|
|
id=file.id,
|
|
filename=file.filename,
|
|
file_size=file.file_size,
|
|
thumbnail_path=file.thumbnail_path,
|
|
folder_id=file.folder_id,
|
|
folder_name=folder_name,
|
|
created_by_id=file.created_by_id,
|
|
created_by_username=username,
|
|
deleted_at=file.deleted_at,
|
|
auto_purge_at=auto_purge_at,
|
|
)
|
|
)
|
|
|
|
return TrashListResponse(items=items, total=total, retention_days=retention_days)
|
|
|
|
|
|
async def _load_trashed_file(
|
|
db: AsyncSession,
|
|
file_id: int,
|
|
user: User | None,
|
|
can_modify_all: bool,
|
|
) -> LibraryFile:
|
|
"""Fetch a trashed file, enforcing ownership for non-admins."""
|
|
result = await db.execute(
|
|
select(LibraryFile).where(
|
|
LibraryFile.id == file_id,
|
|
LibraryFile.deleted_at.isnot(None),
|
|
)
|
|
)
|
|
file = result.scalar_one_or_none()
|
|
if file is None:
|
|
raise HTTPException(status_code=404, detail="Trashed file not found")
|
|
if not can_modify_all:
|
|
if user is None or file.created_by_id != user.id:
|
|
raise HTTPException(status_code=403, detail="You can only manage your own trashed files")
|
|
return file
|
|
|
|
|
|
@router.post("/trash/{file_id}/restore")
|
|
async def restore_from_trash(
|
|
file_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
auth_result: tuple[User | None, bool] = Depends(
|
|
require_ownership_permission(
|
|
Permission.LIBRARY_DELETE_ALL,
|
|
Permission.LIBRARY_DELETE_OWN,
|
|
)
|
|
),
|
|
):
|
|
user, can_modify_all = auth_result
|
|
file = await _load_trashed_file(db, file_id, user, can_modify_all)
|
|
await library_trash_service.restore(db, file)
|
|
return {"status": "success", "id": file.id}
|
|
|
|
|
|
@router.delete("/trash/{file_id}")
|
|
async def hard_delete_from_trash(
|
|
file_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
auth_result: tuple[User | None, bool] = Depends(
|
|
require_ownership_permission(
|
|
Permission.LIBRARY_DELETE_ALL,
|
|
Permission.LIBRARY_DELETE_OWN,
|
|
)
|
|
),
|
|
):
|
|
"""Permanently delete a single trashed file + its bytes. Irreversible."""
|
|
user, can_modify_all = auth_result
|
|
file = await _load_trashed_file(db, file_id, user, can_modify_all)
|
|
await library_trash_service.hard_delete_now(db, file)
|
|
return {"status": "success"}
|
|
|
|
|
|
@router.delete("/trash", response_model=EmptyTrashResponse)
|
|
async def empty_trash(
|
|
db: AsyncSession = Depends(get_db),
|
|
auth_result: tuple[User | None, bool] = Depends(
|
|
require_ownership_permission(
|
|
Permission.LIBRARY_DELETE_ALL,
|
|
Permission.LIBRARY_DELETE_OWN,
|
|
)
|
|
),
|
|
):
|
|
"""Permanently delete all trashed files in the caller's scope.
|
|
|
|
Regular users empty only their own trash; admins empty everyone's.
|
|
"""
|
|
user, can_modify_all = auth_result
|
|
conditions = [LibraryFile.deleted_at.isnot(None)]
|
|
if not can_modify_all:
|
|
if user is None:
|
|
raise HTTPException(status_code=403, detail="Authentication required")
|
|
conditions.append(LibraryFile.created_by_id == user.id)
|
|
|
|
rows_result = await db.execute(select(LibraryFile).where(*conditions))
|
|
rows = rows_result.scalars().all()
|
|
deleted = 0
|
|
for row in rows:
|
|
await library_trash_service.hard_delete_now(db, row)
|
|
deleted += 1
|
|
return EmptyTrashResponse(deleted=deleted)
|
|
|
|
|
|
# ===================== Retention settings (admin only) =====================
|
|
|
|
|
|
@router.get("/trash/settings", response_model=TrashSettings)
|
|
async def get_trash_settings(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
|
|
):
|
|
retention = await library_trash_service.get_retention_days(db)
|
|
auto = await library_trash_service.get_auto_purge_settings(db)
|
|
return TrashSettings(
|
|
retention_days=retention,
|
|
auto_purge_enabled=auto["enabled"],
|
|
auto_purge_days=auto["days"],
|
|
auto_purge_include_never_printed=auto["include_never_printed"],
|
|
)
|
|
|
|
|
|
@router.put("/trash/settings", response_model=TrashSettings)
|
|
async def update_trash_settings(
|
|
body: TrashSettings,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
|
|
):
|
|
if body.retention_days < MIN_RETENTION_DAYS or body.retention_days > MAX_RETENTION_DAYS:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"retention_days must be between {MIN_RETENTION_DAYS} and {MAX_RETENTION_DAYS}",
|
|
)
|
|
saved_retention = await library_trash_service.set_retention_days(db, body.retention_days)
|
|
saved_auto = await library_trash_service.set_auto_purge_settings(
|
|
db,
|
|
enabled=body.auto_purge_enabled,
|
|
days=body.auto_purge_days,
|
|
include_never_printed=body.auto_purge_include_never_printed,
|
|
)
|
|
return TrashSettings(
|
|
retention_days=saved_retention,
|
|
auto_purge_enabled=saved_auto["enabled"],
|
|
auto_purge_days=saved_auto["days"],
|
|
auto_purge_include_never_printed=saved_auto["include_never_printed"],
|
|
)
|