Browse Source

Location filter for queue and auth fixes (Issue #220)

Features:
- Add location filter for "Any {Model}" queue assignments
- Queue items can target a specific location (e.g., "Any X1C in Workshop")
- Location dropdown filter on Queue page to view jobs by location
- Scheduler considers location when assigning model-based jobs

Closes #220
maziggy 3 months ago
parent
commit
018a744475

+ 3 - 0
.gitignore

@@ -58,3 +58,6 @@ firmware/
 node_modules/
 node_modules/
 
 
 data/
 data/
+
+# JWT secret file (should be in data dir, but protect project root too)
+.jwt_secret

+ 7 - 0
CHANGELOG.md

@@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.7b] - Not released
 ## [0.1.7b] - Not released
 
 
 ### Enhancements
 ### Enhancements
+- **Location Filter for Queue** (Issue #220):
+  - Filter queue jobs by printer location in the Queue page
+  - "Any {Model}" queue assignments can now specify a target location (e.g., "Any X1C in Workshop")
+  - Location filter dropdown shows all unique locations from printers and queue items
+  - Location is saved with queue items and displayed in the queue list
 - **Ownership-Based Permissions** (Issue #205):
 - **Ownership-Based Permissions** (Issue #205):
   - Users can now only update/delete their own items unless they have elevated permissions
   - Users can now only update/delete their own items unless they have elevated permissions
   - Update/delete permissions split into `*_own` and `*_all` variants:
   - Update/delete permissions split into `*_own` and `*_all` variants:
@@ -51,6 +56,8 @@ All notable changes to Bambuddy will be documented in this file.
   - Removed ~2000 lines of legacy JSON-based backup/restore code
   - Removed ~2000 lines of legacy JSON-based backup/restore code
 
 
 ### Fixes
 ### Fixes
+- **JWT secret key not persistent across restarts** - Fixed JWT secret key generation to properly use data directory, ensuring tokens remain valid across container restarts
+- **Images/thumbnails returning 401 when auth enabled** - Fixed auth middleware to allow public access to image/media endpoints (thumbnails, photos, QR codes, timelapses, camera streams) since browser elements like `<img>` don't send Authorization headers
 - **Library thumbnails missing after restore** - Fixed library files using absolute paths that break after restore on different systems:
 - **Library thumbnails missing after restore** - Fixed library files using absolute paths that break after restore on different systems:
   - Library now stores relative paths in database for portability
   - Library now stores relative paths in database for portability
   - Automatic migration converts existing absolute paths to relative on startup
   - Automatic migration converts existing absolute paths to relative on startup

+ 1 - 1
README.md

@@ -77,7 +77,7 @@
 ### ⏰ Scheduling & Automation
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
 - Print queue with drag-and-drop
 - Multi-printer selection (send to multiple printers at once)
 - Multi-printer selection (send to multiple printers at once)
-- Model-based queue assignment (send to "any X1C" for load balancing)
+- Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
 - Filament validation (only assign to printers with required filaments)
 - Filament validation (only assign to printers with required filaments)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Scheduled prints (date/time)

+ 2 - 0
backend/app/api/routes/print_queue.py

@@ -121,6 +121,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "id": item.id,
         "id": item.id,
         "printer_id": item.printer_id,
         "printer_id": item.printer_id,
         "target_model": item.target_model,
         "target_model": item.target_model,
+        "target_location": item.target_location,
         "required_filament_types": required_filament_types_parsed,
         "required_filament_types": required_filament_types_parsed,
         "waiting_reason": item.waiting_reason,
         "waiting_reason": item.waiting_reason,
         "archive_id": item.archive_id,
         "archive_id": item.archive_id,
@@ -289,6 +290,7 @@ async def add_to_queue(
     item = PrintQueueItem(
     item = PrintQueueItem(
         printer_id=data.printer_id,
         printer_id=data.printer_id,
         target_model=target_model_norm,
         target_model=target_model_norm,
+        target_location=data.target_location,
         required_filament_types=required_filament_types,
         required_filament_types=required_filament_types,
         archive_id=data.archive_id,
         archive_id=data.archive_id,
         library_file_id=data.library_file_id,
         library_file_id=data.library_file_id,

+ 7 - 1
backend/app/core/auth.py

@@ -48,7 +48,13 @@ def _get_jwt_secret() -> str:
         return env_secret
         return env_secret
 
 
     # 2. Check for secret file in data directory
     # 2. Check for secret file in data directory
-    data_dir = Path(os.environ.get("BAMBUDDY_DATA_DIR", "/app/data"))
+    # Use DATA_DIR env var (same as rest of app), fallback to data/ subdirectory
+    data_dir_env = os.environ.get("DATA_DIR")
+    if data_dir_env:
+        data_dir = Path(data_dir_env)
+    else:
+        # Fallback to data/ subdirectory under project root (not project root itself!)
+        data_dir = Path(__file__).parent.parent.parent.parent / "data"
     secret_file = data_dir / ".jwt_secret"
     secret_file = data_dir / ".jwt_secret"
 
 
     if secret_file.exists():
     if secret_file.exists():

+ 6 - 0
backend/app/core/database.py

@@ -1044,6 +1044,12 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         pass
 
 
+    # Migration: Add target_location column to print_queue for location-based filtering (Issue #220)
+    try:
+        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)"))
+    except Exception:
+        pass
+
     # Migration: Convert absolute paths to relative paths in library_files table
     # Migration: Convert absolute paths to relative paths in library_files table
     # This ensures backup/restore portability across different installations
     # This ensures backup/restore portability across different installations
     try:
     try:

+ 28 - 1
backend/app/main.py

@@ -2543,11 +2543,14 @@ app = FastAPI(
 # =============================================================================
 # =============================================================================
 # Public routes that don't require authentication even when auth is enabled
 # Public routes that don't require authentication even when auth is enabled
 PUBLIC_API_ROUTES = {
 PUBLIC_API_ROUTES = {
-    # Auth routes needed before login
+    # Auth routes needed before/during login
     "/api/v1/auth/status",
     "/api/v1/auth/status",
     "/api/v1/auth/login",
     "/api/v1/auth/login",
+    "/api/v1/auth/setup",  # Needed for initial setup and recovery
     # Version check for updates (no sensitive data)
     # Version check for updates (no sensitive data)
     "/api/v1/updates/version",
     "/api/v1/updates/version",
+    # Metrics endpoint handles its own prometheus_token authentication
+    "/api/v1/metrics",
 }
 }
 
 
 # Route prefixes that are public (for routes with dynamic segments)
 # Route prefixes that are public (for routes with dynamic segments)
@@ -2556,6 +2559,25 @@ PUBLIC_API_PREFIXES = [
     "/api/v1/ws",
     "/api/v1/ws",
 ]
 ]
 
 
+# Route patterns that are public (read-only display data)
+# These are checked with "in path" - needed because browsers load images/videos
+# via <img src> and <video src> which don't include Authorization headers
+PUBLIC_API_PATTERNS = [
+    # Thumbnails
+    "/thumbnail",  # /archives/{id}/thumbnail, /library/files/{id}/thumbnail
+    "/plate-thumbnail/",  # /archives/{id}/plate-thumbnail/{plate_id}
+    # Images and media
+    "/photos/",  # /archives/{id}/photos/{filename}
+    "/project-image/",  # /archives/{id}/project-image/{path}
+    "/qrcode",  # /archives/{id}/qrcode
+    "/timelapse",  # /archives/{id}/timelapse (video)
+    "/cover",  # /printers/{id}/cover
+    "/icon",  # /external-links/{id}/icon
+    # Camera (streams loaded via <img> tag)
+    "/camera/stream",  # /printers/{id}/camera/stream
+    "/camera/snapshot",  # /printers/{id}/camera/snapshot
+]
+
 
 
 @app.middleware("http")
 @app.middleware("http")
 async def auth_middleware(request, call_next):
 async def auth_middleware(request, call_next):
@@ -2581,6 +2603,11 @@ async def auth_middleware(request, call_next):
         if path.startswith(prefix):
         if path.startswith(prefix):
             return await call_next(request)
             return await call_next(request)
 
 
+    # Allow public patterns (read-only display data like thumbnails)
+    for pattern in PUBLIC_API_PATTERNS:
+        if pattern in path:
+            return await call_next(request)
+
     # Check if auth is enabled
     # Check if auth is enabled
     try:
     try:
         async with async_session() as db:
         async with async_session() as db:

+ 3 - 0
backend/app/models/print_queue.py

@@ -18,6 +18,9 @@ class PrintQueueItem(Base):
     # Target printer model for model-based assignment (mutually exclusive with printer_id)
     # Target printer model for model-based assignment (mutually exclusive with printer_id)
     # When set, scheduler assigns to any idle printer of matching model
     # When set, scheduler assigns to any idle printer of matching model
     target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
     target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
+    # Target location filter for model-based assignment (only used with target_model)
+    # When set, only printers in this location are considered
+    target_location: Mapped[str | None] = mapped_column(String(100), nullable=True)
     # Required filament types for model-based assignment (JSON array, e.g., '["PLA", "PETG"]')
     # Required filament types for model-based assignment (JSON array, e.g., '["PLA", "PETG"]')
     # Used by scheduler to validate printer has compatible filaments loaded
     # Used by scheduler to validate printer has compatible filaments loaded
     required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)
     required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 3 - 0
backend/app/schemas/print_queue.py

@@ -18,6 +18,7 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
 class PrintQueueItemCreate(BaseModel):
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
     printer_id: int | None = None  # None = unassigned, user assigns later
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
+    target_location: str | None = None  # Target location filter (only used with target_model)
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     # Either archive_id OR library_file_id must be provided
     # Either archive_id OR library_file_id must be provided
     archive_id: int | None = None
     archive_id: int | None = None
@@ -43,6 +44,7 @@ class PrintQueueItemCreate(BaseModel):
 class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemUpdate(BaseModel):
     printer_id: int | None = None
     printer_id: int | None = None
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
+    target_location: str | None = None  # Target location filter (only used with target_model)
     position: int | None = None
     position: int | None = None
     scheduled_time: datetime | None = None
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
     require_previous_success: bool | None = None
@@ -63,6 +65,7 @@ class PrintQueueItemResponse(BaseModel):
     id: int
     id: int
     printer_id: int | None  # None = unassigned
     printer_id: int | None  # None = unassigned
     target_model: str | None = None  # Target printer model for model-based assignment
     target_model: str | None = None  # Target printer model for model-based assignment
+    target_location: str | None = None  # Target location filter for model-based assignment
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     waiting_reason: str | None = None  # Why a model-based job hasn't started yet
     waiting_reason: str | None = None  # Why a model-based job hasn't started yet
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     archive_id: int | None  # None if library_file_id is set (archive created at print start)

+ 13 - 4
backend/app/services/print_scheduler.py

@@ -148,7 +148,7 @@ class PrintScheduler:
                             pass
                             pass
 
 
                     printer_id, waiting_reason = await self._find_idle_printer_for_model(
                     printer_id, waiting_reason = await self._find_idle_printer_for_model(
-                        db, item.target_model, busy_printers, required_types
+                        db, item.target_model, busy_printers, required_types, item.target_location
                     )
                     )
 
 
                     # Update waiting_reason if changed and send notification when first waiting
                     # Update waiting_reason if changed and send notification when first waiting
@@ -225,6 +225,7 @@ class PrintScheduler:
         model: str,
         model: str,
         exclude_ids: set[int],
         exclude_ids: set[int],
         required_filament_types: list[str] | None = None,
         required_filament_types: list[str] | None = None,
+        target_location: str | None = None,
     ) -> tuple[int | None, str | None]:
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
         """Find an idle, connected printer matching the model with compatible filaments.
 
 
@@ -234,6 +235,7 @@ class PrintScheduler:
             exclude_ids: Printer IDs to exclude (already busy)
             exclude_ids: Printer IDs to exclude (already busy)
             required_filament_types: Optional list of filament types needed (e.g., ["PLA", "PETG"])
             required_filament_types: Optional list of filament types needed (e.g., ["PLA", "PETG"])
                                      If provided, only printers with all required types loaded will match.
                                      If provided, only printers with all required types loaded will match.
+            target_location: Optional location filter. If provided, only printers in this location are considered.
 
 
         Returns:
         Returns:
             Tuple of (printer_id, waiting_reason):
             Tuple of (printer_id, waiting_reason):
@@ -242,15 +244,22 @@ class PrintScheduler:
         """
         """
         # Normalize model name and use case-insensitive matching
         # Normalize model name and use case-insensitive matching
         normalized_model = normalize_printer_model(model) or model
         normalized_model = normalize_printer_model(model) or model
-        result = await db.execute(
+        query = (
             select(Printer)
             select(Printer)
             .where(func.lower(Printer.model) == normalized_model.lower())
             .where(func.lower(Printer.model) == normalized_model.lower())
             .where(Printer.is_active == True)  # noqa: E712
             .where(Printer.is_active == True)  # noqa: E712
         )
         )
+
+        # Add location filter if specified
+        if target_location:
+            query = query.where(Printer.location == target_location)
+
+        result = await db.execute(query)
         printers = list(result.scalars().all())
         printers = list(result.scalars().all())
 
 
+        location_suffix = f" in {target_location}" if target_location else ""
         if not printers:
         if not printers:
-            return None, f"No active {normalized_model} printers configured"
+            return None, f"No active {normalized_model} printers{location_suffix} configured"
 
 
         # Track reasons for skipping printers
         # Track reasons for skipping printers
         printers_busy = []
         printers_busy = []
@@ -295,7 +304,7 @@ class PrintScheduler:
         if printers_offline:
         if printers_offline:
             reasons.append(f"Offline: {', '.join(printers_offline)}")
             reasons.append(f"Offline: {', '.join(printers_offline)}")
 
 
-        return None, " | ".join(reasons) if reasons else f"No available {model} printers"
+        return None, " | ".join(reasons) if reasons else f"No available {model} printers{location_suffix}"
 
 
     def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]:
     def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]:
         """Get the list of required filament types that are not loaded on the printer.
         """Get the list of required filament types that are not loaded on the printer.

+ 85 - 0
backend/tests/integration/test_auth_api.py

@@ -689,3 +689,88 @@ class TestChangePasswordAPI:
         )
         )
 
 
         assert response.status_code == 401
         assert response.status_code == 401
+
+
+class TestAuthMiddlewarePublicRoutes:
+    """Tests for auth middleware public route configuration.
+
+    These routes must be accessible without authentication, even when auth is enabled,
+    because browser elements like <img src> and <video src> don't send Authorization headers.
+    """
+
+    @pytest.fixture
+    async def enabled_auth(self, async_client: AsyncClient):
+        """Enable auth for testing middleware behavior."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "middlewareadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_status_is_public(self, async_client: AsyncClient, enabled_auth):
+        """Verify /api/v1/auth/status is accessible without auth."""
+        response = await async_client.get("/api/v1/auth/status")
+        assert response.status_code == 200
+        assert "auth_enabled" in response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_login_is_public(self, async_client: AsyncClient, enabled_auth):
+        """Verify /api/v1/auth/login is accessible without auth."""
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "middlewareadmin", "password": "adminpassword123"},
+        )
+        # Should not return 401 (unauthorized) - it should either succeed or return
+        # a different error (like 400 for wrong credentials)
+        assert response.status_code != 401 or "token" in response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_setup_is_public(self, async_client: AsyncClient):
+        """Verify /api/v1/auth/setup is accessible without auth (needed for setup/recovery)."""
+        # Don't enable auth first - test that setup endpoint itself is accessible
+        response = await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": False},
+        )
+        # Should not be 401
+        assert response.status_code != 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_updates_version_is_public(self, async_client: AsyncClient, enabled_auth):
+        """Verify /api/v1/updates/version is accessible without auth."""
+        response = await async_client.get("/api/v1/updates/version")
+        # Should not be 401
+        assert response.status_code != 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_protected_route_requires_auth(self, async_client: AsyncClient, enabled_auth):
+        """Verify non-public routes return 401 without token."""
+        response = await async_client.get("/api/v1/printers/")
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_protected_route_works_with_token(self, async_client: AsyncClient, enabled_auth):
+        """Verify non-public routes work with valid token."""
+        # Login to get token
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "middlewareadmin", "password": "adminpassword123"},
+        )
+        token = login_response.json()["access_token"]
+
+        # Access protected route
+        response = await async_client.get(
+            "/api/v1/printers/",
+            headers={"Authorization": f"Bearer {token}"},
+        )
+        assert response.status_code == 200

