mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
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).
345 lines
11 KiB
Python
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
|