Add batch print quantity to print/schedule dialog (#342)

This commit is contained in:
maziggy
2026-04-01 11:55:30 +02:00
parent 9aa9fdc586
commit 3270179090
22 changed files with 818 additions and 132 deletions
+1
View File
@@ -14,6 +14,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Prefer Lowest Remaining Filament** ([#805](https://github.com/maziggy/bambuddy/issues/805)) — New optional setting in Settings → Filament that prefers AMS spools with the lowest remaining filament during auto-matching. When multiple spools match the same type and color, the one with the least filament remaining is selected first. Helps consume partial spools before starting new ones. Applies to queue scheduling, print modal, and multi-printer mapping. Unknown remain values (e.g. external spools without sensors) are treated as full. Disabled by default. Requested by @Mofoss.
- **REST/Webhook Smart Plug Type** ([#472](https://github.com/maziggy/bambuddy/issues/472)) — New "REST" smart plug type for controlling power via generic HTTP APIs. Works with any home automation platform that has an HTTP endpoint (openHAB, ioBroker, FHEM, Node-RED, etc.). Configure separate ON/OFF URLs with custom HTTP methods (GET/POST/PUT/PATCH), request bodies, and headers. Optional status polling via a GET endpoint with JSON path extraction for state, power, and energy monitoring. Fully controllable — supports auto on/off with prints, daily scheduling, sidebar quick-toggle, and power alerts. Requested by @Percy2Live.
- **Configurable Default Print Options** ([#858](https://github.com/maziggy/bambuddy/issues/858)) — Print options (bed levelling, flow calibration, vibration calibration, first layer inspection, timelapse) now have configurable defaults in Settings → Workflow. Set your preferred defaults once and every new print dialog starts with those values. Still overridable per print. Requested by @NoahTingey.
- **Batch Print Quantity** ([#342](https://github.com/maziggy/bambuddy/issues/342)) — Print multiple copies of a file in one step. The print and schedule dialogs now have a quantity field — set it to any number and the system creates that many queue items automatically. When quantity is greater than one, items are grouped into a batch for tracking. In the direct print dialog, the first copy prints immediately while the remaining copies are queued. The queue page shows a batch badge on grouped items. Batch progress and cancellation are available via the API. Requested by @cimdDev.
### Improved
- **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.
+1
View File
@@ -108,6 +108,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
- Print queue with drag-and-drop and timeline schedule view
- Multi-printer selection (send to multiple printers at once)
- Batch print quantity (print multiple copies — set quantity in the print/schedule dialog, first copy prints immediately, rest are queued)
- Staggered batch start (start printers in groups with configurable interval to avoid power spikes — works in both Print and Queue dialogs)
- Configurable default print options (bed levelling, flow/vibration calibration, first layer inspection, timelapse) in Settings → Workflow
- Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
+184 -33
View File
@@ -18,10 +18,12 @@ from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.archive import PrintArchive
from backend.app.models.library import LibraryFile
from backend.app.models.print_batch import PrintBatch
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.models.user import User
from backend.app.schemas.print_queue import (
PrintBatchResponse,
PrintQueueBulkUpdate,
PrintQueueBulkUpdateResponse,
PrintQueueItemCreate,
@@ -209,6 +211,9 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
# User tracking (Issue #206)
"created_by_id": item.created_by_id,
"created_by_username": item.created_by.username if item.created_by else None,
# Batch grouping
"batch_id": item.batch_id,
"batch_name": item.batch.name if item.batch else None,
}
response = PrintQueueItemResponse(**item_dict)
if item.archive:
@@ -281,6 +286,7 @@ async def list_queue(
selectinload(PrintQueueItem.printer),
selectinload(PrintQueueItem.library_file),
selectinload(PrintQueueItem.created_by),
selectinload(PrintQueueItem.batch),
)
.order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
)
@@ -407,6 +413,36 @@ async def add_to_queue(
all_types = existing_types | set(override_types)
required_filament_types = json.dumps(sorted(all_types))
# Validate quantity
quantity = max(1, data.quantity)
# Create batch if quantity > 1
batch = None
batch_id = None
if quantity > 1:
# Derive batch name from source file
batch_name_base = "Batch"
if archive:
batch_name_base = archive.print_name or archive.filename or "Batch"
elif library_file:
if library_file.file_metadata:
batch_name_base = library_file.file_metadata.get("print_name") or library_file.filename
else:
batch_name_base = library_file.filename
batch_name_base = batch_name_base.replace(".gcode.3mf", "").replace(".3mf", "")
batch = PrintBatch(
name=f"{batch_name_base} ×{quantity}",
archive_id=data.archive_id,
library_file_id=data.library_file_id,
quantity=quantity,
status="active",
created_by_id=current_user.id if current_user else None,
)
db.add(batch)
await db.flush() # Get batch.id before creating items
batch_id = batch.id
# Get next position for this printer (or for unassigned/model-based items)
if data.printer_id is not None:
result = await db.execute(
@@ -423,40 +459,48 @@ async def add_to_queue(
)
max_pos = result.scalar() or 0
item = PrintQueueItem(
printer_id=data.printer_id,
target_model=target_model_norm,
target_location=data.target_location,
required_filament_types=required_filament_types,
filament_overrides=filament_overrides_json,
archive_id=data.archive_id,
library_file_id=data.library_file_id,
scheduled_time=data.scheduled_time,
require_previous_success=data.require_previous_success,
auto_off_after=data.auto_off_after,
manual_start=data.manual_start,
ams_mapping=json.dumps(data.ams_mapping) if data.ams_mapping else None,
plate_id=data.plate_id,
bed_levelling=data.bed_levelling,
flow_cali=data.flow_cali,
vibration_cali=data.vibration_cali,
layer_inspect=data.layer_inspect,
timelapse=data.timelapse,
use_ams=data.use_ams,
position=max_pos + 1,
status="pending",
created_by_id=current_user.id if current_user else None,
)
db.add(item)
await db.commit()
await db.refresh(item)
ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
items = []
for i in range(quantity):
item = PrintQueueItem(
printer_id=data.printer_id,
target_model=target_model_norm,
target_location=data.target_location,
required_filament_types=required_filament_types,
filament_overrides=filament_overrides_json,
archive_id=data.archive_id,
library_file_id=data.library_file_id,
scheduled_time=data.scheduled_time,
require_previous_success=data.require_previous_success,
auto_off_after=data.auto_off_after,
manual_start=data.manual_start,
ams_mapping=ams_mapping_json,
plate_id=data.plate_id,
bed_levelling=data.bed_levelling,
flow_cali=data.flow_cali,
vibration_cali=data.vibration_cali,
layer_inspect=data.layer_inspect,
timelapse=data.timelapse,
use_ams=data.use_ams,
position=max_pos + 1 + i,
status="pending",
created_by_id=current_user.id if current_user else None,
batch_id=batch_id,
)
db.add(item)
items.append(item)
# Load relationships for response
await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
await db.commit()
# Refresh the first item for the response
item = items[0]
await db.refresh(item)
await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
logger.info("Added %s to queue for %s", source_name, target_desc)
qty_desc = f" (×{quantity})" if quantity > 1 else ""
logger.info("Added %s to queue for %s%s", source_name, target_desc, qty_desc)
# MQTT relay - publish queue job added
try:
@@ -481,6 +525,8 @@ async def add_to_queue(
else f"Job #{item.id}"
)
job_name = job_name.replace(".gcode.3mf", "").replace(".3mf", "")
if quantity > 1:
job_name = f"{job_name} ×{quantity}"
target = (
item.printer.name if item.printer else (f"Any {item.target_model}" if target_model_norm else "Unassigned")
)
@@ -561,6 +607,106 @@ async def bulk_update_queue_items(
)
# --- Batch endpoints ---
@router.get("/batches", response_model=list[PrintBatchResponse])
async def list_batches(
status: str | None = Query(None, description="Filter by status (active, completed, cancelled)"),
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
):
"""List all print batches with progress stats."""
query = select(PrintBatch).order_by(PrintBatch.created_at.desc())
if status:
query = query.where(PrintBatch.status == status)
result = await db.execute(query)
batches = result.scalars().all()
responses = []
for batch in batches:
responses.append(await _build_batch_response(db, batch))
return responses
@router.get("/batches/{batch_id}", response_model=PrintBatchResponse)
async def get_batch(
batch_id: int,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
):
"""Get a print batch with progress stats."""
result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
batch = result.scalar_one_or_none()
if not batch:
raise HTTPException(404, "Batch not found")
return await _build_batch_response(db, batch)
@router.delete("/batches/{batch_id}")
async def cancel_batch(
batch_id: int,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
):
"""Cancel all pending items in a batch and mark batch as cancelled."""
result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
batch = result.scalar_one_or_none()
if not batch:
raise HTTPException(404, "Batch not found")
# Cancel all pending queue items in this batch
result = await db.execute(
select(PrintQueueItem).where(and_(PrintQueueItem.batch_id == batch_id, PrintQueueItem.status == "pending"))
)
pending_items = result.scalars().all()
cancelled_count = 0
for item in pending_items:
item.status = "cancelled"
cancelled_count += 1
batch.status = "cancelled"
await db.commit()
return {"message": f"Batch cancelled, {cancelled_count} pending items cancelled"}
async def _build_batch_response(db: AsyncSession, batch: PrintBatch) -> PrintBatchResponse:
"""Build a batch response with derived counts from queue items."""
# Count queue items by status
result = await db.execute(
select(PrintQueueItem.status, func.count(PrintQueueItem.id))
.where(PrintQueueItem.batch_id == batch.id)
.group_by(PrintQueueItem.status)
)
status_counts = {row[0]: row[1] for row in result.fetchall()}
# Load created_by for username
created_by_username = None
if batch.created_by_id:
result = await db.execute(select(User).where(User.id == batch.created_by_id))
user = result.scalar_one_or_none()
if user:
created_by_username = user.username
return PrintBatchResponse(
id=batch.id,
name=batch.name,
archive_id=batch.archive_id,
library_file_id=batch.library_file_id,
quantity=batch.quantity,
status=batch.status,
created_at=batch.created_at,
created_by_id=batch.created_by_id,
created_by_username=created_by_username,
pending_count=status_counts.get("pending", 0),
printing_count=status_counts.get("printing", 0),
completed_count=status_counts.get("completed", 0),
failed_count=status_counts.get("failed", 0),
cancelled_count=status_counts.get("cancelled", 0),
)
@router.get("/{item_id}", response_model=PrintQueueItemResponse)
async def get_queue_item(
item_id: int,
@@ -575,6 +721,7 @@ async def get_queue_item(
selectinload(PrintQueueItem.printer),
selectinload(PrintQueueItem.library_file),
selectinload(PrintQueueItem.created_by),
selectinload(PrintQueueItem.batch),
)
.where(PrintQueueItem.id == item_id)
)
@@ -656,7 +803,7 @@ async def update_queue_item(
setattr(item, field, value)
await db.commit()
await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
logger.info("Updated queue item %s", item_id)
return _enrich_response(item)
@@ -844,7 +991,11 @@ async def start_queue_item(
"""
result = await db.execute(
select(PrintQueueItem)
.options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
.options(
selectinload(PrintQueueItem.archive),
selectinload(PrintQueueItem.printer),
selectinload(PrintQueueItem.batch),
)
.where(PrintQueueItem.id == item_id)
)
item = result.scalar_one_or_none()
@@ -857,7 +1008,7 @@ async def start_queue_item(
# Clear manual_start flag so scheduler picks it up
item.manual_start = False
await db.commit()
await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
logger.info("Manually started queue item %s (cleared manual_start flag)", item_id)
return _enrich_response(item)
+9
View File
@@ -95,6 +95,7 @@ async def init_db():
notification_template,
orca_base_cache,
pending_upload,
print_batch,
print_log,
print_queue,
printer,
@@ -1609,6 +1610,14 @@ async def run_migrations(conn):
except OperationalError:
pass # Already applied
# Migration: Add batch_id column to print_queue for batch grouping
try:
await conn.execute(
text("ALTER TABLE print_queue ADD COLUMN batch_id INTEGER REFERENCES print_batches(id) ON DELETE SET NULL")
)
except OperationalError:
pass
# Seed default settings keys that must exist on fresh install
default_settings = [
("advanced_auth_enabled", "false"),
+2
View File
@@ -14,6 +14,7 @@ from backend.app.models.notification import NotificationLog
from backend.app.models.notification_template import NotificationTemplate
from backend.app.models.orca_base_cache import OrcaBaseProfile
from backend.app.models.pending_upload import PendingUpload
from backend.app.models.print_batch import PrintBatch
from backend.app.models.printer import Printer
from backend.app.models.project import Project
from backend.app.models.settings import Settings
@@ -44,6 +45,7 @@ __all__ = [
"AMSSensorHistory",
"AmsLabel",
"PendingUpload",
"PrintBatch",
"LibraryFolder",
"LibraryFile",
"User",
+45
View File
@@ -0,0 +1,45 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class PrintBatch(Base):
"""Batch grouping for multiple queue items created from the same file."""
__tablename__ = "print_batches"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255))
# Source file (one of these)
archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
library_file_id: Mapped[int | None] = mapped_column(
ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
)
# Total requested quantity (for display — actual items may differ if cancelled)
quantity: Mapped[int] = mapped_column(Integer, default=1)
# Status: active, completed, cancelled
status: Mapped[str] = mapped_column(String(20), default="active")
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
# User tracking
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
# Relationships
archive: Mapped["PrintArchive | None"] = relationship()
library_file: Mapped["LibraryFile | None"] = relationship()
created_by: Mapped["User | None"] = relationship()
queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="batch")
from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.library import LibraryFile # noqa: E402
from backend.app.models.print_queue import PrintQueueItem # noqa: E402
from backend.app.models.user import User # noqa: E402
+3
View File
@@ -33,6 +33,7 @@ class PrintQueueItem(Base):
ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
)
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
# Scheduling
position: Mapped[int] = mapped_column(Integer, default=0) # Queue order
@@ -84,11 +85,13 @@ class PrintQueueItem(Base):
archive: Mapped["PrintArchive | None"] = relationship()
library_file: Mapped["LibraryFile | None"] = relationship()
project: Mapped["Project | None"] = relationship(back_populates="queue_items")
batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
created_by: Mapped["User | None"] = relationship()
from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.library import LibraryFile # noqa: E402
from backend.app.models.print_batch import PrintBatch # noqa: E402
from backend.app.models.printer import Printer # noqa: E402
from backend.app.models.project import Project # noqa: E402
from backend.app.models.user import User # noqa: E402
+29
View File
@@ -40,6 +40,8 @@ class PrintQueueItemCreate(BaseModel):
layer_inspect: bool = False
timelapse: bool = False
use_ams: bool = True
# Batch: create multiple copies (creates a batch if > 1)
quantity: int = 1
class PrintQueueItemUpdate(BaseModel):
@@ -111,6 +113,10 @@ class PrintQueueItemResponse(BaseModel):
created_by_id: int | None = None
created_by_username: str | None = None
# Batch grouping
batch_id: int | None = None
batch_name: str | None = None
class Config:
from_attributes = True
@@ -149,3 +155,26 @@ class PrintQueueBulkUpdateResponse(BaseModel):
updated_count: int
skipped_count: int # Items that were not pending
message: str
class PrintBatchResponse(BaseModel):
"""Response for a print batch with progress stats."""
id: int
name: str
archive_id: int | None = None
library_file_id: int | None = None
quantity: int
status: str
created_at: UTCDatetime
created_by_id: int | None = None
created_by_username: str | None = None
# Derived counts
pending_count: int = 0
printing_count: int = 0
completed_count: int = 0
failed_count: int = 0
cancelled_count: int = 0
class Config:
from_attributes = True
@@ -1423,3 +1423,215 @@ class TestAbortedStatusNormalisation:
pass
assert item.status == "completed"
# ========================================================================
# Batch quantity tests
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_quantity_default(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Verify quantity=1 (default) creates a single item with no batch."""
printer = await printer_factory()
archive = await archive_factory()
data = {
"printer_id": printer.id,
"archive_id": archive.id,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["batch_id"] is None
assert result["batch_name"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_quantity_one_explicit(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Verify quantity=1 explicitly creates a single item with no batch."""
printer = await printer_factory()
archive = await archive_factory()
data = {
"printer_id": printer.id,
"archive_id": archive.id,
"quantity": 1,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["batch_id"] is None
assert result["batch_name"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_quantity_creates_batch(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Verify quantity > 1 creates a batch and multiple queue items."""
printer = await printer_factory()
archive = await archive_factory()
data = {
"printer_id": printer.id,
"archive_id": archive.id,
"quantity": 3,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
# First item is returned, linked to a batch
assert result["batch_id"] is not None
assert result["batch_name"] is not None
assert "×3" in result["batch_name"]
# Verify all 3 items were created
list_response = await async_client.get("/api/v1/queue/")
items = list_response.json()
batch_items = [i for i in items if i["batch_id"] == result["batch_id"]]
assert len(batch_items) == 3
# All items should have the same settings
for item in batch_items:
assert item["printer_id"] == printer.id
assert item["archive_id"] == archive.id
assert item["status"] == "pending"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_quantity_sequential_positions(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Verify batch items get sequential positions."""
printer = await printer_factory()
archive = await archive_factory()
data = {
"printer_id": printer.id,
"archive_id": archive.id,
"quantity": 3,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
batch_id = response.json()["batch_id"]
list_response = await async_client.get("/api/v1/queue/")
items = list_response.json()
batch_items = sorted(
[i for i in items if i["batch_id"] == batch_id],
key=lambda i: i["position"],
)
positions = [i["position"] for i in batch_items]
assert positions == [positions[0], positions[0] + 1, positions[0] + 2]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_quantity_with_print_options(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Verify print options are applied to all batch items."""
printer = await printer_factory()
archive = await archive_factory()
data = {
"printer_id": printer.id,
"archive_id": archive.id,
"quantity": 2,
"bed_levelling": False,
"timelapse": True,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
batch_id = response.json()["batch_id"]
list_response = await async_client.get("/api/v1/queue/")
batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
assert len(batch_items) == 2
for item in batch_items:
assert item["bed_levelling"] is False
assert item["timelapse"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_batch(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
"""Verify batch can be retrieved with progress stats."""
printer = await printer_factory()
archive = await archive_factory()
# Create a batch of 3
data = {
"printer_id": printer.id,
"archive_id": archive.id,
"quantity": 3,
}
response = await async_client.post("/api/v1/queue/", json=data)
batch_id = response.json()["batch_id"]
# Get batch
response = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
assert response.status_code == 200
result = response.json()
assert result["id"] == batch_id
assert result["quantity"] == 3
assert result["status"] == "active"
assert result["pending_count"] == 3
assert result["printing_count"] == 0
assert result["completed_count"] == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_batches(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
"""Verify batches can be listed."""
printer = await printer_factory()
archive = await archive_factory()
# Create two batches
for qty in [2, 3]:
await async_client.post(
"/api/v1/queue/",
json={"printer_id": printer.id, "archive_id": archive.id, "quantity": qty},
)
response = await async_client.get("/api/v1/queue/batches")
assert response.status_code == 200
batches = response.json()
assert len(batches) >= 2
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cancel_batch(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
"""Verify cancelling a batch cancels all pending items."""
printer = await printer_factory()
archive = await archive_factory()
data = {
"printer_id": printer.id,
"archive_id": archive.id,
"quantity": 3,
}
response = await async_client.post("/api/v1/queue/", json=data)
batch_id = response.json()["batch_id"]
# Cancel the batch
response = await async_client.delete(f"/api/v1/queue/batches/{batch_id}")
assert response.status_code == 200
# Verify all items are cancelled
list_response = await async_client.get("/api/v1/queue/")
batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
for item in batch_items:
assert item["status"] == "cancelled"
# Verify batch status
batch_response = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
assert batch_response.json()["status"] == "cancelled"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_batch_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent batch."""
response = await async_client.get("/api/v1/queue/batches/9999")
assert response.status_code == 404
@@ -48,6 +48,8 @@ const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueu
archive_thumbnail: null,
printer_name: 'Test Printer',
print_time_seconds: 3600,
batch_id: null,
batch_name: null,
...overrides,
});
@@ -1057,4 +1059,85 @@ describe('PrintModal', () => {
expect((queueRequests[2] as { plate_id: number }).plate_id).toBe(3);
});
});
describe('batch quantity', () => {
it('shows quantity input in reprint mode', () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
});
it('shows quantity input in add-to-queue mode', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
});
it('does not show quantity input in edit-queue-item mode', () => {
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Benchy"
queueItem={createMockQueueItem()}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.queryByLabelText('Quantity')).not.toBeInTheDocument();
});
it('defaults quantity to 1', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
const input = screen.getByLabelText('Quantity') as HTMLInputElement;
expect(input.value).toBe('1');
});
it('quantity input has default value of 1 and accepts changes', async () => {
const user = userEvent.setup();
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
const input = screen.getByLabelText('Quantity') as HTMLInputElement;
expect(input.value).toBe('1');
await user.tripleClick(input);
await user.keyboard('5');
expect(input.value).toBe('5');
});
});
});
+30
View File
@@ -1365,6 +1365,26 @@ export interface PrintQueueItem {
// User tracking (Issue #206)
created_by_id?: number | null;
created_by_username?: string | null;
// Batch grouping
batch_id?: number | null;
batch_name?: string | null;
}
export interface PrintBatch {
id: number;
name: string;
archive_id: number | null;
library_file_id: number | null;
quantity: number;
status: string;
created_at: string;
created_by_id: number | null;
created_by_username: string | null;
pending_count: number;
printing_count: number;
completed_count: number;
failed_count: number;
cancelled_count: number;
}
export interface PrintQueueItemCreate {
@@ -1387,6 +1407,8 @@ export interface PrintQueueItemCreate {
layer_inspect?: boolean;
timelapse?: boolean;
use_ams?: boolean;
// Batch: create multiple copies (creates a batch if > 1)
quantity?: number;
}
export interface PrintQueueItemUpdate {
@@ -3448,6 +3470,14 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(data),
}),
// Batches
getBatches: (status?: string) => {
const params = status ? `?status=${status}` : '';
return request<PrintBatch[]>(`/queue/batches${params}`);
},
getBatch: (id: number) => request<PrintBatch>(`/queue/batches/${id}`),
cancelBatch: (id: number) =>
request<{ message: string }>(`/queue/batches/${id}`, { method: 'DELETE' }),
// K-Profiles
getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
+49 -4
View File
@@ -87,6 +87,9 @@ export function PrintModal({
// Derived single-plate value for filament queries and single-select contexts
const selectedPlate = selectedPlates.size === 1 ? [...selectedPlates][0] : null;
// Quantity — number of copies (creates a batch if > 1)
const [quantity, setQuantity] = useState(1);
const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
if (mode === 'edit-queue-item' && queueItem) {
return {
@@ -686,7 +689,9 @@ export function PrintModal({
await updateQueueMutation.mutateAsync(updateData);
} else {
// Add-to-queue mode with model-based assignment
await addToQueueMutation.mutateAsync(getQueueData(null, plateId));
const queueData = getQueueData(null, plateId);
if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
await addToQueueMutation.mutateAsync(queueData);
}
results.success++;
} catch (error) {
@@ -735,6 +740,12 @@ export function PrintModal({
...printOptions,
});
}
// Queue remaining copies if quantity > 1
if (effectiveQuantity > 1) {
const queueData = getQueueData(printerId, plateId);
queueData.quantity = effectiveQuantity - 1;
await addToQueueMutation.mutateAsync(queueData);
}
} else if (mode === 'edit-queue-item' && progressCounter === 1) {
// Edit mode - update the original queue item for the first entry
const printerMapping = getMappingForPrinter(printerId);
@@ -756,6 +767,7 @@ export function PrintModal({
} else {
// Add-to-queue mode, stagger-reprint mode, or edit mode with additional entries
const queueData = getQueueData(printerId, plateId);
if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
// Apply stagger offset for groups after the first
if (useStagger) {
const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
@@ -825,18 +837,25 @@ export function PrintModal({
return true;
}, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlates.size, isPending]);
// Quantity only applies for single-printer or model-based assignment (not multi-printer)
const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
// Modal title and action button text based on mode
const getModalConfig = () => {
const printerCount = selectedPrinters.length;
if (mode === 'reprint') {
const staggerReprint = willUseStagger && printerCount > 1;
let submitText = staggerReprint
? t('printModal.staggerToPrinters', { count: printerCount, defaultValue: 'Stagger to {{count}} printers' })
: printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print');
if (effectiveQuantity > 1) {
submitText = `${submitText} ×${effectiveQuantity}`;
}
return {
title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
icon: Printer,
submitText: staggerReprint
? t('printModal.staggerToPrinters', { count: printerCount, defaultValue: 'Stagger to {{count}} printers' })
: printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print'),
submitText,
submitIcon: staggerReprint ? Calendar : Printer,
loadingText: submitProgress.total > 1
? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
@@ -850,6 +869,9 @@ export function PrintModal({
} else if (printerCount > 1) {
submitText = t('queue.queueToPrinters', { count: printerCount });
}
if (effectiveQuantity > 1) {
submitText = `${submitText} ×${effectiveQuantity}`;
}
return {
title: t('queue.schedulePrint'),
icon: Calendar,
@@ -1037,6 +1059,29 @@ export function PrintModal({
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />
)}
{/* Quantity — create multiple copies (batch). Hidden for multi-printer selection. */}
{mode !== 'edit-queue-item' && (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
<div className="flex items-center gap-3">
<label htmlFor="printQuantity" className="text-sm text-bambu-gray whitespace-nowrap">
{t('queue.quantity', 'Quantity')}
</label>
<input
id="printQuantity"
type="number"
min={1}
max={999}
value={quantity}
onChange={(e) => setQuantity(Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))}
className="w-20 px-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green"
/>
{quantity > 1 && (
<span className="text-xs text-bambu-gray">
{t('queue.quantityHint', 'Creates {{count}} queue items', { count: quantity })}
</span>
)}
</div>
)}
{/* Stagger option for reprint mode with multiple printers */}
{mode === 'reprint' && assignmentMode === 'printer' && selectedPrinters.length > 1 && (
<div className="space-y-2 pb-2">
+10
View File
@@ -856,6 +856,16 @@ export default {
clearPlateSuccess: 'Druckplatte freigegeben — bereit für nächsten Druck',
plateReady: 'Druckplatte freigegeben — bereit für nächsten Druck',
plateNumber: 'Platte {{index}}',
// Batch / quantity
quantity: 'Menge',
quantityHint: 'Erstellt {{count}} Warteschlangeneinträge',
activeBatches: 'Aktive Stapel',
batchProgress: '{{completed}} von {{total}} abgeschlossen',
cancelBatch: 'Verbleibende abbrechen',
batchCancelled: 'Verbleibende Stapeleinträge abgebrochen',
cancelBatchConfirmTitle: 'Stapel abbrechen',
cancelBatchConfirmMessage: 'Alle verbleibenden ausstehenden Einträge in diesem Stapel abbrechen?',
batch: 'Stapel',
// Sections
sections: {
currentlyPrinting: 'Aktuell druckend',
+10
View File
@@ -856,6 +856,16 @@ export default {
clearPlateSuccess: 'Plate cleared — ready for next print',
plateReady: 'Plate cleared — ready for next print',
plateNumber: 'Plate {{index}}',
// Batch / quantity
quantity: 'Quantity',
quantityHint: 'Creates {{count}} queue items',
activeBatches: 'Active Batches',
batchProgress: '{{completed}} of {{total}} completed',
cancelBatch: 'Cancel Remaining',
batchCancelled: 'Remaining batch items cancelled',
cancelBatchConfirmTitle: 'Cancel Batch',
cancelBatchConfirmMessage: 'Cancel all remaining pending items in this batch?',
batch: 'Batch',
// Sections
sections: {
currentlyPrinting: 'Currently Printing',
+10
View File
@@ -856,6 +856,16 @@ export default {
clearPlateSuccess: 'Plateau vidé — prêt pour l\'impression suivante',
plateReady: 'Plateau vidé — prêt pour l\'impression suivante',
plateNumber: 'Plateau {{index}}',
// Batch / quantity
quantity: 'Quantité',
quantityHint: 'Crée {{count}} éléments de file d\'attente',
activeBatches: 'Lots actifs',
batchProgress: '{{completed}} sur {{total}} terminés',
cancelBatch: 'Annuler les restants',
batchCancelled: 'Éléments restants du lot annulés',
cancelBatchConfirmTitle: 'Annuler le lot',
cancelBatchConfirmMessage: 'Annuler tous les éléments en attente restants dans ce lot ?',
batch: 'Lot',
// Sections
sections: {
currentlyPrinting: 'En cours',
+10
View File
@@ -856,6 +856,16 @@ export default {
clearPlateSuccess: 'Piatto liberato — pronto per la prossima stampa',
plateReady: 'Piatto liberato — pronto per la prossima stampa',
plateNumber: 'Piatto {{index}}',
// Batch / quantity
quantity: 'Quantità',
quantityHint: 'Crea {{count}} elementi in coda',
activeBatches: 'Lotti attivi',
batchProgress: '{{completed}} di {{total}} completati',
cancelBatch: 'Annulla rimanenti',
batchCancelled: 'Elementi rimanenti del lotto annullati',
cancelBatchConfirmTitle: 'Annulla lotto',
cancelBatchConfirmMessage: 'Annullare tutti gli elementi in sospeso rimanenti in questo lotto?',
batch: 'Lotto',
// Sections
sections: {
currentlyPrinting: 'In stampa',
+10
View File
@@ -855,6 +855,16 @@ export default {
clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
plateNumber: 'プレート {{index}}',
// Batch / quantity
quantity: '数量',
quantityHint: '{{count}}件のキューアイテムを作成',
activeBatches: 'アクティブなバッチ',
batchProgress: '{{total}}件中{{completed}}件完了',
cancelBatch: '残りをキャンセル',
batchCancelled: '残りのバッチアイテムをキャンセルしました',
cancelBatchConfirmTitle: 'バッチをキャンセル',
cancelBatchConfirmMessage: 'このバッチの残りの保留中アイテムをすべてキャンセルしますか?',
batch: 'バッチ',
// Sections
sections: {
currentlyPrinting: '印刷中',
+10
View File
@@ -856,6 +856,16 @@ export default {
clearPlateSuccess: 'Placa limpa — pronta para a próxima impressão',
plateReady: 'Placa limpa — pronta para a próxima impressão',
plateNumber: 'Placa {{index}}',
// Batch / quantity
quantity: 'Quantidade',
quantityHint: 'Cria {{count}} itens na fila',
activeBatches: 'Lotes ativos',
batchProgress: '{{completed}} de {{total}} concluídos',
cancelBatch: 'Cancelar restantes',
batchCancelled: 'Itens restantes do lote cancelados',
cancelBatchConfirmTitle: 'Cancelar lote',
cancelBatchConfirmMessage: 'Cancelar todos os itens pendentes restantes neste lote?',
batch: 'Lote',
// Sections
sections: {
currentlyPrinting: 'Imprimindo Atualmente',
+10
View File
@@ -856,6 +856,16 @@ export default {
clearPlateSuccess: '打印板已清理 — 准备进行下一个打印',
plateReady: '打印板已清理 — 准备进行下一个打印',
plateNumber: '板 {{index}}',
// Batch / quantity
quantity: '数量',
quantityHint: '创建 {{count}} 个队列项目',
activeBatches: '活跃批次',
batchProgress: '已完成 {{completed}}/{{total}}',
cancelBatch: '取消剩余',
batchCancelled: '已取消剩余批次项目',
cancelBatchConfirmTitle: '取消批次',
cancelBatchConfirmMessage: '取消此批次中所有剩余的待处理项目?',
batch: '批次',
// Sections
sections: {
currentlyPrinting: '正在打印',
+5
View File
@@ -480,6 +480,11 @@ function SortableQueueItem({
<ExternalLink className="w-3.5 h-3.5" />
</Link>
) : null}
{item.batch_name && (
<span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-purple-500/20 text-purple-300 rounded border border-purple-500/30">
{item.batch_name}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BhHLaTQH.js"></script>
<script type="module" crossorigin src="/assets/index-DKoL3gto.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BGA3I7Jb.css">
</head>
<body>