Files
bambuddy/backend/app/api/routes/webhook.py
maziggy 9beb001a17 Record who queued a file from the Library and the webhook API
PrintQueueItem.created_by_id is what the queue:read_own / queue:update_own /
queue:delete_own permissions filter on, but only three of the paths that create
queue items were setting it.

The Library's bulk "Add to queue" required Permission.QUEUE_CREATE and then
bound the dependency to `_`, discarding the user, so every item it created was
ownerless -- and invisible to the person who added it if their permissions are
scoped to their own work. That is the one path built for adding many files at
once, which is where it was hardest to notice.

The webhook queue endpoint has no request user, but APIKey.user_id records the
key's owner, which is the acting identity everywhere else the key is used, so
its items are credited to that owner. Keys minted before per-user ownership
have no user_id and their items stay ownerless.

The virtual-printer path is left as-is on purpose. VirtualPrinter carries no
owner, and the obvious substitute is wrong rather than incomplete: one admin
typically configures the VP while everyone sends prints through it, so
crediting those to the admin would make the "added by" column lie and put other
people's jobs in the admin's own queue. Existing NULL rows are not backfilled
-- there is no record of who created them, and the ownerless case is already
handled throughout.

Tests pin both fixed paths and the two cases that must stay ownerless (auth
disabled, legacy key).
2026-08-06 11:06:15 +02:00

345 lines
11 KiB
Python