+ 213 - 0
backend/tests/integration/test_print_queue_api.py

@@ -963,3 +963,216 @@ class TestBulkUpdateEndpoint:
         )
         )
         assert response.status_code == 400
         assert response.status_code == 400
         assert "printer not found" in response.json()["detail"].lower()
         assert "printer not found" in response.json()["detail"].lower()
+
+
+class TestTargetLocationFeature:
+    """Tests for queue items with target_location (Issue #220)."""
+
+    @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"Location Test Printer {counter}",
+                "ip_address": f"192.168.1.{50 + counter}",
+                "serial_number": f"TESTLOC{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"location_test_{counter}.3mf",
+                "print_name": f"Location Test Print {counter}",
+                "file_path": f"/tmp/location_test_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"lochash{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 and "target_model" 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_add_to_queue_with_target_location(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify item can be added with target_model and target_location."""
+        # Create a printer with model X1C so the API can validate
+        await printer_factory(model="X1C", location="Office")
+        archive = await archive_factory()
+
+        data = {
+            "target_model": "X1C",
+            "target_location": "Workbench",
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_model"] == "X1C"
+        assert result["target_location"] == "Workbench"
+        assert result["printer_id"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_location_without_model_ignored(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify target_location without target_model is allowed (location is just ignored)."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "target_location": "Workbench",  # This gets ignored since printer_id is set
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        # The API accepts this but the location is only used with target_model
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+        # Location may or may not be stored since it's meaningless without target_model
+        # The important thing is the request succeeds
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_item_target_location_in_response(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """Verify target_location is returned in queue item response."""
+        item = await queue_item_factory(
+            printer_id=None,
+            target_model="X1C",
+            target_location="Workshop",
+        )
+
+        response = await async_client.get(f"/api/v1/queue/{item.id}")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_model"] == "X1C"
+        assert result["target_location"] == "Workshop"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_list_includes_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify target_location is included in queue list."""
+        await queue_item_factory(
+            printer_id=None,
+            target_model="P1S",
+            target_location="Garage",
+        )
+
+        response = await async_client.get("/api/v1/queue/")
+        assert response.status_code == 200
+        items = response.json()
+        assert len(items) >= 1
+
+        # Find our item
+        our_item = next((i for i in items if i["target_location"] == "Garage"), None)
+        assert our_item is not None
+        assert our_item["target_model"] == "P1S"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_queue_item_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify target_location can be updated on existing queue item."""
+        item = await queue_item_factory(
+            printer_id=None,
+            target_model="X1C",
+            target_location="Office",
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            json={"target_location": "Basement"},
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_location"] == "Basement"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify target_location can be cleared (set to None)."""
+        item = await queue_item_factory(
+            printer_id=None,
+            target_model="X1C",
+            target_location="Office",
+        )
+
+        # Note: Setting to empty string should clear it
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            json={"target_location": None},
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_location"] is None

+ 3 - 0
frontend/src/api/client.ts

@@ -1067,6 +1067,7 @@ export interface PrintQueueItem {
   id: number;
   id: number;
   printer_id: number | null;  // null = unassigned
   printer_id: number | null;  // null = unassigned
   target_model: string | null;  // Target printer model for model-based assignment
   target_model: string | null;  // Target printer model for model-based assignment
+  target_location: string | null;  // Target location filter for model-based assignment
   required_filament_types: string[] | null;  // Required filament types for model-based assignment
   required_filament_types: string[] | null;  // Required filament types for model-based assignment
   waiting_reason: string | null;  // Why a model-based job hasn't started yet
   waiting_reason: string | null;  // Why a model-based job hasn't started yet
   // Either archive_id OR library_file_id must be set (archive created at print start)
   // Either archive_id OR library_file_id must be set (archive created at print start)
@@ -1105,6 +1106,7 @@ export interface PrintQueueItem {
 export interface PrintQueueItemCreate {
 export interface PrintQueueItemCreate {
   printer_id?: number | null;  // null = unassigned
   printer_id?: number | null;  // null = unassigned
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
+  target_location?: string | null;  // Target location filter (only used with target_model)
   // Either archive_id OR library_file_id must be provided
   // Either archive_id OR library_file_id must be provided
   archive_id?: number | null;
   archive_id?: number | null;
   library_file_id?: number | null;
   library_file_id?: number | null;
@@ -1126,6 +1128,7 @@ export interface PrintQueueItemCreate {
 export interface PrintQueueItemUpdate {
 export interface PrintQueueItemUpdate {
   printer_id?: number | null;  // null = unassign
   printer_id?: number | null;  // null = unassign
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
+  target_location?: string | null;  // Target location filter (only used with target_model)
   position?: number;
   position?: number;
   scheduled_time?: string | null;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   require_previous_success?: boolean;

+ 69 - 5
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -38,6 +38,10 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
   targetModel?: string | null;
   targetModel?: string | null;
   /** Handler for target model change */
   /** Handler for target model change */
   onTargetModelChange?: (model: string | null) => void;
   onTargetModelChange?: (model: string | null) => void;
+  /** Selected target location (when assignmentMode is 'model') */
+  targetLocation?: string | null;
+  /** Handler for target location change */
+  onTargetLocationChange?: (location: string | null) => void;
   /** Suggested model from sliced file (for pre-selection) */
   /** Suggested model from sliced file (for pre-selection) */
   slicedForModel?: string | null;
   slicedForModel?: string | null;
 }
 }
@@ -227,6 +231,8 @@ export function PrinterSelector({
   onAssignmentModeChange,
   onAssignmentModeChange,
   targetModel,
   targetModel,
   onTargetModelChange,
   onTargetModelChange,
+  targetLocation,
+  onTargetLocationChange,
   slicedForModel,
   slicedForModel,
 }: PrinterSelectorWithMappingProps) {
 }: PrinterSelectorWithMappingProps) {
   // State for showing all printers vs only matching model
   // State for showing all printers vs only matching model
@@ -257,6 +263,16 @@ export function PrinterSelector({
     return [...new Set(models)].sort();
     return [...new Set(models)].sort();
   }, [activePrinters]);
   }, [activePrinters]);
 
 
+  // Get unique locations for the selected target model (for location filtering)
+  const uniqueLocations = useMemo(() => {
+    if (!targetModel) return [];
+    const locations = activePrinters
+      .filter(p => p.model === targetModel && p.location)
+      .map(p => p.location)
+      .filter((l): l is string => Boolean(l));
+    return [...new Set(locations)].sort();
+  }, [activePrinters, targetModel]);
+
   // Check if model-based assignment is available (need callbacks and multiple printers of same model)
   // Check if model-based assignment is available (need callbacks and multiple printers of same model)
   const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
   const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
 
 
@@ -370,11 +386,59 @@ export function PrinterSelector({
         </div>
         </div>
       )}
       )}
 
 
-      {/* Model info (when in model mode) */}
-      {assignmentMode === 'model' && modelAssignmentAvailable && targetModel && (
-        <p className="text-xs text-bambu-gray mb-4">
-          Scheduler will assign to first available idle {targetModel} printer
-        </p>
+      {/* Model selection and location filter (when in model mode) */}
+      {assignmentMode === 'model' && modelAssignmentAvailable && (
+        <div className="space-y-3 mb-4">
+          {/* Model selector */}
+          <div>
+            <label className="block text-xs text-bambu-gray mb-1">Target Model</label>
+            <select
+              value={targetModel || ''}
+              onChange={(e) => {
+                onTargetModelChange!(e.target.value || null);
+                // Clear location when model changes
+                if (onTargetLocationChange) {
+                  onTargetLocationChange(null);
+                }
+              }}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+            >
+              <option value="">Select a model...</option>
+              {uniqueModels.map((model) => (
+                <option key={model} value={model}>
+                  {model}
+                </option>
+              ))}
+            </select>
+          </div>
+
+          {/* Location filter (only show when target model is selected and locations exist) */}
+          {targetModel && uniqueLocations.length > 0 && onTargetLocationChange && (
+            <div>
+              <label className="block text-xs text-bambu-gray mb-1">Location Filter (optional)</label>
+              <select
+                value={targetLocation || ''}
+                onChange={(e) => onTargetLocationChange(e.target.value || null)}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+              >
+                <option value="">Any location</option>
+                {uniqueLocations.map((location) => (
+                  <option key={location} value={location}>
+                    {location}
+                  </option>
+                ))}
+              </select>
+            </div>
+          )}
+
+          {/* Info text */}
+          {targetModel && (
+            <p className="text-xs text-bambu-gray">
+              Scheduler will assign to first available idle {targetModel} printer
+              {targetLocation ? ` in ${targetLocation}` : ''}
+            </p>
+          )}
+        </div>
       )}
       )}
 
 
       {/* Multi-select header (only in printer mode) */}
       {/* Multi-select header (only in printer mode) */}

+ 13 - 0
frontend/src/components/PrintModal/index.tsx

@@ -135,6 +135,14 @@ export function PrintModal({
     return null;
     return null;
   });
   });
 
 
+  // Target location for model-based assignment (optional filter)
+  const [targetLocation, setTargetLocation] = useState<string | null>(() => {
+    if (mode === 'edit-queue-item' && queueItem?.target_location) {
+      return queueItem.target_location;
+    }
+    return null;
+  });
+
   // Track initial values for clearing mappings on change (edit mode only)
   // Track initial values for clearing mappings on change (edit mode only)
   const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
   const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
   const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
   const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
@@ -369,6 +377,7 @@ export function PrintModal({
     const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
     const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
       printer_id: assignmentMode === 'printer' ? printerId : null,
       printer_id: assignmentMode === 'printer' ? printerId : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
+      target_location: assignmentMode === 'model' ? targetLocation : null,
       // Use library_file_id for library files, archive_id for archives
       // Use library_file_id for library files, archive_id for archives
       archive_id: isLibraryFile ? undefined : archiveId,
       archive_id: isLibraryFile ? undefined : archiveId,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
@@ -397,6 +406,7 @@ export function PrintModal({
           const updateData: PrintQueueItemUpdate = {
           const updateData: PrintQueueItemUpdate = {
             printer_id: null,
             printer_id: null,
             target_model: targetModel,
             target_model: targetModel,
+            target_location: targetLocation,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             auto_off_after: scheduleOptions.autoOffAfter,
             auto_off_after: scheduleOptions.autoOffAfter,
             manual_start: scheduleOptions.scheduleType === 'manual',
             manual_start: scheduleOptions.scheduleType === 'manual',
@@ -445,6 +455,7 @@ export function PrintModal({
             const updateData: PrintQueueItemUpdate = {
             const updateData: PrintQueueItemUpdate = {
               printer_id: printerId,
               printer_id: printerId,
               target_model: null,
               target_model: null,
+              target_location: null,
               require_previous_success: scheduleOptions.requirePreviousSuccess,
               require_previous_success: scheduleOptions.requirePreviousSuccess,
               auto_off_after: scheduleOptions.autoOffAfter,
               auto_off_after: scheduleOptions.autoOffAfter,
               manual_start: scheduleOptions.scheduleType === 'manual',
               manual_start: scheduleOptions.scheduleType === 'manual',
@@ -626,6 +637,8 @@ export function PrintModal({
               onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
               onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
               targetModel={targetModel}
               targetModel={targetModel}
               onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
               onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
+              targetLocation={targetLocation}
+              onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
               slicedForModel={slicedForModel}
               slicedForModel={slicedForModel}
             />
             />
 
 

+ 4 - 0
frontend/src/components/PrintModal/types.ts

@@ -130,6 +130,10 @@ export interface PrinterSelectorProps {
   targetModel?: string | null;
   targetModel?: string | null;
   /** Handler for target model change */
   /** Handler for target model change */
   onTargetModelChange?: (model: string | null) => void;
   onTargetModelChange?: (model: string | null) => void;
+  /** Selected target location (when assignmentMode is 'model') */
+  targetLocation?: string | null;
+  /** Handler for target location change */
+  onTargetLocationChange?: (location: string | null) => void;
   /** Suggested model from sliced file (for pre-selection) */
   /** Suggested model from sliced file (for pre-selection) */
   slicedForModel?: string | null;
   slicedForModel?: string | null;
 }
 }

+ 13 - 1
frontend/src/pages/PrintersPage.tsx

@@ -3548,6 +3548,7 @@ function AddPrinterModal({
     ip_address: '',
     ip_address: '',
     access_code: '',
     access_code: '',
     model: '',
     model: '',
+    location: '',
     auto_archive: true,
     auto_archive: true,
   });
   });
 
 
@@ -3894,6 +3895,17 @@ function AddPrinterModal({
                 </optgroup>
                 </optgroup>
               </select>
               </select>
             </div>
             </div>
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Location / Group (optional)</label>
+              <input
+                type="text"
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                value={form.location || ''}
+                onChange={(e) => setForm({ ...form, location: e.target.value })}
+                placeholder="e.g., Workshop, Office, Basement"
+              />
+              <p className="text-xs text-bambu-gray mt-1">Used to group printers and filter queue jobs</p>
+            </div>
             <div className="flex items-center gap-2">
             <div className="flex items-center gap-2">
               <input
               <input
                 type="checkbox"
                 type="checkbox"
@@ -4265,7 +4277,7 @@ function EditPrinterModal({
                 onChange={(e) => setForm({ ...form, location: e.target.value })}
                 onChange={(e) => setForm({ ...form, location: e.target.value })}
                 placeholder="e.g., Workshop, Office, Basement"
                 placeholder="e.g., Workshop, Office, Basement"
               />
               />
-              <p className="text-xs text-bambu-gray mt-1">Used to group printers on the dashboard</p>
+              <p className="text-xs text-bambu-gray mt-1">Used to group printers and filter queue jobs</p>
             </div>
             </div>
             <div className="flex items-center gap-2">
             <div className="flex items-center gap-2">
               <input
               <input

+ 62 - 7
frontend/src/pages/QueuePage.tsx

@@ -1,4 +1,4 @@
-import { useState, useMemo, useEffect } from 'react';
+import { useState, useMemo, useEffect, useCallback } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Link } from 'react-router-dom';
 import { Link } from 'react-router-dom';
 import {
 import {
@@ -419,7 +419,7 @@ function SortableQueueItem({
             <span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
             <span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
               <Printer className="w-3.5 h-3.5" />
               <Printer className="w-3.5 h-3.5" />
               {item.target_model
               {item.target_model
-                ? `Any ${item.target_model}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
+                ? `Any ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
                 : item.printer_id === null
                 : item.printer_id === null
                   ? 'Unassigned'
                   ? 'Unassigned'
                   : (item.printer_name || `Printer #${item.printer_id}`)}
                   : (item.printer_name || `Printer #${item.printer_id}`)}
@@ -579,6 +579,7 @@ export function QueuePage() {
   const { hasPermission, hasAnyPermission, canModify } = useAuth();
   const { hasPermission, hasAnyPermission, canModify } = useAuth();
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterStatus, setFilterStatus] = useState<string>('');
   const [filterStatus, setFilterStatus] = useState<string>('');
+  const [filterLocation, setFilterLocation] = useState<string>('');
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
   const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
   const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
   const [requeueItem, setRequeueItem] = useState<PrintQueueItem | null>(null);
   const [requeueItem, setRequeueItem] = useState<PrintQueueItem | null>(null);
@@ -738,8 +739,39 @@ export function QueuePage() {
     );
     );
   };
   };
 
 
+  // Get unique locations from printers for the filter dropdown
+  const uniqueLocations = useMemo(() => {
+    const locations = new Set<string>();
+    printers?.forEach(p => {
+      if (p.location) locations.add(p.location);
+    });
+    // Also include locations from queue items (for model-based assignments)
+    queue?.forEach(item => {
+      if (item.target_location) locations.add(item.target_location);
+    });
+    return Array.from(locations).sort();
+  }, [printers, queue]);
+
+  // Helper to check if a queue item matches the location filter
+  const matchesLocationFilter = useCallback((item: PrintQueueItem): boolean => {
+    if (!filterLocation) return true;
+    // For model-based assignments, check target_location
+    if (item.target_location) return item.target_location === filterLocation;
+    // For printer-based assignments, check the printer's location
+    if (item.printer_id) {
+      const printer = printers?.find(p => p.id === item.printer_id);
+      return printer?.location === filterLocation;
+    }
+    return false;
+  }, [filterLocation, printers]);
+
   const pendingItems = useMemo(() => {
   const pendingItems = useMemo(() => {
-    const items = queue?.filter(i => i.status === 'pending') || [];
+    let items = queue?.filter(i => i.status === 'pending') || [];
+
+    // Apply location filter
+    if (filterLocation) {
+      items = items.filter(matchesLocationFilter);
+    }
 
 
     // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
     // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
     const getScheduledTime = (item: PrintQueueItem): number => {
     const getScheduledTime = (item: PrintQueueItem): number => {
@@ -766,7 +798,7 @@ export function QueuePage() {
       }
       }
       return pendingSortAsc ? cmp : -cmp;
       return pendingSortAsc ? cmp : -cmp;
     });
     });
-  }, [queue, pendingSortBy, pendingSortAsc]);
+  }, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation]);
 
 
   const handleSelectAll = () => {
   const handleSelectAll = () => {
     const allPendingIds = pendingItems.map(i => i.id);
     const allPendingIds = pendingItems.map(i => i.id);
@@ -777,9 +809,19 @@ export function QueuePage() {
     }
     }
   };
   };
 
 
-  const activeItems = queue?.filter(i => i.status === 'printing') || [];
+  const activeItems = useMemo(() => {
+    let items = queue?.filter(i => i.status === 'printing') || [];
+    if (filterLocation) {
+      items = items.filter(matchesLocationFilter);
+    }
+    return items;
+  }, [queue, filterLocation, matchesLocationFilter]);
+
   const historyItems = useMemo(() => {
   const historyItems = useMemo(() => {
-    const items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
+    let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
+    if (filterLocation) {
+      items = items.filter(matchesLocationFilter);
+    }
     return [...items].sort((a, b) => {
     return [...items].sort((a, b) => {
       let cmp: number;
       let cmp: number;
       if (historySortBy === 'name') {
       if (historySortBy === 'name') {
@@ -794,7 +836,7 @@ export function QueuePage() {
       }
       }
       return historySortAsc ? -cmp : cmp;
       return historySortAsc ? -cmp : cmp;
     });
     });
-  }, [queue, historySortBy, historySortAsc]);
+  }, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]);
 
 
   // Calculate total queue time
   // Calculate total queue time
   const totalQueueTime = useMemo(() => {
   const totalQueueTime = useMemo(() => {
@@ -923,6 +965,19 @@ export function QueuePage() {
           <option value="cancelled">Cancelled</option>
           <option value="cancelled">Cancelled</option>
         </select>
         </select>
 
 
+        {uniqueLocations.length > 0 && (
+          <select
+            className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            value={filterLocation}
+            onChange={(e) => setFilterLocation(e.target.value)}
+          >
+            <option value="">All Locations</option>
+            {uniqueLocations.map((loc) => (
+              <option key={loc} value={loc}>{loc}</option>
+            ))}
+          </select>
+        )}
+
         <div className="flex-1" />
         <div className="flex-1" />
 
 
         {historyItems.length > 0 && (
         {historyItems.length > 0 && (

+ 7 - 0
frontend/vitest.config.ts

@@ -7,6 +7,13 @@ export default defineConfig({
   test: {
   test: {
     globals: true,
     globals: true,
     environment: 'jsdom',
     environment: 'jsdom',
+    pool: 'threads',
+    poolOptions: {
+      threads: {
+        maxThreads: 14,
+        minThreads: 4,
+      },
+    },
     environmentOptions: {
     environmentOptions: {
       jsdom: {
       jsdom: {
         url: 'http://localhost:3000',
         url: 'http://localhost:3000',

File diff suppressed because it is too large
+ 0 - 0
icons/5f21bc794a4e4521b72c6564029ed5d9.svg


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D-vJDFzo.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-1q7Yxq-H.js"></script>
+    <script type="module" crossorigin src="/assets/index-D-vJDFzo.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
     <link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff