mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Add resizable printer cards and Queue Only mode
Features:
- Resizable printer cards (S/M/L/XL) with toolbar controls on Printers page
- Queue Only mode for staging prints without automatic scheduling
- "Queue Only" option in add/edit queue modals
- Purple "Staged" badge and Play button to release to queue
- manual_start field in database with migration
Fixes:
- Improved camera stream stuck detection with automatic reconnection
Tests:
- Added 16 integration tests for print queue API endpoints
This commit is contained in:
@@ -2,6 +2,28 @@
|
||||
|
||||
All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [0.1.6b6] - 2026-01-04
|
||||
|
||||
### Added
|
||||
- **Resizable printer cards** - Adjust printer card size from the Printers page toolbar:
|
||||
- Four sizes: Small, Medium (default), Large, XL
|
||||
- Plus/minus buttons in toolbar header
|
||||
- Size preference saved to localStorage
|
||||
- Responsive grid adapts to selected size
|
||||
- **Queue Only mode** - Stage prints without automatic scheduling:
|
||||
- New "Queue Only" option when adding prints to queue
|
||||
- Staged prints show purple "Staged" badge
|
||||
- Play button to manually release staged prints to the queue
|
||||
- Edit queue items to switch between ASAP, Scheduled, and Queue Only modes
|
||||
- Useful for preparing print batches before activating
|
||||
|
||||
### Fixed
|
||||
- **Camera stream reconnection** - Improved detection of stuck camera streams with automatic reconnection
|
||||
|
||||
### Tests
|
||||
- Added integration tests for print queue API endpoints (16 new tests)
|
||||
- Tests cover queue CRUD, manual_start flag, and start/cancel endpoints
|
||||
|
||||
## [0.1.6b5] - 2026-01-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
- Live camera streaming (MJPEG) & snapshots
|
||||
- Fan status monitoring (part cooling, auxiliary, chamber)
|
||||
- Printer control (stop, pause, resume)
|
||||
- Resizable printer cards (S/M/L/XL)
|
||||
- Skip objects during print
|
||||
- AMS slot RFID re-read
|
||||
- HMS error monitoring with history
|
||||
@@ -68,6 +69,7 @@
|
||||
### ⏰ Scheduling & Automation
|
||||
- Print queue with drag-and-drop
|
||||
- Scheduled prints (date/time)
|
||||
- Queue Only mode (stage without auto-start)
|
||||
- Smart plug integration (Tasmota)
|
||||
- Energy consumption tracking
|
||||
- Auto power-on before print
|
||||
|
||||
@@ -167,7 +167,7 @@ async def generate_rtsp_mjpeg_stream(
|
||||
ffmpeg = get_ffmpeg_path()
|
||||
if not ffmpeg:
|
||||
logger.error("ffmpeg not found - camera streaming requires ffmpeg")
|
||||
yield (b"--frame\r\n" b"Content-Type: text/plain\r\n\r\n" b"Error: ffmpeg not installed\r\n")
|
||||
yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
|
||||
return
|
||||
|
||||
port = get_camera_port(model)
|
||||
@@ -303,7 +303,7 @@ async def generate_rtsp_mjpeg_stream(
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("ffmpeg not found - camera streaming requires ffmpeg")
|
||||
yield (b"--frame\r\n" b"Content-Type: text/plain\r\n\r\n" b"Error: ffmpeg not installed\r\n")
|
||||
yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"Camera stream task cancelled (stream_id={stream_id})")
|
||||
except GeneratorExit:
|
||||
|
||||
@@ -89,6 +89,7 @@ async def add_to_queue(
|
||||
scheduled_time=data.scheduled_time,
|
||||
require_previous_success=data.require_previous_success,
|
||||
auto_off_after=data.auto_off_after,
|
||||
manual_start=data.manual_start,
|
||||
position=max_pos + 1,
|
||||
status="pending",
|
||||
)
|
||||
@@ -272,3 +273,34 @@ async def stop_queue_item(
|
||||
asyncio.create_task(cooldown_and_poweroff())
|
||||
|
||||
return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}
|
||||
|
||||
|
||||
@router.post("/{item_id}/start")
|
||||
async def start_queue_item(
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Manually start a staged (manual_start) queue item.
|
||||
|
||||
This clears the manual_start flag so the scheduler will pick it up,
|
||||
or starts immediately if the printer is ready.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
|
||||
.where(PrintQueueItem.id == item_id)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(404, "Queue item not found")
|
||||
|
||||
if item.status != "pending":
|
||||
raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
|
||||
|
||||
# Clear manual_start flag so scheduler picks it up
|
||||
item.manual_start = False
|
||||
await db.commit()
|
||||
await db.refresh(item, ["archive", "printer"])
|
||||
|
||||
logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
|
||||
return _enrich_response(item)
|
||||
|
||||
@@ -478,6 +478,7 @@ async def export_backup(
|
||||
"scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None,
|
||||
"require_previous_success": qi.require_previous_success,
|
||||
"auto_off_after": qi.auto_off_after,
|
||||
"manual_start": qi.manual_start,
|
||||
"status": qi.status,
|
||||
"started_at": qi.started_at.isoformat() if qi.started_at else None,
|
||||
"completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
|
||||
@@ -1468,6 +1469,7 @@ async def import_backup(
|
||||
position=qi_data.get("position", 0),
|
||||
require_previous_success=qi_data.get("require_previous_success", False),
|
||||
auto_off_after=qi_data.get("auto_off_after", False),
|
||||
manual_start=qi_data.get("manual_start", False),
|
||||
status=qi_data.get("status", "pending"),
|
||||
error_message=qi_data.get("error_message"),
|
||||
)
|
||||
|
||||
@@ -381,6 +381,12 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add manual_start column to print_queue for staged prints
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
|
||||
+1
-1
@@ -1616,7 +1616,7 @@ async def track_printer_runtime():
|
||||
needs_commit = True
|
||||
logger.debug(
|
||||
f"[{printer.name}] Runtime tracking: added {int(elapsed)}s, "
|
||||
f"total={printer.runtime_seconds}s ({printer.runtime_seconds/3600:.2f}h)"
|
||||
f"total={printer.runtime_seconds}s ({printer.runtime_seconds / 3600:.2f}h)"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
|
||||
@@ -21,6 +21,7 @@ class PrintQueueItem(Base):
|
||||
# Scheduling
|
||||
position: Mapped[int] = mapped_column(Integer, default=0) # Queue order
|
||||
scheduled_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # None = ASAP
|
||||
manual_start: Mapped[bool] = mapped_column(Boolean, default=False) # Requires manual trigger to start
|
||||
|
||||
# Conditions
|
||||
require_previous_success: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
@@ -21,6 +21,7 @@ class PrintQueueItemCreate(BaseModel):
|
||||
scheduled_time: datetime | None = None # None = ASAP (next when idle)
|
||||
require_previous_success: bool = False
|
||||
auto_off_after: bool = False # Power off printer after print completes
|
||||
manual_start: bool = False # Requires manual trigger to start (staged)
|
||||
|
||||
|
||||
class PrintQueueItemUpdate(BaseModel):
|
||||
@@ -29,6 +30,7 @@ class PrintQueueItemUpdate(BaseModel):
|
||||
scheduled_time: datetime | None = None
|
||||
require_previous_success: bool | None = None
|
||||
auto_off_after: bool | None = None
|
||||
manual_start: bool | None = None
|
||||
|
||||
|
||||
class PrintQueueItemResponse(BaseModel):
|
||||
@@ -39,6 +41,7 @@ class PrintQueueItemResponse(BaseModel):
|
||||
scheduled_time: UTCDatetime
|
||||
require_previous_success: bool
|
||||
auto_off_after: bool
|
||||
manual_start: bool
|
||||
status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
|
||||
started_at: UTCDatetime
|
||||
completed_at: UTCDatetime
|
||||
|
||||
@@ -566,7 +566,7 @@ class TasmotaScanner:
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"Batch {i//batch_size} error: {e}")
|
||||
logger.warning(f"Batch {i // batch_size} error: {e}")
|
||||
self._scanned = min(i + batch_size, len(hosts))
|
||||
|
||||
logger.info(f"Tasmota scan complete. Found {len(self._discovered)} devices.")
|
||||
|
||||
@@ -72,6 +72,10 @@ class PrintScheduler:
|
||||
if item.scheduled_time and item.scheduled_time > datetime.utcnow():
|
||||
continue
|
||||
|
||||
# Skip items that require manual start
|
||||
if item.manual_start:
|
||||
continue
|
||||
|
||||
# Check if printer is idle
|
||||
printer_idle = self._is_printer_idle(item.printer_id)
|
||||
printer_connected = printer_manager.is_connected(item.printer_id)
|
||||
|
||||
@@ -162,9 +162,7 @@ class SmartPlugManager:
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Print completed successfully on printer {printer_id}, " f"scheduling turn-off for plug '{plug.name}'"
|
||||
)
|
||||
logger.info(f"Print completed successfully on printer {printer_id}, scheduling turn-off for plug '{plug.name}'")
|
||||
|
||||
if plug.off_delay_mode == "time":
|
||||
self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
|
||||
@@ -227,7 +225,7 @@ class SmartPlugManager:
|
||||
# Cancel any existing task for this plug
|
||||
self._cancel_pending_off(plug.id)
|
||||
|
||||
logger.info(f"Scheduling temperature-based turn-off for plug '{plug.name}' " f"(threshold: {temp_threshold}°C)")
|
||||
logger.info(f"Scheduling temperature-based turn-off for plug '{plug.name}' (threshold: {temp_threshold}°C)")
|
||||
|
||||
# Mark as pending in database (survives restarts)
|
||||
asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
|
||||
@@ -281,9 +279,7 @@ class SmartPlugManager:
|
||||
f"threshold={temp_threshold}°C"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, " f"threshold={temp_threshold}°C"
|
||||
)
|
||||
logger.info(f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, threshold={temp_threshold}°C")
|
||||
|
||||
if max_nozzle_temp < temp_threshold:
|
||||
# All nozzles are below threshold, turn off
|
||||
@@ -398,7 +394,7 @@ class SmartPlugManager:
|
||||
elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
|
||||
if elapsed > 7200: # 2 hours
|
||||
logger.warning(
|
||||
f"Auto-off for plug '{plug.name}' was pending for {elapsed/60:.0f} minutes, "
|
||||
f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
|
||||
f"clearing stale pending state"
|
||||
)
|
||||
plug.auto_off_pending = False
|
||||
@@ -406,7 +402,7 @@ class SmartPlugManager:
|
||||
await db.commit()
|
||||
continue
|
||||
|
||||
logger.info(f"Resuming pending auto-off for plug '{plug.name}' " f"(printer {plug.printer_id})")
|
||||
logger.info(f"Resuming pending auto-off for plug '{plug.name}' (printer {plug.printer_id})")
|
||||
|
||||
# Resume the appropriate off mode
|
||||
if plug.off_delay_mode == "temperature":
|
||||
|
||||
@@ -577,9 +577,7 @@ class SpoolmanClient:
|
||||
)
|
||||
|
||||
# Spool not found - auto-create it
|
||||
logger.info(
|
||||
f"Creating new spool in Spoolman for {tray.tray_sub_brands} " f"(tray_uuid: {tray.tray_uuid[:16]}...)"
|
||||
)
|
||||
logger.info(f"Creating new spool in Spoolman for {tray.tray_sub_brands} (tray_uuid: {tray.tray_uuid[:16]}...)")
|
||||
|
||||
# First find or create the filament type
|
||||
filament = await self._find_or_create_filament(tray)
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Integration tests for Print Queue API endpoints."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestPrintQueueAPI:
|
||||
"""Integration tests for /api/v1/queue endpoints."""
|
||||
|
||||
@pytest.fixture
|
||||
async def printer_factory(self, db_session):
|
||||
"""Factory to create test printers."""
|
||||
_counter = [0]
|
||||
|
||||
async def _create_printer(**kwargs):
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
_counter[0] += 1
|
||||
counter = _counter[0]
|
||||
|
||||
defaults = {
|
||||
"name": f"Test Printer {counter}",
|
||||
"ip_address": f"192.168.1.{100 + counter}",
|
||||
"serial_number": f"TESTSERIAL{counter:04d}",
|
||||
"access_code": "12345678",
|
||||
"model": "X1C",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
printer = Printer(**defaults)
|
||||
db_session.add(printer)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(printer)
|
||||
return printer
|
||||
|
||||
return _create_printer
|
||||
|
||||
@pytest.fixture
|
||||
async def archive_factory(self, db_session):
|
||||
"""Factory to create test archives."""
|
||||
_counter = [0]
|
||||
|
||||
async def _create_archive(**kwargs):
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
_counter[0] += 1
|
||||
counter = _counter[0]
|
||||
|
||||
defaults = {
|
||||
"filename": f"test_print_{counter}.3mf",
|
||||
"print_name": f"Test Print {counter}",
|
||||
"file_path": f"/tmp/test_print_{counter}.3mf",
|
||||
"file_size": 1024,
|
||||
"content_hash": f"testhash{counter:08d}",
|
||||
"status": "completed",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
archive = PrintArchive(**defaults)
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
return archive
|
||||
|
||||
return _create_archive
|
||||
|
||||
@pytest.fixture
|
||||
async def queue_item_factory(self, db_session, printer_factory, archive_factory):
|
||||
"""Factory to create test queue items."""
|
||||
_counter = [0]
|
||||
|
||||
async def _create_queue_item(**kwargs):
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
_counter[0] += 1
|
||||
counter = _counter[0]
|
||||
|
||||
# Create printer and archive if not provided
|
||||
if "printer_id" not in kwargs:
|
||||
printer = await printer_factory()
|
||||
kwargs["printer_id"] = printer.id
|
||||
|
||||
if "archive_id" not in kwargs:
|
||||
archive = await archive_factory()
|
||||
kwargs["archive_id"] = archive.id
|
||||
|
||||
defaults = {
|
||||
"status": "pending",
|
||||
"position": counter,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
item = PrintQueueItem(**defaults)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(item)
|
||||
return item
|
||||
|
||||
return _create_queue_item
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_list_queue_empty(self, async_client: AsyncClient):
|
||||
"""Verify empty list when no queue items exist."""
|
||||
response = await async_client.get("/api/v1/queue/")
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
|
||||
"""Verify item can be added to queue."""
|
||||
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["printer_id"] == printer.id
|
||||
assert result["archive_id"] == archive.id
|
||||
assert result["status"] == "pending"
|
||||
assert result["manual_start"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_with_manual_start(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""Verify item can be added to queue with manual_start=True."""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory()
|
||||
|
||||
data = {
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
"manual_start": True,
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["printer_id"] == printer.id
|
||||
assert result["archive_id"] == archive.id
|
||||
assert result["status"] == "pending"
|
||||
assert result["manual_start"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
|
||||
"""Verify single queue item can be retrieved."""
|
||||
item = await queue_item_factory()
|
||||
response = await async_client.get(f"/api/v1/queue/{item.id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == item.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_queue_item_not_found(self, async_client: AsyncClient):
|
||||
"""Verify 404 for non-existent queue item."""
|
||||
response = await async_client.get("/api/v1/queue/9999")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
|
||||
"""Verify queue item can be updated."""
|
||||
item = await queue_item_factory()
|
||||
response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"auto_off_after": True})
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["auto_off_after"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_queue_item_manual_start(self, async_client: AsyncClient, queue_item_factory, db_session):
|
||||
"""Verify queue item manual_start can be updated."""
|
||||
item = await queue_item_factory(manual_start=False)
|
||||
response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"manual_start": True})
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["manual_start"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
|
||||
"""Verify queue item can be deleted."""
|
||||
item = await queue_item_factory()
|
||||
response = await async_client.delete(f"/api/v1/queue/{item.id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Queue item deleted"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_queue_item_not_found(self, async_client: AsyncClient):
|
||||
"""Verify 404 for deleting non-existent queue item."""
|
||||
response = await async_client.delete("/api/v1/queue/9999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestQueueStartEndpoint:
|
||||
"""Tests for the /queue/{item_id}/start endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
async def printer_factory(self, db_session):
|
||||
"""Factory to create test printers."""
|
||||
_counter = [0]
|
||||
|
||||
async def _create_printer(**kwargs):
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
_counter[0] += 1
|
||||
counter = _counter[0]
|
||||
|
||||
defaults = {
|
||||
"name": f"Test Printer {counter}",
|
||||
"ip_address": f"192.168.1.{100 + counter}",
|
||||
"serial_number": f"TESTSERIAL{counter:04d}",
|
||||
"access_code": "12345678",
|
||||
"model": "X1C",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
printer = Printer(**defaults)
|
||||
db_session.add(printer)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(printer)
|
||||
return printer
|
||||
|
||||
return _create_printer
|
||||
|
||||
@pytest.fixture
|
||||
async def archive_factory(self, db_session):
|
||||
"""Factory to create test archives."""
|
||||
_counter = [0]
|
||||
|
||||
async def _create_archive(**kwargs):
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
_counter[0] += 1
|
||||
counter = _counter[0]
|
||||
|
||||
defaults = {
|
||||
"filename": f"test_print_{counter}.3mf",
|
||||
"print_name": f"Test Print {counter}",
|
||||
"file_path": f"/tmp/test_print_{counter}.3mf",
|
||||
"file_size": 1024,
|
||||
"content_hash": f"testhash{counter:08d}",
|
||||
"status": "completed",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
archive = PrintArchive(**defaults)
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
return archive
|
||||
|
||||
return _create_archive
|
||||
|
||||
@pytest.fixture
|
||||
async def queue_item_factory(self, db_session, printer_factory, archive_factory):
|
||||
"""Factory to create test queue items."""
|
||||
_counter = [0]
|
||||
|
||||
async def _create_queue_item(**kwargs):
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
_counter[0] += 1
|
||||
counter = _counter[0]
|
||||
|
||||
if "printer_id" not in kwargs:
|
||||
printer = await printer_factory()
|
||||
kwargs["printer_id"] = printer.id
|
||||
|
||||
if "archive_id" not in kwargs:
|
||||
archive = await archive_factory()
|
||||
kwargs["archive_id"] = archive.id
|
||||
|
||||
defaults = {
|
||||
"status": "pending",
|
||||
"position": counter,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
item = PrintQueueItem(**defaults)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(item)
|
||||
return item
|
||||
|
||||
return _create_queue_item
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_start_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
|
||||
"""Verify starting a staged (manual_start=True) queue item clears the flag."""
|
||||
item = await queue_item_factory(manual_start=True)
|
||||
assert item.manual_start is True
|
||||
|
||||
response = await async_client.post(f"/api/v1/queue/{item.id}/start")
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["manual_start"] is False
|
||||
assert result["status"] == "pending"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_start_non_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
|
||||
"""Verify starting a non-staged queue item still works (idempotent)."""
|
||||
item = await queue_item_factory(manual_start=False)
|
||||
assert item.manual_start is False
|
||||
|
||||
response = await async_client.post(f"/api/v1/queue/{item.id}/start")
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["manual_start"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_start_queue_item_not_found(self, async_client: AsyncClient):
|
||||
"""Verify 404 for non-existent queue item."""
|
||||
response = await async_client.post("/api/v1/queue/9999/start")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_start_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
|
||||
"""Verify 400 error when trying to start a non-pending queue item."""
|
||||
item = await queue_item_factory(status="printing", manual_start=True)
|
||||
|
||||
response = await async_client.post(f"/api/v1/queue/{item.id}/start")
|
||||
assert response.status_code == 400
|
||||
assert "pending" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_start_completed_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
|
||||
"""Verify 400 error when trying to start a completed queue item."""
|
||||
item = await queue_item_factory(status="completed", manual_start=True)
|
||||
|
||||
response = await async_client.post(f"/api/v1/queue/{item.id}/start")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestQueueCancelEndpoint:
|
||||
"""Tests for the /queue/{item_id}/cancel endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
async def printer_factory(self, db_session):
|
||||
"""Factory to create test printers."""
|
||||
|
||||
async def _create_printer(**kwargs):
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
defaults = {
|
||||
"name": "Cancel Test Printer",
|
||||
"ip_address": "192.168.1.200",
|
||||
"serial_number": "TESTCANCEL001",
|
||||
"access_code": "12345678",
|
||||
"model": "X1C",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
printer = Printer(**defaults)
|
||||
db_session.add(printer)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(printer)
|
||||
return printer
|
||||
|
||||
return _create_printer
|
||||
|
||||
@pytest.fixture
|
||||
async def archive_factory(self, db_session):
|
||||
"""Factory to create test archives."""
|
||||
|
||||
async def _create_archive(**kwargs):
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
defaults = {
|
||||
"filename": "cancel_test.3mf",
|
||||
"print_name": "Cancel Test Print",
|
||||
"file_path": "/tmp/cancel_test.3mf",
|
||||
"file_size": 1024,
|
||||
"content_hash": "cancelhash001",
|
||||
"status": "completed",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
archive = PrintArchive(**defaults)
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
return archive
|
||||
|
||||
return _create_archive
|
||||
|
||||
@pytest.fixture
|
||||
async def queue_item_factory(self, db_session, printer_factory, archive_factory):
|
||||
"""Factory to create test queue items."""
|
||||
|
||||
async def _create_queue_item(**kwargs):
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
if "printer_id" not in kwargs:
|
||||
printer = await printer_factory()
|
||||
kwargs["printer_id"] = printer.id
|
||||
|
||||
if "archive_id" not in kwargs:
|
||||
archive = await archive_factory()
|
||||
kwargs["archive_id"] = archive.id
|
||||
|
||||
defaults = {
|
||||
"status": "pending",
|
||||
"position": 1,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
item = PrintQueueItem(**defaults)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(item)
|
||||
return item
|
||||
|
||||
return _create_queue_item
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_cancel_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
|
||||
"""Verify cancelling a pending queue item."""
|
||||
item = await queue_item_factory(status="pending")
|
||||
|
||||
response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Queue item cancelled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_cancel_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
|
||||
"""Verify 400 error when trying to cancel a non-pending queue item."""
|
||||
item = await queue_item_factory(status="printing")
|
||||
|
||||
response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
|
||||
assert response.status_code == 400
|
||||
@@ -789,6 +789,7 @@ export interface PrintQueueItem {
|
||||
scheduled_time: string | null;
|
||||
require_previous_success: boolean;
|
||||
auto_off_after: boolean;
|
||||
manual_start: boolean; // Requires manual trigger to start (staged)
|
||||
status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
@@ -806,6 +807,7 @@ export interface PrintQueueItemCreate {
|
||||
scheduled_time?: string | null;
|
||||
require_previous_success?: boolean;
|
||||
auto_off_after?: boolean;
|
||||
manual_start?: boolean; // Requires manual trigger to start (staged)
|
||||
}
|
||||
|
||||
export interface PrintQueueItemUpdate {
|
||||
@@ -814,6 +816,7 @@ export interface PrintQueueItemUpdate {
|
||||
scheduled_time?: string | null;
|
||||
require_previous_success?: boolean;
|
||||
auto_off_after?: boolean;
|
||||
manual_start?: boolean;
|
||||
}
|
||||
|
||||
// MQTT Logging types
|
||||
@@ -1914,6 +1917,8 @@ export const api = {
|
||||
request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
|
||||
stopQueueItem: (id: number) =>
|
||||
request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
|
||||
startQueueItem: (id: number) =>
|
||||
request<PrintQueueItem>(`/queue/${id}/start`, { method: 'POST' }),
|
||||
|
||||
// K-Profiles
|
||||
getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Calendar, Clock, X, AlertCircle, Power } from 'lucide-react';
|
||||
import { Calendar, Clock, X, AlertCircle, Power, Hand } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { PrintQueueItemCreate } from '../api/client';
|
||||
import { Card, CardContent } from './Card';
|
||||
@@ -18,7 +18,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [printerId, setPrinterId] = useState<number | null>(null);
|
||||
const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled'>('asap');
|
||||
const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled' | 'manual'>('asap');
|
||||
const [scheduledTime, setScheduledTime] = useState('');
|
||||
const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(false);
|
||||
const [autoOffAfter, setAutoOffAfter] = useState(false);
|
||||
@@ -68,6 +68,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
|
||||
archive_id: archiveId,
|
||||
require_previous_success: requirePreviousSuccess,
|
||||
auto_off_after: autoOffAfter,
|
||||
manual_start: scheduleType === 'manual',
|
||||
};
|
||||
|
||||
if (scheduleType === 'scheduled' && scheduledTime) {
|
||||
@@ -142,7 +143,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
|
||||
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
|
||||
scheduleType === 'asap'
|
||||
? 'bg-bambu-green border-bambu-green text-white'
|
||||
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
|
||||
@@ -150,11 +151,11 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
|
||||
onClick={() => setScheduleType('asap')}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
ASAP (when idle)
|
||||
ASAP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
|
||||
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
|
||||
scheduleType === 'scheduled'
|
||||
? 'bg-bambu-green border-bambu-green text-white'
|
||||
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
|
||||
@@ -164,6 +165,18 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
|
||||
<Calendar className="w-4 h-4" />
|
||||
Scheduled
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
|
||||
scheduleType === 'manual'
|
||||
? 'bg-bambu-green border-bambu-green text-white'
|
||||
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
onClick={() => setScheduleType('manual')}
|
||||
>
|
||||
<Hand className="w-4 h-4" />
|
||||
Queue Only
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -215,7 +228,9 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{scheduleType === 'asap'
|
||||
? 'Print will start as soon as the printer is idle.'
|
||||
: 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'}
|
||||
: scheduleType === 'scheduled'
|
||||
? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
|
||||
: 'Print will be staged but won\'t start automatically. Use the Start button to release it to the queue.'}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Calendar, Clock, X, AlertCircle, Power, Pencil } from 'lucide-react';
|
||||
import { Calendar, Clock, X, AlertCircle, Power, Pencil, Hand } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client';
|
||||
import { Card, CardContent } from './Card';
|
||||
@@ -22,9 +22,11 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
|
||||
const isPlaceholderDate = item.scheduled_time &&
|
||||
new Date(item.scheduled_time).getTime() > Date.now() + (180 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled'>(
|
||||
item.scheduled_time && !isPlaceholderDate ? 'scheduled' : 'asap'
|
||||
);
|
||||
const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled' | 'manual'>(() => {
|
||||
if (item.manual_start) return 'manual';
|
||||
if (item.scheduled_time && !isPlaceholderDate) return 'scheduled';
|
||||
return 'asap';
|
||||
});
|
||||
const [scheduledTime, setScheduledTime] = useState(() => {
|
||||
if (item.scheduled_time && !isPlaceholderDate) {
|
||||
// Convert ISO to local datetime-local format
|
||||
@@ -69,6 +71,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
|
||||
printer_id: printerId,
|
||||
require_previous_success: requirePreviousSuccess,
|
||||
auto_off_after: autoOffAfter,
|
||||
manual_start: scheduleType === 'manual',
|
||||
};
|
||||
|
||||
if (scheduleType === 'scheduled' && scheduledTime) {
|
||||
@@ -146,7 +149,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
|
||||
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
|
||||
scheduleType === 'asap'
|
||||
? 'bg-bambu-green border-bambu-green text-white'
|
||||
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
|
||||
@@ -154,11 +157,11 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
|
||||
onClick={() => setScheduleType('asap')}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
ASAP (when idle)
|
||||
ASAP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
|
||||
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
|
||||
scheduleType === 'scheduled'
|
||||
? 'bg-bambu-green border-bambu-green text-white'
|
||||
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
|
||||
@@ -168,6 +171,18 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
|
||||
<Calendar className="w-4 h-4" />
|
||||
Scheduled
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
|
||||
scheduleType === 'manual'
|
||||
? 'bg-bambu-green border-bambu-green text-white'
|
||||
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
onClick={() => setScheduleType('manual')}
|
||||
>
|
||||
<Hand className="w-4 h-4" />
|
||||
Queue Only
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -219,7 +234,9 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{scheduleType === 'asap'
|
||||
? 'Print will start as soon as the printer is idle.'
|
||||
: 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'}
|
||||
: scheduleType === 'scheduled'
|
||||
? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
|
||||
: 'Print will be staged but won\'t start automatically. Use the Start button to release it to the queue.'}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
Layers,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Hand,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { PrintQueueItem } from '../api/client';
|
||||
@@ -101,6 +102,7 @@ function SortableQueueItem({
|
||||
onRemove,
|
||||
onStop,
|
||||
onRequeue,
|
||||
onStart,
|
||||
}: {
|
||||
item: PrintQueueItem;
|
||||
position?: number;
|
||||
@@ -109,6 +111,7 @@ function SortableQueueItem({
|
||||
onRemove: () => void;
|
||||
onStop: () => void;
|
||||
onRequeue: () => void;
|
||||
onStart: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -198,7 +201,7 @@ function SortableQueueItem({
|
||||
{formatDuration(item.print_time_seconds)}
|
||||
</span>
|
||||
)}
|
||||
{isPending && (
|
||||
{isPending && !item.manual_start && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{formatRelativeTime(item.scheduled_time)}
|
||||
@@ -208,6 +211,12 @@ function SortableQueueItem({
|
||||
|
||||
{/* Options badges */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{item.manual_start && (
|
||||
<span className="text-xs px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
|
||||
<Hand className="w-3 h-3" />
|
||||
Staged
|
||||
</span>
|
||||
)}
|
||||
{item.require_previous_success && (
|
||||
<span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
|
||||
Requires previous success
|
||||
@@ -258,6 +267,17 @@ function SortableQueueItem({
|
||||
)}
|
||||
{isPending && (
|
||||
<>
|
||||
{item.manual_start && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onStart}
|
||||
title="Start Print"
|
||||
className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -393,6 +413,15 @@ export function QueuePage() {
|
||||
onError: () => showToast('Failed to stop print', 'error'),
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: (id: number) => api.startQueueItem(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
showToast('Print released to queue');
|
||||
},
|
||||
onError: () => showToast('Failed to start print', 'error'),
|
||||
});
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
|
||||
onSuccess: () => {
|
||||
@@ -627,6 +656,7 @@ export function QueuePage() {
|
||||
onRemove={() => {}}
|
||||
onStop={() => setConfirmAction({ type: 'stop', item })}
|
||||
onRequeue={() => {}}
|
||||
onStart={() => {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -689,6 +719,7 @@ export function QueuePage() {
|
||||
onRemove={() => {}}
|
||||
onStop={() => {}}
|
||||
onRequeue={() => {}}
|
||||
onStart={() => startMutation.mutate(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -740,6 +771,7 @@ export function QueuePage() {
|
||||
onRemove={() => setConfirmAction({ type: 'remove', item })}
|
||||
onStop={() => {}}
|
||||
onRequeue={() => setRequeueItem(item)}
|
||||
onStart={() => {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BWv5Xlm9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ta6Ao_MI.css">
|
||||
<script type="module" crossorigin src="/assets/index-DXIzheUf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-YKsbWJQ9.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user