mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
@@ -58,3 +58,6 @@ firmware/
|
||||
node_modules/
|
||||
|
||||
data/
|
||||
|
||||
# JWT secret file (should be in data dir, but protect project root too)
|
||||
.jwt_secret
|
||||
|
||||
@@ -4,7 +4,27 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [0.1.7b] - Not released
|
||||
|
||||
## [0.1.6.2] - 2026-02-02
|
||||
|
||||
> **Security Release**: This release addresses critical security vulnerabilities. Users running authentication-enabled instances should upgrade immediately.
|
||||
|
||||
### Security
|
||||
- **Critical: Hardcoded JWT Secret Key** (GHSA-gc24-px2r-5qmf, CWE-321) - Fixed hardcoded JWT secret key that could allow attackers to forge authentication tokens:
|
||||
- JWT secret now loaded from `JWT_SECRET_KEY` environment variable (recommended for production)
|
||||
- Falls back to auto-generated `.jwt_secret` file in data directory with secure permissions (0600)
|
||||
- Generates cryptographically secure 64-byte random secret if neither exists
|
||||
- **Action Required**: Existing users will need to re-login after upgrading
|
||||
- **Critical: Missing API Authentication** (GHSA-gc24-px2r-5qmf, CWE-306) - Fixed 77+ API endpoints that lacked authentication checks:
|
||||
- Added HTTP middleware enforcing authentication on ALL `/api/` routes when auth is enabled
|
||||
- Only essential public endpoints are exempt (login, auth status, version check, WebSocket)
|
||||
- All other API calls now require valid JWT token or API key
|
||||
|
||||
### 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):
|
||||
- Users can now only update/delete their own items unless they have elevated permissions
|
||||
- Update/delete permissions split into `*_own` and `*_all` variants:
|
||||
@@ -51,11 +71,28 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- Removed ~2000 lines of legacy JSON-based backup/restore code
|
||||
|
||||
### Fixes
|
||||
- **File Manager permissions not enforced** (Issue #224) - Fixed backend not checking `library:read` permission for File Manager endpoints:
|
||||
- Added `library:read` permission check to all list/view endpoints (files, folders, stats)
|
||||
- Added `library:upload` permission check to upload and folder creation endpoints
|
||||
- Added `queue:create` permission check to add-to-queue endpoint
|
||||
- Added `printers:control` permission check to direct print endpoint
|
||||
- Added ownership-based permission checks to file move operation
|
||||
- Users without `library:read` permission can no longer view files in the File Manager
|
||||
- Users can now only delete/update their own files unless they have `*_all` permissions
|
||||
- **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 now stores relative paths in database for portability
|
||||
- Automatic migration converts existing absolute paths to relative on startup
|
||||
- Thumbnails and files now display correctly after restoring backups
|
||||
- **File uploads failing with authentication enabled** - Fixed all file upload functions (archives, photos, timelapses, library files, etc.) not sending authentication headers when auth is enabled
|
||||
- **External spool AMS mapping causing "Failed to get AMS mapping table"** (Issue #213) - Fixed external spool `ams_mapping2` slot_id handling that caused AMS mapping failures
|
||||
- **Filename matching for files with spaces** (Issue #218) - Fixed file detection when filenames contain spaces
|
||||
- **P2S FTP upload failure** (Issue #218) - Fixed FTP uploads to P2S printers by passing `skip_session_reuse` to ImplicitFTP_TLS
|
||||
- **Printer deletion freeze** (Issue #214) - Fixed UI freeze when deleting printers, and now allows multiple smart plugs per printer
|
||||
- **Stack trace exposure in error responses** (CodeQL Alert #68) - Fixed stack traces being exposed in API error responses in archives.py
|
||||
- **Printer serial numbers exposed in support bundle** (Issue #216) - Sanitized printer serial numbers in support bundle logs for privacy
|
||||
- **Missing sliced_for_model migration** (Issue #211) - Fixed database migration for `sliced_for_model` column that was missing in some upgrade paths
|
||||
|
||||
## [0.1.6-final] - 2026-01-31
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
### ⏰ Scheduling & Automation
|
||||
- Print queue with drag-and-drop
|
||||
- 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)
|
||||
- Per-printer AMS mapping (individual slot configuration for print farms)
|
||||
- Scheduled prints (date/time)
|
||||
|
||||
@@ -16,7 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.core.auth import (
|
||||
require_auth_if_enabled,
|
||||
require_ownership_permission,
|
||||
require_permission_if_auth_enabled,
|
||||
)
|
||||
@@ -237,7 +236,11 @@ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", "
|
||||
|
||||
@router.get("/folders", response_model=list[FolderTreeItem])
|
||||
@router.get("/folders/", response_model=list[FolderTreeItem])
|
||||
async def list_folders(response: Response, db: AsyncSession = Depends(get_db)):
|
||||
async def list_folders(
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get all folders as a tree structure."""
|
||||
# Prevent browser caching of folder list
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
@@ -289,7 +292,11 @@ async def list_folders(response: Response, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/folders/by-project/{project_id}", response_model=list[FolderResponse])
|
||||
async def get_folders_by_project(project_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_folders_by_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get all folders linked to a specific project."""
|
||||
result = await db.execute(
|
||||
select(LibraryFolder, Project.name)
|
||||
@@ -326,7 +333,11 @@ async def get_folders_by_project(project_id: int, db: AsyncSession = Depends(get
|
||||
|
||||
|
||||
@router.get("/folders/by-archive/{archive_id}", response_model=list[FolderResponse])
|
||||
async def get_folders_by_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_folders_by_archive(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get all folders linked to a specific archive."""
|
||||
result = await db.execute(
|
||||
select(LibraryFolder, PrintArchive.print_name)
|
||||
@@ -364,7 +375,11 @@ async def get_folders_by_archive(archive_id: int, db: AsyncSession = Depends(get
|
||||
|
||||
@router.post("/folders", response_model=FolderResponse)
|
||||
@router.post("/folders/", response_model=FolderResponse)
|
||||
async def create_folder(data: FolderCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_folder(
|
||||
data: FolderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
|
||||
):
|
||||
"""Create a new folder."""
|
||||
# Verify parent exists if specified
|
||||
if data.parent_id is not None:
|
||||
@@ -415,7 +430,11 @@ async def create_folder(data: FolderCreate, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/folders/{folder_id}", response_model=FolderResponse)
|
||||
async def get_folder(folder_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_folder(
|
||||
folder_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get a folder by ID."""
|
||||
result = await db.execute(
|
||||
select(LibraryFolder, Project.name, PrintArchive.print_name)
|
||||
@@ -449,8 +468,17 @@ async def get_folder(folder_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("/folders/{folder_id}", response_model=FolderResponse)
|
||||
async def update_folder(folder_id: int, data: FolderUpdate, db: AsyncSession = Depends(get_db)):
|
||||
"""Update a folder."""
|
||||
async def update_folder(
|
||||
folder_id: int,
|
||||
data: FolderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
|
||||
):
|
||||
"""Update a folder.
|
||||
|
||||
Note: Folders require library:update_all permission since they don't have
|
||||
ownership tracking.
|
||||
"""
|
||||
result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
|
||||
folder = result.scalar_one_or_none()
|
||||
|
||||
@@ -595,6 +623,7 @@ async def list_files(
|
||||
folder_id: int | None = None,
|
||||
include_root: bool = True,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""List files, optionally filtered by folder.
|
||||
|
||||
@@ -669,7 +698,7 @@ async def upload_file(
|
||||
folder_id: int | None = None,
|
||||
generate_stl_thumbnails: bool = Query(default=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = Depends(require_auth_if_enabled),
|
||||
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
|
||||
):
|
||||
"""Upload a file to the library."""
|
||||
try:
|
||||
@@ -805,7 +834,7 @@ async def extract_zip_file(
|
||||
create_folder_from_zip: bool = Query(default=False),
|
||||
generate_stl_thumbnails: bool = Query(default=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = Depends(require_auth_if_enabled),
|
||||
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
|
||||
):
|
||||
"""Upload and extract a ZIP file to the library.
|
||||
|
||||
@@ -1064,9 +1093,13 @@ async def extract_zip_file(
|
||||
async def batch_generate_stl_thumbnails(
|
||||
request: BatchThumbnailRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
|
||||
):
|
||||
"""Generate thumbnails for STL files in batch.
|
||||
|
||||
Note: Requires library:update_all permission since this is a batch operation
|
||||
that may affect files owned by different users.
|
||||
|
||||
Can generate thumbnails for:
|
||||
- Specific file IDs (file_ids)
|
||||
- All STL files in a folder (folder_id)
|
||||
@@ -1188,6 +1221,7 @@ def is_sliced_file(filename: str) -> bool:
|
||||
async def add_files_to_queue(
|
||||
request: AddToQueueRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
|
||||
):
|
||||
"""Add library files to the print queue.
|
||||
|
||||
@@ -1266,6 +1300,7 @@ async def add_files_to_queue(
|
||||
async def get_library_file_plates(
|
||||
file_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get available plates from a multi-plate 3MF library file.
|
||||
|
||||
@@ -1477,6 +1512,7 @@ async def get_library_file_filament_requirements(
|
||||
file_id: int,
|
||||
plate_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get filament requirements from a library file.
|
||||
|
||||
@@ -1599,6 +1635,7 @@ async def print_library_file(
|
||||
printer_id: int,
|
||||
body: FilePrintRequest | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
|
||||
):
|
||||
"""Print a library file directly.
|
||||
|
||||
@@ -1786,7 +1823,11 @@ async def print_library_file(
|
||||
|
||||
|
||||
@router.get("/files/{file_id}", response_model=FileResponseSchema)
|
||||
async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_file(
|
||||
file_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get a file by ID with full details."""
|
||||
result = await db.execute(
|
||||
select(LibraryFile).options(selectinload(LibraryFile.created_by)).where(LibraryFile.id == file_id)
|
||||
@@ -1961,7 +2002,11 @@ async def delete_file(
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/download")
|
||||
async def download_file(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def download_file(
|
||||
file_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Download a file."""
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
file = result.scalar_one_or_none()
|
||||
@@ -2008,7 +2053,11 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/gcode")
|
||||
async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_gcode(
|
||||
file_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get gcode for a file (for preview)."""
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
file = result.scalar_one_or_none()
|
||||
@@ -2046,8 +2095,22 @@ async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/files/move")
|
||||
async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Move multiple files to a folder."""
|
||||
async def move_files(
|
||||
data: FileMoveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.LIBRARY_UPDATE_ALL,
|
||||
Permission.LIBRARY_UPDATE_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Move multiple files to a folder.
|
||||
|
||||
Files not owned by the user are skipped (unless user has *_all permission).
|
||||
"""
|
||||
user, can_modify_all = auth_result
|
||||
|
||||
# Verify folder exists if specified
|
||||
if data.folder_id is not None:
|
||||
folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
|
||||
@@ -2056,14 +2119,19 @@ async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
# Update files
|
||||
moved = 0
|
||||
skipped = 0
|
||||
for file_id in data.file_ids:
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
file = result.scalar_one_or_none()
|
||||
if file:
|
||||
# Ownership check
|
||||
if not can_modify_all and file.created_by_id != user.id:
|
||||
skipped += 1
|
||||
continue
|
||||
file.folder_id = data.folder_id
|
||||
moved += 1
|
||||
|
||||
return {"status": "success", "moved": moved}
|
||||
return {"status": "success", "moved": moved, "skipped": skipped}
|
||||
|
||||
|
||||
@router.post("/bulk-delete", response_model=BulkDeleteResponse)
|
||||
@@ -2133,7 +2201,10 @@ async def bulk_delete(
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||
async def get_library_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get library statistics."""
|
||||
# Total files
|
||||
total_files_result = await db.execute(select(func.count(LibraryFile.id)))
|
||||
|
||||
@@ -121,6 +121,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
||||
"id": item.id,
|
||||
"printer_id": item.printer_id,
|
||||
"target_model": item.target_model,
|
||||
"target_location": item.target_location,
|
||||
"required_filament_types": required_filament_types_parsed,
|
||||
"waiting_reason": item.waiting_reason,
|
||||
"archive_id": item.archive_id,
|
||||
@@ -289,6 +290,7 @@ async def add_to_queue(
|
||||
item = PrintQueueItem(
|
||||
printer_id=data.printer_id,
|
||||
target_model=target_model_norm,
|
||||
target_location=data.target_location,
|
||||
required_filament_types=required_filament_types,
|
||||
archive_id=data.archive_id,
|
||||
library_file_id=data.library_file_id,
|
||||
|
||||
@@ -595,6 +595,15 @@ async def get_printer_cover(
|
||||
possible_filenames.append(f"{subtask_name}.gcode.3mf")
|
||||
possible_filenames.append(f"{subtask_name}.3mf")
|
||||
|
||||
# Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
|
||||
if " " in subtask_name:
|
||||
normalized = subtask_name.replace(" ", "_")
|
||||
if normalized.endswith(".3mf"):
|
||||
possible_filenames.append(normalized)
|
||||
else:
|
||||
possible_filenames.append(f"{normalized}.gcode.3mf")
|
||||
possible_filenames.append(f"{normalized}.3mf")
|
||||
|
||||
# Build list of all remote paths to try
|
||||
remote_paths = []
|
||||
for filename in possible_filenames:
|
||||
@@ -1574,6 +1583,15 @@ async def get_printable_objects(
|
||||
possible_filenames.append(f"{subtask_name}.gcode.3mf")
|
||||
possible_filenames.append(f"{subtask_name}.3mf")
|
||||
|
||||
# Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
|
||||
if " " in subtask_name:
|
||||
normalized = subtask_name.replace(" ", "_")
|
||||
if normalized.endswith(".3mf"):
|
||||
possible_filenames.append(normalized)
|
||||
else:
|
||||
possible_filenames.append(f"{normalized}.gcode.3mf")
|
||||
possible_filenames.append(f"{normalized}.3mf")
|
||||
|
||||
# Download 3MF from printer
|
||||
temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{possible_filenames[0]}"
|
||||
temp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import jwt
|
||||
@@ -19,13 +22,76 @@ from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Password hashing
|
||||
# Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues
|
||||
# pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
|
||||
def _get_jwt_secret() -> str:
|
||||
"""Get the JWT secret key from environment, file, or generate a new one.
|
||||
|
||||
Priority:
|
||||
1. JWT_SECRET_KEY environment variable
|
||||
2. .jwt_secret file in data directory
|
||||
3. Generate new random secret and save to file
|
||||
|
||||
Returns:
|
||||
The JWT secret key
|
||||
"""
|
||||
# 1. Check environment variable first
|
||||
env_secret = os.environ.get("JWT_SECRET_KEY")
|
||||
if env_secret:
|
||||
logger.info("Using JWT secret from JWT_SECRET_KEY environment variable")
|
||||
return env_secret
|
||||
|
||||
# 2. Check for secret file in data directory
|
||||
# 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"
|
||||
|
||||
if secret_file.exists():
|
||||
try:
|
||||
secret = secret_file.read_text().strip()
|
||||
if secret and len(secret) >= 32:
|
||||
logger.info("Using JWT secret from %s", secret_file)
|
||||
return secret
|
||||
except Exception as e:
|
||||
logger.warning("Failed to read JWT secret file: %s", e)
|
||||
|
||||
# 3. Generate new random secret
|
||||
new_secret = secrets.token_urlsafe(64)
|
||||
|
||||
# Try to save it
|
||||
try:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Note: CodeQL flags this as "clear-text storage of sensitive information" but this is
|
||||
# intentional and secure - JWT secrets must be readable by the app, we set 0600 permissions,
|
||||
# and this is standard practice for self-hosted applications (same as .env files).
|
||||
secret_file.write_text(new_secret) # nosec B105 - intentional secure storage
|
||||
# Restrict permissions (owner read/write only)
|
||||
secret_file.chmod(0o600)
|
||||
logger.info("Generated new JWT secret and saved to %s", secret_file)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not save JWT secret to file (%s). "
|
||||
"Secret will be regenerated on restart, invalidating existing tokens. "
|
||||
"Set JWT_SECRET_KEY environment variable for persistence.",
|
||||
e,
|
||||
)
|
||||
|
||||
return new_secret
|
||||
|
||||
|
||||
# JWT settings
|
||||
SECRET_KEY = "bambuddy-secret-key-change-in-production" # TODO: Move to settings/env
|
||||
SECRET_KEY = _get_jwt_secret()
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
# Application version - single source of truth
|
||||
APP_VERSION = "0.1.7b"
|
||||
APP_VERSION = "0.1.6.2"
|
||||
GITHUB_REPO = "maziggy/bambuddy"
|
||||
|
||||
# App directory - where the application is installed (for static files)
|
||||
|
||||
@@ -1044,6 +1044,12 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
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
|
||||
# This ensures backup/restore portability across different installations
|
||||
try:
|
||||
|
||||
+153
-1
@@ -1002,6 +1002,13 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
possible_names.append(f"{fname}.gcode.3mf")
|
||||
possible_names.append(f"{fname}.3mf")
|
||||
|
||||
# Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
|
||||
space_variants = []
|
||||
for name in possible_names:
|
||||
if " " in name:
|
||||
space_variants.append(name.replace(" ", "_"))
|
||||
possible_names.extend(space_variants)
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
possible_names = [x for x in possible_names if not (x in seen or seen.add(x))]
|
||||
@@ -1085,7 +1092,10 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
if f.get("is_directory"):
|
||||
continue
|
||||
fname = f.get("name", "")
|
||||
if fname.endswith(".3mf") and search_term in fname.lower():
|
||||
# Normalize both for comparison (spaces and underscores are equivalent)
|
||||
fname_normalized = fname.lower().replace(" ", "_")
|
||||
search_normalized = search_term.replace(" ", "_")
|
||||
if fname.endswith(".3mf") and search_normalized in fname_normalized:
|
||||
logger.info(f"Found matching file in {search_dir}: {fname}")
|
||||
temp_path = app_settings.archive_dir / "temp" / fname
|
||||
temp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2527,6 +2537,148 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Authentication Middleware - Secures ALL API routes by default
|
||||
# =============================================================================
|
||||
# Public routes that don't require authentication even when auth is enabled
|
||||
PUBLIC_API_ROUTES = {
|
||||
# Auth routes needed before/during login
|
||||
"/api/v1/auth/status",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/setup", # Needed for initial setup and recovery
|
||||
# Version check for updates (no sensitive data)
|
||||
"/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)
|
||||
PUBLIC_API_PREFIXES = [
|
||||
# WebSocket connections handle their own auth
|
||||
"/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")
|
||||
async def auth_middleware(request, call_next):
|
||||
"""Enforce authentication on all API routes when auth is enabled.
|
||||
|
||||
This middleware provides defense-in-depth by checking auth at the API gateway level,
|
||||
regardless of whether individual routes have auth dependencies.
|
||||
"""
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
path = request.url.path
|
||||
|
||||
# Only apply to API routes
|
||||
if not path.startswith("/api/"):
|
||||
return await call_next(request)
|
||||
|
||||
# Allow public routes
|
||||
if path in PUBLIC_API_ROUTES:
|
||||
return await call_next(request)
|
||||
|
||||
# Allow public prefixes
|
||||
for prefix in PUBLIC_API_PREFIXES:
|
||||
if path.startswith(prefix):
|
||||
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
|
||||
try:
|
||||
async with async_session() as db:
|
||||
from backend.app.core.auth import is_auth_enabled
|
||||
|
||||
auth_enabled = await is_auth_enabled(db)
|
||||
|
||||
if not auth_enabled:
|
||||
# Auth disabled, allow all requests
|
||||
return await call_next(request)
|
||||
except Exception:
|
||||
# If we can't check auth status, allow request (fail open for DB issues)
|
||||
return await call_next(request)
|
||||
|
||||
# Auth is enabled - require valid token
|
||||
auth_header = request.headers.get("Authorization")
|
||||
x_api_key = request.headers.get("X-API-Key")
|
||||
|
||||
# Check for API key auth first
|
||||
if x_api_key or (auth_header and auth_header.startswith("Bearer bb_")):
|
||||
# API key authentication - let the request through to be validated by route handler
|
||||
# API keys are validated per-route since they have different permission levels
|
||||
return await call_next(request)
|
||||
|
||||
# Check for JWT auth
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Authentication required"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Validate JWT token
|
||||
try:
|
||||
import jwt
|
||||
|
||||
from backend.app.core.auth import ALGORITHM, SECRET_KEY
|
||||
|
||||
token = auth_header.replace("Bearer ", "")
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise ValueError("No username in token")
|
||||
|
||||
# Verify user exists and is active
|
||||
async with async_session() as db:
|
||||
from backend.app.core.auth import get_user_by_username
|
||||
|
||||
user = await get_user_by_username(db, username)
|
||||
if not user or not user.is_active:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "User not found or inactive"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except jwt.ExpiredSignatureError:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Token has expired"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except (jwt.InvalidTokenError, ValueError, Exception):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Invalid token"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# API routes
|
||||
app.include_router(auth.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(users.router, prefix=app_settings.api_prefix)
|
||||
|
||||
@@ -18,6 +18,9 @@ class PrintQueueItem(Base):
|
||||
# Target printer model for model-based assignment (mutually exclusive with printer_id)
|
||||
# When set, scheduler assigns to any idle printer of matching model
|
||||
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"]')
|
||||
# Used by scheduler to validate printer has compatible filaments loaded
|
||||
required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -18,6 +18,7 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
|
||||
class PrintQueueItemCreate(BaseModel):
|
||||
printer_id: int | None = None # None = unassigned, user assigns later
|
||||
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
|
||||
# Either archive_id OR library_file_id must be provided
|
||||
archive_id: int | None = None
|
||||
@@ -43,6 +44,7 @@ class PrintQueueItemCreate(BaseModel):
|
||||
class PrintQueueItemUpdate(BaseModel):
|
||||
printer_id: int | None = None
|
||||
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
|
||||
scheduled_time: datetime | None = None
|
||||
require_previous_success: bool | None = None
|
||||
@@ -63,6 +65,7 @@ class PrintQueueItemResponse(BaseModel):
|
||||
id: int
|
||||
printer_id: int | None # None = unassigned
|
||||
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
|
||||
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)
|
||||
|
||||
@@ -148,7 +148,7 @@ class PrintScheduler:
|
||||
pass
|
||||
|
||||
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
|
||||
@@ -225,6 +225,7 @@ class PrintScheduler:
|
||||
model: str,
|
||||
exclude_ids: set[int],
|
||||
required_filament_types: list[str] | None = None,
|
||||
target_location: str | None = None,
|
||||
) -> tuple[int | None, str | None]:
|
||||
"""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)
|
||||
required_filament_types: Optional list of filament types needed (e.g., ["PLA", "PETG"])
|
||||
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:
|
||||
Tuple of (printer_id, waiting_reason):
|
||||
@@ -242,15 +244,22 @@ class PrintScheduler:
|
||||
"""
|
||||
# Normalize model name and use case-insensitive matching
|
||||
normalized_model = normalize_printer_model(model) or model
|
||||
result = await db.execute(
|
||||
query = (
|
||||
select(Printer)
|
||||
.where(func.lower(Printer.model) == normalized_model.lower())
|
||||
.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())
|
||||
|
||||
location_suffix = f" in {target_location}" if target_location else ""
|
||||
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
|
||||
printers_busy = []
|
||||
@@ -295,7 +304,7 @@ class PrintScheduler:
|
||||
if 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]:
|
||||
"""Get the list of required filament types that are not loaded on the printer.
|
||||
|
||||
@@ -117,10 +117,11 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
|
||||
async def mock_init_printer_connections(db):
|
||||
pass # No-op - don't connect to real printers
|
||||
|
||||
# Also patch the module-level async_session used by services and auth
|
||||
# Also patch the module-level async_session used by services, auth, and middleware
|
||||
with (
|
||||
patch("backend.app.core.database.async_session", test_async_session),
|
||||
patch("backend.app.core.auth.async_session", test_async_session),
|
||||
patch("backend.app.main.async_session", test_async_session),
|
||||
patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
|
||||
):
|
||||
# Seed default groups for tests that need them
|
||||
|
||||
@@ -689,3 +689,88 @@ class TestChangePasswordAPI:
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -848,3 +848,210 @@ class TestLibraryPathHelpers:
|
||||
|
||||
assert to_absolute_path(None) is None
|
||||
assert to_absolute_path("") is None
|
||||
|
||||
|
||||
class TestLibraryPermissions:
|
||||
"""Tests for library permission enforcement."""
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_setup(self, db_session):
|
||||
"""Set up auth with users of different permission levels."""
|
||||
from backend.app.core.auth import create_access_token, get_password_hash
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
|
||||
# Enable auth
|
||||
settings = Settings(key="auth_enabled", value="true")
|
||||
db_session.add(settings)
|
||||
await db_session.commit()
|
||||
|
||||
# Groups are auto-seeded during db init, but we need to commit them
|
||||
await db_session.commit()
|
||||
|
||||
# Get groups
|
||||
from sqlalchemy import select
|
||||
|
||||
admin_group = (await db_session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
|
||||
operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
|
||||
viewer_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
|
||||
|
||||
password_hash = get_password_hash("password")
|
||||
|
||||
# Create users
|
||||
admin_user = User(username="admin_lib", password_hash=password_hash, role="admin", is_active=True)
|
||||
admin_user.groups.append(admin_group)
|
||||
|
||||
operator_user = User(username="operator_lib", password_hash=password_hash, is_active=True)
|
||||
operator_user.groups.append(operator_group)
|
||||
|
||||
viewer_user = User(username="viewer_lib", password_hash=password_hash, is_active=True)
|
||||
viewer_user.groups.append(viewer_group)
|
||||
|
||||
db_session.add_all([admin_user, operator_user, viewer_user])
|
||||
await db_session.commit()
|
||||
|
||||
# Create tokens
|
||||
admin_token = create_access_token(data={"sub": admin_user.username})
|
||||
operator_token = create_access_token(data={"sub": operator_user.username})
|
||||
viewer_token = create_access_token(data={"sub": viewer_user.username})
|
||||
|
||||
return {
|
||||
"admin_user": admin_user,
|
||||
"operator_user": operator_user,
|
||||
"viewer_user": viewer_user,
|
||||
"admin_token": admin_token,
|
||||
"operator_token": operator_token,
|
||||
"viewer_token": viewer_token,
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
async def test_file(self, db_session, auth_setup):
|
||||
"""Create a test file owned by the operator user."""
|
||||
from backend.app.models.library import LibraryFile
|
||||
|
||||
operator_user = auth_setup["operator_user"]
|
||||
lib_file = LibraryFile(
|
||||
filename="test.txt",
|
||||
file_path="data/archive/library/files/test.txt",
|
||||
file_type="txt",
|
||||
file_size=100,
|
||||
created_by_id=operator_user.id,
|
||||
)
|
||||
db_session.add(lib_file)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(lib_file)
|
||||
return lib_file
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_list_files_requires_library_read(self, async_client: AsyncClient, db_session, auth_setup):
|
||||
"""Verify list_files requires library:read permission."""
|
||||
viewer_token = auth_setup["viewer_token"]
|
||||
|
||||
# Viewers have library:read, should succeed
|
||||
response = await async_client.get("/api/v1/library/files", headers={"Authorization": f"Bearer {viewer_token}"})
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_list_files_denied_without_permission(self, async_client: AsyncClient, db_session):
|
||||
"""Verify list_files denied without auth when auth is enabled."""
|
||||
from backend.app.models.settings import Settings
|
||||
|
||||
# Enable auth
|
||||
settings = Settings(key="auth_enabled", value="true")
|
||||
db_session.add(settings)
|
||||
await db_session.commit()
|
||||
|
||||
# Request without token should fail
|
||||
response = await async_client.get("/api/v1/library/files")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_file_own_by_owner(self, async_client: AsyncClient, db_session, auth_setup, test_file):
|
||||
"""Verify operator can delete their own files."""
|
||||
from pathlib import Path
|
||||
|
||||
# Create actual file on disk so delete doesn't fail
|
||||
from backend.app.core.config import settings as app_settings
|
||||
|
||||
file_path = Path(app_settings.base_dir) / test_file.file_path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text("test content")
|
||||
|
||||
operator_token = auth_setup["operator_token"]
|
||||
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_file_own_denied_for_others_file(self, async_client: AsyncClient, db_session, auth_setup):
|
||||
"""Verify operator cannot delete files owned by others."""
|
||||
# Create another operator user with a file
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.auth import create_access_token
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.library import LibraryFile
|
||||
from backend.app.models.user import User
|
||||
|
||||
operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
|
||||
|
||||
from backend.app.core.auth import get_password_hash as get_pw_hash
|
||||
|
||||
other_user = User(username="other_op", password_hash=get_pw_hash("password"), is_active=True)
|
||||
other_user.groups.append(operator_group)
|
||||
db_session.add(other_user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(other_user)
|
||||
|
||||
# Create file owned by other user
|
||||
other_file = LibraryFile(
|
||||
filename="other.txt",
|
||||
file_path="data/archive/library/files/other.txt",
|
||||
file_type="txt",
|
||||
file_size=100,
|
||||
created_by_id=other_user.id,
|
||||
)
|
||||
db_session.add(other_file)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(other_file)
|
||||
|
||||
# Original operator should not be able to delete it
|
||||
operator_token = auth_setup["operator_token"]
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/library/files/{other_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "your own files" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_file_admin_can_delete_any(self, async_client: AsyncClient, db_session, auth_setup):
|
||||
"""Verify admin can delete any file."""
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.models.library import LibraryFile
|
||||
|
||||
# Create file owned by operator
|
||||
operator_user = auth_setup["operator_user"]
|
||||
lib_file = LibraryFile(
|
||||
filename="admin_can_delete.txt",
|
||||
file_path="data/archive/library/files/admin_can_delete.txt",
|
||||
file_type="txt",
|
||||
file_size=100,
|
||||
created_by_id=operator_user.id,
|
||||
)
|
||||
db_session.add(lib_file)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(lib_file)
|
||||
|
||||
# Create actual file on disk
|
||||
file_path = Path(app_settings.base_dir) / lib_file.file_path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text("test content")
|
||||
|
||||
# Admin should be able to delete it
|
||||
admin_token = auth_setup["admin_token"]
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/library/files/{lib_file.id}", headers={"Authorization": f"Bearer {admin_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_viewer_cannot_delete_files(self, async_client: AsyncClient, db_session, auth_setup, test_file):
|
||||
"""Verify viewer cannot delete any files."""
|
||||
viewer_token = auth_setup["viewer_token"]
|
||||
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {viewer_token}"}
|
||||
)
|
||||
# Viewers don't have delete_own or delete_all permissions
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -700,7 +700,10 @@ class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify archive still exists but is now ownerless
|
||||
archive_response = await async_client.get(f"/api/v1/archives/{archive_id}")
|
||||
archive_response = await async_client.get(
|
||||
f"/api/v1/archives/{archive_id}",
|
||||
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
||||
)
|
||||
assert archive_response.status_code == 200
|
||||
assert archive_response.json()["created_by_id"] is None
|
||||
|
||||
@@ -736,5 +739,8 @@ class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify archive was deleted
|
||||
archive_response = await async_client.get(f"/api/v1/archives/{archive_id}")
|
||||
archive_response = await async_client.get(
|
||||
f"/api/v1/archives/{archive_id}",
|
||||
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
||||
)
|
||||
assert archive_response.status_code == 404
|
||||
|
||||
@@ -963,3 +963,216 @@ class TestBulkUpdateEndpoint:
|
||||
)
|
||||
assert response.status_code == 400
|
||||
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
|
||||
|
||||
@@ -273,7 +273,7 @@ export const handlers = [
|
||||
// Auth
|
||||
// ========================================================================
|
||||
|
||||
http.get('/api/v1/auth/status', () => {
|
||||
http.get('*/api/v1/auth/status', () => {
|
||||
return HttpResponse.json({
|
||||
auth_enabled: false,
|
||||
requires_setup: false,
|
||||
|
||||
@@ -11,17 +11,12 @@ import { server } from './mocks/server';
|
||||
// Initialize i18n for tests (suppresses react-i18next warnings)
|
||||
import '../i18n';
|
||||
|
||||
// Setup MSW server - bypass WebSocket requests so our mock handles them
|
||||
// Setup MSW server
|
||||
beforeAll(() =>
|
||||
server.listen({
|
||||
onUnhandledRequest: (request, _print) => {
|
||||
// Allow WebSocket requests to pass through to our mock
|
||||
if (request.url.includes('/ws')) {
|
||||
return;
|
||||
}
|
||||
// Silently ignore unhandled requests in tests to reduce noise
|
||||
// Remove 'warn' to completely silence, or use print.warning() to show warnings
|
||||
},
|
||||
// Bypass unhandled requests silently (don't warn, just let them through)
|
||||
// Handlers use wildcard (*) prefix to match any origin
|
||||
onUnhandledRequest: 'bypass',
|
||||
})
|
||||
);
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1067,6 +1067,7 @@ export interface PrintQueueItem {
|
||||
id: number;
|
||||
printer_id: number | null; // null = unassigned
|
||||
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
|
||||
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)
|
||||
@@ -1105,6 +1106,7 @@ export interface PrintQueueItem {
|
||||
export interface PrintQueueItemCreate {
|
||||
printer_id?: number | null; // null = unassigned
|
||||
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
|
||||
archive_id?: number | null;
|
||||
library_file_id?: number | null;
|
||||
@@ -1126,6 +1128,7 @@ export interface PrintQueueItemCreate {
|
||||
export interface PrintQueueItemUpdate {
|
||||
printer_id?: number | null; // null = unassign
|
||||
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;
|
||||
scheduled_time?: string | null;
|
||||
require_previous_success?: boolean;
|
||||
|
||||
@@ -38,6 +38,10 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
|
||||
targetModel?: string | null;
|
||||
/** Handler for target model change */
|
||||
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) */
|
||||
slicedForModel?: string | null;
|
||||
}
|
||||
@@ -227,6 +231,8 @@ export function PrinterSelector({
|
||||
onAssignmentModeChange,
|
||||
targetModel,
|
||||
onTargetModelChange,
|
||||
targetLocation,
|
||||
onTargetLocationChange,
|
||||
slicedForModel,
|
||||
}: PrinterSelectorWithMappingProps) {
|
||||
// State for showing all printers vs only matching model
|
||||
@@ -257,6 +263,16 @@ export function PrinterSelector({
|
||||
return [...new Set(models)].sort();
|
||||
}, [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)
|
||||
const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
|
||||
|
||||
@@ -370,11 +386,59 @@ export function PrinterSelector({
|
||||
</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) */}
|
||||
|
||||
@@ -135,6 +135,14 @@ export function PrintModal({
|
||||
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)
|
||||
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));
|
||||
@@ -369,6 +377,7 @@ export function PrintModal({
|
||||
const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
|
||||
printer_id: assignmentMode === 'printer' ? printerId : null,
|
||||
target_model: assignmentMode === 'model' ? targetModel : null,
|
||||
target_location: assignmentMode === 'model' ? targetLocation : null,
|
||||
// Use library_file_id for library files, archive_id for archives
|
||||
archive_id: isLibraryFile ? undefined : archiveId,
|
||||
library_file_id: isLibraryFile ? libraryFileId : undefined,
|
||||
@@ -397,6 +406,7 @@ export function PrintModal({
|
||||
const updateData: PrintQueueItemUpdate = {
|
||||
printer_id: null,
|
||||
target_model: targetModel,
|
||||
target_location: targetLocation,
|
||||
require_previous_success: scheduleOptions.requirePreviousSuccess,
|
||||
auto_off_after: scheduleOptions.autoOffAfter,
|
||||
manual_start: scheduleOptions.scheduleType === 'manual',
|
||||
@@ -445,6 +455,7 @@ export function PrintModal({
|
||||
const updateData: PrintQueueItemUpdate = {
|
||||
printer_id: printerId,
|
||||
target_model: null,
|
||||
target_location: null,
|
||||
require_previous_success: scheduleOptions.requirePreviousSuccess,
|
||||
auto_off_after: scheduleOptions.autoOffAfter,
|
||||
manual_start: scheduleOptions.scheduleType === 'manual',
|
||||
@@ -626,6 +637,8 @@ export function PrintModal({
|
||||
onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
|
||||
targetModel={targetModel}
|
||||
onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
|
||||
targetLocation={targetLocation}
|
||||
onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
|
||||
slicedForModel={slicedForModel}
|
||||
/>
|
||||
|
||||
|
||||
@@ -130,6 +130,10 @@ export interface PrinterSelectorProps {
|
||||
targetModel?: string | null;
|
||||
/** Handler for target model change */
|
||||
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) */
|
||||
slicedForModel?: string | null;
|
||||
}
|
||||
|
||||
@@ -3548,6 +3548,7 @@ function AddPrinterModal({
|
||||
ip_address: '',
|
||||
access_code: '',
|
||||
model: '',
|
||||
location: '',
|
||||
auto_archive: true,
|
||||
});
|
||||
|
||||
@@ -3894,6 +3895,17 @@ function AddPrinterModal({
|
||||
</optgroup>
|
||||
</select>
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -4265,7 +4277,7 @@ function EditPrinterModal({
|
||||
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 on the dashboard</p>
|
||||
<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">
|
||||
<input
|
||||
|
||||
@@ -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 { Link } from 'react-router-dom';
|
||||
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' : ''}`}>
|
||||
<Printer className="w-3.5 h-3.5" />
|
||||
{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
|
||||
? 'Unassigned'
|
||||
: (item.printer_name || `Printer #${item.printer_id}`)}
|
||||
@@ -579,6 +579,7 @@ export function QueuePage() {
|
||||
const { hasPermission, hasAnyPermission, canModify } = useAuth();
|
||||
const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<string>('');
|
||||
const [filterLocation, setFilterLocation] = useState<string>('');
|
||||
const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
|
||||
const [editItem, setEditItem] = 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 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)
|
||||
const getScheduledTime = (item: PrintQueueItem): number => {
|
||||
@@ -766,7 +798,7 @@ export function QueuePage() {
|
||||
}
|
||||
return pendingSortAsc ? cmp : -cmp;
|
||||
});
|
||||
}, [queue, pendingSortBy, pendingSortAsc]);
|
||||
}, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation]);
|
||||
|
||||
const handleSelectAll = () => {
|
||||
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 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) => {
|
||||
let cmp: number;
|
||||
if (historySortBy === 'name') {
|
||||
@@ -794,7 +836,7 @@ export function QueuePage() {
|
||||
}
|
||||
return historySortAsc ? -cmp : cmp;
|
||||
});
|
||||
}, [queue, historySortBy, historySortAsc]);
|
||||
}, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]);
|
||||
|
||||
// Calculate total queue time
|
||||
const totalQueueTime = useMemo(() => {
|
||||
@@ -923,6 +965,19 @@ export function QueuePage() {
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</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" />
|
||||
|
||||
{historyItems.length > 0 && (
|
||||
|
||||
@@ -7,6 +7,18 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
pool: 'threads',
|
||||
poolOptions: {
|
||||
threads: {
|
||||
maxThreads: 14,
|
||||
minThreads: 4,
|
||||
},
|
||||
},
|
||||
environmentOptions: {
|
||||
jsdom: {
|
||||
url: 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg id="Capa_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><g><g><path d="m153.221 184.577c19.326 4.833 44.858 7.496 71.895 7.496 27.024 0 52.549-2.662 71.871-7.496 24.458-6.118 36.347-14.976 36.347-27.081 0-12.092-11.89-20.943-36.349-27.057-19.327-4.831-44.85-7.491-71.869-7.491-27.03 0-52.562 2.661-71.893 7.491-24.462 6.113-36.353 14.963-36.353 27.057 0 12.105 11.89 20.963 36.351 27.081zm10.048-35.938c17.113-3.67 39.078-5.691 61.847-5.691 22.759 0 44.715 2.021 61.824 5.691 14.725 3.159 22.072 6.719 25.102 8.864-3.025 2.146-10.368 5.711-25.093 8.873-17.105 3.673-39.065 5.696-61.834 5.696-22.779 0-44.748-2.023-61.857-5.696-14.727-3.162-22.071-6.727-25.098-8.873 3.033-2.145 10.381-5.705 25.109-8.864z"/><path d="m511.621 299.939c-8.759-30.997-41.987-53.503-69.154-61.298-14.206-4.075-27.122-4.542-37.871-1.528v-19.488c29.607-16.814 45.608-37.674 45.608-60.13 0-27.828-24.579-53.21-69.209-71.469-41.8-17.101-97.159-26.518-155.879-26.518-58.733 0-114.101 9.418-155.904 26.518-44.632 18.259-69.212 43.64-69.212 71.47 0 22.464 16.011 43.33 45.636 60.146v76.727c-29.882 16.917-45.636 37.623-45.636 60.135 0 27.84 24.58 53.226 69.213 71.48 41.796 17.094 97.164 26.508 155.904 26.508 58.728 0 114.087-9.414 155.879-26.508 44.63-18.255 69.209-43.64 69.209-71.48 0-22.505-15.744-43.211-45.608-60.134v-35.729c7.473-4.29 18.961-4.619 32.356-.776 26.055 7.475 49.882 27.901 55.423 47.511 1.245 4.406 5.255 7.283 9.618 7.283.899 0 1.815-.123 2.724-.379 5.314-1.5 8.404-7.026 6.903-12.341zm-445.985 30.134c31.44 17.594 91.184 23.268 113.866 24.826.233.016.465.024.695.024 5.211 0 9.604-4.039 9.967-9.315.378-5.51-3.781-10.284-9.291-10.662-34.324-2.358-65.154-7.512-86.81-14.513-23.602-7.63-28.427-14.866-28.427-16.386v-24.377c9.234 5.161 21.644 9.701 37.204 13.591 32.773 8.195 76.199 12.707 122.276 12.707 46.066 0 89.487-4.513 122.264-12.707 15.565-3.891 27.979-8.432 37.216-13.594v24.381c0 1.52-4.827 8.755-28.44 16.386-21.663 7-52.499 12.155-86.826 14.513-5.51.378-9.669 5.152-9.291 10.662.362 5.277 4.756 9.315 9.967 9.315.229 0 .462-.008.695-.024 22.687-1.559 82.449-7.234 113.895-24.83v24.435c0 2.06-7.14 11.536-42.066 20.268-31.237 7.809-72.935 12.11-117.413 12.11-44.489 0-86.191-4.301-117.424-12.11-34.918-8.731-42.055-18.208-42.055-20.268v-24.432zm-45.636-172.577c0-18.893 20.697-38.195 56.785-52.958 39.456-16.141 92.134-25.03 148.331-25.03 56.184 0 108.854 8.889 148.306 25.03 36.086 14.763 56.782 34.065 56.782 52.958 0 15.801-14.467 31.885-40.286 45.363-.047.025-.094.049-.141.074-5.032 2.621-10.486 5.144-16.355 7.546-39.449 16.142-92.118 25.033-148.306 25.033-56.2 0-108.878-8.89-148.33-25.033-5.792-2.37-11.183-4.858-16.16-7.442-.197-.114-.399-.219-.604-.319-25.652-13.449-40.022-29.475-40.022-45.222zm49.212 71.493c41.8 17.103 97.167 26.522 155.905 26.522 58.724 0 114.083-9.419 155.88-26.522 1.217-.498 2.413-1.004 3.6-1.512v26.113c0 2.06-7.14 11.536-42.066 20.268-31.236 7.81-72.935 12.11-117.413 12.11-44.489 0-86.191-4.301-117.424-12.11-34.918-8.73-42.055-18.208-42.055-20.268v-26.103c1.177.506 2.364 1.008 3.573 1.502zm360.992 125.515c0 18.903-20.696 38.209-56.781 52.969-39.445 16.134-92.115 25.019-148.307 25.019-56.205 0-108.883-8.885-148.332-25.019-36.087-14.759-56.784-34.066-56.784-52.969 0-12.419 8.988-25.106 25.637-36.587v36.587c0 16.832 19.246 30.18 57.204 39.67 32.773 8.195 76.199 12.707 122.276 12.707 46.066 0 89.487-4.513 122.264-12.707 37.966-9.491 57.216-22.838 57.216-39.67v-36.577c16.629 11.481 25.607 24.165 25.607 36.577z"/><path d="m225.116 356.425h.028c5.522 0 9.986-4.477 9.986-10s-4.492-10-10.015-10-10 4.477-10 10c.001 5.523 4.479 10 10.001 10z"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-1q7Yxq-H.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D-vJDFzo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user