import logging
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 check_permission, check_printer_access, get_api_key
from backend.app.core.database import get_db
from backend.app.models.api_key import APIKey
from backend.app.models.archive import PrintArchive
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.services.printer_manager import printer_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhook", tags=["webhook"])
# Request schemas
class QueueAddRequest(BaseModel):
archive_id: int
printer_id: int
project_id: int | None = None
scheduled_time: str | None = None # ISO format datetime
require_previous_success: bool = False
auto_off_after: bool = False
class QueueAddResponse(BaseModel):
id: int
archive_id: int
printer_id: int
position: int
status: str
message: str
class PrinterStatusResponse(BaseModel):
id: int
name: str
connected: bool
state: str | None
current_print: str | None
progress: float | None
remaining_time: int | None
class QueueStatusResponse(BaseModel):
printer_id: int
printer_name: str
pending: int
printing: int
items: list[dict]
# Webhook endpoints
@router.post("/queue/add", response_model=QueueAddResponse)
async def webhook_add_to_queue(
data: QueueAddRequest,
api_key: APIKey = Depends(get_api_key),
db: AsyncSession = Depends(get_db),
):
"""Add a print to the queue via webhook.
Requires 'can_queue' permission.
"""
check_permission(api_key, "queue")
check_printer_access(api_key, data.printer_id)
# Verify archive exists
result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
archive = result.scalar_one_or_none()
if not archive:
raise HTTPException(status_code=404, detail="Archive not found")
# Verify printer exists
result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
# Get next position
result = await db.execute(
select(PrintQueueItem.position)
.where(
PrintQueueItem.printer_id == data.printer_id,
PrintQueueItem.status == "pending",
)
.order_by(PrintQueueItem.position.desc())
.limit(1)
)
max_position = result.scalar()
next_position = (max_position or 0) + 1
# Parse scheduled time if provided
scheduled_time = None
if data.scheduled_time:
from datetime import datetime
try:
scheduled_time = datetime.fromisoformat(data.scheduled_time.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
# Create queue item
queue_item = PrintQueueItem(
printer_id=data.printer_id,
archive_id=data.archive_id,
project_id=data.project_id,
position=next_position,
scheduled_time=scheduled_time,
require_previous_success=data.require_previous_success,
auto_off_after=data.auto_off_after,
# Attribute to the key's owner so the item shows up under `queue:read_own`
# for the person whose key it is. Legacy keys predating per-user ownership
# have no `user_id`, and those rows stay ownerless.
created_by_id=api_key.user_id,
)
db.add(queue_item)
await db.flush()
await db.refresh(queue_item)
return QueueAddResponse(
id=queue_item.id,
archive_id=queue_item.archive_id,
printer_id=queue_item.printer_id,
position=queue_item.position,
status=queue_item.status,
message=f"Added to queue at position {queue_item.position}",
)
@router.post("/printer/{printer_id}/start")
async def webhook_start_print(
printer_id: int,
api_key: APIKey = Depends(get_api_key),
db: AsyncSession = Depends(get_db),
):
"""Trigger the next manual-start queue item on a printer.
Mirrors `POST /print-queue/{item_id}/start`: clears `manual_start` on
the next pending item so the scheduler picks it up — which handles
FTP upload, AMS mapping, and all print options (timelapse,
bed_levelling, etc.) correctly via the queue's stored fields. The
previous implementation called `printer_manager.start_print()`
directly with `archive_id` as the filename arg and no print options,
bypassing the upload step entirely and discarding the user's
workflow choices — it 500'd before ever reaching the printer.
Requires 'can_control_printer' permission.
"""
check_permission(api_key, "control_printer")
check_printer_access(api_key, printer_id)
# Get printer
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
# Get next pending queue item
result = await db.execute(
select(PrintQueueItem)
.where(
PrintQueueItem.printer_id == printer_id,
PrintQueueItem.status == "pending",
)
.order_by(PrintQueueItem.position)
.limit(1)
)
queue_item = result.scalar_one_or_none()
if not queue_item:
raise HTTPException(status_code=404, detail="No pending prints in queue")
# Clear manual_start so the scheduler will dispatch. If the item was
# already auto-dispatchable this is a no-op; the scheduler will still
# pick it up on its next tick.
queue_item.manual_start = False
await db.commit()
await db.refresh(queue_item)
logger.info("Webhook started queue item %s on printer %s", queue_item.id, printer_id)
return {"message": "Print started", "queue_item_id": queue_item.id}
@router.post("/printer/{printer_id}/stop")
async def webhook_stop_print(
printer_id: int,
api_key: APIKey = Depends(get_api_key),
):
"""Stop the current print on a printer.
Requires 'can_control_printer' permission.
"""
check_permission(api_key, "control_printer")
check_printer_access(api_key, printer_id)
status = printer_manager.get_status(printer_id)
# `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass
# (see backend/app/services/bambu_mqtt.py), not a dict — `.get(...)` on it
# raises AttributeError and surfaces as a generic 500 (#1584).
if not status or not status.connected:
raise HTTPException(status_code=503, detail="Printer not connected")
if status.state != "RUNNING":
raise HTTPException(status_code=409, detail="No print in progress")
try:
await printer_manager.stop_print(printer_id)
except Exception as e:
logger.error("Failed to stop print: %s", e)
raise HTTPException(status_code=500, detail=str(e))
return {"message": "Print stopped"}
@router.post("/printer/{printer_id}/cancel")
async def webhook_cancel_print(
printer_id: int,
api_key: APIKey = Depends(get_api_key),
):
"""Cancel the current print on a printer.
Requires 'can_control_printer' permission.
"""
check_permission(api_key, "control_printer")
check_printer_access(api_key, printer_id)
status = printer_manager.get_status(printer_id)
# Same dataclass-not-dict shape as stop_print above (#1584).
if not status or not status.connected:
raise HTTPException(status_code=503, detail="Printer not connected")
if status.state not in ["RUNNING", "PAUSE"]:
raise HTTPException(status_code=409, detail="No print to cancel")
try:
await printer_manager.cancel_print(printer_id)
except Exception as e:
logger.error("Failed to cancel print: %s", e)
raise HTTPException(status_code=500, detail=str(e))
return {"message": "Print cancelled"}
@router.get("/printer/{printer_id}/status", response_model=PrinterStatusResponse)
async def webhook_get_printer_status(
printer_id: int,
api_key: APIKey = Depends(get_api_key),
db: AsyncSession = Depends(get_db),
):
"""Get status of a printer.
Requires 'can_read_status' permission.
"""
check_permission(api_key, "read_status")
check_printer_access(api_key, printer_id)
# Get printer
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
status = printer_manager.get_status(printer_id)
# `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass —
# attribute access, not dict lookup. The previous `.get(...)` calls raised
# AttributeError and surfaced as a generic 500 for any printer that
# actually had a status row (#1584).
return PrinterStatusResponse(
id=printer.id,
name=printer.name,
connected=status.connected if status else False,
state=status.state if status else None,
current_print=status.current_print if status else None,
progress=status.progress if status else None,
remaining_time=status.remaining_time if status else None,
)
@router.get("/queue", response_model=list[QueueStatusResponse])
async def webhook_get_queue_status(
printer_id: int | None = None,
api_key: APIKey = Depends(get_api_key),
db: AsyncSession = Depends(get_db),
):
"""Get queue status for all printers or a specific printer.
Requires 'can_read_status' permission.
"""
check_permission(api_key, "read_status")
# Get printers
if printer_id:
check_printer_access(api_key, printer_id)
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printers = result.scalars().all()
else:
result = await db.execute(select(Printer))
printers = result.scalars().all()
# Filter by allowed printers if limited
if api_key.printer_ids is not None:
printers = [p for p in printers if p.id in api_key.printer_ids]
response = []
for printer in printers:
# Get queue items
result = await db.execute(
select(PrintQueueItem)
.where(
PrintQueueItem.printer_id == printer.id,
PrintQueueItem.status.in_(["pending", "printing"]),
)
.order_by(PrintQueueItem.position)
)
items = result.scalars().all()
pending_count = sum(1 for i in items if i.status == "pending")
printing_count = sum(1 for i in items if i.status == "printing")
response.append(
QueueStatusResponse(
printer_id=printer.id,
printer_name=printer.name,
pending=pending_count,
printing=printing_count,
items=[
{
"id": item.id,
"archive_id": item.archive_id,
"position": item.position,
"status": item.status,
}
for item in items
],
)
)
return response