Files
bambuddy/backend/app/api/routes/pending_uploads.py
T
maziggy c2e7f8eb4b feat(vp): add archive name source toggle (metadata/filename) (#1152)
Slicer-uploaded archives picked up their display name from the 3MF's
  embedded print_name (the creator-baked title); users who renamed a job
  in BambuStudio's "Send to printer" dialog never saw that name surface
  because the FTP filename was only used as a fallback when metadata was
  empty.

  Settings -> Virtual Printer now exposes an Archive name source toggle
  (Metadata / Filename, default Metadata) that flips precedence in
  ArchiveService.archive_print via a new prefer_filename_for_name param.
  All four VP-sourced archive paths read the new
  virtual_printer_archive_name_source setting and forward the flag:
  _archive_file, _add_to_print_queue, POST /pending-uploads/archive-all,
  POST /pending-uploads/{id}/archive.
2026-04-29 06:54:08 +02:00

276 lines
8.2 KiB
Python

"""API routes for pending uploads (virtual printer queue mode)."""
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.pending_upload import PendingUpload
from backend.app.models.user import User
from backend.app.services.archive import ArchiveService
router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
class ArchiveRequest(BaseModel):
"""Request to archive a pending upload."""
tags: str | None = None
notes: str | None = None
project_id: int | None = None
class PendingUploadResponse(BaseModel):
"""Response model for pending upload."""
id: int
filename: str
file_size: int
source_ip: str | None
status: str
tags: str | None
notes: str | None
project_id: int | None
uploaded_at: datetime
class Config:
from_attributes = True
@router.get("/", response_model=list[PendingUploadResponse])
async def list_pending_uploads(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
):
"""List all pending uploads."""
result = await db.execute(
select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
)
return result.scalars().all()
@router.get("/count")
async def get_pending_count(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
):
"""Get count of pending uploads."""
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
count = len(result.scalars().all())
return {"count": count}
# Note: Bulk operations must be defined BEFORE parameterized routes
# to prevent FastAPI from matching /archive-all as /{upload_id}
@router.post("/archive-all")
async def archive_all_pending(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
):
"""Archive all pending uploads."""
from backend.app.api.routes.settings import get_setting
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
pending_uploads = result.scalars().all()
archived = 0
failed = 0
service = ArchiveService(db)
prefer_filename = (await get_setting(db, "virtual_printer_archive_name_source")) == "filename"
for pending in pending_uploads:
file_path = Path(pending.file_path)
if not file_path.exists():
pending.status = "discarded"
failed += 1
continue
try:
archive = await service.archive_print(
printer_id=None,
source_file=file_path,
print_data={
"status": "archived",
"source": "virtual_printer",
"source_ip": pending.source_ip,
},
prefer_filename_for_name=prefer_filename,
)
if archive:
pending.status = "archived"
pending.archived_id = archive.id
pending.archived_at = datetime.now(timezone.utc)
archived += 1
# Clean up temp file
try:
file_path.unlink()
except OSError:
pass # Best-effort temp file cleanup after archiving
else:
failed += 1
except Exception: # Mixed async DB + archive operations
failed += 1
await db.commit()
return {
"archived": archived,
"failed": failed,
}
@router.delete("/discard-all")
async def discard_all_pending(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
):
"""Discard all pending uploads."""
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
pending_uploads = result.scalars().all()
discarded = 0
for pending in pending_uploads:
# Delete file from disk
try:
file_path = Path(pending.file_path)
file_path.unlink(missing_ok=True)
except OSError:
pass # Best-effort file deletion; record is still marked discarded
pending.status = "discarded"
discarded += 1
await db.commit()
return {"discarded": discarded}
@router.get("/{upload_id}", response_model=PendingUploadResponse)
async def get_pending_upload(
upload_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
):
"""Get a specific pending upload."""
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
pending = result.scalar_one_or_none()
if not pending:
raise HTTPException(status_code=404, detail="Upload not found")
return pending
@router.post("/{upload_id}/archive")
async def archive_pending_upload(
upload_id: int,
request: ArchiveRequest = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
):
"""Archive a pending upload."""
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
pending = result.scalar_one_or_none()
if not pending:
raise HTTPException(status_code=404, detail="Upload not found")
if pending.status != "pending":
raise HTTPException(status_code=400, detail="Upload already processed")
# Check file exists
file_path = Path(pending.file_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Upload file not found on disk")
# Archive the file
from backend.app.api.routes.settings import get_setting
prefer_filename = (await get_setting(db, "virtual_printer_archive_name_source")) == "filename"
service = ArchiveService(db)
archive = await service.archive_print(
printer_id=None,
source_file=file_path,
print_data={
"status": "archived",
"source": "virtual_printer",
"source_ip": pending.source_ip,
},
prefer_filename_for_name=prefer_filename,
)
if not archive:
raise HTTPException(status_code=500, detail="Failed to archive file")
# Apply tags/notes/project from request
if request:
if request.tags:
archive.tags = request.tags
if request.notes:
archive.notes = request.notes
if request.project_id:
archive.project_id = request.project_id
# Update pending record
pending.status = "archived"
pending.archived_id = archive.id
pending.archived_at = datetime.now(timezone.utc)
if request:
pending.tags = request.tags
pending.notes = request.notes
pending.project_id = request.project_id
await db.commit()
# Clean up temp file
try:
file_path.unlink()
except OSError:
pass # Best-effort temp file cleanup after successful archive
return {
"id": archive.id,
"print_name": archive.print_name,
"filename": archive.filename,
}
@router.delete("/{upload_id}")
async def discard_pending_upload(
upload_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
):
"""Discard a pending upload without archiving."""
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
pending = result.scalar_one_or_none()
if not pending:
raise HTTPException(status_code=404, detail="Upload not found")
# Delete file from disk
file_path = Path(pending.file_path)
try:
file_path.unlink(missing_ok=True)
except OSError:
pass # Best-effort file deletion on discard
# Update status
pending.status = "discarded"
await db.commit()
return {"success": True}